JavaScriptのタグ付きテンプレートリテラルを理解する
タグ付きテンプレートリテラルは何?
ES2015(ES6)で導入されたテンプレートリテラルは、式を埋め込むことができる文字列リテラル。
以下のように、バッククォートで囲まれた文字列中の${...}
の中に埋め込まれた式は評価された文字列値で置き換えられる。
const name = "Alice";
const age = 20;
// Alice is 20 years old.
console.log(`${name} is ${age} years old.`);
${...}
の中には変数だけでなく、任意の式を埋め込めるので、以下のように演算や関数呼び出しを記述できる。
const name = "Alice";
const age = 20;
const toUpper = (s) => s.toUpperCase();
// ALICE will be 21 years old.
console.log(`${toUpper(name)} will be ${age + 1} years old.`);
タグ付きテンプレートリテラルは、テンプレートリテラルの振る舞いをカスタマイズするもので、例としてString.raw
がある。
const path = String.raw`C:¥temp`;
// C:¥temp
console.log(path);
String.raw
を付与したテンプレートリテラルでは、バックスラッシュ ¥
がエスケープ文字列と見なされないので、"C:¥¥temp"
と二重にバックスラッシュを書かずに済む。
仕組み
プレフィックスにStrign.raw
というタグを付けられたテンプレートリテラルは、String.raw()関数の呼び出しに解釈される。
タグ付きテンプレートリテラルは、タグ関数によってテンプレートリテラルの振る舞いをカスタマイズするものなのだ。
タグ関数は以下のシグネチャで記述し、文字列に限らず任意のオブジェクトを返却することができる。
const tagFunc = (fragments, ...values) => {
return { /* any object */};
};
簡単なサンプルで見てみよう。以下のsample
関数は、埋め込まれた式以外の文字列を大文字に変換する。
const name = "Alice";
const age = 20;
const sample = (fragments, ...values) => {
// ["She is ", ", ", " years old."]
console.log(fragments);
// ["Alice", 20]
console.log(values);
const length = values.length;
let s = "";
for (i=0; i<length; i++) {
s = s + fragments[i].toUpperCase() + values[i];
}
s = s + fragments[length].toUpperCase();
return s;
};
// "SHE IS Alice, 20 YEARS OLD."
console.log(sample`She is ${name}, ${age} years old.`);
引数values
には、テンプレートリテラルに埋め込まれた式の評価結果が配列で入り、それ以外の文字列の断片は引数fragments
に入る。(よって、fragments
の配列長はvalues
よりも1だけ長い)。
元のテンプレートリテラルの解析は処理系が行ってくれるので、タグ関数の実装者は渡される引数を使って、望む変換処理を記述すればよい。
活用例
様々なライブラリが、タグ付きテンプレートリテラルを用いたAPIを提供している。
例えば、graphql-tag
というライブラリを使うと、GraphQLのクエリをテンプレートリテラルで記述し、それをパースしてクエリを投げることができる。
const getData = (id) => gql`
query getCharactor {
person(personID: ${id}) {
name
gender
homeworld {
name
}
}
}
`
const {loading, error, data} = useQuery(getData(4));
このような使い方は、ホストのプログラミング言語(ここではJavaScript)の言語機能を用い、ライブラリの利用者にとって使いやすいAPIを提供するもので、DSL(ドメイン固有言語)の一例と言える(内部DSL)。
例をもう一つ。
テスティング・ライブラリJestのtest.each
を使ったパラメータ化テストのサンプルである。
describe('Story', () => {
test.each`
point | asignee | expected | desc
${3} | ${null} | ${false} | ${'未アサインは開始不可'}
${0} | ${'山田'} | ${false} | ${'ポイントを振ってない場合は開始不可'}
${0} | ${null} | ${false} | ${'ポイントもアサインも入ってない場合は開始不可'}
${3} | ${'山田'} | ${true} | ${'ポイントもアサインも入っている場合は開始可'}
`("開始可能か: $desc", ({point, asignee, expected}) => {
// Arrange
const sut = aStory({point, asignee});
// Act
const canBeStarted = sut.canBeStarted;
// Assert
expect(canBeStarted).toBe(expected);
});
});
test.each
でタグ付けされたテンプレートリテラルに続く括弧内の2つ目の引数({point, asignee, expected}) => {...}
がテストケースの本体である。(アロー関数で記述された関数オブジェクト)。
複数行で記述されたテンプレートリテラルは、見ての通りテーブル形式となっており
- 1行目がヘッダ
- 2行目以降の各行がテストデータ
を表す。ヘッダ行もテストデータ行も、個々のデータは縦棒|
で区切られている。
上記の例だとテストデータ行が3行あるので、テストケースは3回実行されることになる。例えば最初のテストケース(1つ目のデータ行)に対して
{ point: 3, asignee: null, expected: false, desc: "未アサインは開始不可" }
というオブジェクトが生成され、テストケースの関数に引数で渡される。
test.each
は非常に便利で強力な機能なのだが、実はこのような実装は意外ととシンプルに実現できるのだ。
test.eachっぽいことを実装してみる
以下のように、Jestのtest.each
と同じような形式でパラメータを定義し、引数で渡した関数を繰り返し実行できるようなeach
タグを実装してみる。
each`
name | age
${"Alice"} | ${20}
${"Bob"} | ${30}
`(({name, age}) => {
console.log(`${name} is ${age} years old.`);
});
サンプルのため細かなチェックなどは省いているが、以下のようにeach
タグ関数は20行に満たないコードで実装できる。
function each(fragments, ...values) {
const attrs = fragments[0].split('|').map(s => s.trim());
const numOfAttrs = attrs.length;
const array = [];
let index = 0;
while (index < values.length) {
const row = values.slice(index, index + numOfAttrs);
const obj = attrs.reduce((o, attr, i) => {
return {...o, [attr]: row[i]};
}, {});
array.push(obj);
index += numOfAttrs;
}
return (callback) => {
array.forEach((e) => callback(e));
};
}
以下、each
関数の実装内容の簡単な解説。
まず、each
タグの使用例は以下の構造となっている。
each`パラメータをテーブル形式で記述したテンプレートリテラル`(実行したい関数)`
つまり、each
タグ付きテンプレートが表すモノは、関数オブジェクトを一つ引数に受け取る、高次の関数である。これが、each
関数の最後の文。
return (callback) => {
array.forEach((e) => callback(e));
};
array
には、テンプレートリテラルから解析されたパラメータが配列で格納される。具体的に書くと以下のイメージ。
// array
[{
age: 20,
name: "Alice"
}, {
age: 30,
name: "Bob"
}]
あとは、each
関数に渡される引数の情報から、どのようにしてこの情報を作り出すかだ。
自力で解析が必要な部分は実は少なくて、ヘッダ行だけで済む。
fragments
引数には、テンプレートリテラルの埋め込み値以外の文字列断片が入っているのだった。よってfragments[0]
にはヘッダ行の文字列が入っている。これを|
で分割してトリムしよう。
const attrs = fragments[0].split('|').map(s => s.trim());
結果として得られるものは、(ヘッダ行に書かれた)属性名の配列である(attrs
)。
属性名の配列が得られるということは、属性の数もわかるということだ。一方、もう一つの引数values
にはテンプレートリテラルに埋め込まれた値が配列で入っているのだった。values
から属性数ずつ要素を取り出していけば、テーブルの1行分のデータを得ることができる。
const array = [];
let index = 0;
while (index < values.length) {
// valuesから属性数だけ要素を取り出す
const row = values.slice(index, index + numOfAttrs);
// 要素名: 値 でオブジェクトにReduce
const obj = attrs.reduce((o, attr, i) => {
return {...o, [attr]: row[i]};
}, {});
array.push(obj);
index += numOfAttrs;
}
(※)今回のサンプルコードは、簡単のため、利用者がミスなくテーブル形式で記述している前提としている。実用なコードとしては、fragments
の2つ目以降の要素も解析して必要なチェックを行うべきだろう。
補足
Jestを模したサンプル(each
タグ関数)の細かなJSテクニックの解説はブログ記事の方にまとた。
Discussion