AJAX とスニペット

機能がサーバーとブラウザに分かれることの多い現代のウェブアプリケーションにおいて、AJAX は欠かせないつなぎ役です。この領域で Nette Framework は何を提供するのでしょうか。

  • テンプレートの一部、いわゆるスニペットの送信
  • PHP と JavaScript のあいだの変数の受け渡し
  • AJAX リクエストをデバッグするための道具

AJAX リクエスト

AJAX のリクエストは、本質的には通常の HTTP リクエストと変わりません。特定のパラメータでプレゼンターが呼ばれます。リクエストにどう応えるかはプレゼンター次第で、JSON 形式のデータを返すことも、HTML コードの一部や XML のドキュメントを送ることもできます。

ブラウザ側では、fetch() 関数で AJAX のリクエストを始めます。

fetch(url, {
	headers: {'X-Requested-With': 'XMLHttpRequest'},
})
.then(response => response.json())
.then(payload => {
	// レスポンスを処理します
});

サーバー側では、HTTP リクエストを包むサービスの $httpRequest->isAjax() メソッドで AJAX のリクエストを見分けます。検出には X-Requested-With の HTTP ヘッダーを使うので、それを送ることが決定的に重要です。プレゼンターの中では $this->isAjax() メソッドが使えます。

JSON 形式でデータを送りたい場合は sendJson()メソッドを使います。このメソッドはプレゼンターの動作も終わらせます。

public function actionExport(): void
{
	$this->sendJson($this->model->getData());
}

AJAX 用に用意した特別なテンプレートで応えるつもりなら、次のようにできます。

public function handleClick($param): void
{
	if ($this->isAjax()) {
		$this->template->setFile('path/to/ajax.latte');
	}
	// ...
}

スニペット

サーバーとクライアントをつなぐために Nette が提供する最も強力な道具がスニペットです。これを使えば、ごくわずかな手間と数行のコードで、ふつうのアプリケーションを AJAX のアプリケーションに変えられます。Fifteen の例が全体の動きを示していて、そのコードは GitHubにあります。

スニペットを使うと、ページ全体を読み込み直す代わりに、一部だけを更新できます。速く効率がよいだけでなく、より快適な利用体験も提供します。スニペットは Ruby on Rails の Hotwire や Symfony UX Turbo を思い起こさせるかもしれません。面白いことに、Nette はスニペットをその 14 年前に導入していました。

スニペットはどう動くのでしょうか。ページが最初に読み込まれるとき(AJAX でないリクエスト)は、すべてのスニペットを含むページ全体が読み込まれます。ユーザーがページを操作すると(ボタンをクリックする、フォームを送信するなど)、ページ全体を読み込み直す代わりに AJAX のリクエストが始まります。プレゼンターのコードが処理を行い、どのスニペットを更新すべきかを決めます。Nette はそのスニペットを描き、スニペットの配列を含む JSON のペイロードとして送ります。ブラウザ側の処理コードが、受け取ったスニペットをページに戻します。こうして変わったスニペットのコードだけが転送されるので、ページの内容全体を転送する場合と比べて帯域を節約でき、読み込みも速くなります。redrawControl() でスニペットが無効化されなければ、Nette は AJAX のリクエストでもページ全体を返します。スニペットが送られるのは、何かが無効化されたときだけです。

Naja

ブラウザ側でスニペットを扱うには Naja ライブラリを使います。Node.js のパッケージとしてインストールしてください(Webpack、Rollup、Vite、Parcel などのバンドラーと使う場合)。

npm install naja

…あるいはページのテンプレートに直接書き込みます。

<script src="https://unpkg.com/naja@3/dist/Naja.min.js"></script>

まずライブラリを初期化する必要があります。

naja.initialize();

ふつうのリンク(シグナル)やフォームの送信を AJAX のリクエストに変えるには、対象のリンク、フォーム、ボタンに ajax クラスを付けるだけです。

<a n:href="go!" class="ajax">Go</a>

<form n:name="form" class="ajax">
    <input n:name="submit">
</form>

または

<form n:name="form">
    <input n:name="submit" class="ajax">
</form>

スニペットの再描画

Controlクラスのオブジェクト(プレゼンター自身も含みます)はすべて、再描画が必要な変更が起きたかどうかを覚えています。そのために redrawControl() メソッドを使います。

public function handleLogin(string $user): void
{
	// ログイン後に該当する部分を再描画する必要があります
	$this->redrawControl();
	// ...
}

Nette は、何を再描画すべきかをさらに細かく制御できます。このメソッドはスニペットの名前を引数に取れます。つまりテンプレートの部分の水準で無効化(すなわち再描画の強制)ができます。コンポーネント全体が無効化されると、その中のすべてのスニペットも再描画されます。

// 'header' スニペットを無効化します
$this->redrawControl('header');

第 2 パラメータ $redraw を使えば、保留中の無効化を取り消すこともできます。$this->redrawControl('header', redraw: false) を呼ぶと、そのスニペットは再描画不要と印を付けられます。完全なシグネチャは redrawControl(?string $snippet = null, bool $redraw = true) です。

Latte でのスニペット

Latte でスニペットを使うのはきわめて簡単です。テンプレートの一部をスニペットとして定義するには、{snippet}{/snippet} のタグで囲むだけです。

{snippet header}
	<h1>Hello ... </h1>
{/snippet}

スニペットは、生成された特別な id を持つ <div> 要素を HTML のページに作ります。スニペットが再描画されると、この要素の内容が更新されます。ですから、ページが最初に描かれるときには、たとえ最初は空でも、すべてのスニペットが描かれている必要があります。

n:属性を使えば、<div> 以外の要素でスニペットを作ることもできます。

<article n:snippet="header" class="foo bar">
	<h1>Hello ... </h1>
</article>

スニペット領域

スニペットの名前は式にもできます。

{foreach $items as $id => $item}
	<li n:snippet="item-{$id}">{$item}</li>
{/foreach}

これだけでは動かない途中の段階です。静的な {snippet}{snippetArea} の外で描かれた動的スニペットは、*Dynamic snippets are allowed only inside static snippet/snippetArea.* というメッセージとともに E_USER_WARNING を出します。これは以下で直します。

これは item-0item-1 といった複数のスニペットを作ります。動的スニペット(たとえば item-1)を直接無効化しても、何も再描画されません。理由は、スニペットが本当に抜粋として働き、それ自身だけが直接描かれるからです。しかしテンプレートの中には、技術的には item-1 という名前のスニペットは存在しません。それはスニペットを囲むコード、つまり foreach ループが実行されてはじめて生まれます。ですから、実行が必要なテンプレートの部分に {snippetArea} タグで印を付けます。

<ul n:snippetArea="itemsContainer">
	{foreach $items as $id => $item}
		<li n:snippet="item-{$id}">{$item}</li>
	{/foreach}
</ul>

そして個々のスニペットと親の領域の両方の再描画を要求します。

$this->redrawControl('itemsContainer');
$this->redrawControl('item-1');

あわせて、$items 配列には再描画すべき項目だけが入るようにするとよいでしょう。

{include} タグを使って、スニペットを含む別のテンプレートを主テンプレートに取り込む場合は、そのテンプレートの取り込みをもう一度 snippetArea で包み、スニペットとともに無効化する必要があります。

{snippetArea include}
	{include 'included.latte'}
{/snippetArea}
{* included.latte *}
{snippet item}
	...
{/snippet}
$this->redrawControl('include');
$this->redrawControl('item');

コンポーネントの中のスニペット

コンポーネントの中にもスニペットを作れ、Nette が自動的に再描画します。ただし制限があります。スニペットを再描画するために、Nette は render() メソッドをパラメータなしで呼びます。ですからテンプレートでパラメータを渡しても働きません。

OK
{control productGrid}

動きません:
{control productGrid $arg, $arg}
{control productGrid:paginator}

独自データの送信

スニペットとあわせて、任意の追加データをクライアントに送れます。payload オブジェクトに書き込むだけです。

public function actionDelete(int $id): void
{
	// ...
	if ($this->isAjax()) {
		$this->payload->message = 'Success';
	}
}

リダイレクト

AJAX のリクエスト中、redirect()redirectUrl() メソッドは HTTP のリダイレクトを送りません。代わりに、AJAX のレスポンスで送られるデータオブジェクトであるペイロードの payload.redirect プロパティに宛先の URL を書き込んで送ります。実際のリダイレクトはクライアント側のライブラリ(Naja)が行います。

パラメータの受け渡し

AJAX のリクエストでコンポーネントにパラメータを送るとき、それがシグナルのパラメータであれ永続パラメータであれ、コンポーネント名を含むグローバルな名前をリクエストで指定しなければなりません。getParameterId() メソッドが完全なパラメータ名を返します。

let url = new URL({link //foo!});
url.searchParams.set({$control->getParameterId('bar')}, bar);

fetch(url, {
	headers: {'X-Requested-With': 'XMLHttpRequest'},
})

そしてコンポーネント側の、対応するパラメータを持つ handle メソッドです。

public function handleFoo(int $bar): void
{
}

関連情報

バージョン: 4.x