実装に関するメモ
この章は stack リコンサイラ (reconciler) の実装に関するメモを集めたものです。
これは非常に技術的な内容であり、React の公開 API だけでなく、React がどのようにコア、レンダラ (renderer) 、そしてリコンサイラに分割されているかについても、深く理解していることを前提としています。React のコードベースにあまり精通していないのであれば、まずコードベースの概要を読んでください。
また、これは React のコンポーネント、インスタンスおよび要素の違いについての理解を前提としています。
stack リコンサイラは、React 15 およびそれ以前のバージョンで使われていました。src/renderers/shared/stack/reconciler で見つけることができます。
動画:React をスクラッチで作成する
このドキュメントは、Paul O’Shannessy 氏の行った講演 building React from scratch に大いに啓発されています。
このドキュメントと彼の講演は、ともに実際のコードベースを簡素化したもので、両方に親しむことでより深く理解することができるでしょう。
概要
リコンサイラそのものは公開 API を持ちません。リコンサイラは、React DOM や React Native のような レンダラ が、ユーザの記述した React コンポーネントに応じてユーザインターフェースを効率よく更新するために使用されます。
再帰的な処理としてマウントする
一番最初にコンポーネントをマウントするときのことを考えてみましょう:
const root = ReactDOM.createRoot(rootEl);
root.render(<App />);root.render はリコンサイラに <App /> を渡します。<App /> が React 要素であること、つまり、何をレンダーするかの説明書きであることを思い出してください。これはプレーンなオブジェクトとして考えることができます:
console.log(<App />);
// { type: App, props: {} }リコンサイラは App がクラスか関数かをチェックします。
もし App が関数なら、リコンサイラは App(props) を呼び出してレンダーされた要素を取得します。
もし App がクラスなら、リコンサイラは new App(props) で App をインスタンス化し、componentWillMount() ライフサイクルメソッドを呼び出し、それから render() メソッドを呼び出してレンダーされた要素を取得します。
どちらにせよ、リコンサイラは App が「レンダーされた」結果となる要素を手に入れます。
このプロセスは再帰的です。App は <Greeting /> へとレンダーされるかもしれませんし、Greeting は <Button /> にレンダーされるかもしれない、といったように続いていきます。リコンサイラはそれぞれのコンポーネントが何にレンダーされるかを学習しながら、ユーザ定義コンポーネントを再帰的に「掘り下げて」いきます。
この処理の流れは擬似コードで想像することができます:
function isClass(type) {
// React.Component subclasses have this flag
return (
Boolean(type.prototype) &&
Boolean(type.prototype.isReactComponent)
);
}
// This function takes a React element (e.g. <App />)
// and returns a DOM or Native node representing the mounted tree.
function mount(element) {
var type = element.type;
var props = element.props;
// We will determine the rendered element
// by either running the type as function
// or creating an instance and calling render().
var renderedElement;
if (isClass(type)) {
// Component class
var publicInstance = new type(props);
// Set the props
publicInstance.props = props;
// Call the lifecycle if necessary
if (publicInstance.componentWillMount) {
publicInstance.componentWillMount();
}
// Get the rendered element by calling render()
renderedElement = publicInstance.render();
} else {
// Component function
renderedElement = type(props);
}
// This process is recursive because a component may
// return an element with a type of another component.
return mount(renderedElement);
// Note: this implementation is incomplete and recurses infinitely!
// It only handles elements like <App /> or <Button />.
// It doesn't handle elements like <div /> or <p /> yet.
}
var rootEl = document.getElementById('root');
var node = mount(<App />);
rootEl.appendChild(node);補足:
これは全くの擬似コードです。本物の実装に近いものではありません。また、いつ再帰を止めるか検討していないため、このコードはスタックオーバーフローを引き起こします。
上記の例でいくつかの鍵となるアイデアをおさらいしましょう:
- React 要素とはコンポーネントの型(例えば
App)と props を表すプレーンなオブジェクトである。 - ユーザ定義コンポーネント(例えば
App)はクラスであっても関数であってもよいが、それらは全て要素へと「レンダーされる」。 - 「マウント」とは、最上位の React 要素(例えば
<App />)を受け取り、DOM もしくはネイティブなツリーを構築する再帰的な処理である。
host要素のマウント
このようにして要素ができても、それを使って画面に何か表示しなければ意味がありません。
ユーザ定義 (“composite”) コンポーネントに加え、React 要素はプラットフォームに固有な (“host”) コンポーネントも表すことができます。例えば、Button は render メソッドから <div /> を返すことが考えられます。
もし要素の type プロパティが文字列なら、私たちはいま host 要素を扱っていることになります:
console.log(<div />);
// { type: 'div', props: {} }host 要素に関連付けられているユーザ定義のコードはありません。
リコンサイラは host 要素を見つけると、レンダラに host 要素のマウントを任せます。例えば、React DOM は DOM ノードを生成します。
host 要素に子要素がある場合、リコンサイラは前節で述べたものと同じアルゴリズムに従い、子要素を再帰的にマウントします。子要素が(<div><hr /></div> のような)host なのか、(<div><Button /></div> のような)composite なのか、もしくはその両方が含まれているかに関わらず、再帰的な処理が実行されます。
子コンポーネントにより生成された DOM ノードは親の DOM ノードに追加され、それが再帰的に行われることで、完全な DOM 構造が組み立てられます。
補足:
リコンサイラそのものは DOM と結合していません。マウントの結果自体(時にソースコードでは “mount image” とも呼ばれます)はレンダラに依存し、それは(React DOM なら)DOM ノード であったり、(React DOM Server なら)文字列であったり、(React Native なら)ネイティブのビューを表す数字であったりします。
前出のコードを host 要素も扱えるように拡張するとすれば、以下のようなものになるでしょう:
function isClass(type) {
// React.Component subclasses have this flag
return (
Boolean(type.prototype) &&
Boolean(type.prototype.isReactComponent)
);
}
// This function only handles elements with a composite type.
// For example, it handles <App /> and <Button />, but not a <div />.
function mountComposite(element) {
var type = element.type;
var props = element.props;
var renderedElement;
if (isClass(type)) {
// Component class
var publicInstance = new type(props);
// Set the props
publicInstance.props = props;
// Call the lifecycle if necessary
if (publicInstance.componentWillMount) {
publicInstance.componentWillMount();
}
renderedElement = publicInstance.render();
} else if (typeof type === 'function') {
// Component function
renderedElement = type(props);
}
// This is recursive but we'll eventually reach the bottom of recursion when
// the element is host (e.g. <div />) rather than composite (e.g. <App />):
return mount(renderedElement);
}
// This function only handles elements with a host type.
// For example, it handles <div /> and <p /> but not an <App />.
function mountHost(element) {
var type = element.type;
var props = element.props;
var children = props.children || [];
if (!Array.isArray(children)) {
children = [children];
}
children = children.filter(Boolean);
// This block of code shouldn't be in the reconciler.
// Different renderers might initialize nodes differently.
// For example, React Native would create iOS or Android views.
var node = document.createElement(type);
Object.keys(props).forEach(propName => {
if (propName !== 'children') {
node.setAttribute(propName, props[propName]);
}
});
// Mount the children
children.forEach(childElement => {
// Children may be host (e.g. <div />) or composite (e.g. <Button />).
// We will also mount them recursively:
var childNode = mount(childElement);
// This line of code is also renderer-specific.
// It would be different depending on the renderer:
node.appendChild(childNode);
});
// Return the DOM node as mount result.
// This is where the recursion ends.
return node;
}
function mount(element) {
var type = element.type;
if (typeof type === 'function') {
// User-defined components
return mountComposite(element);
} else if (typeof type === 'string') {
// Platform-specific components
return mountHost(element);
}
}
var rootEl = document.getElementById('root');
var node = mount(<App />);
rootEl.appendChild(node);このコードは動作しますが、それでもまだ現実のリコンサイラの実装方法からは隔たりがあります。ここにあるべき鍵となる要素は、更新に対応することです。
内部インスタンスの導入
React の鍵となる機能は、あらゆるものを再描画できることであり、その際に DOM を再生成したり、state をリセットしたりしないことです:
root.render(<App />);
// Should reuse the existing DOM:
root.render(<App />);しかし、前節で実装したコードは最初のツリーをマウントする方法しか知りません。前節のコードは、全ての publicInstance や、どの DOM node がどのコンポーネントに対応しているかなど、必要な全情報を保有しているわけではないので、更新を実行することができません。
stack リコンサイラのコードベースでは、この問題を mount() 関数をメソッドとしてクラスに置くことで解決しています。しかしこのアプローチには欠点があるため、進行中のリコンサイラの書き直し作業では、反対の方向に進んでいます。それでも現時点では、この方式で動作しています。
別々の mountHost と mountComposite 関数の代わりに、2 つのクラスを作成します: DOMComponent と CompositeComponent です。
両方のクラスが element を受け入れるコンストラクタと、マウントされたノードを返す mount() メソッドを持ちます。最上位の mount() 関数を、正しいクラスをインスタンス化するファクトリに置き換えます:
function instantiateComponent(element) {
var type = element.type;
if (typeof type === 'function') {
// User-defined components
return new CompositeComponent(element);
} else if (typeof type === 'string') {
// Platform-specific components
return new DOMComponent(element);
}
}まず、CompositeComponent の実装から考えてみましょう:
class CompositeComponent {
constructor(element) {
this.currentElement = element;
this.renderedComponent = null;
this.publicInstance = null;
}
getPublicInstance() {
// For composite components, expose the class instance.
return this.publicInstance;
}
mount() {
var element = this.currentElement;
var type = element.type;
var props = element.props;
var publicInstance;
var renderedElement;
if (isClass(type)) {
// Component class
publicInstance = new type(props);
// Set the props
publicInstance.props = props;
// Call the lifecycle if necessary
if (publicInstance.componentWillMount) {
publicInstance.componentWillMount();
}
renderedElement = publicInstance.render();
} else if (typeof type === 'function') {
// Component function
publicInstance = null;
renderedElement = type(props);
}
// Save the public instance
this.publicInstance = publicInstance;
// Instantiate the child internal instance according to the element.
// It would be a DOMComponent for <div /> or <p />,
// and a CompositeComponent for <App /> or <Button />:
var renderedComponent = instantiateComponent(renderedElement);
this.renderedComponent = renderedComponent;
// Mount the rendered output
return renderedComponent.mount();
}
}以前の mountComposite() の実装と大きな違いはありませんが、更新時に使用する this.currentElement 、this.renderedComponent や、this.publicInstance のような情報を保存できるようになりました。
CompositeComponent のインスタンスは、ユーザが指定する element.type のインスタンスとは同一ではないことに注意してください。CompositeComponent はリコンサイラの実装の詳細であり、ユーザには決して公開されません。ユーザ定義クラスとは element.type から読み込むものであり、CompositeComponent がそのインスタンスを作成するのです。
混乱を避けるために、CompositeComponent と DOMComponent のインスタンスを「内部インスタンス」と呼ぶことにします。内部インスタンスは、長期間利用されるデータとそれらを関連付けられるようにするために存在します。それらの存在はレンダラとリコンサイラのみが認識しています。
一方、ユーザ定義クラスのインスタンスは「公開インスタンス」と呼ぶことにします。公開インスタンスは、独自コンポーネントの render() やその他のメソッド内で this として現れるものです。
mountHost() 関数は、DOMComponent クラスの mount() メソッドとしてリファクタリングされ、こちらも見慣れたものになります:
class DOMComponent {
constructor(element) {
this.currentElement = element;
this.renderedChildren = [];
this.node = null;
}
getPublicInstance() {
// For DOM components, only expose the DOM node.
return this.node;
}
mount() {
var element = this.currentElement;
var type = element.type;
var props = element.props;
var children = props.children || [];
if (!Array.isArray(children)) {
children = [children];
}
// Create and save the node
var node = document.createElement(type);
this.node = node;
// Set the attributes
Object.keys(props).forEach(propName => {
if (propName !== 'children') {
node.setAttribute(propName, props[propName]);
}
});
// Create and save the contained children.
// Each of them can be a DOMComponent or a CompositeComponent,
// depending on whether the element type is a string or a function.
var renderedChildren = children.map(instantiateComponent);
this.renderedChildren = renderedChildren;
// Collect DOM nodes they return on mount
var childNodes = renderedChildren.map(child => child.mount());
childNodes.forEach(childNode => node.appendChild(childNode));
// Return the DOM node as mount result
return node;
}
}mountHost() からリファクタリングした後の主な違いは、this.node と this.renderedChildren を内部の DOM コンポーネントインスタンスに関連付け続けていることです。これらは、将来的に非破壊的な更新を適用する際にも使用します。
結果として、それが composite であれ host であれ、内部インスタンスはそれぞれの子内部インスタンスを指すようになります。<App> 関数コンポーネントが <Button> コンポーネントをレンダーし、<Button> クラスが <div>をレンダーする場合、視覚的にわかりやすくすると、内部インスタンスのツリーはこのようになります:
[object CompositeComponent] {
currentElement: <App />,
publicInstance: null,
renderedComponent: [object CompositeComponent] {
currentElement: <Button />,
publicInstance: [object Button],
renderedComponent: [object DOMComponent] {
currentElement: <div />,
node: [object HTMLDivElement],
renderedChildren: []
}
}
}DOM の中では、<div>しか見えません。しかしながら、内部インスタンスのツリーは composite の内部インスタンスと host の内部インスタンスの両方を保有しています。
composite 内部インスタンスは以下のものを格納する必要があります:
- 現在の要素。
- 要素の型がクラスの場合、公開インスタンス。
- 単独の、レンダーされた内部インスタンス。これは
DOMComponentかCompositeComponentのいずれかにあたります。
host 内部インスタンスは以下のものを格納する必要があります:
- 現在の要素。
- DOM ノード。
- すべての子内部インタスタンス。各インスタンスは、
DOMComponentまたはCompositeComponentのいずれかになります。
より複雑なアプリケーションにおいて、内部インスタンスのツリーがどのような構造になるのか想像しづらい場合は、React DevTools が host インスタンスを灰色に、composite インスタンスを紫色にハイライトしてくれるので、内部インスタンスのツリーにかなり近いものを得ることができます: