🏷️

テンプレートリテラルよもやま話 & dedentライブラリ

2023/03/11に公開

本記事ではJavaScriptのテンプレートリテラルとタグ付きテンプレートの言語仕様について説明します。その後、筆者が作成したdedentライブラリ (@qnighy/dedent) を紹介し、このライブラリがどのようにテンプレートの機能を活用しているかを説明します。

いつものように言語仕様の細かい話がメインなので、そういう話が好きな人にはおすすめです。

テンプレートリテラル

テンプレートリテラルとは

テンプレートリテラル (template literal; see: MDN, ES) は ` で始まるリテラルで、文字列を返します。

const text1 = `foo bar`;

テンプレートリテラルはES5以前からある文字列リテラル (string literal; see: ES) と役割が似ていますが、いくつかの違いがあります。ここではまずテンプレートリテラルと文字列リテラルの違いを説明していきます。

置換

置換 (substitution) はテンプレートリテラルを最もよく特徴づける機能です。テンプレートリテラル中に ${ ... } で囲まれた式を埋め込むことができます。文字列補間 (string interpolation) と呼ばれることもあります。

const text1 = `foo ${bar()} baz`;

中の式は評価されたあとToStringで文字列にキャストされ、連結されます。つまり上の式は以下とおおよそ等価です。

const text1 = "foo " + bar() + "baz";

テンプレートリテラル中に ${ をそのまま書く必要がある場合は、 \${$\{ のようにエスケープする必要があります。

置換の構文的な特性

置換は } で終端されますが、この文字は他にもブロック終端やオブジェクトリテラルの終端などで使われています。この2種は後続するテキストの種類が異なります:

この曖昧性を解消するためには、字句解析器がスタックマシンのような状態管理を行う必要があります。ECMAScriptの仕様上は、スタックを陽に持つのではなくパーサー状態を参照する形で定義されています。関連する曖昧性として以下のような問題もあり、これらも同様の方法で定義されています。

  • / を除算演算子と解釈するか、正規表現リテラルの開始と解釈するか。
  • 自動セミコロン挿入の判定。

改行

文字列リテラルには改行を記述することができず、エスケープする必要があります。

ソース文字 文字列リテラル内 テンプレート内 備考
LF (\n) × ✔️
CR (\r) × ✔️ LFに変換される
CRLF (\r\n) × ✔️ LFに変換される
LS (\u2028) ✔️ ✔️ JSON superset
PS (\u2029) ✔️ ✔️ JSON superset
\ + 改行 ✔️ ✔️ 除去される (行継続)

テンプレートリテラルでは、改行はそのまま改行文字を表しているものとして扱われます。

// Error
const text = "foo
              bar";

// OK
const text = `foo
              bar`;

改行とは異なり、行継続 (line continuation) は文字列リテラル・テンプレートリテラルの双方で利用できます。行継続とは、行末にバックスラッシュ \ を置くことで、次の改行はなかったものとして扱う機能のことです。

// OK
const text = "foo\
              bar";

// OK
const text = `foo\
              bar`;

JavaScriptではLF, CR, CRLFの3種類の改行シーケンスのほかに、Unicodeの LS (行区切り) と PS (段落区切り) も改行として扱います。しかし、JSONとの互換性を保証するため、ES2019以降ではこれらを文字列リテラル内でも許可するようになっています

8進エスケープ

文字列リテラルには \017 などの8進エスケープが存在していましたが、これはレガシー機能のためstrict modeでは禁止されています。

function foo() {
  "use strict";
  // Error
  const text = "\112\123";
}

同様の理由で、8進エスケープはテンプレートリテラルでは使えません。 (これはstrict modeか否かによりません)

// Error
const text = `\112\123`;

ただし、 \0 の単独出現は許されていたり、 \8\9 なども一緒に禁止されていたりと、実際のルールはやや複雑です。これについては過去記事を参照してください。

タグ付きテンプレート

一方、タグ付きテンプレート (tagged template; see: MDN, ES) はテンプレートリテラルの手前にタグと呼ばれる式をくっつけたものです。これは文字列以外にも色々な用途で使えるようになっています。

//           vvv ここがtag
const node = ast`function ${name}() {
  return ${expr};
}`;

仕組みは簡単です。タグ部分は関数になっていて、その関数にテンプレートリテラルの連結前の状態がそのまま渡されるようになっています。たとえば、上の式はおおよそ以下と同等です。

const node = ast(["function ", "() {\n  return ", ";\n}"], name, expr);

タグ付きテンプレートの構文的特性

タグ付きテンプレートは見た目の上ではテンプレートリテラルが本体でタグが左から作用しているように見えますが、文法上はタグが本体でテンプレートリテラルが右から作用しているような形になっています。優先度上は以下の構文と同等です:

  • 関数呼び出し f(args)
  • コンストラクタ呼び出し new C(args)
    • 括弧を使わない呼び出し new C は除く
  • ドットによるメンバーアクセス obj.prop
    • obj.#prop も含む
  • 括弧によるメンバーアクセス obj[prop]

興味深いことに、ECMAScriptの仕様にはタグ付きテンプレートのoptional chainへの言及があります。これは以下のように自動セミコロン挿入に関する曖昧性があるケースを明示的にエラーにするためです。

// foo?.bar;`baz` ではなく、 foo?.bar`baz` と解釈させた上でエラーにしたい
foo?.bar
`baz`

タグ付きテンプレートとメソッド

タグ付きテンプレートのタグ部分がメンバーアクセスになっている場合は、メソッド呼び出しとして解釈されます。たとえば、

const node = parser.ast`1 + 2`;

は、おおよそ以下と等価です。

const node = parser.ast(["1 + 2"]);

つまり、 parser.ast の呼び出し時に this 引数には parser が入ることになります。

メソッド呼び出しがどのように仕様化されているかについては過去記事: JavaScriptの参照レコードとthisバインディング でより詳しく説明しています。

タグ付きテンプレートとエスケープ

タグ付きテンプレートはエスケープの扱いに特徴があります。それは以下の2点です。

  • エスケープ解釈前の生の情報 (raw) も取得できる。
  • 不正なエスケープがあってもエラーにならない。

タグ付きテンプレートのrawデータ

タグ付きテンプレートでは、エスケープ解釈後のテキストのほかにエスケープ解釈前のテキストも取得できるようになっています。エスケープ解釈前のテキストをraw, エスケープ解釈後のテキストをcookedと呼びます。お肉で喩えているようですね。

ここまでの説明ではわかりやすさのために省いていましたが、rawデータは以下のように渡されます

// const text = tag`foo = \`${foo}\`, bar = \`${bar}\``;
const text = tag(Object.assign(
  // エスケープ解釈後の情報 (cooked)
  ["foo = `", "`, bar = `", "`"],
  {
    // エスケープ解釈前の情報
    raw: ["foo = \\`", "\\`, bar = \\`", "\\`"]
  }
), foo, bar)

つまり、タグ付きテンプレートに渡されるテンプレートは単なる配列ではなく、配列にrawというプロパティーを足したものになっています。

不正なエスケープの扱い

テンプレートリテラルでは以下のエスケープは不正なエスケープとして構文エラーになります

  • 8進エスケープまたは \8, \9 (\0 の単独出現を除く)
  • 不完全な16進エスケープ
  • 不完全なUnicodeエスケープ、不正なコードポイントを指しているUnicodeエスケープ

しかし、タグ付きテンプレートの場合はこれらの不正なエスケープも構文エラーにはなりません。不正なエスケープが見つかった場合は以下のような結果になります。

  • rawの当該セグメントには生の情報が入る。
  • cookedの当該セグメントはundefinedが入る
// Error
const text1 = `\x\y\z`;
// エラーにはならず、 "\\x\\y\\z" が入る
const text2 = String.raw`\x\y\z`;

改行コードの扱い

rawにはエスケープ解釈前のデータが入るため、ほぼソースコード上の字面がそのまま表現されることになります。ただし例外として、ソースコード中の改行コードの正規化 (CRLFとCRをLFに変換する処理) は行われるため、rawにはCRが入ることはありません。

String.raw

rawプロパティーを使っている例として String.raw (MDN, ES) があります。

