Help us understand the problem. What is going on with this article?

Yii2のPjaxとフラグメントキャッシュでページを爆速にする

More than 5 years have passed since last update.

Web サイトを作っていると、アクセスの大半を占める一覧表示ページなのに、そこが一番重い、というのはよくありますよね。ブログは個別の記事よりブックマークされているトップページが一番重い系のアレです。しかもページネーションとかカテゴリ別一覧とかもう...

もし Yii2 を使っているなら、そんなページは Pjax とフラグメントキャッシュで爆速にしてやりましょう。

Pjax

Yii 1.1 の CGridView/CListView はデフォルトで Ajax を使って DOM の内容を更新するウィジェットでした。ページングのとき余計なリロードがなくなって高速なのはいいのですが、初心者はその特殊な挙動に戸惑うこともありました。

http://qiita.com/tanakahisateru/items/597d151fab5ab83b7974

Yii 2 の GridView/ListView のナビゲーションは通常のハイパーリンクを出力するようになりました。特別扱いのウィジェットではなく、普通のHTMLを出力するものになり、理解にも実行にも無駄がなくなりました。

え、でも 1.1 より機能が下がってるんじゃ...? いえ、大丈夫です。Yii 2 では DOM の部分更新とブラウザ履歴を加工する history.pushState が GridView/ListView から引き剥がされ、Pjax として一般化されました。なんとこれを使うと、特別なウィジェットを使わないデザイン作り込みリストページでも、Ajax で取得した HTML で DOM の部分更新を実現できます。

たとえばページングして変化するのが以下の領域だけなんだとしたら:

    <div class="posts">
        <?php foreach($postsQuery->all() as $post): ?>
            <div class="post">
                ...
            </div>
        <?php endforeach; ?>
    </div>
    <div class="pagination">
        <ul>
            <?php foreach($pages as $page): ?>
                <li><?= Html::a(Html::encode($page), ['index', 'page'=>$page]) ?></li>
            <?php endforeach; ?>
        </ul>
    </div>

Pjaxを使ったページング方法はこうです。

<?php
use yii\widgets\Pjax;
?>

<?php Pjax::begin([
    'linkSelector' => '#posts-pjax-region .pagination a',
    'options' => [
        'id' => '#posts-pjax-region'
    ]
]); /* ここから */ ?>
    <div class="posts">
        <?php foreach($postsQuery->all() as $post): ?>
            <div class="post">
                ...
            </div>
        <?php endforeach; ?>
    </div>
    <div class="pagination">
        ...
    </div>
<?php Pjax::end(); /* ここまで */ ?>

Pjax ウィジェットは linkSelector のセレクタで示した要素のクリックイベントを奪い、本来のリンク先に Ajax でリクエストを送って、返ってきた HTML で DOM を書き換えます。CSS も JS も画像も、いっさい再要求されません。サイドバーの Twitter ウィジェットも派手なアニメーション広告もリロードされません。(あ、広告は変わったほうがいいですね → 後述)

サンプルではわかりやすいようにオプションを明示的に書いていますが、単に <?php Pjax::begin(); ?> だけでも、それなりに動きます。領域内の <a> のうち同じアクションのもの(?)が勝手に Pjax リンクになります。

サーバ側のリクエスト処理は、もしそのアクションでレイアウトを含む HTML ページ全体をレンダリングしていても、Pjax のリクエストであることを検出したら、HTMLの中からその領域に対応するノード (と TITLE タグ) だけを抜き出して、それだけを返します。

クライアントサイドで速くなるだけではありませんよ。該当部分しか使わないということは、逆にいえば、もし Yii::$app->request->isPjax がオンなら、Pjax 領域以外の部分の DOM はテキトーにやっておけばいいわけです。データベースに重いクエリを発行するウィジェットが Pjax 領域の外にあるなら、その処理はスキップできます。ヘッダやフッタにログインユーザー用の高度な機能があるページなら、それを飛ばせるのは非常にありがたいです。

フラグメントキャッシュ

