8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

株式会社ラグザイアAdvent Calendar 2023

Day 19

JavaScriptのテンプレートエンジンをつかって簡易テンプレートエンジンをつくる 前編

Last updated at Posted at 2023-12-18

テンプレートリテラル

JavaScriptにはテンプレートリテラルという機能があります。
次のように文字列の途中に「埋め込み式」(下の例では${name})をいれて任意の値を埋め込ます。

const name = 'john'
`Hello ,${name}!` // => 'Hello ,john!'

テンプレートエンジンでは途中で改行も入れられます。
これを使うとHTMLがレンダリングできます。

テンプレートエンジン 1号

次のイメージです。

function renderHTML(name, email, selfIntroduction) {
    return `
        <div class="profile">
          <div class="name">${name}</div>
          <div class="e-mail">${email}</div>
          <div class="self-introduction">${selfIntoroduction}</div>
        </div>
    `
}

element.innerHTML = renderHTML('ledsun', 'ledsun@example.com', 'I love MOON CHILD')

テンプレートエンジンが簡単にできました。
仮にテンプレートエンジン1号としましょう。

クロスサイト・スクリプティング 脆弱性

テンプレートエンジン 1号にはクロスサイト・スクリプティング(XSS)の脆弱性があります。
次のように呼び出すと、HTML中に任意のJavaScriptが埋め込めます。

renderHTML('ledsun', 'ledsun@example.com', '<script>alert("xss")</script>')

これを起点にして、「悪意のあるサイトにパスワードを送信するフォーム」をJavaScriptで埋め込まれるかもしれません。
特に、自己紹介の文字列に入力制限はなさそうです。制約なくHTMLタグを入力できそうです。

XSS対策 HTMLエスケープ

XSSの脆弱性の対応には、表示する文字列を文字実体参照に置き換える手法が知られています。
俗に「HTMLエスケープ」や「HTMLエンティティエンコード」などと呼ばれています。
例えば、<script>alert("xss")</script> に含まれる文字を、次のように置換します。

元の文字 文字実体参照
< &lt;
> &gt;
&quot;

HTML上意味のある文字を文字実体参照という形式に置き換えると、ブラウザはただの文字と解釈します。
HTMLタグやスクリプトとして動かなくなります。
同時にブラウザで表示した際の見た目は元のまま維持されます。

このような置換をしてくれるライブラリーは、すでに存在しています。

お好みのインターフェースのものを使うと良いと思います。
今回は lodash.escape を使う前提で話を進めます。

テンプレートエンジン 2号 手動エスケープ

テンプレートエンジン 1号にエスケープにを追加します。
lodash.escapeのエスケープ関数はescapeです。

import escape from 'lodash.escape'

function renderHTML(name, email, selfIntroduction) {
   return `
       <div class="profile">
         <div class="name">${escape(name)}</div>
         <div class="e-mail">${escape(email)}</div>
         <div class="self-introduction">${escape(selfIntoroduction)}</div>
       </div>
   `
}

element.innerHTML = renderHTML('ledsun', 'ledsun@example.com', 'I love MOON CHILD')

テンプレートエンジン 2号の完成です。
でも、毎回 escape() で値をくくるの、嫌ですよね。

タグ付きテンプレート

JavaScriptにはタグ付きテンプレートという機能があります。

タグを使用すると、テンプレートリテラルを関数で解析できます。タグ関数の最初の引数には、文字列リテラルの配列を含みます。残りの引数は式に関連付けられます。

何を言っているのかよくわからないですよね。

例えば前述のテンプレートリテラルの場合、次のような引数がタグ関数に渡ってきます。

  1. ['<div class="profile"><div class="name">', '</div><div class="e-mail">', '</div><div class="self-introduction">', '</div></div>']
  2. 'ledsun'
  3. 'ledsun@example.com'
  4. 'I love MOON CHILD'

第一引数は「埋め込み式」で区切られた元の文字列の配列です。
本当は空白が入っていますが、ここでは省略しています。
第二引数以降に「埋め込み式」の値が順番に入ります。

タグ関数で、引数を順番に繋ぐと、テンプレートリテラルと同じ動きになります。
次のようなになります。

function normal(strings) {
    // 第一引数を、最初と残りにわけます。
    const [first, ...rest] = strings
    
    let out = first
    // 第二引数以降の値を順番に、
    for (const value of [...arguments].slice(1)) {
                     // 元の文字列の間に挟みます。
        out = `${out}${ value }${rest.shift()}`  
    }
    
    return out
}