const re = String.raw`\d+`;

テンプレートオブジェクトの同一性

タグ付きテンプレートでもう一つ興味深いのが、テンプレートオブジェクトの同一性です。

テンプレートオブジェクト (タグの第一引数として渡されるオブジェクト) は式ごとにキャッシュされ使い回されます。以下の例を見てください。

// barは無視して、 `foo ${...}` に対応するテンプレートオブジェクトを返す
function obj(bar) {
  return (x => x)`foo ${bar}`;
}
console.log(obj(1) === obj(2));
// => true

上の例では obj という関数は2回呼び出されているため、中のタグ付きテンプレートは2回評価されています。ここでタグに指定されている x => x は第一引数を返す関数なので、 obj はテンプレートオブジェクトをそのまま返していることになります。このとき、タグ付きテンプレートは常に同じオブジェクトを返し、結果はtrueになります。

一方、構文木上で異なるノードに由来するテンプレートオブジェクトは異なる同一性を持ちます。

const obj1 = (x => x)`foo`;
const obj2 = (x => x)`foo`;
console.log(obj1 === obj2);
// => false

この性質はかなり特異的です。オブジェクトを生成するリテラルには他に正規表現リテラルがありますが、こちらは評価するごとに毎回異なるオブジェクトを返します。

function re() {
  return /foo/;
}
console.log(re() === re());
// => false

さて、このような設計は合理的なのでしょうか。TC39での議論までは追っていませんが、ここでの仕様から以下のように推測されます。

まず、このように設計する場合、テンプレートオブジェクトをうっかり破壊的に変更してしまうリスクを考える必要があります。この対策として、テンプレートオブジェクトは 作成時にfreezeされるようになっています

const obj = (x => x)`foo`;
obj.push("bar"); // Error

そのため、タグ付きテンプレートの挙動をより正確にあらわそうとするなら、以下のようになります。

// パース時にあらかじめ作っておく
const templateObject = Object.freeze(Object.assign(
  ["foo = `", "`, bar = `", "`"],
  {
    // エスケープ解釈前の情報
    raw: Object.freeze(["foo = \\`", "\\`, bar = \\`", "\\`"])
  }
));

{
  // ...

  // const text = tag`foo = \`${foo}\`, bar = \`${bar}\``;
  const text = tag(templateObject, foo, bar);
  
  // ...
}

逆にわざわざこのような設計にする利点は何かというと、それはタグ側での計算結果のキャッシュを可能にするためだと考えられます。たとえばノードの置換ができるパーサーをタグとして作る場合、毎回テンプレートをパースするよりも、初回だけパースしておいてあとはその計算結果を使い回すということができたほうが有益です。テンプレートオブジェクト自体はfreezeされているので情報を埋め込むことはできませんが、WeakMapを使うことでキャッシュを保持することは可能です。

@qnighy/dedent の紹介

ここまでで紹介したテンプレートリテラル・タグ付きテンプレートの仕様を踏まえて作ったのが以下のライブラリです。

https://www.npmjs.com/package/@qnighy/dedent

使い方はこんな感じで、複数行のテンプレートを書くとインデントを自動的に調整してくれます。詳しくはreadmeを読んでください。

import { dedent } from "@qnighy/dedent";

const mdDoc = dedent`\
  ## Hello!

  - Plants
    - Apples
    - Oranges
  - Animals
    - Dogs
    - Cats
`;

主な想定用途はテストデータの埋め込みですが、テストデータ以外でももちろん使えます。

特徴

似たようなライブラリはたくさんありますが、このライブラリは自分なりに1つの仕事をきちんとやるようなライブラリとして作りました。

このライブラリにおける1つの仕事とは「テンプレートのインデントを、ソースコード上の表現に基づいて除去する」ということです。

ソースコード上の表現を見る

「ソースコード上の表現に基づく」というのは具体的には、「エスケープ置換を空白とみなさない」ということを指しています。たとえば以下の例を考えます。

import { dedent } from "@qnighy/dedent";

const text = dedent`\
  \x20 line 1
  ${"  "}line 2
    line 3
`;

この例の場合、line 1とline 2のインデントは2、line 3のインデントは4と判定されます。このうちの最小をとって、各行からインデントが2ずつ除去されます。

このような判定ができるのは、タグ付きテンプレートでraw配列が入手できるからに他ありません。 @qnighy/dedent ではrawをもとにインデントを判定して、あとからcookedを自力で再生成しています。

このようなルールは以下のような意味で合理的だといえます。

  • インデントを除去するのはソースコード上でのテンプレートの見た目を整えるためなので、ソースコードに基づいて判定するのは理に適っている。
  • 置換の内容によってインデントの判定が変わらないため、挙動の予測がしやすく、最適化しやすい。
  • 除去すべきインデント量の明示をエスケープによって行うことができるようになる。

最後の「除去すべきインデント量の明示」は、全ての行がインデントされているようなテキストを表現しつつ、ソースコードの見た目のために追加のインデントが必要なケースに対応したいときに有益です。たとえば、

import { dedent } from "@qnighy/dedent";

const text = dedent`\
  \
    line 1
    line 2
`;

と書くと、これは

const text = `\
\
  line1
  line2
`;

と同等になり、これをさらに言い換えると

const text = "  line1\n  line2\n";

と同等ということになります。

インデントの例外

この「テンプレートのインデントを、ソースコード上の表現に基づいて除去する」というルールには例外があります。具体的には、テンプレートの置換内でのインデントは取得できないため、考慮されません。

ただしこれは以下のように式が適切にインデントされていない場合にのみ発生します。元々この節自体がほぼコーナーケースの話しかしていませんが、その中でも特に稀なユースケースだと考えられるため、特に問題はないかなと思います。

import { dedent } from "@qnighy/dedent";

const text = dedent`
  result = ${f(
100 // ここのソースコード上のインデントは0だが、dedentの計算上は考慮されない
  )}
`;

インデント量の推定

スペースとタブの混在は考慮しておらず、スペースまたはタブの連続した文字数としてインデント量を推定しています。

特別なルールとして、スペースまたはタブのみからなる行 (空行を含む) はインデント量を無限大として扱っています。これは、「空行をインデントしてもスペース・タブは実際には含めずに空行のままにする」という一般的な慣習をサポートするためのものです。全ての行がスペースとタブのみかなる場合は全てのインデントが取り除かれ、空行のみの結果になります。

import { dedent } from "@qnighy/dedent";

const text = dedent`\
  ↓下の行は空行

  ↑上の行は空行
`;

タグ付きテンプレート

タグ付きテンプレートも以下のようにサポートしています。

import { dedent } from "@qnighy/dedent";

// foo`...` として実行される
const text = dedent(foo)`\
  line1
  line2
`;

タグ付きテンプレートの場合は、テンプレートオブジェクトに相当するオブジェクトを自前で生成する必要があります。このために以下を実装しています。

cookedの生成

@qnighy/dedent はrawをもとにインデントを判定・除去します。しかし、タグ付きテンプレートはrawとcookedの両方を生成するので、タグ付きテンプレートの挙動を模倣する @qnighy/dedent でも同様にcookedを生成する必要があります。

そのため、 @qnighy/dedent ではECMAScriptの仕様にあわせたエスケープ解釈処理をJavaScriptレベルで再実装しています。

テンプレートオブジェクトの管理

タグに渡されるテンプレートオブジェクトはイミュータブルで、式ノードごとに1つだけ生成されるのでした。そのため以下の処理を実装しています。

  • 生成したテンプレートオブジェクトをfreezeする。
  • テンプレートオブジェクトを一度しか生成しないようにする。これはWeakMapを使って、 @qnighy/dedent に渡されるテンプレートオブジェクトが同一であれば生成されるテンプレートオブジェクトも同一になるようにしている。

エスケープの検査

