Skip to main content

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);

実装コードに大きな開きがあるのを見て取れます。 なぜこのように冗長的なコードになるのか説明します。

解説編

まずは実装上のルールを以下のように定めています。

コーディングルール

  1. classの生成や、DOMの生成する、などNative APIを叩く関数は接頭辞にcreateを利用する
  2. データ変換をする関数の命名は以下のようにする
    • convertAtoB([Aのパラメーター])
      • 入力するAの型が確定している場合
    • generate[TargetName]([入力パラメーター])
      • 入力するパラメーターの型が
        • 曖昧な場合
        • 複数存在し、1つの意味で表現できない場合
  3. DOM生成の関数のパラメーターpropsと命名する
    • propsはJavaScriptのObjectにする
    • props内のキー名はpropsを利用する関数が必要とするパラメーター名にする
    • propsの少勇者は生成する対象自身とする
  4. 同一スコープ内で命名がかぶる場合は、関数名に具象度が1つ高い名前を含める
    • スコープ内で衝突が起きなくなるまでこれを繰り返す
  5. 関数を以下の方針で分割する
    • 意味のある単位
      • 意味 ≒ 要件
    • 同じ実装が複数回繰り返された場合
    • Mock/Stubテストが必要となりそうなAPIを直接叩いている場合

それでは、最短で書いた場合のコードを見つつ、ルールを適用します。 ルールをコードに適用していく際、どのようにしていくかはある程度の慣れが必要ですので、 手を動かして検証してみてください。

Step 1:意味のある単位で分割する

最初に着手するのは要件からです。 わかりやすのは表示の部分で、最終出力はul > li > aという構造を持っています。 タグだけは意味が伝わらないので、命名をします。

MenuItem : li > a のHTMLElement
Menu : ul > MenuItem のHTMLElement

これにより、MenuItemMenuを生成する必要があります。 返り値は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の型定義を参照してみるのも良いでしょう。

参考

Step 2:引数を決定する

createMenucreateMenuItem引数の名前をまずは決めましょう。 今回は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の入力変数、つまり、Menuprops -> MenuPropsになります。 ゆえに、

const generateMenuProps = () => {}

と関数名が決定されます。この関数の引数はMenuPropsを生成に必要な素材を渡すだけです。

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点で注意する点があります。

技術的には

  1. HTMLElementNodeListをそのままappendChildできない
  2. appendChildを都度行う場合パフォーマン的を下げるパターンがある

実装の思想的には

  1. 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を消せた
});
}

これで最初に提示した完成版へたどり着くことができました。

参考

Step 5:応用編

ここまで読んでくれた方にもう少し発展系を提示します。 完成版ですが、もう少し手を加えると以下のようなことができます。

  • createElementcreateFragmentを含める
  • anchor.text = / anchor.hrefcreateElementの引数として渡す

これを行うことで、createElementがより抽象化され、createMenucreateMenuItemをシンプルに書くことができます。 下記にそれを記すので、自分でチャレンジしたい方はまだスクロールをここで止めてください。

変更した関数だけ以下に記述します。

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は配列も受け取ることができる
});
}

これにより、createMenucreateMenuItemcreateElementだけで表現できるようになりました。

Step5の実装コードはこちら

まとめ

  • 要件に対する最短のコーディングを提示し、プロダクション環境に持っていくコードで、自分以外の人間が保守する可能性のあるコードを前提としたリファクタリングを行いました。
  • コーディングルールを決めておくことで、命名が自動的に決定されることを紹介しました。

最後に

実は、createElementの引数や構造化はReactのReact.createElementあたりを意識しつつ作っています。 JSXを利用してReactを見ていると今回のようなcreateElementはなかなか目にしませんが、本質的には近い処理を行っています。 また、Reactの管理下にあると、DOMの差分変更を行えたりより多くの恩恵を受けることができます。 とはいえ、これはcreateElementを深掘って行ったときの話です。 今回の本題はコーディングを勧めていく上の考え方を紹介することにあります。 ある程度のコーディングルールを決めておくことで命名や構造化はほとんどの場合、要件を固めた時点で確定します。 本記事でその一端でもつかめると書いた甲斐があったなぁと思います。

2019年も残すところ僅かです。みなさんんおより良い開発を願ってます!

それでは!