🧑‍🏫

JavaScriptのタグ付きテンプレートリテラルを理解する

2021/09/04に公開

タグ付きテンプレートリテラルは何?

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