はじめに
こんにちは!この記事は、Reactを使っていてずっとモヤモヤしていた、配列からmapを使って要素を挿入する方法が動く理由についてモヤモヤが解消されたので、同じ快感を共有したいと思い執筆しました。
感じていたモヤモヤとは、「mapって配列から新しい配列を生成するものであってJSX.Element[]とかは返せてもJSX.Element自体は返せなくない???」というモヤモヤです。
結論、「JSXがparseされると全部配列になってその後再帰処理で配列の各要素にちゃんと処理が施されhtmlになるから」ということで納得いったのですが、今までJSXをElement.innerHTML脳で眺めていたのでモヤモヤしていました。以下で一つずつ説明して参ります。
Reactでmapを使うとは
配列に何か処理を加えてから配列の要素を差し込むテクニックです。
公式ドキュメントのこの辺りに書いてあります。
具体例として、以下のように使います。
const List = () => {
const itemList = [1, 2, 3, 4, 5]
return (
<ul>
{itemList.map(item => (
<li key={item}>{item}</li>
))}
</ul>
)
}
JSXで色々試す。モヤモヤする。
mapメソッドが配列から新たに配列を生成するものであることから、先ほどのサンプルコードは次と同じであることが分かります。
const List = () => {
return (
<ul>
{[
<li key="1">1</li>,
<li key="2">2</li>,
<li key="3">3</li>,
<li key="4">4</li>,
<li key="5">5</li>
]}
</ul>
)
}
つまりただ配列を唐突に置き去っているだけです。HTMLやReact Componentではありません。配列をです。
ちなみに、こんなこともできちゃいます。実行結果は上のコードと全く同じです。階層の深さは関係ありません。
const List = () => {
return (
<ul>
{[
<li key="1">1</li>
[
<li key="2">2</li>
[
<li key="3">3</li>,
[
<li key="4">4</li>,
[
<li key="5">5</li>
]
]
]
]
]}
</ul>
)
}
しかしこれ、ちょっと直観的ではありませんよね。return文の中って、基本的にはReact ComponentとかHTMLとか文字列が入るイメージなので、直接配列を埋め込んでいる考えると、ナンジャコリャ?となります。
また、蛇足かもですがExpress.jsのejs (Embded JavaScript, Railsのerbのようなもの)を使ったことがある場合は同じ目的で次のようなコードを書くので何が違うのか分からず、混乱しまくること必定です。
<ul>
<% for (let i = 0; i < arr.length; i++) { %>
<li><%= arr[i] %></li>
<% } %>
</ul>
またまた蛇足ですが、個人的にしっくりくる配列の値を埋め込む方法は以下のような感じです。ヒトカタマリのJSXに整形してから返しているので、配列の場合と違って普通にコンポーネントを埋め込むのと同じ気分になれます。実際、これでも動きはしますが、mapの書き方と比べて冗長で分かりにくいですよね。
const List = () => {
const itemList = [1, 2, 3, 4, 5]
return (
<ul>
{itemList.reduce((prev, item) => {
return (
<>
{prev}
<li key={item}>{item}</li>
</>
);
})}
</ul>
)
}
// 上記は以下と同じ意味。React.Fragmentのネストは深いけど配列を埋め込むわけではなくヒトカタマリのJSXを埋め込んでいるので文法上の違和感はない。
const List = () => {
const itemList = [1, 2, 3, 4, 5]
return (
<ul>
<>
<>
<>
<>
<>
<li key="1">1</li>
</>
<li key="2">2</li>
</>
<li key="3">3</li>
</>
<li key="4">4</li>
</>
<li key="5">5</li>
</>
</ul>
)
}
そんなこんなでモヤモヤを共有できたと思います。では、次の見出しからモヤモヤの解消をしていきます!
JSXを深く理解する。まだ幸せにはなれない
まずは公式ドキュメントを見ます。
JSXを深く理解する
中でも最初に出てくる次の一節が気になりました。
JSX とは、つまるところ React.createElement(component, props, ...children) の糖衣構文にすぎません。
ここで、今回重要なのは...children
の部分です。つまり、childrenは第3引数以降に配列ではなく配列を展開した形で指定しましょうという風に見えます
// React.createElementの説明から導かれる正しいchildrenの渡し方
React.createElement('ul', null, <li key="1">1</li>, <li key="2">2</li>, <li key="3">3</li>)
// React.createElementの説明的に間違いっぽいchildrenの渡し方
React.createElement(component, props, [<li key="1">1</li>, <li key="2">2</li>, <li key="3">3</li>])
しかし、どういうことでしょう。どちらのパターンでもReactは正しくレンダリングしてくれるのです!!!
これはReact.createElementの内部実装を覗く他ありません。
以下のgithubにてReact.createElementの内部実装を確認できます。
https://github.com/facebook/react/blob/main/packages/react/src/ReactElement.js#L362
概要としては受け取った値を色々処理してキレイにしたのちreactElement(...)を返すといった感じでしょうか。
今回特に興味がある、childrenに関する処理は以下の通りです。
// Children can be more than one argument, and those are transferred onto
// the newly allocated props object.
const childrenLength = arguments.length - 2;
if (childrenLength === 1) {
props.children = children;
} else if (childrenLength > 1) {
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
if (__DEV__) {
if (Object.freeze) {
Object.freeze(childArray);
}
}
props.children = childArray;
}
要は、createElementにchildrenが2つ以上渡されたらその2つの要素を配列に格納しているということですね。つまり配列が渡されたならそれはそれで良いわけです。最初のif文に入って何事もなく処理されていきます。
どうしたものか...元々配列がJSXに入るのはおかしい!!!というモチベーションで調べていたのに、むしろ最終的には配列になっていたなんて...もうJSXに埋め込まれた配列が最終的に仮想DOMと一体化する理由は迷宮入りなんだろうか。。
・
・
・
・
・
・
・
...ん?...仮想DOM?
仮想DOMに思いを馳せ、幸せになる。
そうでした。そもそもReactは仮想DOMを構築する技術だったのでした。そう考えるとネストの深い配列と一次元の配列とで同じ実行結果になるのも納得です。
DOMについておさらい
jsはHTMLにアクセスする時、このDOMを介してページの情報を操作します。詳しくはMDN(MDN | DOM の紹介)に任せますが、このDOMというのは木構造をとっています。下のような図を見たことはないでしょうか。DOMはこんな感じで枝分かれしたデータ構造をとっています。
閑話休題。仮想DOMについて
Reactの内部で構築される仮想DOMもDOMと同様に木構造をもっています。ここで重要なこととして、以下のようにReact.createElementのネスト構造も仮想DOMも木構造に分類できて等しい関係にあるということです。
では、上のReact.createElementで書かれたコードと実際のDOMとをつなぐ処理はなんでしょうか?そこに<ul>{[<li></li>, <li></li>]}</ul>
←これの謎が隠されているはずです。
再帰処理で納得する
そう、それは再帰処理です。
再帰処理とは処理したい要素のネストの深さがわからない時に使える処理です。例えば2次元配列であればforを入れ子にするなどすれば全て処理可能だと思いますが、10次元とか100次元とかどんな次元の配列が来るかわからないけどどれにでも対応しといて〜という要求に応えられるのが再帰処理です。
おそらく、React内部ではざっくり以下のような再帰処理によってReactElement → DOMに変換しています。
※以下の例では分かりやすさのためTypeScriptを使用しています。
※以下の例では単純にするためDOMではなくDOMっぽいHTMLを生成していますが本質は多分同じです
type ReactElement = {
type: string;
props: { children: (ReactElement | string | number)[] };
key: string | null | undefined;
};
const convertReactElementToDOMString = (el: ReactElement | string | number) => {
if (typeof el === "string" || typeof el === "number") {
return el;
}
const type = el.type;
const children = el.props.children;
return `<${type}>${children.reduce((prev, child) => {
if (typeof child === "number") child = `${child}`;
return prev + convertReactElementToDOMString(child);
}, '')}</${type}>`;
};
例えば、上記のconvertReactElementToDOMString
を使うと以下のように変換が行われます。
const reactElement = {
type: "div",
props: {
children: [
{ type: "li", props: { children: [1] }, key: null },
{ type: "li", props: { children: [2] }, key: null },
{ type: "li", props: { children: [3] }, key: null }
]
},
key: null
}
convertReactElementToDOMString(reactElement)
// 実行結果: '<ul><li>1</li><li>2</li><li>3</li></ul>'
つまり、全体としてReact内部で行われている以下のような処理がイメージできれば
<ul>{[<li></li>, <li></li>]}</ul>
←これの違和感から脱却
できるというわけです(タイトル回収)。
1.jsxで実装される
const List = () => {
const itemList = [1, 2, 3, 4, 5]
return (
<ul>
{itemList.map(item => (
<li key={item}>{item}</li>
))}
</ul>
)
}
2.babelなどがJSXをReact.createElementにparseする
const List = () => {
const itemList = [1, 2, 3, 4, 5];
return (
React.createElement(
"ul",
null,
itemList.map(item => React.createElement("li", { key: item }, item))
)
)
};
3.React.createElementとReactElementの実行 (以下のコードはkeyのあたりとかは勘)
const List = () => {
const itemList = [1, 2, 3, 4, 5];
return (
{
type: "ul",
{
children: [
{type: 'li', props: { children: '1' }, key: '1'},
{type: 'li', props: { children: '2' }, key: '2'},
{type: 'li', props: { children: '3' }, key: '3'},
{type: 'li', props: { children: '4' }, key: '4'},
{type: 'li', props: { children: '5' }, key: '5'}
]
},
key: null
}
)
};
4.再帰処理などをしてDOMを構築
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
まとめ
長くなりましたが、なんとか幸せになれました。
要は
①JSXに値を埋め込んだ値は元がどんな形であれ最終的に配列としてchildrenに格納される
②配列であるchildrenは再帰処理で一個ずつhtmlに戻される
という2点を理解していれば違和感なくReactのmap処理を理解できたということでした。
再帰処理の説明など、雑になってしまいましたが深い部分は落ち着いてご自身で調べていただくのが良いと思います。
では、みなさまが幸せな日々を過ごせますように