メインコンテンツが重いときは、Pjax だけで負荷を軽減するのは困難です。そこで効いてくるのがビューのキャッシュです。ビューキャッシュといっても、HTMLページ全体をキャッシュできるのなら、静的コンテンツの定期書き出しでもかまわないし、Nginx でも Vernish でもいいわけですね。Yii 2 には「フラグメントキャッシュ」という、HTML の断片をキャッシュする方法があります。

まず Yii::$app->cache になんでもいいのでキャッシュ実装が入っているのを確認して下さい。memcache がなければファイルキャッシュでもかまいません。

できたら、さきほどの重い例をフラグメントキャッシュで解決してみます。こうです。

<?php if ($this->beginCache('post_list', [
    'variations' => [
        'page' => Yii::$app->request->get('page', 1)
    ],
    'dependency' => [
        'class' => 'yii\caching\DbDependency',
        'sql' => 'SELECT MAX(updated_at) FROM posts',
    ],
    'duration' => 180, // sec
])): /* ここから */ ?>
    <div class="posts">
        <?php foreach($postsQuery->all() as $post): ?>
            <div class="post">
                ...
            </div>
        <?php endforeach; ?>
    </div>
    <div class="pagination">
        ...
    </div>
    <?php $this->endCache(); ?>
<?php endif; /* ここまで */ ?>

またまた囲むだけ。ちょろい。このフラグメントキャッシュは View が直接サポートしているぐらい激推しの機能です。

キャッシュのキーは第一引数と variations の組み合わせで決定されます。「このページのこの部分」を意味するユニーク名、それと、クエリ文字列のページごとに別のキャッシュでないと困るのでそれも。もし他にバリエーションがあるなら、すべて variations に追加しておきましょう。

dependency には「予定した期限を待たずしてキャッシュが無効になる条件」を指定します。例では、データベースにクエリを発行して、以前と異なる値が出てきたら無効になります。もちろんデータベース以外に問い合わせてもOKです。

全体が if 文になっているのがミソです。キャッシュにヒットすれば、この中でいちばん重そうな $postsQuery->all() の部分を含めて処理全体がスキップされるわけです。やったね。<div class="post"> の中にも、関連テーブルへのアクセスを小刻みに発生させる要素がたくさんありそうです。

簡単なページで試してみると、初回が 16 クエリ 140ms だったのに対して、その後が 6 クエリ 88 ms になりました。自分の実務の例ですが、かなり複雑なページのレスポンス時間を、200ms 〜 400ms あたりから安定して 70 ms 前後にまで下げることができました。

ここで Yii が Query なり DataProvider なりを使って、じっさいにビューにレンダリングするまでデータベースにアクセスしないようになっていることを思い出してください。Yii の ActiveRecord もデフォルトがレイジーです。

「結局データベースに同じだけ問い合わせるんでしょ」と、コントローラーでデータを全部取ってテンプレートエンジンに流しておしまいというような MVC フレームワークでは、いくらビューにフラグメントキャッシュがあったとしても、それだけでは肝心のデータベースアクセス回避ができません。

ビジネスロジックでクエリを作り、本当に表示するまで取得しないという Yii のスタイルを貫くことで、キャッシュの有無を気にしてロジックに if 持ち込まなければならない、なんて状況は起こらなくなります。

Pjax + フラグメントキャッシュ = 爆速

そしてこの2つを組み合わせて、爆速ページのできあがり〜

<?php
use yii\widgets\Pjax;
?>

<?php Pjax::begin([
    'linkSelector' => '#posts-pjax-region .pagination a',
    'options' => [
        'id' => '#posts-pjax-region'
    ]
]); ?>
    <?php if ($this->beginCache('post_list', [
        'variations' => [
            'page' => Yii::$app->request->get('page', 1)
        ],
        'dependency' => [
            'class' => 'yii\caching\DbDependency',
            'sql' => 'SELECT MAX(updated_at) FROM posts',
        ],
        'duration' => 180, // sec
    ])): ?>
        <div class="posts">
            <?php foreach($postsQuery->all() as $post): ?>
                <div class="post">
                    ...
                </div>
            <?php endforeach; ?>
        </div>
        <div class="pagination">
            ...
        </div>
        <?php $this->endCache(); ?>
    <?php endif; ?>
