この記事は 株式会社 ACCESS Advent Calendar 2019 24 日目の記事です。
先週、@Momijinn (全くの別人でした。失礼しました。) 社内のとあるエンジニアに「Build your own React」という記事の存在を教えてもらいました。React の内部実装を把握するためにスクラッチで自分の手を使って実装する手順を紹介している記事です。
元記事は JavaScript で記述されていたのですが、そのまま写経するのもつまらないので TypeScript に書き換えながら実装してみました。元記事の部分的な日本語訳や実装しながら得た知見をこの記事で紹介したいと思います。
この記事は実装の最終形を機能ごとに分割して説明しますが、元記事は React を構成する重要な要素を順番に紹介しています。深い理解を得たい方は元記事を読みながら自分の手で実装することがおすすめです。
自分が実装したコードは こちら で公開してます。
今回実装した React の API は
動作環境
requestIdleCallback と String.prototype.startsWith が動作するブラウザであることが前提です。
JSX (TSX) と DOM
公式 でも言及されていますが、JSX の記述は React.createElement のシンタックスシュガーです。JSX を使用した記述は下記例のように書き換えられます。
const hello = <div id="hoge">Hello {this.props.toWhat}</div>;
const hello = React.createElement('div', { id: 'hoge' }, `Hello ${this.props.toWhat}`)
React.crateElement の引数は下記のようになっています。
React.createElement(
type, // タグ名の文字列
[props], // タグに付与する属性
[...children] // 子要素
)
では TypeScript で React.createElement を実装してみます。
const TEXT_ELEMENT = 'TEXT_ELEMENT' as const
type TagType = string // ここの拡張を頑張るとタグの型定義ができる
type ElementType = typeof TEXT_ELEMENT | TagType
interface Props {
nodeValue?: string
children: Element[]
[key: string]: any // タグに付与する属性が入る
}
interface Element {
type: ElementType
props: Props
}
const createTextElement = (text: string): Element => ({
type: TEXT_ELEMENT,
props: {
nodeValue: text,
children: []
}
})
const createElement = (type: ElementType, props: Props, ...children: Element[]): Element => ({
type,
props: {
...props,
children: children.map(child => (typeof child === 'object' ? child : createTextElement(child)))
}
})
crateElement
を実行すると、children に対して再起的にcreateElement
を実行します。そして子要素を持たない末端のノードに対してはcreateTextElement
を実行します。
下記に実行例を挙げます。
const sample = (
<div id="foo">
<a href="/" target="_blank">
link
<span id="baz">hoge</span>
</a>
</div>
)
を
const sample = {
type: 'div',
props: {
id: 'foo',
children: [
{
type: 'a',
props: {
href: '/',
target: '_blank',
children: [
{ type: 'TEXT_ELEMENT', props: { nodeValue: 'link', children: [] } },
{
type: 'span',
props: { id: 'baz', children: [{ type: 'TEXT_ELEMENT', props: { nodeValue: 'hoge', children: [] } }] }
}
]
}
}
]
}
}
のような構造に変換してくれる関数がcreateElement
です。
おそらくこの Element のツリー構造がいわゆる仮想 DOM に当たります。React 開発者から見る・操作することができる DOM ツリーが上記 Object です。
こうして変換された Element を DOM に反映させるためのcreateDOM
を実装します。
const isEvent = (key: string) => key.startsWith('on')
const isProperty = (key: string) => key !== 'children' && !isEvent(key)
const toEventType = (key: string) => key.toLocaleLowerCase().substring(2)
const createDom = (element: Element) => {
if (element.type === TEXT_ELEMENT) {
return document.createTextNode(element.props.nodeValue!)
}
const dom = document.createElement(element.type)
Object.keys(element.props)
.filter(isEvent)
.forEach(name => dom.addEventListener(toEventType(name), element.props[name]))
Object.keys(element.props)
.filter(isProperty)
.forEach(name => dom.setAttribute(name, element.props[name]))
return dom
}
子要素を持たない末端のノードは TextNode として生成します。
末端のノード以外は通常の HTMLElement として生成します。on から始まるイベント属性をaddEventListener
で設定し、その他の属性はsetAttribute
で設定します。
JSX (TSX) -> DOM 生成までの繋ぎこみが上記の実装により完了しました。
レンダリング
生成された DOM を実際に描画させます。描画に使用するのが requestIdleCallback です。ブラウザがアイドル状態の時に、既に描画済みの DOM に子として生成された DOM を追加します。
export type Fiber =
| ({
dom: HTMLElement | Text
parent?: Fiber
child?: Fiber
sibling?: Fiber
} & Element)
| null
const requestIdleCallbackFunc = (window as any).requestIdleCallback
let nextUnitOfWork: Fiber = null
let wipRoot: Fiber = null
const commitWork = (fiber: Fiber) => {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
const commitRoot = () => {
commitWork(wipRoot.child)
wipRoot = null
}
const workLoop = () => {
while (nextUnitOfWork) {
// performUnitOfWork() は後述
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallbackFunc(workLoop)
}
requestIdleCallbackFunc(workLoop)
ここで Fiber なる概念が登場します。上記の Element の要素に加えて、DOM の実体、親・子・兄弟 Fiber への参照を持っています。React の描画処理は Fiber を元に実行されます。
現時点の実装ではブラウザがアイドル状態になった時にworkLoop
を呼びだし、全ての DOM を更新しています。差分検出(Reconciliation)は現時点では未実装です。
performUnitOfWork
は差分検出を実装することを見据えて分割して呼び出せるようになっています。
const performUnitOfWork = (fiber: Fiber) => {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
const elements = fiber.props.children
let index = 0
let prevSibling: Fiber = null
while (index < elements.length) {
const element = elements[index]
const newFiber: Fiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null
}
if (index === 0) {
fiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
return null
}
performUnitOfWork
は何をやっているのかというと
- Fiber に DOM の実体がなければ生成
- 次に実行する Fiber を返す
- 子 Element があれば子 Fiber を生成して返す
- 子 Element がなく、兄弟 Fiber があれば返す
- 子 Element・兄弟 Fiber がなく、親の兄弟 Fiber があれば返す
- 以降、親要素の兄弟 Fiber を探し続ける。なければ null を返す
上述したように、Element を Fiber 単位に基づいて DOM の描画処理を行います。
最後にrender
関数を実装します。
const render = (element: Element, container: HTMLElement) => {
wipRoot = {
type: container.tagName,
props: {
children: [element]
},
dom: container
}
nextUnitOfWork = wipRoot
}
const sample = (
<div id="foo">
<a href="/" target="_blank">
link
<span id="baz">hoge</span>
</a>
</div>
)
const container = document.getElementById('root')
render(sample, container)
これで描画まで実行されるようになりました。
差分検出
Fiber 管理
まず Fiber の型定義を修正します。
type EffectTagType = 'UPDATE' | 'PLACEMENT' | 'DELETION'
export type Fiber =
| ({
dom: HTMLElement | Text
alternate: Fiber
effectTag: EffectTagType
parent?: Fiber
child?: Fiber
sibling?: Fiber
} & Element)
| null
プロパティに alternate と effectTag が追加されました。
alternate は更新時に現在の Fiber に置換されるための Fiber です。
effectTag は下記 3 種類の状態で構成されます。
- UPDATE
- DOM が更新されます。Fiber -> DOM への反映時に alternate プロパティの Fiber が参照されて DOM が更新されます。
- PLACEMENT
- DOM を新しく作成します。上述したように dom プロパティに DOM への参照が格納されます。
- DELETION
- DOM が削除されます。
performUnitOfWork から一部コードを切り出して差分検出用関数を実装します。
export const reconcileChildren = (deletions: Fiber[]) => (wipFiber: Fiber, elements: Element[]) => {
let index = 0
let oldFiber = wipFiber.alternate && wipFiber.alternate.child
let prevSibling: Fiber = null
while (index < elements.length || oldFiber) {
const element = elements[index]
let newFiber: Fiber = null
const sameType = oldFiber && element && oldFiber.type === element.type
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: 'UPDATE'
}
}
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: 'PLACEMENT'
}
}
if (oldFiber && !sameType) {
oldFiber.effectTag = 'DELETION'
deletions.push(oldFiber)
}
if (oldFiber) {
oldFiber = oldFiber.sibling
}
if (index === 0) {
wipFiber.child = newFiber
} else if (element) {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
}
削除対象の Fiber 配列 (deletions) を引数に取る HOC になっています。
関数使用時には Fiber と子要素の Element に対して差分を検出しています。
- 処理中の Fiber と子要素の Element が同じ type ならば UPDATE 用の Fiber を生成
- 処理中の Fiber と子要素の Element が同じ type でない
- 子要素の Element に有効な値が入っていれば REPLACEMENT 用の Fiber を生成
- 子要素の Element に有効な値が入ってなければ DELETION 用の Fiber を生成
上記の実装を元に Fiber 管理をする performUnitOfWork を書き直します。
let wipFiber: Fiber = null
const updateFunctionComponent = (fiber: Fiber) => {
if (fiber.type instanceof Function) {
wipFiber = fiber
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
// hooks についての説明は後述
hookIndex = 0
wipFiber.hooks = []
}
}
const updateHostComponent = (fiber: Fiber) => {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
reconcileChildren(fiber, fiber.props.children)
}
const performUnitOfWork = (fiber: Fiber) => {
const isFunctionComponent = fiber.type instanceof Function
if (isFunctionComponent) {
updateFunctionComponent(fiber)
} else {
updateHostComponent(fiber)
}
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
Fiber type が function である Functional Component に対して差分検出更新を実装できました。
Fiber -> DOM へ反映
let deletions: Fiber[] = []
const commitDeletion = (fiber: Fiber, domParent: HTMLElement | Text) => {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
const commitWork = (fiber: Fiber) => {
if (!fiber) {
return
}
let domParentFiber = fiber.parent
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent
}
const domParent = domParentFiber.dom
if (fiber.effectTag === 'PLACEMENT' && fiber.dom !== null) {
domParent.appendChild(fiber.dom)
} else if (fiber.effectTag === 'UPDATE' && fiber.dom !== null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props)
} else if (fiber.effectTag === 'DELETION') {
commitDeletion(fiber, domParent)
}
commitWork(fiber.child)
commitWork(fiber.sibling)
}
Fiber が property として所有する DOM をブラウザ上に表示するために繋ぎ込みをする commitWork を実装変更しました。
commitDeletion で DOM の削除を実施しています。
また実 DOM 更新用の関数を実装しました。
const isEvent = (key: string) => key.startsWith('on')
const isProperty = (key: string) => key !== 'children' && !isEvent(key)
const toEventType = (key: string) => key.toLocaleLowerCase().substring(2)
// 上記関数は再掲
const isNew = (prev: Props, next: Props) => (key: string) => prev[key] !== next[key]
const isGone = (next: Props) => (key: string) => !(key in next)
export const updateDom = (dom: HTMLElement | Text, prevProps: Props, nextProps: Props) => {
Object.keys(prevProps)
.filter(isEvent)
.filter(key => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach(name => dom.removeEventListener(toEventType(name), prevProps[name]))
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(nextProps))
.forEach(name => setValue(dom, name, ''))
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => setValue(dom, name, nextProps[name]))
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => dom.addEventListener(toEventType(name), nextProps[name]))
}
hooks useState
Fiber の型定義をさらに修正します。
type Hook<T> = { state: T; queue: ((arg: T) => void)[] }
export type Fiber =
| ({
dom: HTMLElement | Text
alternate: Fiber
effectTag: EffectTagType
parent?: Fiber
child?: Fiber
sibling?: Fiber
hooks?: Hook<any>[]
} & Element)
| null
Hook を配列として持たせます。
let hookIndex = 0
const useState = <T>(initial: T) => {
const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [] as ((arg: T) => void)[]
}
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
hook.state = action(hook.state)
})
const setState = (action: (arg: T) => void) => {
hook.queue.push(action)
wipRoot = {
...currentRoot,
alternate: currentRoot
}
nextUnitOfWork = wipRoot
deletions = []
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state, setState]
}
useState を実装しました。
hooks は過去の state を全て queue として保存し、state が更新されるたびに過去の履歴である queue から全て計算し直して現在の状態を算出していました。
これには驚きました。
メモリリークが発生を防ぐため、この方法が採用されているようです。
最後に
TypeScript 最高です。バニラの JS だと写経でも写し間違えに気づかず詰んでいた可能性すらあります。リファクタリングや思考の整理にも、型があるとないとでは捗りに天地の差があると思います。なるべく新規の案件には導入していきましょう!
明日は @naohikowatanabe さんで「要素の表示非表示は visibility:hidden の方が display:none よりも高速」だそうです。お楽しみに。