このタグ関数を改造すると、「埋め込み式」を自動的にエスケープできそうです。

テンプレートエンジン 3号 自動エスケープ

前述のタグ関数に「埋め込み式」の値のエスケープを追加します。

import escape from 'lodash.escape'

function autoEscape(strings) {
    const [first, ...rest] = strings
    
    let out = first
    for (const value of [...arguments].slice(1)) {
                     // 挟む前にエスケープします。
        out = `${out}${ escape(value) }${rest.shift()}` 
    }
    
    return out
}

第一引数の文字列の配列の間に、エスケープした第二引数以降の文字列を入れます。
既存のライブラリーも存在します。

改良したタグ関数 autoEscape をテンプレートエンジンに組み込みます。

function renderHTML(name, email, selfIntroduction) {
    return autoEscape`
        <div class="profile">
          <div class="name">${name}</div>
          <div class="e-mail">${email}</div>
          <div class="self-introduction">${selfIntoroduction}</div>
        </div>
    `
}

element.innerHTML = renderHTML('ledsun', 'ledsun@example.com', 'I love MOON CHILD')

これで安全なHTML文字列が生成できます。
テンプレートエンジン 3号ができました。

部分テンプレート

HTMLにはリストなどの繰り返し要素があるものがあります。
たとえば次のようなものです。

<ul>
    <li>りんご</li>
    <li>みかん</li>
    <li>なし</li>
</ul>

こういうHTMLもレンダリングしたいですよね?
テンプレートエンジン 1号で書くとこうです。

function renderHTML(items) {
    return `
        <ul>
          ${ items.map(item => `<li>${item}</li>`) }
        </ul>
    `
}

element.innerHTML = renderHTML(['リンゴ', 'みかん', 'なし'])

「埋め込み式」は式なので、ループ処理も書けます。
エスケープしたいので、テンプレートエンジン 3号を使いましょう。

function renderHTML(items) {
    return autoEscape`
        <ul>
          ${ items.map(item => `<li>${item}</li>`) }
        </ul>
    `
}

element.innerHTML = renderHTML(['リンゴ', 'みかん', 'なし'])

すると残念な表示になります。
<li>りんご</li><li>みかん</li><li>なし</li> が、そのまま表示されます。
なぜでしょうか?

items.map(item => `<li>${item}</li>`) で作った文字列 <li>りんご</li><li>みかん</li><li>なし</li> がエスケープされます。HTMLタグとして機能しなくなります。

代わりにテンプレートエンジン 2号を使います。
次のように書けば、安全な期待されたHTMLが手に入ります

function renderHTML(items) {
    return `
        <ul>
          ${ items.map(item => `<li>${escape(item)}</li>`) }
        </ul>
    `
}

element.innerHTML = renderHTML(['リンゴ', 'みかん', 'なし'])

条件を整理しましょう。

  • 「埋め込み式」が値を返すときはテンプレートエンジン 3号を使います。
  • 「埋め込み式」がHTMLを返すときはテンプレートエンジン 2号を使います。

上記のように、「埋め込み式」がHTMLをレンダリングしている事がぱっと見でわかれば上手く使い分けできそうです。

次のようにリストをレンダリングする関数をわけたらどうでしょうか?

// 部分テンプレート
function renderList(item) {
    return `<li>${escape(item)}</li>`
}
function renderHTML(items) {
    return `
        <ul>
          ${ items.map(renderList) }
        </ul>
    `
}

element.innerHTML = renderHTML(['リンゴ', 'みかん', 'なし'])

renderList関数の実装を見ないと、テンプレートエンジン 3号が使えるかわかりません。

また、つぎのような状況も考えられます。

  • レンダリングしてみたら、途中でHTMLを返す「埋め込み式」がみつかり、つかえないことがわかる
  • 部分テンプレートの動作が変わりHTMLタグを返すようになったら使えなくなる

せっかく作ったテンプレートエンジン 3号ですが、残念ながら、あんまり使いやすくなさそうです。
「テンプレートリテラルを使えば簡易テンプレートエンジンを作れる」は成り立たないのでしょうか?

前編のまとめ

JavaScritのタグ付きテンプレートをつかって、自動的にHTMLエスケープをするテンプレートエンジン 3号ができました。しかし、あんまり使いやすくありません。

前編はここまでです。
何か工夫をしたら、簡易テンプレートエンジンをもう少し使いやすくできるのでしょうか?

参考

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?