dedent はタグ付きテンプレートですが、使われ方によってテンプレートリテラルまたはタグ付きテンプレートとして振る舞う必要があります。そのため、以下のようにエスケープの検査を実装し分けています。

  • テンプレートリテラルとして振る舞うときは、不正なエスケープに対してはSyntaxErrorを投げる。
  • タグ付きテンプレートとして振る舞うときは、不正なエスケープは無視してcookedの当該セグメントにundefinedを入れて渡す。

含めなかった機能

いくつかのライブラリは最初の行は最後の行の自動除去を行いますが、 @qnighy/dedent では最初の行や最後の行には手を加えないようにしています。

たとえば、最初の行を使って

import { dedent } from "@qnighy/dedent";

const text = dedent`  line 1
  line 2
  line 3
`;

と書いた場合、line1のみインデントが残ります。この仕様にした理由は以下の通りです。

  • 1行目を綺麗にフォーマッティングしたければ行継続を使うだけでよく、このために1行目に特別な処理をする必要はないため。 (これはdedent-tagというライブラリのアイデアを借りました)
  • 1行目のインデントはソースコード上の位置として考えると1列目から始まっているわけではなく、ソースコード上のインデントとはみなさないほうが一貫しているため。

また、最後の行にも余計な手を加えていないため、以下のように末尾改行の有無も書かれた通りの結果になります。

import { dedent } from "@qnighy/dedent";

// 末尾改行が含まれる ("line1\nline2\n")
const text1 = dedent`\
  line1
  line2
`;
// 末尾改行が含まれない ("line1\nline2")
const text1 = dedent`\
  line1
  line2`;

ここまで紹介してきた仕様のほとんどはコーナーケースの話ですが、この末尾改行の扱いに関しては実用上の利点があります。

dedentの最も重要なユースケースはテストの入出力データの埋め込みですが、出力に末尾改行が含まれるかどうかはテスト対象の実装によってまちまちです。たとえば、Babelのジェネレーターは末尾改行を出力しません。余計なことをせずに出力の完全一致でテストを書けるようにするには、末尾改行に関してユーザーがわかりやすく明示できるのが望ましいので、このような仕様になっています。

なお、上の例からもわかるように、テンプレートの最後が空白 + ` で終わっている場合は、最後の行は空行と同等として扱っています。

最適化

@qnighy/dedent の1番の想定用途はテストや開発用なので、基本的に最適化は必要ありません。

ただ、productionでの利用も一応想定して、最適化のためのプラグインとして @qnighy/babel-plugin-dedent@qnighy/swc-plugin-dedent も作ってみました。

これらはいずれも以下のような処理をします。

  1. @qnighy/dedent の呼び出しを探す。
  2. @qnighy/dedent の呼び出し箇所のテンプレートからインデントを事前評価して、 @qnighy/dedent を取り除いた等価な形に変形する。
  3. @qnighy/dedent のインポートが不要になっている場合は消す。

変換時のコーナーケースとして、以下のようなものがあります。

import { dedent } from "@qnighy/dedent";

const ast = dedent(parser.ast)`1 + 1`;

これを厳密に等価な実装に変換すると以下のようになります。

// parser.astのthis文脈をはがすために (0, _) を使っている
const ast = (0, parser.ast)`1 + 1`;

@qnighy/babel-plugin-dedent@qnighy/swc-plugin-dedent は念のためこの実装を行っていますが、これは生成されたタグ付きテンプレートをさらに別の変換 (babel-plugin-styled-componentsなど) にかける場合はかえって不都合かもしれず、悩ましいところです。

まとめ

  • テンプレートリテラルとタグ付きテンプレートについて説明した。
    • テンプレートリテラル: 文字列リテラルと似ているが、置換複数行が使える。
    • タグ付きテンプレート: テンプレートの置換・エスケープ展開前のセグメントを使ってユーザー定義の処理が行える式。
  • テンプレートからインデントを適切に除去する @qnighy/dedent を作った。
    • テンプレートの言語仕様を細かく考慮して、いい感じのライブラリになった。

Discussion