背景
HTMLを生成するためのテンプレートエンジンとしてHandlebars.jsがあります。
HandlebarsはMustacheの記法{{}}
を引き継いだJavaScriptのテンプレートエンジンです。
テンプレートを事前コンパイルすることで、Mustacheより高速に動作するため人気がありました。
個人的には{{}}
記法が、HTMLと混ざった時に見分けやすく好きです。
しかし、Webpack等のモジュールバンドラーを使った開発では辛い点が出てきました。
Handlebarsは部分テンプレートを定義できます。
定義した部分テンプレートは次のように登録します。
Handlebars.registerPartial('myPartial', '{{prefix}}');
一見問題ないように見えます。
しかし部分テンプレートはグローバルに登録されます。
このため、部分テンプレートの登録は、モジュールの依存関係とは異なる順序で行う必要があります。
例えば次の2つです。
- アプリケーション全体の初期化時
- (重複に目をつむるって)部分テンプレートを使用するすべてのモジュールで登録
しかし、どちらの手法をとっても、モジュールバンドラーを使った開発とは、あまりしっくり来るものではありません。
モジュールバンドラーを使うときは、
- 部分テンプレートを使わなくなったときはバンドルしないで欲しい
- 使うときは1回だけバンドルして欲しい
このために、部分テンプレートをモジュールにして、import
したモジュールで使えるようにしたいです。
目的
ES6から導入されたテンプレートリテラルがあります。
これを使ってHandlebarsのテンプレートを置き換えられます。
例えば、Handlebars.jsを使った次のテンプレートがあります。
import Handlebars from 'handlebars'
const template = Handlebars.compile(`
<div class="edit-value-and-pred-dialog__container">
<div class="edit-value-and-pred-dialog__input-box">
<label>Predicate:</label><br>
<input
class="value-and-pred-dialog--predicate"
value="type"
disabled="disabled">
</div>
<div class="edit-value-and-pred-dialog__input-box ui-front">
<label class="edit-value-and-pred-dialog--label">Value:<span>{{label}}</span></label><br>
<input
class="edit-value-and-pred-dialog--value promise-daialog__observable-element"
value="{{value}}">
</div>
</div>`)
テンプレートリテラルで置き換えると次のようになります。
function template(context) {
const { label, value } = context
return `
<div class="edit-value-and-pred-dialog__container">
<div class="edit-value-and-pred-dialog__input-box">
<label>Predicate:</label><br>
<input
class="edit-value-and-pred-dialog--predicate"
value="type"
disabled="disabled">
</div>
<div class="edit-value-and-pred-dialog__input-box ui-front">
<label class="edit-value-and-pred-dialog--label">Value:<span>${label}</span></label><br>
<input
class="edit-value-and-pred-dialog--value promise-daialog__observable-element"
value="${value}">
</div>
</div>`
}
テンプレートリテラルはES6の一部なので、当然モジュールにすることも可能です。
また、テンプレートリテラルから、他のテンプレートリテラルを呼び出すことも可能です。
これによって、モジュールバンドラーフレンドリーな開発が可能です。
置き換え手順
Handlebarsテンプレートはテンプレートリテラルで論理的に置き換え可能です。
すでに実装済みのHandlebarsテンプレートがある場合は、どのような手順で置き換えていけばいいでしょうか?
なるべく、安全な手順がうれしいです。
次の手順を考えました。
関数宣言する
Handlebars.compile
はcontext
オブジェクトを引数として、文字列を返却する関数を生成します。
関数のシグネチャーは変更しません。
次のような関数を定義します。
function template(context) {
return ``
}
関数名はHandlebars.compile
の戻り値を代入している変数名と同一とします。
これによりHandlebarsテンプレートの呼び出し側には修正が不要になります。
テンプレートの貼り付け
作成した関数の空テンプレートリテラル部分にHandlebars用のテンプレートをそのまま貼り付けます。
function template(context) {
return `
<div class="edit-value-and-pred-dialog__container">
<div class="edit-value-and-pred-dialog__input-box">
<label>Predicate:</label><br>
<input
class="value-and-pred-dialog--predicate"
value="type"
disabled="disabled">
</div>
<div class="edit-value-and-pred-dialog__input-box ui-front">
<label class="edit-value-and-pred-dialog--label">Value:<span>{{label}}</span></label><br>
<input
class="edit-value-and-pred-dialog--value promise-daialog__observable-element"
value="{{value}}">
</div>
</div>`
}
この時点で動作はしますが、返却する文字列のプレースホルダーには変数は適用されず、{{label}}
のようにそのまま出力されます。
プレースホルダーの置き換え
{{
を${
に}}
を}
に置き換えます。
エディターの置換機能を使うとよいでしょう。
function template(context) {
return `
<div class="edit-value-and-pred-dialog__container">
<div class="edit-value-and-pred-dialog__input-box">
<label>Predicate:</label><br>
<input
class="value-and-pred-dialog--predicate"
value="type"
disabled="disabled">
</div>
<div class="edit-value-and-pred-dialog__input-box ui-front">
<label class="edit-value-and-pred-dialog--label">Value:<span>${label}</span></label><br>
<input
class="edit-value-and-pred-dialog--value promise-daialog__observable-element"
value="${value}">
</div>
</div>`
}
この時点で、エディターにVSCodeなどを使っていれば、label
とvalue
が宣言されていないことがわかります。
Handlebarsのテンプレートで、if
, unless
, each
などを使っている場合、JavaScriptのシンタックスエラーになります。つまり「label
とvalue
が宣言されていないこと」がわかりません。
その場合は、後述のif
の置き換え、each
の置き換えを先に実行してください。
変数宣言
label
とvalue
はcontext
引数のプロパティに存在するはずです。
これを変数として宣言します。
分割代入を使うと楽に宣言できます。
const { label, value } = context
function template(context) {
const { label, value } = context
return `
<div class="edit-value-and-pred-dialog__container">
<div class="edit-value-and-pred-dialog__input-box">
<label>Predicate:</label><br>
<input
class="value-and-pred-dialog--predicate"
value="type"
disabled="disabled">
</div>
<div class="edit-value-and-pred-dialog__input-box ui-front">
<label class="edit-value-and-pred-dialog--label">Value:<span>${label}</span></label><br>
<input
class="edit-value-and-pred-dialog--value promise-daialog__observable-element"
value="${value}">
</div>
</div>`
}
変数の値がundefined
またはnull
になるとき
Handlebarsでは{{value}}
のvalue
がundefined
またはnull
のとき空文字に置き換わります。
テンプレートリテラルでは${value}
のvalue
がundefined
またはnull
のとき"undefined"
または"null"
に置き換わります。
このときの動作をあわせるために初期値として空文字を指定する必要があります。
${value || ''}
と書きましょう。
変数value
がundefined
またはnull
になるかわからないとき、
例えば、
- 仕様が不明
- 十分なテストで「変数
value
がundefined
またはnull
にならないこと」が担保できない
場合は常に${value || ''}
に置き換えても良いと思います。
応用編
HandlebarsにはBuilt-in Helperがあります。
これを使ってテンプレート中にロジックを書くことができます。
代表的なものにif/elseとeachがあります。
if/elseの置き換え
if/elseは条件演算子で置き換えます。
例えば次のHandlebarsテンプレートは
{{#if showDefault}}
<div class="create-attribute-definition-dialog__default">
<label>Default:</label><br>
<input value="{{defaultValue}}">
</div>
{{/if}}
テンプレートリテラルでは次のようになります。
${
showDefault
? `
<div class="create-attribute-definition-dialog__default">
<label>Default:</label><br>
<input value="${defaultValue}">
</div>
`
: ``
}
まず、{{#if showDefault}}
を${showDefault ? `` : ``}
に置き換えます。
次に、条件演算子の第二項にifの中身をコピペします。
${showDefault ? `
<div class="create-attribute-definition-dialog__default">
<label>Default:</label><br>
<input value="{{defaultValue}}">
</div>
` : ``}
プレースホルダーを置き換えます。
${showDefault ? `
<div class="create-attribute-definition-dialog__default">
<label>Default:</label><br>
<input value="${defaultValue}">
</div>
` : ``}
条件演算子が読みにくい場合は、関数として抽出すると良いでしょう。
${inputDefault(showDefault, defaultValue)}
// 中略
function inputDefault(showDefault, defaultValue) {
if(showDefault) {
return `
<div class="create-attribute-definition-dialog__default">
<label>Default:</label><br>
<input value="${defaultValue}">
</div>`
}
return ''
}
関数の抽出はVSCodeのJS Refactor Extensionを使うと、安全に実行できます。
eachの置き換え
Handlebarsのeachは配列を受け取り、繰り返し文字列に変換します。
例えば次のテンプレートがあります。
{{#each types}}
<tr class="type-pallet__row" style="background-color: {{color}};">
<td class="type-pallet__label" data-id="{{id}}">
<span title={{id}}>
{{id}}
</span>
</td>
<td class="type-pallet__short-label">
{{label}}
</td>
<td class="type-pallet__table-buttons">
<button
type="button"
class="type-pallet__table-button type-pallet__select-all
title="Select all the cases of this type."
data-id="{{id}}"
data-use-number="{{useNumber}}">
</button>
</td>
</tr>
{{/each}}
テンプレートリテラルで置き換えると、次のようになります。
${types
.map(
({ color, id, label, useNumber }) => `
<tr class="type-pallet__row" style="background-color: ${color};">
<td class="type-pallet__label" data-id="${id}">
<span title=${id}>
${id}
</span>
</td>
<td class="type-pallet__short-label">
${label || ''}
</td>
<td class="type-pallet__table-buttons">
<button
type="button"
class="type-pallet__table-button type-pallet__select-all"
title="Select all the cases of this type."
data-id="${id}"
data-use-number="${useNumber}">
</button>
</td>
</tr>
`
)
.join('\n')}
配列を繰り返して文字列に変換するのでmapを使います。
{{#each types}}
を${types.map(() => ``)}
に置き換えます。
テンプレートリテラル内で配列を返すと,
で結合されます。
事前に好みの文字で結合します。
ここではHTMLを変換するので改行に置き換えます。
${types.map(() => ``).join('\n')}
ブラウザ上でレンダリングされなければ空文字でも空白文字でも構いません。
テンプレートリテラル部にHandlebarsテンプレートをコピペします。
${types.map(() => `
<tr class="type-pallet__row" style="background-color: {{color}};">
<td class="type-pallet__label" data-id="{{id}}">
<span title={{id}}>
{{id}}
</span>
</td>
<td class="type-pallet__short-label">
{{label}}
</td>
<td class="type-pallet__table-buttons">
<button
type="button"
class="type-pallet__table-button type-pallet__select-all
title="Select all the cases of this type."
data-id="{{id}}"
data-use-number="{{useNumber}}">
</button>
</td>
</tr>
`).join('\n')}
プレースホルダーを置き換えます。
${types.map(() => `
<tr class="type-pallet__row" style="background-color: ${color};">
<td class="type-pallet__label" data-id="${id}">
<span title=${id}>
${id}
</span>
</td>
<td class="type-pallet__short-label">
${label}
</td>
<td class="type-pallet__table-buttons">
<button
type="button"
class="type-pallet__table-button type-pallet__select-all
title="Select all the cases of this type."
data-id="${id}"
data-use-number="${useNumber}">
</button>
</td>
</tr>
`).join('\n')}
変数宣言します。
Handlebarsのeachでは、繰り返し対象の配列の要素のプロパティを参照します。
mapにわたす関数の第一引数から取得できます。
ここでも分割代入を使うと便利です。
${types.map(({ color, id, label, useNumber }) => `
<tr class="type-pallet__row" style="background-color: ${color};">
<td class="type-pallet__label" data-id="${id}">
<span title=${id}>
${id}
</span>
</td>
<td class="type-pallet__short-label">
${label}
</td>
<td class="type-pallet__table-buttons">
<button
type="button"
class="type-pallet__table-button type-pallet__select-all
title="Select all the cases of this type."
data-id="${id}"
data-use-number="${useNumber}">
</button>
</td>
</tr>
`).join('\n')}
また関数を抽出すると見た目はスッキリします。
${types.map(toRow).join('\n')}
関数の抽出はVSCodeのJS Refactor Extensionを使うと、安全に実行できます。