Dev Study
← アプリケーションの土台
レッスン 4 / 11·約 9 分·第7期 アプリケーションの土台

文字列を返しているだけ — テンプレートとJSXの正体

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

前回はハンドラが返したものが socket へ write されるまで。今回はその「返すもの」の中身です。HTML文字列か、描画の指示を表すオブジェクトか。どちらかしかありません。

まず結論

テンプレートもJSXも、値を受け取ってHTML文字列を組み立てるか、描画の指示を表すオブジェクトを組み立てるかのどちらかです。返しているのはこの2つしかありません。

エスケープは「値をHTMLの構文にしない」ための境界処理です。第41週『なぜ入力がコードになるのか』の「値が構文になれる位置」と同じ話です。

? 読む前に予測

`<h1>{name}</h1>` のようなテンプレートに `name = "<b>x</b>"` を渡すと、画面には何が出るでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

テンプレートは、値を受け取って文字列を返す関数

前回、ハンドラが返したものが socket へ書き出されるのを見ました。その「返すもの」はどう作られているのでしょうか。

テンプレートエンジンがやるのは2つだけです。固定の文字列(<h1> や </h1>)を並べ、その間に渡された値を差し込む。値を引数に取って文字列を返す関数です。

ただし差し込みに判断が要ります。値が <b>x</b> のとき、タグとして扱うのか、文字として扱うのか。差し込む先はHTMLで、< は要素の開始を意味します。そのまま置けば値の中の < が構文の記号として読まれます。これを避けて < を &lt; に置き換えるのがエスケープです。

第41週『なぜ入力がコードになるのか』で、危険の正体は特定の記号ではなく値が構文になれる位置にあることだと見ました。{{ }} や {} がその位置です。

具体実物

同じテンプレート・同じ値で、結果が2通り出る

同じテンプレートと同じ値で、autoescape の指定だけを変えた実機出力です。

$ venv/bin/python src/jinja_demo.py(jinja2 3.1.6 / Python 3.14.6 / macOS)コマンド出力
jinja2 3.1.6 python 3.14.6
autoescape=True: '<h1>&lt;script&gt;alert(1)&lt;/script&gt;</h1>'
autoescape=False: '<h1><script>alert(1)</script></h1>'
select_autoescape()('page.html') = True
select_autoescape()('page.txt') = False
select_autoescape()(None) = True
Template default: '<h1><script>alert(1)</script></h1>'

テンプレートは両方とも <h1>{{ v }}</h1>、値も同じ。違うのは autoescape が True か False かだけです。それだけで片方は &lt;script&gt; という文字に、もう片方は <script> というタグになります。

最後の行が重要です。Template default は jinja2.Template(...) を引数なしで作った結果で、エスケープされていません。Template Designer 文書はこう書きます。

The default configuration is no automatic escaping; for various reasons:

✓ ここまでの確認

Jinja2 で `jinja2.Template("<h1>{{ v }}</h1>")` と引数なしで書いたとき、`v` はエスケープされますか。

抽象原理

境界の置き方は、ひとつではない

エスケープを「危険な文字を置換する処理」と覚えると次で詰まります。同じ値でも、置かれる場所で危険な文字が違うからです。

テキスト中なら < が問題です。しかし href="..." の中なら問題は " で、値が x" onmouseover="alert(1) なら " が属性を閉じ、その先が新しい属性になります。

つまり境界処理は、変換の中身より先に「その値がどの文脈に置かれるか」を決める必要があります。Go の html/template はこれをエンジン側で判定します。もう一つの解き方は文字列を組み立てないことです。

同じ値から出発する2つの経路
経路A: 値文字列 <b>x</b>
経路B: 値同じ文字列
ここが分岐点
エスケープ(境界)< を &lt; に置き換える
createElement文字列に混ぜない
HTML文字列<h1>&lt;b&gt;x&lt;/b&gt;</h1>
React element(描画の指示)type / props を持つオブジェクト
受け取る側
パーサが読む&lt; は文字として読まれる
React が後で描画する文字列への連結が起きない
  • ・経路Aは「構文記号になれない形に変えてから混ぜる」
  • ・経路Bは「混ぜる工程を作らない」
  • ・React でも renderToString を通れば経路A

経路Aは値を変換してから混ぜ、経路Bは混ぜません。目的は同じ、手段が違う。

具体実物

createElement が返すのは文字列ではない

React で同じ値を扱った実機出力です。

$ node src/react_demo.mjs(react 19.3.0 / react-dom 19.3.0 / Node v24.21.0 / macOS)コマンド出力
react 19.3.0 react-dom 19.3.0 node v24.21.0
typeof el: object
JSON.stringify(el): {"type":"h1","key":null,"props":{"children":"<b>x</b>"},"_owner":null,"_store":{}}
renderToString(el): <h1>&lt;b&gt;x&lt;/b&gt;</h1>
renderToString(dangerouslySetInnerHTML): <div><b>x</b></div>
renderToString(attr): <a href="x&quot; onmouseover=&quot;alert(1)">link</a>

