動的スニペット

Latte の動的スニペットを使い、AJAX でページのうち実際に変わる部分だけ、たとえばリストの個々の項目だけを更新する方法です。

アプリケーションを開発していると、テーブルの行やリストの項目ごとに AJAX の操作を行いたくなることがよくあります。例として、記事の一覧を考えてみましょう。ログインしたユーザーは各記事に「いいね」や「よくない」の評価を付けられます。AJAX なしのプレゼンターのコードと対応するテンプレートは、次のようなものになります(関係の深い部分だけを示します。評価の処理と記事の取得を担うサービスがあると仮定しますが、その具体的な実装はこのガイドの本筋ではありません)。

public function handleLike(int $articleId): void
{
	$this->ratingService->saveLike($articleId, $this->user->id);
	$this->redirect('this');
}

public function handleUnlike(int $articleId): void
{
	$this->ratingService->removeLike($articleId, $this->user->id);
	$this->redirect('this');
}

テンプレート:

<article n:foreach="$articles as $article">
	<h2>{$article->title}</h2>
	<div class="content">{$article->content}</div>
	{if !$article->liked}
		<a n:href="like! $article->id" class=ajax>いいね</a>
	{else}
		<a n:href="unlike! $article->id" class=ajax>いいねを取り消す</a>
	{/if}
</article>

AJAX 化

では、この簡単なアプリケーションに AJAX の機能を足しましょう。記事の評価を変えることはページ全体のリダイレクトを要するほど重大ではないので、理想的にはバックグラウンドの AJAX で済ませたいところです。AJAX のリンクに CSS クラス ajax を付けるという一般的な決まりに従って、アドオンのハンドラースクリプトを使います。

しかし具体的にはどう実装すればよいでしょうか。Nette には 2 つのやり方があります。動的スニペットと、コンポーネントです。それぞれ長所と短所があるので、両方を見ていきましょう。

動的スニペットのやり方

Latte の用語でいう動的スニペットとは、スニペットの名前に変数を使う {snippet} タグの特別な使い方を指します。そうしたスニペットはテンプレートのどこにでも置けるわけではなく、静的な(通常の)スニペットで包むか、{snippetArea} の中に置く必要があります。テンプレートは次のように書き換えられます。

{snippet articlesContainer}
	<article n:foreach="$articles as $article">
		<h2>{$article->title}</h2>
		<div class="content">{$article->content}</div>
		{snippet article-{$article->id}}
			{if !$article->liked}
				<a n:href="like! $article->id" class=ajax>いいね</a>
			{else}
				<a n:href="unlike! $article->id" class=ajax>いいねを取り消す</a>
			{/if}
		{/snippet}
	</article>
{/snippet}

各記事が、記事の ID を名前に含むスニペットを定義するようになりました。これらの動的スニペットはすべて、articlesContainer という名前の静的スニペットでまとめて包まれています。この外側のスニペットを省くと、Latte は例外を投げます。

あとはプレゼンターに再描画のロジックを足すだけです。静的な包みを再描画すればよいのです。

public function handleLike(int $articleId): void
{
	$this->ratingService->saveLike($articleId, $this->user->id);
	if ($this->isAjax()) {
		$this->redrawControl('articlesContainer');
		// $this->redrawControl('article-' . $articleId); -- 不要です
	} else {
		$this->redirect('this');
	}
}

対応する handleUnlike() メソッドも同じように書き換えれば、AJAX が動きます。

ただし、この解には欠点があります。AJAX のリクエストをよく調べると、外からは効率よく見えても(特定の記事のスニペットひとつしか返しません)、サーバー側では実際にすべてのスニペットを描いていることが分かります。必要なスニペットをペイロードに入れ、ほかは捨てているのです(つまり無駄に取得して描いています)。

これを最適化するには、$articles のコレクションがテンプレートに渡される場所(たとえば renderDefault() メソッド)に手を入れる必要があります。シグナルの処理が render<Something> メソッドより前に起こることを利用します。

public function handleLike(int $articleId): void
{
	// ...
	if ($this->isAjax()) {
		// ...
		$this->template->articles = [
			$this->db->table('articles')->get($articleId),
		];
	} else {
		// ...
}

public function renderDefault(): void
{
	if (!isset($this->template->articles)) {
		$this->template->articles = $this->db->table('articles');
	}
}

これでシグナルの処理中は、記事のコレクション全体ではなく、該当する記事ひとつだけを含む配列がテンプレートに渡されます。描いてペイロードでブラウザに送ろうとしている、まさにその記事です。おかげで {foreach} ループは一度しか回らず、不要なスニペットは描かれません。

コンポーネントのやり方

まったく別のやり方として、動的スニペットをそもそも使わない方法があります。仕掛けは、ロジック全体を独立したコンポーネントにまとめてしまうことです。プレゼンターが評価を扱う代わりに、専用の LikeControl がそれを担当します。クラスは次のようになります(renderhandleUnlike などのメソッドも含みます)。

class LikeControl extends Nette\Application\UI\Control
{
	public function __construct(
		private Article $article,
	) {
	}

	public function handleLike(): void
	{
		$this->ratingService->saveLike($this->article->id, $this->presenter->user->id);
		if ($this->presenter->isAjax()) {
			$this->redrawControl();
		} else {
			$this->presenter->redirect('this');
		}
	}
}

コンポーネントのテンプレート:

{snippet}
	{if !$article->liked}
		<a n:href="like!" class=ajax>いいね</a>
	{else}
		<a n:href="unlike!" class=ajax>いいねを取り消す</a>
	{/if}
{/snippet}

当然ながらビューのテンプレートも変わり、プレゼンターにファクトリを足す必要があります。データベースから取得した記事ごとにこのコンポーネントのインスタンスを作るので、その生成を管理するために Multiplierクラスを使います。

protected function createComponentLikeControl()
{
	$articles = $this->db->table('articles');
	return new Nette\Application\UI\Multiplier(function (int $articleId) use ($articles) {
		return new LikeControl($articles[$articleId]);
	});
}

ビューのテンプレートは最小限にまで縮み、しかもスニペットが完全になくなります。

<article n:foreach="$articles as $article">
	<h2>{$article->title}</h2>
	<div class="content">{$article->content}</div>
	{control "likeControl-$article->id"}
</article>

これでほぼ完成です。アプリケーションは AJAX で動くようになります。ここでも最適化は必要で、Nette Database を使っているためにシグナルの処理が該当する記事だけでなくすべての記事を無駄に読み込んでしまいます。ただし利点として、描画されるのは特定のコンポーネントのインスタンスだけなので、無駄な描画は起こりません。