<?php Pjax::end(); ?>

そこそこいいサーバーだと PHP なのに秒間 50〜80 ページぐらい出るんじゃないかと思います。(究極的にはキャッシュからHTMLを返すだけなので)

シングルページアプリ用の技術を使ったり、特別なことをしなくても、普通のアプリケーション実装者が普通に Web ページアプリケーションを作って、そこから、それを壊すことなく行追加だけでここまでできるのが Yii の素晴らしいところですね。

いろいろ補足

Pjax イベント

ざっとソースを見た感じ、Pjax イベントにはこんなのがありました。

  • pjax:click
  • pjax:clicked
  • pjax:beforeSend
  • pjax:timeout
  • pjax:complete
  • pjax:end
  • pjax:error
  • pjax:beforeReplace
  • pjax:success
  • pjax:start
  • pjax:send
  • pjax:popstate

jQuery のイベントなのでこんな感じでキャッチできます。

<?php $this->registerJs(<<<JS
jQuery('#posts-pjax-region').on('pjax:success', function() {
    console.log('pjax success');
});
JS
) ?>

これを使えば、ページが変わったら広告を前のと違うのに変える、なんて小細工もできますね。

Pjax フォーム送信

Pjax はナビゲーションだけでなく、フォームの送信にも使えます。ページ遷移なしでじゃんじゃんデータを入力してもらいたいときに活きてきます。

<?php Pjax::begin([
    'formSelector' => '#data-editor form',
    'options' => [
        'id' => '#data-pjax-region'
    ]
]); ?>
    <?= GridView::widget([
        'dataProvider' => $dataProvider,
        'columns' => [
            'name',
            'value',
        ]
    ]) ?>
<?php Pjax::end(); ?>

<div class="form-inline" id="data-editor">
    <?php $form = ActiveForm::begin(); ?>
    <?= $form->field($editorModel, 'name') ?>
    <?= $form->field($editorModel, 'value') ?>
    <?= Html::submitButton('Add', ['class' => 'btn btn-primary']) ?>
    <?php $form->end(); ?>
</div>

Pjax は formSelector で示したフォームのサブミットに割り込んで、Ajax で POST し、そのレスポンスを使って Pjax 領域を更新。これで同じページにいながら、じゃんじゃんデータを追加できます。

このままだとフォームがクリアされないので、上の送信成功イベントが使えますね。

受け側では、一覧表示の URL が POST メソッドで来た場合に追加できるようにしておき、Pjax リクエストの場合はいつものリダイレクトではなく、更新されたHTMLを返すようにしないといけません。ちょっとここは変則的です。

public function actionIndex()
{
    $editorModel = new DataModel();
    if (Yii::$app->request->isPost) {
        if ($editorModel->load(Yii::$app->request->post()) &&
            $editorModel->save()
        ) {
            if (!Yii::$app->request->isPjax) { // Pjaxでない場合のみ
                return $this->redirect(...);
            }
        } else {
            $errorMessage = $editorModel->...; // なんかうまいことやる
        }
    }
    $dataProvider = ...;
    $this->render(...);
}

もしかしたらRESTコントローラーを使ったほうがいいのかも。

ちゃんとバリデーションしたい場合はオススメじゃないです。書き込みに失敗したらエラー応答を Pjax 領域の中に入れ込む必要があり、フォームのほうには反映させられません。あまり頑張らず、クライアントサイドバリデーションで送信ボタンが制御できるから、その程度にしておきましょう。

各種キャッシュ

キャッシュにはフラグメントキャッシュだけでなく、同じストレージに、純粋にデータをキャッシュしたり、HTML全体をキャッシュしたりする選択肢もあります。

あと、いろいろやりましたが、もしユーザーのログインセッションがないなら、Last-ModifiedETag を使ってブラウザのキャッシュを活かすのを最初に考えたほうがいいですよね。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした