12k
All articles

タグ付きテンプレートリテラル:JavaScriptでミニDSLを構築する

JavaScriptのタグ付きテンプレートリテラルを解説。cookedとraw、WeakMapキャッシュ、安全なHTML、SQL、ミニDSL例まで。

OpenReplay Team
OpenReplay Team
タグ付きテンプレートリテラル:JavaScriptでミニDSLを構築する

タグ付きテンプレートリテラルとは、テンプレートリテラルから引数を受け取る関数呼び出しです。ランタイムはリテラルを各${...}の境界で分割し、静的な文字列セグメントと評価済みの補間値を指定した関数に渡し、その関数が任意の値を返せるようにします。最後の点こそが本質です。styled.div`...`gql`...`と書くとき、テンプレートの各部分を受け取り、文字列ではないもの——Reactコンポーネントやパース済みのGraphQLドキュメント——を返す関数を呼び出しています。本記事では呼び出しの仕組みを詳しく説明した上で、値ベースのインジェクションを構造的に防ぐパラメータ化SQLタグを含む、4つの実用的なミニDSLを構築します。

この構文を使ったことがあるなら、以下でその仕組みと独自実装の方法を解説します。

重要なポイント

  • タグ付きテンプレート tag`...`tag(strings, ...values) の糖衣構文です。strings は静的セグメントの配列、...values は左から右へ評価された補間式です。
  • strings 配列は常に values より要素数が1つ多くなります——strings.length === values.length + 1——テンプレートリテラルは常に文字列セグメントで始まり終わるからです(セグメントが空文字列であっても同様です)。
  • タグ関数は文字列を返す必要はありません。styled-components はReactコンポーネントを返し、graphql-tagDocumentNode ASTを返し、lit-htmlTemplateResult を返します。
  • 特定のタグ付きテンプレートが評価されるたびに同じフリーズされたテンプレートオブジェクトが再利用されるため、strings 配列をキーとする WeakMap を使ってそのリテラルのパース結果をキャッシュできます。
  • 安全なSQLタグは、補間値をクエリテキストに連結するのではなくクエリパラメータに変換することで、値ベースのSQLインジェクションを防ぎます——ただし、常に値をパラメータ化し、生のSQLや識別子を直接スプライスしないことが前提です。

呼び出しシグネチャ:tag`...`tag(strings, ...values) に展開される

タグ付きテンプレートは通常の関数呼び出しの糖衣構文です。tag`Hi, ${name}!` という式は、静的セグメントを第1引数、各補間値を後続の引数として tag を呼び出すことと等価です。以下の2つの形式は、呼び出しシグネチャを説明する上でほぼ等価です。

const name = "Zach";

// タグ付きテンプレートリテラル
tag`Hi, ${name}!`;

// 展開後の等価な形式
tag(["Hi, ", "!"], name);

第1パラメータは各${...}で分割された静的文字列セグメントの配列です。残りの引数は補間値で、レストパラメータで受け取ります。(MDNのタグ付きテンプレートのドキュメントにこの呼び出し形式が記載されています。)

function tag(strings, ...values) {
  console.log(strings); // ["Hi, ", "!"]
  console.log(values);  // ["Zach"]
}

const name = "Zach";
tag`Hi, ${name}!`;

補間値は呼び出し時に左から右へ評価され、実際の値として渡されます——文字列化はされません。タグは関数オブジェクト、配列、オブジェクトをそのまま受け取ります。

function tag(strings, ...values) {
  return values;
}

tag`${() => 42} and ${[1, 2, 3]}`;
// [ [Function], [1, 2, 3] ] — 文字列ではなく実際の値

これが通常のテンプレートリテラルとの最初の実質的な違いです。通常のテンプレートリテラルは補間値を常に文字列に変換し、常に文字列を返します。タグは変換が行われる前に各部分を受け取ります。

strings.length === values.length + 1 という不変条件

strings 配列は常に values 配列より要素数が1つ多くなります——strings.length === values.length + 1——テンプレートリテラルは常に文字列セグメントで始まり終わるからです(セグメントが空文字列であっても同様です)。補間が3つあるリテラルには文字列セグメントが4つあり、${x} で始まるリテラルは単純に最初のセグメントが空文字列になります。

function tag(strings, ...values) {
  console.log(strings.length, values.length);
}

tag`${1}${2}`;
// strings: ["", "", ""]  →  3
// values:  [1, 2]        →  2

この不変条件があるため、strings をreduceするのがセグメントと値を交互に並べる最もクリーンな方法です。文字列を順に処理し、最後を除く各文字列の後に values[i] を追加します。

Cooked vs. Raw:その違いが重要になる場面

strings 配列はcooked(調理済み)の解釈を保持します。\n は改行、\t はタブになります。strings.raw プロパティはraw(生の)ソース文字を保持します。\n はバックスラッシュと n の2文字のシーケンスです。この違いは、DSLが構造的にバックスラッシュを使うテキスト——正規表現パターン、LaTeXマークアップ、Windowsのファイルパス——を処理する場合に重要になります。

function showBoth(strings) {
  console.log(strings[0]);     // cooked: 実際の改行文字
  console.log(strings.raw[0]); // raw: \ と n の文字
}

showBoth`line\nbreak`;
// cooked → "line"、次の行に "break"
// raw    → "line\nbreak"

JavaScriptにはこの目的のための組み込みタグが1つあります:String.raw です。リテラルのraw解釈を返すため、エスケープシーケンスがリテラル文字として保持されます。(MDNの String.raw リファレンスでは、これが唯一の組み込みテンプレートタグとして記載されています。リテラルにエスケープシーケンスが含まれない場合のみ、恒等タグのように振る舞います。)

const winPath = String.raw`C:\Users\dev\project`;
console.log(winPath); // "C:\Users\dev\project" — バックスラッシュが保持される

const pattern = new RegExp(String.raw`\d{3}-\d{4}`);
pattern.test("555-1234"); // true — 二重エスケープ不要

String.raw を使わない場合、正規表現のソースはcookedされても正しく動作するよう \\d{3}-\\d{4} と書く必要があります。自作のタグでも strings の代わりに strings.raw を読むことで同じ動作を実現できます。

タグ関数は何でも返せる

タグ関数はただの関数であり、その戻り値は作者が決めるものです。これこそが、タグ付きテンプレートを単なる文字列フォーマットの道具からドメイン固有言語を構築するためのツールへと変える唯一の洞察です。静的セグメントと値は素材であり、タグはそこから何を構築するかを決めます。styled-components はReactコンポーネントを返し、graphql-tagDocumentNode ASTを返し、lit-htmlTemplateResult を返します。

以下の4つのDSLは、文字列出力からパラメータ化されたクエリオブジェクトへと段階的に発展し、通常のテンプレートリテラルでは実現できない戻り値の型を示します。

HTMLエスケープタグ(XSSへの対策)

HTMLエスケープタグは、補間された値がDOMに渡る前にHTMLエンティティエンコーディングでラップします。これにより、ユーザー入力がテキストコンテキストから抜け出せなくなります。防ぐべきバグ:信頼されていない入力をマークアップに直接補間することは、保存型または反射型XSSの攻撃ベクタになります——値にペイロードが含まれていると、文字列がDOMに挿入されたときに実行されます。

const userInput = '<img src="x" onerror="alert(1)">';  
const markup = `<div>${userInput}</div>`;  
// element.innerHTML = markup → onerrorハンドラが実行される

safeHtml タグは各補間値を受け取り、出力に含まれる前にエスケープします。一方、静的セグメント——ユーザーではなく開発者が書いたもの——はそのまま残します。

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function safeHtml(strings, ...values) {
  return strings.reduce((acc, segment, i) => {
    const value = i < values.length ? escapeHtml(values[i]) : "";
    return acc + segment + value;
  }, "");
}

const userInput = '<img src="x" onerror="alert(1)">';  
const markup = safeHtml`<div>${userInput}</div>`;  
// "<div>&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;</div>" — 無害化済み

この非対称性こそが重要です。静的セグメントは信頼できる(あなたが書いた)、補間値は信頼できない(ユーザー入力を含む可能性がある)、そしてタグはその区別を強制する唯一の場所です。

安全なSQLタグ(構造的なパラメータ化クエリ)

安全なSQLタグは、補間値をSQLテキストに連結するのではなくクエリパラメータに変換することで、値ベースのSQLインジェクションを防ぎます。文字列連結によるクエリ構築は典型的なSQLインジェクションのバグです——値をクエリテキストに連結すると、その値がクエリの構造を変えられてしまいます。

// 危険 — 使用しないこと
const id = "1 OR 1=1";
const query = `SELECT * FROM users WHERE id = ${id}`;
// "SELECT * FROM users WHERE id = 1 OR 1=1" → 全行が返される

安全なSQLタグは、補間値を常にパラメータ化し、生のSQLや識別子をクエリにスプライスしないことを前提に、値ベースのSQLインジェクションを防ぎます。タグは { text, values } オブジェクトを返します。text フィールドはプレースホルダー($1$2、…)を持ち、values は実際のデータを保持し、クエリ構造とは分離されます。

function sql(strings, ...values) {
  const text = strings.reduce((acc, segment, i) => {
    return acc + segment + (i < values.length ? `$${i + 1}` : "");
  }, "");
  return { text, values };
}

const id = "1 OR 1=1";
const query = sql`SELECT * FROM users WHERE id = ${id}`;
// {
//   text: "SELECT * FROM users WHERE id = $1",
//   values: ["1 OR 1=1"]
// }

この { text, values } の形式は、node-postgresがパラメータ化クエリとして受け付ける形式と完全に一致します。client.query(query) はクエリテキストとパラメータ値を別々にPostgreSQLに渡し、データベースはパラメータ値をSQL構文として解釈しません。悪意ある文字列 "1 OR 1=1" は単一のパラメータ値として扱われ、SQL構文としては扱われないため、クエリ構造の一部になることはありません。

これが文字列連結版より安全な理由は構造的なものです。タグを使う場合、補間値は常にパラメータになるしかありません。タグ付きの形式では、値をクエリテキストにスプライスする構文は存在しません。この安全性はに対して成立します。動的なテーブル名やカラム名(識別子)には適用されず、補間ではなく別途バリデーションが必要です。

WeakMapによる同一性キャッシュを使ったi18nタグ

ECMAScript仕様で定義されているように、特定のタグ付きテンプレートが評価されるたびに同じフリーズされたテンプレートオブジェクトが再利用されます。そのため、strings 配列をキーとする WeakMap を使ってそのリテラルのパース結果をキャッシュできます。一度パースすれば、以降の評価ではキャッシュ済みの結果を再利用できます。これが、テンプレートベースのライブラリがレンダリングのたびに同じテンプレートを再パースしないようにするための同一性トリックです。

この再利用は観察可能です。同じタグ付きテンプレートへの呼び出し間で、同じ strings 配列が参照として同一です。

let first, second;
function capture(strings) {
  return strings;
}

function render() {
  return capture`hello ${1} world`;
}

first = render();
second = render();
console.log(first === second); // true — 同じフリーズされた配列オブジェクト

これはブラウザのコンソールやNode.js REPLで確認できます。実装固有の挙動ではなく、仕様で定義されたテンプレートオブジェクトの再利用です。(ECMAScript仕様のテンプレート評価セマンティクスでは、特定のテンプレートリテラルに対して同じテンプレートオブジェクトが返されることが定義されています。)

strings をキーとする WeakMap でこれをキャッシュに活用できます。以下のi18nタグは、リテラルごとにフォーマット文字列を一度だけ事前計算して再利用します。

const cache = new WeakMap();

function t(strings, ...values) {
  let key = cache.get(strings);
  if (key === undefined) {
    // 一意のリテラルごとに一度だけ実行されるコストの高い処理
    key = strings.join("{}");
    cache.set(strings, key);
  }
  return lookupTranslation(key, values);
}

WeakMap はキーを弱参照で保持するため、テンプレートオブジェクトへの参照がなくなるとキャッシュエントリはGCの対象になります。手動での削除も文字列キーの衝突も不要です。

CSS-in-JSスタイルタグ

スタイルタグは、styled-components や Emotion がCSSリテラルをコンポーネントシステムで利用できる形式に変換するのと同様に、コンシューマーが別の場所で組み合わせるプレーンオブジェクトを返せます。文字列を返す代わりに、タグはデクラレーションをキーと値のオブジェクトにパースします。

function css(strings, ...values) {
  const cssText = strings.reduce(
    (acc, segment, i) => acc + segment + (values[i] ?? ""),
    ""
  );
  return Object.fromEntries(
    cssText
      .split(";")
      .map((d) => d.trim())
      .filter(Boolean)
      .map((d) => {
        const [prop, val] = d.split(":").map((s) => s.trim());
        return [prop, val];
      })
  );
}

const styles = css`
  display: flex;
  align-items: center;
`;
// { display: "flex", "align-items": "center" }

これは実際のライブラリと同じ構造です。styled-components では styled.div`...` がReactコンポーネントを返します。styled-componentsのドキュメントに完全なAPIが記載されています。仕組みは上記のシンプルな例と同じです——タグ関数がCSSリテラルを受け取り、フレームワークが利用できる非文字列の値を返します。

タグ付きテンプレートで構築された実世界のライブラリ

以下のライブラリはすべて、タグ付きテンプレートをパブリックAPIとして使用しています。それぞれが異なる言語をタグ付けし、異なる型を返します。これが、戻り値が作者の選択であることを最も明確に示しています。

ライブラリタグ付けするもの返すものユースケース
styled-componentsCSSReactコンポーネントコンポーネントスコープのCSS-in-JS
EmotionCSSクラス名 / スタイル付きコンポーネントオブジェクトとタグ付きテンプレートAPIを持つCSS-in-JS
lit-htmlHTMLTemplateResult効率的なDOMレンダリング
graphql-tagGraphQLDocumentNode ASTGraphQLクライアント向けのクエリパース
sql-template-stringsSQLプリペアドステートメントオブジェクトパラメータ化SQL(注:現在は積極的なメンテナンスなし)
common-tagsテキストフォーマット済み文字列ES2015+向けの再利用可能なテンプレートリテラルタグ関数

タグ関数のTypeScriptシグネチャ

タグ関数のTypeScriptシグネチャは function tag(strings: TemplateStringsArray, ...values: unknown[]): T です。TemplateStringsArrayReadonlyArray<string>readonly raw: readonly string[] メンバーを追加した型です。この型はTypeScriptの標準ライブラリに組み込まれており、インポートは不要です。

// タグ関数の汎用的な形式
type Tag<T> = (
  strings: TemplateStringsArray,
  ...values: unknown[]
) => T;

// 安全なSQLタグへの適用:
interface SqlQuery {
  text: string;
  values: unknown[];
}

function sql(strings: TemplateStringsArray, ...values: unknown[]): SqlQuery {
  const text = strings.reduce(
    (acc, segment, i) => acc + segment + (i < values.length ? `$${i + 1}` : ""),
    ""
  );
  return { text, values };
}

TemplateStringsArraylib.es5.d.ts で定義されており、TypeScriptの初期リリースから提供されています。(TypeScript 1.5のリリースノートでは、タグ付きテンプレートの例として TemplateStringsArray が使用されています。)なお、ここでの readonly モデリングは型レベルの記述です。テンプレートオブジェクトのランタイムフリーズはECMAScriptの別の動作であり、TypeScriptの型が引き起こすものではありません。

タグ付きテンプレートを使うべきでない場合

DSLが本格的な文法——演算子の優先順位、先読み、再帰構造——を必要とする場合、タグ付きテンプレートは適切なツールではありません。タグ関数が受け取るのは文字列セグメントと評価済みの値のフラットなリストであり、パースツリーではありません。そのため、左から右への補間評価以上のものが必要な場合は、パーサーコンビネータやPEG文法を使うべきです。本格的な言語が必要になったら、nearleyPeggy などの専用パーサーを選択してください。

タグ付きテンプレートを使い続けるべきでないことを示す3つの具体的なサイン:

  1. 文法が必要な場合。 優先順位、ネストされたスコープ、バックトラッキングは、文字列と値のフラットな分割では表現できません。パーサーを使ってください。
  2. 左から右への評価順序以外が必要な場合。 補間値はタグが実行される前に、呼び出し時に左から右へ評価されます。タグはそれらを遅延させたり並べ替えたりできません——結果だけを受け取ります。
  3. ホットループ内での使用。 補間式は同じテンプレートオブジェクトが再利用されても毎回の呼び出しで評価されます。そのため、タイトなレンダリングループ内でコストの高い補間を行うと、毎回そのコストが発生します。strings 配列は同一性でキャッシュされますが、はキャッシュされません。

単純な補間と本格的な言語の間にあるもの——エスケープ、パラメータ化、キャッシュのキー付け、スタイル付きコンポーネントの構築——については、タグ付きテンプレートがちょうど適切なサイズのツールです。

まずは安全なSQLタグから始めてみてください。一度で読み切れるほど小さく、node-postgresと互換性のある { text, values } オブジェクトを返し、タグ付きテンプレートを使う価値のある特性を示しています。${...} の境界を越える値を、文字列連結では決して保証できない安全な役割に強制できるという特性です。

よくある質問

タグ付きテンプレートでstrings.lengthが常にvalues.lengthより1つ多い理由は?

テンプレートリテラルは常に文字列セグメントで始まり終わります。そのセグメントが空であっても同様です。したがって、静的セグメントはすべての補間を両側から挟む形になります。n個の補間があればn+1個の文字列セグメントがあり、strings.length === values.length + 1という不変条件が成立します。tag`${x}`のようなリテラルは補間の前に空文字列、後に空文字列を持つため、2つの補間は3つの文字列セグメントを生成します。

タグ関数におけるcookedとrawの文字列の違いは?

stringsとしてアクセスされるcooked配列はエスケープシーケンスを解釈します。バックスラッシュ+nは実際の改行になり、バックスラッシュ+tはタブになります。strings.rawとしてアクセスされるraw配列はリテラルのソース文字を保持するため、バックスラッシュ+nはバックスラッシュとnの2文字のままです。この違いは、DSLが構造的にバックスラッシュを使うテキスト——正規表現パターン、LaTeXマークアップ、Windowsのファイルパスなど——を処理する場合に重要です。cookingによって入力が破損してしまうためです。

タグ付きテンプレートリテラルは追加設定なしでTypeScriptで動作しますか?

はい。タグ関数のシグネチャはfunction tag(strings: TemplateStringsArray, ...values: unknown[]): Tです。TemplateStringsArrayはTypeScriptの標準ライブラリファイルlib.es5.d.tsで定義されており、インポートは不要です。readonlyなstringのReadonlyArrayに、追加のreadonlyなrawメンバーを持つ型として定義されています。readonlyモデリングは型レベルのみです。テンプレートオブジェクトのランタイムフリーズはECMAScriptの別の動作であり、TypeScriptの型が強制するものではありません。

タグ付きテンプレートのタグ関数は文字列以外のものを返せますか?

はい。タグ関数は通常の関数であり、作者が選んだ任意の値を返せます。styled-componentsはReactコンポーネントを返し、graphql-tagはDocumentNode ASTを返し、lit-htmlはTemplateResultを返します。安全なSQLタグはnode-postgresと互換性のあるtextとvaluesオブジェクトを返せます。通常のテンプレートリテラルは常に文字列に変換されますが、タグは変換が行われる前にセグメントと値を受け取るため、戻り値の型は完全に実装次第です。

We use cookies to improve your experience. By using our site, you accept cookies.