JavaScriptでHTMLを動的に生成するときの書き方の実践例
2019年アドベントカレンダー ドワンゴの15日目の記事です。(過ぎてしまっていますが)
前日の記事中でテクニカルな話が登場しなかったので、 普段、自分がどのようにコードを書いているのかを紹介します。
前提として、プロダクション環境に持っていくコードで、自分以外の人間が保守する可能性のあるコードを書くならどのように書くか、という視点で説明します。
実行環境として、Google ChromeのDevToolsで動くコードを載せていきます。参考にTypeScriptの実装も載せます。
いくつかのユースケースでどのように使うか紹介します。
例題:ブログの情報からメニューを生成する
たとえば本ブログもそうですが、「記事の一覧」を表示する機能を作りたい場合があります。 ゼロから作る場合、メニューに必要な情報に対して取得できる情報に過不足が生じている場合、最終出力までに変換を入れる必要があります。
要件定義
初期データとして以下のようなものを考えます。
const dataSet = [
{
title: "top",
uri: "/top",
description: "このwebサイトのTOPページ",
tags: ["website"],
},
{
title: "about",
uri: "/about",
description: "このwebサイトの説明",
tags: ["about"],
},
{
title: "blog",
uri: "/blog",
description: "ブログ一覧",
tags: ["article"],
}
];
出力としてほしいものは以下のようにします。
<ul>
<li><a href="https://example.com/top">top</a></li>
<li><a href="https://example.com/about">about</a></li>
<li><a href="https://example.com/blog">blog</a></li>
</ul>
最短で書いた場合
取りあえず動くことを目標に書いたコードは以下のように書けます。
const ul = document.createElement("ul");
dataSet.forEach(item => {
const li = document.createElement("li");
const anchor = document.createElement("a");
anchor.text = item.title;
anchor.href = "https://example.com" + item.uri;
li.appendChild(anchor);
ul.appendChild(li);
});
ul;
これと比較して、本番環境に入れる場合のコードは次のように書いています。
完成版
const createElement = ({ tagName, children }) => {
const element = document.createElement(tagName);
if (children) {
element.appendChild(children);
}
return element;
}
const createFragment = ({ children }) => {
const fragment = document.createDocumentFragment()
children.forEach(child => {
fragment.appendChild(child);
});
return fragment;
}
const createMenuItem = (props) => {
const anchor = createElement({ tagName: "a" });
anchor.text = props.anchor.text;
anchor.href = props.anchor.href;
return createElement({ tagName: "li", children: anchor });
}
const createMenu = (props) => {
const menuItems = props.items.map(createMenuItem);
return createElement({ tagName: "ul", children: createFragment(menuItems) });
}
const generateMenuProps = (dataSet, baseUrl) => {
const items = dataSet.map(param => {
return {
anchor: {
text: param.title,
href: baseUrl + param.uri,
},
};
});
return { items };
}
const menuProps = generateMenuProps(dataSet, "https://example.com");
createMenu(menuProps);
実装コードに大きな開きがあるのを見て取れます。 なぜこのように冗長的なコードになるのか説明します。
解説編
まずは実装上のルールを以下のように定めています。
コーディングルール
- classの生成や、DOMの生成する、などNative APIを叩く関数は接頭辞に
create
を利用する - データ変換をする関数の命名は以下のようにする
convertAtoB([Aのパラメーター])
- 入力するAの型が確定している場合
generate[TargetName]([入力パラメーター])
- 入力するパラメーターの型が
- 曖昧な場合
- 複数存在し、1つの意味で表現できない場合
- 入力するパラメーターの型が
- DOM生成の関数のパラメーター
props
と命名する- propsはJavaScriptのObjectにする
- props内のキー名はpropsを利用する関数が必要とするパラメーター名にする
- propsの少勇者は生成する対象自身とする
- 同一スコープ内で命名がかぶる場合は、関数名に具象度が1つ高い名前を含める
- スコープ内で衝突が起きなくなるまでこれを繰り返す
- 関数を以下の方針で分割する
- 意味のある単位
- 意味 ≒ 要件
- 同じ実装が複数回繰り返された場合
- Mock/Stubテストが必要となりそうなAPIを直接叩いている場合
- 意味のある単位
それでは、最短で書いた場合のコードを見つつ、ルールを適用します。 ルールをコードに適用していく際、どのようにしていくかはある程度の慣れが必要ですので、 手を動かして検証してみてください。
Step 1:意味のある単位で分割する
最初に着手するのは要件からです。
わかりやすのは表示の部分で、最終出力はul > li > a
という構造を持っています。
タグだけは意味が伝わらないので、命名をします。
MenuItem : li > a のHTMLElement
Menu : ul > MenuItem のHTMLElement
これにより、MenuItem
とMenu
を生成する必要があります。
返り値はHTMLElement
であるため、document.createElement
を利用することになります。
これにより、次のように命名が確定します。
// document.createElementはNative APIであるため、接頭辞に`create`を採用
// create + HTMLElement(生成対象)
const createHTMLElement = () => {}
// MenuとMenuItemで名前が衝突するため、具象度を上げる
const createMenuItem = () => {}
const createMenu = () => {}
これで骨格が整いました。骨格だけで実装すると、次のようになります。
const createMenuItem = () => {
const li = document.createElement("li");
const anchor = document.createElement("a");
li.appendChild(anchor);
return li;
}
const createMenu = () => {
const ul = document.createElement("ul");
// menuItemをループで生成
// ul.appendChild(menuItem);
return ul;
}
createMenu();
返り値の命名はcreate[生成対象]
のうち、生成対象
をcamelCaseにすると衝突しにくくなります。
ただ、今回はdocument.createElement
を挟んでいるので、ここは生成するタグ名を利用しました。
// 生成対象は引数に合致するため、変数名は`ul`となる
// https://developer.mozilla.org/ja/docs/Web/API/HTMLAnchorElement
const ul = document.createElement("ul");
// `create + [生成対象]`の公式に当てはめられるため、変数名は`menuItem`となる
const menuItem = createMenuItem();
余談:ちなみに、a
タグ生成時の変数名はa
ではないかというと、Web APIはHTMLAnchorElement
から命名を引っ張ってきているためです。
TypeScriptであれば、React
の型定義を参照してみるのも良いでしょう。
参考
- HTMLAnchorElement
@types/react
Step 2:引数を決定する
createMenu
とcreateMenuItem
引数の名前をまずは決めましょう。
今回はdocument.createElement
で生成したDOMに対してパラメーターを渡すため、props
という変数名になります。
const createMenuItem = (props) => {}
const createMenu = (props) => {}
次に、props
(Object)の中身を決定します。
キー名は生成するHTMLElementの名前を入れるのが良いでしょう。
const createMenuItem = (props) => {
// props = { anchor: {} };
}
const createMenu = (props) => {
// props = { items: [ { anchor: { ... } }, { anchor: { ... } } ] }
}
ここで不思議だと思うのが、createMenu
のpropsの名前がprops.menuItems
ではないことでしょう。
実はこれルールにも書きましたが、props
の所有者は生成対象、つまりmenu
だと考えると
menu.items
と読み変えることができます。これにより要件が増えた場合でも柔軟に対応できる構造を表現できます。
ここまでを実装に落とすと、
const createMenuItem = (props) => {
const li = document.createElement("li");
const anchor = document.createElement("a");
anchor.text = props.anchor.text;
anchor.href = props.anchor.href;
li.appendChild(anchor);
return li;
}
const createMenu = (props) => {
const ul = document.createElement("ul");
props.items.forEach(item => {
const menuItem = createMenuItem(item);
ul.appendChild(menuItem);
});
return ul;
}
createMenu(/** Step3へ */);
と書くことができます。
Step 3:与えられたデータを変換する
Step2までではまだ実装が不十分です。dataSet
に対してcreateMenu
の引数の型が合致していません。
そのため変換が必要となります。
また、dataSetにはベースURLが存在しないため、追加する実装も必要です。 最短の実装では次のように書いていた部分です。
anchor.href = "https://example.com" + item.uri;
つまり、dataSet
とベースURLhttps://example.com
を利用して、createMenu
にわたす変数を生成する関数を定義します。
関数はconvertAtoB
形式かgenerate[TargetName]
のどちらかになりますが、
convertAtoB
を採用する場合は入力する側のパラメーターが意味のある形でまとまっていなければA
を命名することができません。つまり、今回の場合、dataSet
とベースURLの塊に命名ができれば利用できますが、具体的な名前が簡単には思いつかないので、generate[TargetName]
形式の命名を行います。
生成対象はcreateMenu
の入力変数、つまり、Menu
のprops
-> MenuProps
になります。
ゆえに、
const generateMenuProps = () => {}
と関数名が決定されます。この関数の引数はMenu
のProps
を生成に必要な素材を渡すだけです。
const generateMenuProps = (dataSet, baseUrl) => {}
ここまで来たら後は変換する実装コードを書くだけになります。
const generateMenuProps = (dataSet, baseUrl) => {
const items = dataSet.map(param => {
return {
anchor: {
text: param.title,
href: baseUrl + param.uri,
},
};
});
return { items };
}
これで完成です。結合してみると、
const createMenuItem = (props) => {
const li = document.createElement("li");
const anchor = document.createElement("a");
anchor.text = props.anchor.text;
anchor.href = props.anchor.href;
li.appendChild(anchor);
return li;
}
const createMenu = (props) => {
const ul = document.createElement("ul");
props.items.forEach(item => {
const menuItem = createMenuItem(item);
ul.appendChild(menuItem);
});
return ul;
}
const generateMenuProps = (dataSet, baseUrl) => {
const items = dataSet.map(param => {
return {
anchor: {
text: param.title,
href: baseUrl + param.uri,
},
};
});
return { items };
}
const menuProps = generateMenuProps(dataSet, "http://example.com");
createMenu(menuProps);
ほとんどこれで完成と言っても問題ないでしょう。 Step 4移行は体裁を整えてい作業を行います。
Step 4:関数をまとめる
まずやることは関数の分割です。同じような実装を少しだけ抽象化して、まとめていきます。
目にとまるのは、document.createElement
の部分です。
const li = document.createElement("li");
li.appendChild(/** Children */);
このような組み合わせが散見されます。appendChild
を利用しない場合もあるので、分岐が必要です。
DOMを生成する関数を定義する場合、props
という引数を定義しますが、今回はもう少し簡潔に書いてみましょう。
createElement
という関数を新たに作り、「指定されたタグのHTMLElementを生成し、子要素を追加する」処理を与えましょう。
const createElement = ({ tagName, children }) => {
const element = document.createElement(tagName);
if (children) {
element.appendChild(children);
}
return element;
}
名前の衝突がない場合、分割代入を行うと 冗長な書き方が避けられます。
const { tagName, children } = props; // 分割代入
新しく定義したcreateElement
を利用すると、
const createElement = ({ tagName, children }) => {
const element = document.createElement(tagName);
if (children) {
element.appendChild(children);
}
return element;
}
const createMenuItem = (props) => {
const anchor = createElement({ tagName: "a" });
anchor.text = props.anchor.text;
anchor.href = props.anchor.href;
return createElement({ tagName: "li", children: anchor });
}
const createMenu = (props) => {
const ul = createElement({ tagName: "ul" });
props.items.forEach(item => {
const menuItem = createMenuItem(item);
ul.appendChild(menuItem);
});
return ul;
}
const generateMenuProps = (dataSet, baseUrl) => {
const items = dataSet.map(param => {
return {
anchor: {
text: param.title,
href: baseUrl + param.uri,
},
};
});
return { items };
}
const menuProps = generateMenuProps(dataSet, "https://example.com");
createMenu(menuProps);
となります。
createMenu
を見ているとcreateElement
で生成されたul
は後からappendChild
でElementを追加しています。これは技術的に2点、実装の思想として1点で注意する点があります。
技術的には
HTMLElement
にNodeList
をそのままappendChild
できないappendChild
を都度行う場合パフォーマン的を下げるパターンがある
実装の思想的には
createElement
は「子要素を追加する」処理を含む形で実装したがカバーできていない。
技術的な解消方法はシンプルです。
HTMLElementの配列(下記)を直接appendChild
することができません。
これを行うには、DocumentFragmentの形式に変換した上でappendChild
に渡す必要があります。
// HTMLElementの配列の例
[
<li><a>...</a></li>, // createMenuで生成したHTMLElement
<li><a>...</a></li>, // createMenuで生成したHTMLElement
<li><a>...</a></li>, // createMenuで生成したHTMLElement
]
DocumentFragmentを生成するにはdocument.createDocumentFragment
を利用します。
document.createElement
と同じように扱うことができるので、createElement
と似たようなラッパー関数を定義します。
const createFragment = ({ children }) => {
const fragment = document.createDocumentFragment()
children.forEach(child => {
fragment.appendChild(child);
});
return fragment;
}
これを採用するとcreateMenu
は次のようにかけます。
const createMenu = (props) => {
const menuItems = props.items.map(createMenuItem);
return createElement({
tagName: "ul",
children: createFragment(menuItems) // fragment化して渡すことでappendChildを消せた
});
}
これで最初に提示した完成版へたどり着くことができました。
参考
- DocumentFragment
https://developer.mozilla.org/ja/docs/Web/API/DocumentFragment - JavaScript 大量の要素をDOMに追加する際の、createDocumentFragmentの使い方。
https://qiita.com/39_isao/items/2fa8faed283d455f4181 - 七章第四回 ノードをまとめて扱う:DocumentFragment
https://uhyohyo.net/javascript/7_4.html
Step 5:応用編
ここまで読んでくれた方にもう少し発展系を提示します。 完成版ですが、もう少し手を加えると以下のようなことができます。
createElement
にcreateFragment
を含めるanchor.text =
/anchor.href
をcreateElement
の引数として渡す
これを行うことで、createElement
がより抽象化され、createMenu
、createMenuItem
をシンプルに書くことができます。
下記にそれを記すので、自分でチャレンジしたい方はまだスクロールをここで止めてください。
変更した関数だけ以下に記述します。
const createElement = ({ tagName, children, ...props }) => {
const element = document.createElement(tagName);
// propsは`tagName`, `children`を除いたパラメータを受け取ることを利用し、
// createElementの残りのkeyを生成するHTMLElementのプロパティのキーとして利用する
Object.entries(props).forEach(([key, value]) => {
element[key] = value;
});
if (children) {
// 配列の場合はcreateFragmentを通す
element.appendChild(Array.isArray(children) ? createFragment({ children }) : children);
}
return element;
}
const createMenuItem = (props) => {
return createElement({
tagName: "li",
children: createElement({ tagName: "a", ...props.anchor }),
});
}
const createMenu = (props) => {
return createElement({
tagName: "ul",
children: props.items.map(createMenuItem), // childrenは配列も受け取ることができる
});
}
これにより、createMenu
とcreateMenuItem
がcreateElement
だけで表現できるようになりました。
Step5の実装コードはこちら。
まとめ
- 要件に対する最短のコーディングを提示し、プロダクション環境に持っていくコードで、自分以外の人間が保守する可能性のあるコードを前提としたリファクタリングを行いました。
- コーディングルールを決めておくことで、命名が自動的に決定されることを紹介しました。
最後に
実は、createElement
の引数や構造化はReactのReact.createElement
あたりを意識しつつ作っています。
JSXを利用してReactを見ていると今回のようなcreateElement
はなかなか目にしませんが、本質的には近い処理を行っています。
また、Reactの管理下にあると、DOMの差分変更を行えたりより多くの恩恵を受けることができます。
とはいえ、これはcreateElement
を深掘って行ったときの話です。
今回の本題はコーディングを勧めていく上の考え方を紹介することにあります。
ある程度のコーディングルールを決めておくことで命名や構造化はほとんどの場合、要件を固めた時点で確定します。
本記事でその一端でもつかめると書いた甲斐があったなぁと思います。
2019年も残すところ僅かです。みなさんんおより良い開発を願ってます!
それでは!