はじめに
素の JavaScript でフロント開発経験がない React 育ちのエンジニアです。
React でフロントエンド開発をしていて大きく困ることはないのですが hooks, JSX, 様々なライブラリを使用していていると JavaScript を理解していたらという場面がちょこちょこ発生します。
そのため最近は JavaScript の基礎的な勉強をしています。
JavaScript の理解を深めることによってスムーズにキャッチアップできたり、裏側でどのように動作しているかなど想像しやすくなるだろうという目論見のもとで。
そこで、素の JavaScript を使用して TODO リストのためのコンポーネントを作成してみました。
JavaScript の Class を理解していることを前提に述べていこうと思います。
TL;DR
JavaScript 組み込みの shadowDOM を使用すると React のようにユーザーの入力などのイベントによって表示を変更するコンポーネントの再現ができました。
また JavaScript と React の両者でコンポーネントを作成してみて両者を比較した表は以下のようになります。JavaScript を用いたコンポーネント実装でより簡単に実装できる方法があるかもしれないため、あくまでも今回実装したコンポーネントでの話となることをご容赦ください。
JavaScript | React | |
---|---|---|
props の継承 | できない | できる |
state の管理 | state の概念がないため attributeChangedCallback メソッド内でイベントベースで更新する必要がある |
hooks で容易に更新できる |
DOM 描画 | shadowRoot の innerHTML で描画する | JSX |
イベントの管理 | リスナーの登録とイベントの作成をする必要がある | props を継承できるため props にメソッドを渡して該当イベントとして要素に設定するだけ |
以下 URL が今回作成した TODO アプリのリポジトリです。
JavaScript 製: https://github.com/hagmy/todo-app-on-js
React 製: https://github.com/hagmy/todo-app-on-react
要件
以下は今回作成した TODO アプリの要件です。
この要件をもとに Web コンポーネントを作成しました。
- TODO の初期値が表示される
- TODO を作成するためのフォームが配置されている
- そのフォームにユーザーはテキストを入力することができる
- フォームにテキストを入力した後にボタンを押下するとリストの末尾にその内容が追加される
- テキストが空文字列の場合は追加しない
設計
ディレクトリ構成は以下のようにします。
.
├── components
│ ├── todo-create-box.js
│ ├── todo-list-container.js
│ └── todo-list.js
└── todo.html
DOM 内での各コンポーネントの親子関係は以下になります。
<todo-list-container>
<todo-create-box></todo-create-box>
<todo-list></todo-list>
</todo-list-container>
各コンポーネントの責務
本節では各コンポーネントの責務について定義します。
TODO アプリを作成するにあたって 3 つの TodoListContainer, TodoCreateBox, TodoList というコンポーネントに役割を分離しました。
TodoListContainer
- TODO リストの管理
- ユーザーによるフォーム入力値の管理
- カスタムのイベントリスナーの登録
- 初回レンダーを監視するイベント (
todo-list-initial-render
) - フォームの入力を監視するイベント (
set-new-todo
) - TODO を追加する add ボタン押下を監視するイベント (
add-new-todo
)
- 初回レンダーを監視するイベント (
- イベントが発火 (ディスパッチ) した時に実行するメソッドの定義
- onSetTodoList (
todo-list-initial-render
に対応) - onSetNewTodo (
set-new-todo
に対応) - onAddNewTodo (
add-new-todo
に対応)
- onSetTodoList (
- TODO リストの更新
- onSetTodoList
- 初回レンダー時や TODO 追加時に TODO リストの値を更新する
- フォーム入力値のリセット
- onResetInputValue
- TODO 追加時のフォーム内の値を空文字列に更新する
TodoCreateBox
- DOM の描画
-
<input>
と<button>
要素を配置
-
- (React で言うところの) props の定義
- 入力値の
value
- 入力値の
- イベントリスナーの登録
- フォームの入力を監視する
- ボタン押下の監視
- カスタムイベントの発火 (ディスパッチ)
- onSetNewTodo (入力があった時)
- onAddTodo (ボタンが押下された時)
TodoList
- DOM の描画
- TODO リストを描画
-
<ul>
,<li>
を使用
- props の定義
- TODO リストを持つ配列
todoList
- TODO リストを持つ配列
React 製の TODO アプリ
要件定義と設計を終えて素の JavaScript によるコンポーネント実装に入れるかと思いますが、ここで一旦 React の場合の実装を示したいと思います。
それと言うのも、今回の目的は素の JavaScript でコンポーネントを作成することによって React で実装するコンポーネントの理解を深めようという狙いが (少なくとも私には) あるからです。
import { useState } from "react";
import { ItemCreateBox } from "./TodoCreateBox";
import { TodoList } from "./TodoList";
export const TodoListContainer = () => {
const [todoList, setTodoList] = useState(["reading books", "playing soccer"]);
const [newTodo, setNewTodo] = useState("");
const handleSetNewTodo = (value) => {
setNewTodo(value);
};
const handleAddTodo = () => {
if (newTodo === "") return;
setTodoList([...todoList, newTodo]);
setNewTodo("");
};
return (
<>
<ItemCreateBox
newTodo={newTodo}
onSetNewTodo={handleSetNewTodo}
onAddTodo={handleAddTodo}
/>
<TodoList todoList={todoList} />
</>
);
};
export const ItemCreateBox = ({ newTodo, onSetNewTodo, onAddTodo }) => {
return (
<>
<input
id="input"
type="text"
value={newTodo}
onChange={(event) => onSetNewTodo(event.target.value)}
/>
<button onClick={() => onAddTodo()}>add</button>
</>
);
};
export const TodoList = ({ todoList }) => {
return (
<ul>
{todoList.map((todo) => (
<li key={todo}>{todo}</li>
))}
</ul>
);
};
素の JavaScript でコンポーネント作成
それでは JavaScript を用いてコンポーネントを作成します。
以下の MDN のドキュメントを参考にしました。
TodoListContainer
まず、箱 (Class) の定義です。
JavaScript には HTMLElement という Class が組み込みで用意されているのでこれを継承します。
constructor には TODO リストとユーザーが入力する値を管理するインスタンスオブジェクトを定義します。
また カスタム要素 (コンポーネント) を HTML 内で使用するためにインターフェースメソッドである define()
を最後に記述します。
define()
は Window.customElements
のメソッドです。
Window は省略可能のため以下のようにして使用します。
customElements.define("html-element-name", CustomHTMLElementClass);
以上の説明からコンポーネントを定義すると次のようになります。
class TodoListContainer extends HTMLElement {
constructor() {
super();
this.todoList = ["reading novels", "playing soccer"];
this.newTodo = "";
}
}
customElements.define("todo-list-container", TodoListContainer);
続いて、子コンポーネントの props を更新するためのメソッドを定義します。
子コンポーネントの props を使用している要素を取得して更新します。
後述する子コンポーネントのセッターによって子コンポーネントの props の値が更新されます。
class TodoListContainer extends HTMLElement {
// 省略
// for <todo-list>
onSetTodoList() {
const todoListElement = this.querySelector("todo-list");
todoListElement.todoList = this.todoList;
}
// for <todo-create-box>
onResetInputValue() {
const inputElement = this.querySelector("todo-create-box");
inputElement.value = "";
}
}
customElements.define("todo-list-container", TodoListContainer);
次にインスタンスオブジェクトを更新するメソッドを定義します。
onSetNewTodo メソッドは入力イベントを受け取ってイベントの値をインスタンスメソッドである newTodo に格納します。
また onAddTodo はインスタンスメソッドである todoList に newTodo の値を追加して、その後 newTodo の値を空文字列に更新します。
そして最後に 子コンポーネントの props を呼び出すメソッドを実行します。
class TodoListContainer extends HTMLElement {
// 省略
onSetNewTodo(event) {
this.newTodo = event.detail.newTodo;
}
onAddTodo() {
if (this.newTodo === "") return;
this.todoList = [...this.todoList, this.newTodo];
this.newTodo = "";
this.onSetTodoList();
this.onResetInputValue();
}
// 省略
}
customElements.define("todo-list-container", TodoListContainer);
最後に上述のメソッドを実行するためにイベントリスナーの登録をします。
一番最初のイベントリスナーは初期読み込みでレンダーするためのイベントリスナーです。
connectedCallback
はこのコンポーネントが DOM 中に登録されると実行されるメソッドです。
恐らく React のライフサイクルだと ComponentDidMount にあたると思われます。
class TodoListContainer extends HTMLElement {
// 省略
connectedCallback() {
// <todo-list> 初回時のレンダー
this.addEventListener("todo-list-initial-render", this.onSetTodoList);
// <todo-create-box> の入力があったときに発火
this.addEventListener("set-new-todo", this.onSetNewTodo);
// <todo-create-box> の add ボタン押下時に発火
this.addEventListener("add-new-todo", this.onAddTodo);
}
// 省略
}
customElements.define("todo-list-container", TodoListContainer);
最終的には以下のようになります。
// https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements
class TodoListContainer extends HTMLElement {
constructor() {
super();
this.todoList = ["reading novels", "playing soccer"];
this.newTodo = "";
}
/** イベントリスナーの登録 */
connectedCallback() {
// <todo-list> 初回時のレンダー
this.addEventListener("todo-list-initial-render", this.onSetTodoList);
// <todo-create-box> の入力があったときに発火
this.addEventListener("set-new-todo", this.onSetNewTodo);
// <todo-create-box> の add ボタン押下時に発火
this.addEventListener("add-new-todo", this.onAddTodo);
}
/** プロパティ変更 */
onSetNewTodo(event) {
this.newTodo = event.detail.newTodo;
}
onAddTodo() {
if (this.newTodo === "") return;
this.todoList = [...this.todoList, this.newTodo];
this.newTodo = "";
this.onSetTodoList();
this.onResetInputValue();
}
/** 子要素やそれらの属性の変更 */
// for <todo-list>
onSetTodoList() {
const todoListElement = this.querySelector("todo-list");
todoListElement.todoList = this.todoList;
}
// for <todo-create-box>
onResetInputValue() {
const inputElement = this.querySelector("todo-create-box");
inputElement.value = "";
}
}
/**
* コンポーネント (カスタムエレメント) の定義
* see: https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define
*/
customElements.define("todo-list-container", TodoListContainer);
TodoCreateBox
このコンポーネントはユーザーからの入力とその値から TODO を追加するためのボタン周りの制御を行います。
前述で定義した TodoListContainer コンポーネントと異なる点は以下 3 点です。
そのため、本節ではこの 3 点に絞って述べていきます。
- DOM を描画する
- カスタムイベントを発火 (ディスパッチ) する
- (React で言うところの) props を持つ
まず DOM の描画についてです。
DOM を描画するにあたって shadowRoot の登録が constructor で必要になります。
そのために HTMLElement クラスの attachShadow メソッドを使用します。
このメソッドはオプションを引数に取り、今回親コンポーネントの TodoListContainer からこのコンポーネントの要素にアクセス許可をするために open を指定します。
そして DOM 描画のための独自の render メソッドを定義します。
このメソッド内では shadowRoot の innerHTML に代入することで DOM が描画されるようになります。
value はこのコンポーネントの props であり、これについてはこの節の最後に説明します。
class TodoCreateBox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<input id="input" value=${!!this.value ? this.value : ""}>
<button id="button">add</button>
`;
}
}
customElements.define("todo-create-box", TodoCreateBox);
次にカスタムイベントを発火 (ディスパッチ) する処理について説明します。
まず、前節と同様にして connectedCallback
でイベントリスナーを登録します。
ここで登録したイベントは標準イベントです。
これらイベントが発火した時に実行するメソッドとして onSetNewTodo と onAddTodo を定義します。
これらメソッド内で使用している dispatchEvent
メソッドと CustomEvent
クラスについて述べていきます。
dispatchEvent
メソッドはその名の通りイベントをディスパッチします。
次に CustomEvent
クラスですがこのクラスは第一引数にイベント名、第二引数にオプションを取ります。
オプションについては以下の通りです。
- bubbles: イベントのバブリング (親要素に伝播) する
- composed: shadowDOM の外側の要素に対してもイベントを伝える
- detail: リスナーが event.detail として値を受け取れる
上記以外のオプションも存在しますが、今回は使用しないため割愛します。
class TodoCreateBox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.render();
this.input = this.shadowRoot.querySelector("#input");
this.button = this.shadowRoot.querySelector("#button");
}
connectedCallback() {
this.input.addEventListener("input", this.onSetNewTodo);
this.button.addEventListener("click", this.onAddTodo);
}
onSetNewTodo(event) {
this.dispatchEvent(
new CustomEvent("set-new-todo", {
bubbles: true,
composed: true,
detail: {
newTodo: event.target.value,
},
})
);
}
onAddTodo(_event) {
this.dispatchEvent(
new CustomEvent("add-new-todo", {
bubbles: true,
composed: true,
})
);
}
// 省略
}
customElements.define("todo-create-box", TodoCreateBox);
最後に props 周りの制御について説明していきます。
まず observedAttributes
に props を登録します。
React におけるコンポーネント関数の引数に相当すると思います。
次に attributeChangedCallback
というメソッドを使用して props が変化するときに実行されるメソッドで value prop に変更があった場合にその変更を input 要素の value 属性に反映するように記述します。
React における useState の setState のような役割をします。
最後に value prop のセッターとゲッターを定義します。
class TodoCreateBox extends HTMLElement {
/** カスタムプロパティの監視 (camelCase は使用できない) */
static observedAttributes = ["value"];
constructor() {
// 省略
}
/** カスタムプロパティに変更があった場合に呼び出される */
attributeChangedCallback(name, _, newValue) {
switch (name) {
case "value":
this.input.value = newValue;
break;
}
}
/** カスタムプロパティのゲッターとセッター */
get value() {
return this.getAttribute("value");
}
set value(text) {
this.setAttribute("value", text);
}
}
customElements.define("todo-create-box", TodoCreateBox);
最終的には以下のようになります。
class TodoCreateBox extends HTMLElement {
/** カスタムプロパティの監視 (camelCase は使用できない) */
static observedAttributes = ["value"];
constructor() {
super();
this.attachShadow({ mode: "open" });
this.render();
this.input = this.shadowRoot.querySelector("#input");
this.button = this.shadowRoot.querySelector("#button");
}
connectedCallback() {
this.input.addEventListener("input", this.onSetNewTodo);
this.button.addEventListener("click", this.onAddTodo);
}
/**
* カスタムプロパティに変更があった場合に呼び出される
* see: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#responding_to_attribute_changes
*/
attributeChangedCallback(name, _, newValue) {
switch (name) {
case "value":
this.input.value = newValue;
break;
}
}
/** ゲッターとセッター */
get value() {
return this.getAttribute("value");
}
set value(text) {
this.setAttribute("value", text);
}
/**
* カスタムイベントを発火するメソッド群
* dispatchEvent: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent
* CustomEvent: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent
*/
onSetNewTodo(event) {
this.dispatchEvent(
new CustomEvent("set-new-todo", {
bubbles: true, // イベントのバブリング (親要素に伝播) をする
composed: true, // この shadow DOM の外側の要素に対してもイベントを伝える
detail: {
newTodo: event.target.value, // リスナーは event.detail.newTodo でここで登録した値を受け取ることができる
},
})
);
}
onAddTodo(_event) {
this.dispatchEvent(
new CustomEvent("add-new-todo", {
bubbles: true,
composed: true,
})
);
}
/** DOM 要素の設定 */
render() {
this.shadowRoot.innerHTML = `
<input id="input" value=${!!this.value ? this.value : ""}>
<button id="button">add</button>
`;
}
}
customElements.define("todo-create-box", TodoCreateBox);
TodoList
前節、前々節と説明したものを使用して TodoList コンポーネントを作成します。
すると、以下のように作成できるかと思います。
class TodoList extends HTMLElement {
static observedAttributes = ["todolist"];
constructor() {
super();
this.attachShadow({ mode: "open" });
}
/**
* ドキュメントが作成されたタイミングで DOM 要素を設定する
* see: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks
*/
connectedCallback() {
this.initialRender();
}
/** カスタムプロパティに変更があった場合に呼び出される */
attributeChangedCallback(name, _, newValue) {
switch (name) {
case "todolist":
this.render();
break;
}
}
/** ゲッターとセッター */
get todoList() {
// setAttribute 時に string に変換されるため配列化する
// see: https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute
const todoListArray = this.getAttribute("todoList")?.split(",");
return todoListArray;
}
set todoList(value) {
this.setAttribute("todolist", value);
}
/** DOM 要素の設定 */
initialRender() {
this.dispatchEvent(
new CustomEvent("todo-list-initial-render", {
bubbles: true,
composed: true,
})
);
}
render() {
if (!this.todoList || this.todoList.length < 1) {
this.shadowRoot.innerHTML = `<ul></ul>`;
return;
}
const todoListHTML = this.todoList?.map((todo) => {
return `
<li>${todo}</li>
`;
});
this.shadowRoot.innerHTML = `<ul>${todoListHTML?.join("")}</ul>`;
}
}
customElements.define("todo-list", TodoList);
おわりに
JavaScript でコンポーネントを実装してみて、まず思ったことは「React めっちゃ便利!!!」ということでした。
React に慣れているだけかもしれませんが、可読性・スピード・保守性・運用性、その他の観点からも React でコンポーネントを作成した方が良いと実感しました。
さて、本記事のタイトルである「JavaScript でコンポーネントを作成してみて React の気持ちを考えてみる」ですが React の気持ちというよりも結果として React ありがとうの気持ちが芽生えました。
React 自体の実装プログラムを見たことはありませんが、実行するサイクルは JavaScript で作成したコンポーネントと大きく異なるわけではないのかもしれないと推測しています。(React のコードリーディングしてみたい)
有識者の方、何か参考になる記事を知っている方がいらっしゃいましたら、共有いただけると幸いです。
また、より簡単に JavaScript でコンポーネントを作成する方法を知っている方がいらっしゃいましたら、共有いただけると幸いです。
よろしくお願いします。