3行目です。createElement("h1", null, "<b>x</b>") が返したのはオブジェクトで、props.children は <b>x</b> のまま。公式の Returns 節もこうです。

createElement returns a React element object with a few properties: type: The type you have passed. props: The props you have passed except for ref and key.

&lt; に変わるのは4行目、renderToString で文字列にしたときです。エスケープはオブジェクトを作る時点ではなく文字列にする時点。

最後の行では href に渡した " が &quot; になり、属性が途中で閉じません。属性の中は別の境界。

✓ ここまでの確認

React の `createElement("h1", null, "<b>x</b>")` が返すものは何ですか。

⇄ 2つを見比べる
具体例 A

Go の html/template — 位置ごとに変換を差し替える

文書は同じ `{{.}}` が parse 時点で書き換えられると説明します。テキスト中なら `htmlescaper`、`href` 中なら `urlescaper | attrescaper`。文書はこれを contextual autoescaping と呼びます。

html/template 公式文書 Contexts 節(逐語・https://pkg.go.dev/html/template)設定ファイル
At parse time each {{.}} is overwritten to add escaping functions as necessary.
 
<a href="/search?q={{. | urlescaper | attrescaper}}">{{. | htmlescaper}}</a>
 
↑ 元は <a href="/search?q={{.}}">{{.}}</a> の1行。
同じ {{.}} が href の中と外とで別の関数に置き換わる。
具体例 B

React の createElement — 文字列にしない

`createElement` が返すのは type と props を持つオブジェクトで、値は props に置かれたままです。文字列への連結が起きないので、値が構文記号として読まれる場面が生じません。ただし `renderToString` の経路では変換が起きます。

実機(react 19.3.0 / Node v24.21.0)コマンド出力
JSON.stringify(el): {"type":"h1","key":null,"props":{"children":"<b>x</b>"},...}
↑ この時点では <b>x</b> のまま
 
renderToString(el): <h1>&lt;b&gt;x&lt;/b&gt;</h1>
↑ 文字列にする時点で変換された

位置ごとにエスケープ関数を差し替える方式と、文字列を作らない方式。共通する原理は何でしょうか。

「値がタグとして読まれる」のは値がどういう状態のときか、を考えてみてください。

この章のまとめ

  • テンプレートもJSXも、値を受け取って組み立てる関数。作るのはHTML文字列か描画の指示を表すオブジェクトか、どちらかしかない。
  • エスケープは「値をHTMLの構文にしない」境界処理。第41週『なぜ入力がコードになるのか』の値が構文になれる位置と同じ話。
  • Jinja2 の autoescape の既定は False。 「テンプレートエンジンは既定で安全」は誤り。拡張子による切り替えは select_autoescape() を渡したときの話。
  • Go の html/template は、値の位置に応じ parse 時点でエスケープ関数を差し替える(文書の語は contextual autoescaping)。
  • createElement が返すのはオブジェクト。公式は “does not render … or create any DOM elements” と書く。DOM を触る命令ではない。 &lt; になるのは renderToString で文字列にする時点。
  • 「React は文字列を作らない」はクライアント描画に限った話。公式の Returns は “An HTML string.”。
  • dangerouslySetInnerHTML や |safe は表示を直すが、外すのは境界。値の出所を辿ることが対処の中身。
  • JSX 仕様は Draft / August 4, 2022 で “without any defined semantics”。createElement への変換はトランスパイラの規則。

ここで作ったHTML文字列は、レスポンスの本文として送り出されます(クライアント描画の経路では、指示のオブジェクトがブラウザ側で描画に反映されます)。ただし本文を受け取った側が「これはHTMLだ」と知る手立ては本文にありません。次はそのラベルです。

手を動かすなら、フロントエンド の「フロントエンド V(フレームワークと状態管理)」で描画の側を、自動エスケープを扱う設問群で境界処理の側を確かめられます。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 4正解: 0

「React は文字列を作らない」という説明は、どこまで正確ですか。

用語集(3語)
contextual autoescaping
Go の `html/template` の語。値が置かれた位置に応じ、parse 時点でエスケープ関数を差し替える方式。
React element
`createElement` が返すオブジェクト。公式は DOM 要素を作らないと明記し、後で描画せよという React への指示の記述だと説明する。
dangerouslySetInnerHTML
生HTML文字列を渡して DOM ノードの `innerHTML` を上書きする React の prop。公式は信頼できない値で XSS を招くと明記。