はじめに
お疲れ様です。
Remix3 という言葉について、いまいち心当たりが無く「Umm....」状態の方は以下記事のリンクの章を読んでいただければ面白いかなと思います。
仮想DOMを用いたレンダリングは vue や react を筆頭に広く使われています。しかし、これらのライブラリは機能拡張に伴い実装が複雑化しており、レンダリングの仕組み自体を学ぶハードルが高くなっています。
Remix3 は React という巨大な依存関係、複雑性から脱却し、Web標準に準拠した新たなエコシステムを開発しています。
Remix3 の現在の実装は、レンダリングの仕組み自体のミニマムな学習教材として適していると考え、本記事ではRemix3 のコンポーネントの描画の機構について解いてみようかと思っております。
sandbox
下地として、 Remix3 のコンポーネントが表示できる状態を作っておきます。
バンドラーは vite 、テンプレートに react を指定し、ごちゃっていきます。
@vitejs/plugin-react を残しほかの React 関連のライブラリを依存性から削除しました。
@vitejs/plugin-react は jsx を export しているランタイムであれば jsxImportSource に指定可能であるため、HMRの機構等の便利機能にタダ乗りが可能です。
@remix-run/component の 0.4.0 だけ依存関係に追加して、画面だけ表示出来るようになった状態が以下です。
import { createRoot } from '@remix-run/component'
function App() {
return () => (
<div>test</div>
)
}
createRoot(document.getElementById('root')!).render(<App />)
今回は、この createRoot の自作を図ってみます。
Remix側の実装の確認
@remix-run/component の 0.4.0 リリース時点でのリポジトリの魚拓です。
以後、実装のリンクについては 856bcaa16cf9ab613f07c351fcd6c39b55a50ad5 時点のものを貼付します。
createRootの構成
`createRoot` の実装はこちら
export function createRoot(container: HTMLElement, options: VirtualRootOptions = {}): VirtualRoot {
let vroot: VNode | null = null
let frameStub = options.frame ?? createFrameHandle()
let hydrationCursor = container.innerHTML.trim() !== '' ? container.firstChild : undefined
let eventTarget = new TypedEventTarget<VirtualRootEventMap>()
let scheduler =
options.scheduler ?? createScheduler(container.ownerDocument ?? document, eventTarget)
// Forward bubbling error events from DOM to root EventTarget
container.addEventListener('error', (event) => {
eventTarget.dispatchEvent(new ErrorEvent('error', { error: (event as ErrorEvent).error }))
})
return Object.assign(eventTarget, {
render(element: RemixNode) {
let vnode = toVNode(element)
let vParent: VNode = { type: ROOT_VNODE, _svg: false }
scheduler.enqueueTasks([
() => {
diffVNodes(
vroot,
vnode,
container,
frameStub,
scheduler,
vParent,
eventTarget,
undefined,
hydrationCursor,
)
vroot = vnode
hydrationCursor = undefined
},
])
scheduler.dequeue()
},
remove() {
vroot = null
},
flush() {
scheduler.dequeue()
},
})
}
createRoot()
├ createFrameHandle()
├ new TypedEventTarget()
└ createScheduler()
> render(), remove(), flush()
createRoot はだいぶ薄くて読みやすい感じになっています。
render の実装が一瞬気構えますが、読んでみれば渡された要素を VNode化 し順に scheduler.enqueueTasks して diffVNodes に登録しています。
(実装を読んでないのでわかりませんが、 VNode の比較にタスクとしてを追加していく、みたいな感じで間違いなさそうです。 enqueue は先入れ先出しの事らしいですね。)
よって、いったん createRoot で直接実行される createFrameHandle, new TypedEventTarget , createScheduler を見てみます。
createFrameHandle
export function createFrameHandle(
def?: Partial<{
src: string
replace: FrameHandle['replace']
reload: FrameHandle['reload']
}>,
): FrameHandle {
return Object.assign(
new EventTarget(),
{
src: '/',
replace: notImplemented('replace not implemented'),
reload: notImplemented('reload not implemented'),
},
def,
)
}
引数として undefined ないし EventTarget のソース、また replace, reload 時のハンドラを受け取り、EventTarget 型を拡張した FrameHandle 型を返す関数のようです。
undefined が渡されている場合は notImplemented 云々のデフォルト値。
const frame = createFrameHandle({
src: '/test',
async replace(content)
async reload()
})
createRoot(document.body, { frame }).render(<App />)
こんな感じになりそうですね。
## Future
This package is a work in progress. Future features (demo'd at Remix Jam) include:
- Server Rendering
- Selective Hydration
- `<Frame>` for streamable, reloadable partial server UI
未実装の機能らしいですが、 API Routes 的なところから HTML を受け取ってロード/リロード出来るみたいな感じのイメージを持ちました。
他FWにも似たような機能があると噂を聞いたことがありますが、今回深掘りたい内容とは違うのでこのあたりで。
new TypedEventTarget
let eventTarget = new TypedEventTarget<VirtualRootEventMap>()
container.addEventListener('error', (event) => {
eventTarget.dispatchEvent(new ErrorEvent('error', { error: (event as ErrorEvent).error }))
})
DOMの描画で発生したエラーイベントをキャッチし、 eventTarget に送信します。
また、 eventTarget は render の返り値を受け取ります。
return Object.assign(eventTarget, {
render(element: RemixNode) {
let vnode = toVNode(element)
let vParent: VNode = { type: ROOT_VNODE, _svg: false }
scheduler.enqueueTasks([
() => {
diffVNodes(
...
)
vroot = vnode
hydrationCursor = undefined
},
])
scheduler.dequeue()
},
createRoot(document.getElementById('root')!).render(<App />)
されるため、 eventTarget は root に代入されるオブジェクトであると解釈出来そうです。
createScheduler
let scheduler =
options.scheduler ?? createScheduler(container.ownerDocument ?? document, eventTarget)
return Object.assign(eventTarget, {
render(element: RemixNode) {
let vnode = toVNode(element)
let vParent: VNode = { type: ROOT_VNODE, _svg: false }
scheduler.enqueueTasks([
() => {
diffVNodes(
...
)
vroot = vnode
hydrationCursor = undefined
},
])
scheduler.dequeue()
},
option.scheuler が渡されていない場合、 createScheduler が発火します。
createScheduler からは enqueue, enqueueTasks, dequeue という関数が提供されます。
scheduler = {
enqueue(vnode: CommittedComponentNode, domParent: ParentNode, anchor?: Node): void {
scheduled.set(vnode, [domParent, anchor])
scheduleFlush()
},
enqueueTasks(newTasks: EmptyFn[]): void {
tasks.push(...newTasks)
scheduleFlush()
},
dequeue() {
flush()
},
}
return scheduler
}
それぞれ、
-
enqueue:scheduledマップにコンポーネントをセットする-
domParent→ コンポーネントをどのコンポーネントの子として再レンダリングするかの情報 -
anchor→ コンポーネントをどの位置に再レンダリングするかの情報 -
scheduleFlushを実行する
-
-
enqueueTasks: タスクを配列に追加する-
scheduleFlushを実行する
-
-
dequeue:flush関数を実行をする
という役割を持ちます。
scheduleFlush の実装は以下のようになっています。
function scheduleFlush() {
if (flushScheduled) return
flushScheduled = true
queueMicrotask(flush)
}
flushScheduled によって実行の重複を避け、実行中のすべての同期処理の完了後に flush 関数を実行しています。
flush 関数はこのあたりの主役感満載です。
function flush() {
flushScheduled = false
let batch = new Map(scheduled)
scheduled.clear()
let hasWork = batch.size > 0 || tasks.length > 0
if (!hasWork) return
// Mark layout elements within updating components as pending BEFORE capture
// This ensures we only capture/apply for elements whose components are updating
if (batch.size > 0) {
for (let [, [domParent]] of batch) {
markLayoutSubtreePending(domParent)
}
}
// Capture layout snapshots BEFORE any DOM work (for FLIP animations)
captureLayoutSnapshots()
documentState.capture()
if (batch.size > 0) {
let vnodes = Array.from(batch)
let noScheduledAncestor = new Set<VNode>()
for (let [vnode, [domParent, anchor]] of vnodes) {
if (ancestorIsScheduled(vnode, batch, noScheduledAncestor)) continue
let handle = vnode._handle
let curr = vnode._content
let vParent = vnode._parent!
try {
renderComponent(
handle,
curr,
vnode,
domParent,
handle.frame,
scheduler,
rootTarget,
vParent,
anchor,
)
} catch (error) {
dispatchError(error)
}
}
}
// restore before user tasks so users can move focus/selection etc.
documentState.restore()
// Apply FLIP layout animations AFTER DOM work, BEFORE user tasks
applyLayoutAnimations()
if (tasks.length > 0) {
for (let task of tasks) {
try {
task()
} catch (error) {
dispatchError(error)
}
}
tasks = []
}
}
-
flushScheduledをクリア -
scheduled Mapをbatchにコピーし、scheduledをクリアする -
batch内の各コンポーネントの親 DOM 配下で、レイアウトアニメーション対象の要素に「更新予定」フラグを立てる(markLayoutSubtreePending) - FLIPアニメーション用に、フラグが立った要素の現在位置・サイズを記録する(FLIPアニメーション用....?
captureLayoutSnapshots) - 現在のフォーカス要素と選択範囲(カーソル位置等)を保存する(
documentState.capture()) -
batchを配列に変換し、noScheduledAncestorキャッシュを用意する -
renderComponentによって差分比較・DOM更新を実行する - 保存しておいたフォーカスと選択範囲を復元する(DOM 書き換えで失われるため)
- 記録した変更前の位置と変更後の位置を比較し、FLIPレイアウトアニメーションを適用する(FLIPアニメーション....?)
-
tasks配列内のタスクを順に実行する(connect、イベント登録、ユーザーの queueTask 等) - 全タスク完了後、
tasksを空配列にリセットする
FLIPアニメーションがピンと来ていませんが。
おおまか、 scheduled と tasks の実行。 renderComponent の実行が主たる役割に見えます。
function renderComponent(
handle: ComponentHandle,
currContent: VNode | null,
next: ComponentNode,
domParent: ParentNode,
frame: FrameHandle,
scheduler: Scheduler,
rootTarget: EventTarget,
vParent?: VNode,
anchor?: Node,
cursor?: Node | null,
) {
let [element, tasks] = handle.render(next.props)
let content = toVNode(element)
diffVNodes(currContent, content, domParent, frame, scheduler, next, rootTarget, anchor, cursor)
next._content = content
next._handle = handle
next._parent = vParent
let committed = next as CommittedComponentNode
handle.setScheduleUpdate(() => {
scheduler.enqueue(committed, domParent, anchor)
})
scheduler.enqueueTasks(tasks)
}
renderComponent において、レンダリング対象のコンポーネントは第三引数で受け取ります。
enqueue された batch (再レンダリング対象のコンポーネント) を順に flush から受け取っています。
handle.render 後、 element と task の形式で受け取ります。
diffVNodes では仮想DOMの比較・更新を行います。
diffというので最初は比較だけかと思った。処理ほぼ更新だった
export function diffVNodes(
curr: VNode | null,
next: VNode,
domParent: ParentNode,
frame: FrameHandle,
scheduler: Scheduler,
vParent: VNode,
rootTarget: EventTarget,
anchor?: Node,
rootCursor?: Node | null,
) {
next._parent = vParent // set parent for initial render context lookups
next._svg = getSvgContext(vParent, next.type)
// new
if (curr === null) {
insert(next, domParent, frame, scheduler, vParent, rootTarget, anchor, rootCursor)
return
}
if (curr.type !== next.type) {
replace(curr, next, domParent, frame, scheduler, vParent, rootTarget, anchor)
return
}
if (isCommittedTextNode(curr) && isTextNode(next)) {
diffText(curr, next, scheduler, vParent)
return
}
if (isCommittedHostNode(curr) && isHostNode(next)) {
diffHost(curr, next, domParent, frame, scheduler, vParent, rootTarget)
return
}
if (isCommittedComponentNode(curr) && isComponentNode(next)) {
diffComponent(curr, next, frame, scheduler, domParent, vParent, rootTarget)
return
}
if (isFragmentNode(curr) && isFragmentNode(next)) {
diffChildren(
curr._children,
next._children,
domParent,
frame,
scheduler,
vParent,
rootTarget,
undefined,
anchor,
)
return
}
if (curr.type === Frame && next.type === Frame) {
throw new Error('TODO: Frame diff not implemented')
}
invariant(false, 'Unexpected diff case')
}
- 前回なし → 全部新規挿入
- 型が違う → 古いのを消して新しいのを挿入
- テキスト同士 →
textContentを更新 - DOM要素同士 → 属性と子を差分更新
- コンポーネント同士 →
renderComponentで再レンダー -
Fragment同士 → 子の配列を差分更新
ここの比較・仮想DOMの更新ではwhile使ったりしてる関数もあるので、レンダリングコスト(哲学)に踏み込むならこのあたりを深掘るのが良さそうです。
流れを今回は勉強したいため、またの機会に。
next の _content, _handle, _parent を埋め、 CommittedComponentNode 型にキャストします。
CommittedComponentNode 型にキャストされたことで、 scheduler.enqueue に再レンダリングのキューとしてセットすることが可能になるため、 handle.setScheduleUpdate 。
また、 handle.render から受け取った tasks を scheduler.enqueueTasks してタスク配列に追加。
(こうのほうが良くねえかなあって思った)
// let committed = next as CommittedComponentNode
let committed = {
...next,
_content: content,
_handle: handle,
_parent: vParent,
}
insert
diffVNodes で curr === null だった場合に呼ばれる、DOMを新規作成して挿入する関数です。
function insert(
node: VNode,
domParent: ParentNode,
frame: FrameHandle,
scheduler: Scheduler,
vParent: VNode,
rootTarget: EventTarget,
anchor?: Node,
cursor?: Node | null,
): Node | null | undefined {
node._parent = vParent
node._svg = getSvgContext(vParent, node.type)
cursor = skipComments(cursor ?? null)
let doInsert = anchor
? (dom: Node) => domParent.insertBefore(dom, anchor)
: (dom: Node) => domParent.appendChild(dom)
if (isTextNode(node)) {
// テキスト要素をDOMに操作する処理
}
if (isHostNode(node)) {
// ホスト要素をDOMに操作する処理
}
if (isFragmentNode(node)) {
// フラグメントをDOMに操作する処理
}
if (isComponentNode(node)) {
// コンポーネントをDOMに操作する処理
}
}
ノードの種類に応じて4つに分岐します。
テキストノード ("hello", {count} 等)
if (isTextNode(node)) {
let dom = document.createTextNode(node._text)
node._dom = dom
doInsert(dom)
return cursor
}
document.createTextNode で生のテキストを作り、DOMに追加するだけです。シンプル。
ホスト要素 (<div>, <button> 等)
if (isHostNode(node)) {
let dom = node._svg
? document.createElementNS(SVG_NS, node.type)
: document.createElement(node.type)
diffHostProps({}, node.props, dom)
diffChildren(null, node._children, dom, frame, scheduler, node, rootTarget)
setupHostNode(node, dom, domParent, frame, scheduler, rootTarget)
doInsert(dom)
return cursor
}
-
document.createElementでDOM要素を作成する(SVGならcreateElementNS) -
diffHostPropsでpropsをDOMの属性に設定する(className → class等の変換含む) -
diffChildrenで子要素を再帰的にinsertする -
setupHostNodeでconnectコールバック、イベントリスナー(on prop)、enterアニメーションをスケジューラのタスクとして登録する - DOMに追加する
子要素は diffChildren → insert の再帰で処理されるため、ツリー全体がボトムアップに組み立てられていきます。
diffChildrenの実装。
function diffChildren(
curr: VNode[] | null,
next: VNode[],
domParent: ParentNode,
frame: FrameHandle,
scheduler: Scheduler,
vParent: VNode,
rootTarget: EventTarget,
cursor?: Node | null,
anchor?: Node,
) {
// Initial mount / hydration: delegate to insert() for each child so that
// hydration cursors and creation logic remain centralized there.
if (curr === null) {
for (let node of next) {
cursor = insert(node, domParent, frame, scheduler, vParent, rootTarget, anchor, cursor)
}
vParent._children = next
return cursor
}
let currLength = curr.length
let nextLength = next.length
// Detect if any keys are present in the new children. If not, we can fall
// back to the simpler index-based diff which is cheaper and matches
// pre-existing behavior.
let hasKeys = false
for (let i = 0; i < nextLength; i++) {
let node = next[i]
if (node && node.key != null) {
hasKeys = true
break
}
}
if (!hasKeys) {
for (let i = 0; i < nextLength; i++) {
let currentNode = i < currLength ? curr[i] : null
diffVNodes(
currentNode,
next[i],
domParent,
frame,
scheduler,
vParent,
rootTarget,
anchor,
cursor,
)
}
if (currLength > nextLength) {
for (let i = nextLength; i < currLength; i++) {
let node = curr[i]
if (node) remove(node, domParent, scheduler)
}
}
vParent._children = next
return
}
// --- O(n + m) keyed diff with Map-based lookup ------------------------------
let oldChildren = curr
let oldChildrenLength = currLength
let remainingOldChildren = oldChildrenLength
// Build key → index map for O(1) lookup: O(m)
let oldKeyMap = new Map<string, number>()
for (let i = 0; i < oldChildrenLength; i++) {
let c = oldChildren[i]
if (c) {
c._flags = 0
if (c.key != null) {
oldKeyMap.set(c.key, i)
}
}
}
let skew = 0
let newChildren: VNode[] = new Array(nextLength)
// First pass: match new children to old ones using Map lookup: O(n)
for (let i = 0; i < nextLength; i++) {
let childVNode = next[i]
if (!childVNode) {
newChildren[i] = childVNode
continue
}
newChildren[i] = childVNode
childVNode._parent = vParent
let skewedIndex = i + skew
let matchingIndex = -1
let key = childVNode.key
let type = childVNode.type
if (key != null) {
// O(1) Map lookup for keyed children
let mapIndex = oldKeyMap.get(key)
if (mapIndex !== undefined) {
let candidate = oldChildren[mapIndex]
let candidateFlags = candidate?._flags ?? 0
if (candidate && (candidateFlags & MATCHED) === 0 && candidate.type === type) {
matchingIndex = mapIndex
}
}
} else {
// Non-keyed children use positional identity only - no searching
let searchVNode = oldChildren[skewedIndex]
let searchFlags = searchVNode?._flags ?? 0
let available = searchVNode != null && (searchFlags & MATCHED) === 0
if (available && searchVNode.key == null && type === searchVNode.type) {
matchingIndex = skewedIndex
}
}
childVNode._index = matchingIndex
let matchedOldVNode: VNode | null = null
if (matchingIndex !== -1) {
matchedOldVNode = oldChildren[matchingIndex]
remainingOldChildren--
if (matchedOldVNode) {
matchedOldVNode._flags = (matchedOldVNode._flags ?? 0) | MATCHED
}
}
// Determine whether this is a mount vs move and mark INSERT_VNODE
let oldDom = matchedOldVNode && findFirstDomAnchor(matchedOldVNode)
let isMounting = !matchedOldVNode || !oldDom
if (isMounting) {
if (matchingIndex === -1) {
// Adjust skew similar to Preact when lengths differ
if (nextLength > oldChildrenLength) {
skew--
} else if (nextLength < oldChildrenLength) {
skew++
}
}
childVNode._flags = (childVNode._flags ?? 0) | INSERT_VNODE
} else if (matchingIndex !== i + skew) {
if (matchingIndex === i + skew - 1) {
skew--
} else if (matchingIndex === i + skew + 1) {
skew++
} else {
if (matchingIndex! > i + skew) skew--
else skew++
childVNode._flags = (childVNode._flags ?? 0) | INSERT_VNODE
}
}
}
// Unmount any old children that weren't matched
if (remainingOldChildren) {
for (let i = 0; i < oldChildrenLength; i++) {
let oldVNode = oldChildren[i]
if (oldVNode && ((oldVNode._flags ?? 0) & MATCHED) === 0) {
remove(oldVNode, domParent, scheduler)
}
}
}
// Second pass: diff matched pairs and place/move DOM nodes in the correct
// order, similar to Preact's diffChildren + insert.
vParent._children = newChildren
let lastPlaced: Node | null = null
for (let i = 0; i < nextLength; i++) {
let childVNode = newChildren[i]
if (!childVNode) continue
let idx = childVNode._index ?? -1
let oldVNode = idx >= 0 ? oldChildren[idx] : null
diffVNodes(
oldVNode,
childVNode,
domParent,
frame,
scheduler,
vParent,
rootTarget,
anchor,
cursor,
)
let shouldPlace = (childVNode._flags ?? 0) & INSERT_VNODE
let firstDom = findFirstDomAnchor(childVNode)
if (shouldPlace && firstDom && firstDom.parentNode === domParent) {
if (lastPlaced === null) {
if (firstDom !== domParent.firstChild) {
domParent.insertBefore(firstDom, domParent.firstChild)
}
} else {
let target: Node | null = lastPlaced.nextSibling
if (firstDom !== target) {
domParent.insertBefore(firstDom, target)
}
}
}
if (firstDom) lastPlaced = firstDom
// Clear internal flags for next diff
childVNode._flags = 0
childVNode._index = undefined
}
return
}
フラグメント (<>...</>)
if (isFragmentNode(node)) {
for (let child of node._children) {
cursor = insert(child, domParent, frame, scheduler, vParent, rootTarget, anchor, cursor)
}
return cursor
}
フラグメント自体はDOMを持たないため、子を順に insert するだけです。
コンポーネント (<App />, <Counter /> 等)
if (isComponentNode(node)) {
diffComponent(null, node, frame, scheduler, domParent, vParent, rootTarget, anchor, cursor)
return cursor
}
diffComponent に委譲しています。 diffComponent の中では createComponent でハンドルを作り、 renderComponent でユーザー関数を呼び、その結果をまた diffVNodes → insert で再帰的に処理します。
insert (ComponentNode)
└── diffComponent(null, node)
└── createComponent() ... handle を作る
└── renderComponent(handle, null, node)
├── handle.render(props) ... ユーザー関数を呼ぶ
├── toVNode(element) ... JSX → VNode
└── diffVNodes(null, content)
└── insert(content) ... ここに戻ってくる
コンポーネントがコンポーネントを返す場合はこの再帰がネストしていき、最終的にテキストかホスト要素に到達して document.createElement や document.createTextNode で実際のDOMが作られます。
ここまでの全体の流れをまとめると、こういうことになります。
root.render(<App />)
▼
toVNode(<App />) JSX → VNode
▼
diffVNodes(null, vnode, container) 前回なし → insert へ
▼
insert(vnode, container) ComponentNode なので diffComponent へ
▼
diffComponent(null, node) createComponent + renderComponent
▼
renderComponent(handle, null, node)
├── handle.render(props) App({}, undefined) → renderFn
├── renderFn(props) () => <div>test</div>
├── toVNode(<div>test</div>) HostNode { type: 'div', _children: [TextNode] }
└── diffVNodes(null, content) 前回なし → insert へ (再帰)
▼
insert(hostNode, container) HostNode なので createElement
├── document.createElement('div')
├── insert(textNode, div) 子のテキストを insert (再帰)
│ └── document.createTextNode('test')
└── container.appendChild(div)
createRoot(document.getElementById('root')!).render(<App />) の一連の流れは、突き詰めるとこの再帰的な insert に行き着きます。
自前実装するうえでの構成を考える
まとめるとこんな感じになります。
● createRoot(container)
├── createFrameHandle() Frame 用スタブ作成
├── new TypedEventTarget() エラーイベントバス作成 ├── createScheduler(doc, eventTarget) スケジューラ作成
│ └── createDocumentState(doc) フォーカス・選択保存用
├── container.addEventListener('error', ...) DOM エラーを eventTarget に転送
│
└── return { render, remove, flush }
│
├── render(element)
│ ├── toVNode(element) JSX → VNode 変換
│ ├── scheduler.enqueueTasks([diffVNodes]) タスクとして登録
│ └── scheduler.dequeue() 即座に flush 実行
│ │
│ └── flush()
│ ├── markLayoutSubtreePending() FLIP 対象にフラグ
│ ├── captureLayoutSnapshots() 変更前の位置を記録
│ ├── documentState.capture() フォーカス・選択を保存
│ │
│ ├── 各 scheduled コンポーネントに対して:
│ │ └── ancestorIsScheduled() 親が同バッチにいたらスキップ
│ │ └── renderComponent(handle, ...)
│ │
│ ├── documentState.restore() フォーカス・選択を復元
│ ├── applyLayoutAnimations() FLIP アニメーション適用
│ └── tasks を順に実行 connect, イベント登録, queueTask 等
│
├── remove()
│ └── vroot = null 参照を切るだけ
│
└── flush()
└── scheduler.dequeue() 保留中の更新を同期実行
diffVNodes(curr, next, domParent)
├── curr === null
│ └── insert(next) DOM 新規作成・挿入
│ ├── TextNode → createTextNode()
│ ├── HostNode → createElement()
│ │ ├── diffHostProps({}, props, dom) 属性を設定
│ │ │ └── diffCssProp() css prop → styleManager
│ │ ├── diffChildren(null, children) 子を再帰的に insert
│ │ └── setupHostNode() イベント・connect・アニメ設定
│ ├── Fragment → 子を順に insert()
│ └── Component → diffComponent(null, next)
│
├── curr.type !== next.type
│ └── replace(curr, next) 古い DOM 削除 + 新しい DOM 挿入
│ ├── insert(next)
│ └── remove(curr)
│ ├── TextNode → removeChild()
│ ├── HostNode → exit アニメ → removeChild + cleanup
│ ├── Fragment → 子を順に remove()
│ └── Component → remove(content) + handle.remove()
│
├── TextNode 同士
│ └── diffText() textContent を更新
│
├── HostNode 同士
│ └── diffHost()
│ ├── innerHTML の処理
│ ├── diffChildren(curr, next, dom) 子を再帰的に diff
│ │ ├── キーなし → index ベースで diffVNodes + 余剰 remove
│ │ └── キーあり → Map ベース O(n+m) diff + DOM 並び替え
│ ├── diffHostProps(curr, next, dom) 属性の差分更新
│ ├── イベント更新 (eventsContainer.set)
│ └── レイアウトアニメ登録更新
│
├── ComponentNode 同士
│ └── diffComponent(curr, next)
│ ├── curr === null (初回)
│ │ ├── createComponent(config) handle 作成
│ │ │ └── handle = { update, queueTask, signal, context, on, ... }
│ │ └── renderComponent(handle, null, next, ...)
│ │
│ └── curr !== null (更新)
│ └── renderComponent(curr._handle, curr._content, next, ...)
│
└── Fragment 同士
└── diffChildren(curr._children, next._children)
renderComponent(handle, currContent, next, domParent)
├── handle.render(props) ユーザー関数呼び出し → [JSX, tasks]
│ ├── renderCtrl.abort() 前回のタスクシグナルを中断
│ ├── (初回) config.type(handle, setup) セットアップ → renderFn 保存
│ └── renderFn(props) → JSX
│
├── toVNode(element) JSX → VNode
├── diffVNodes(currContent, content, ...) 再帰的に VDOM diff + DOM 更新
│
├── next._content = content レンダー結果を保存
├── next._handle = handle ハンドルを紐づけ
├── next._parent = vParent 親 VNode を紐づけ
│
├── handle.setScheduleUpdate(() => { handle.update() と scheduler を接続
│ scheduler.enqueue(committed, domParent, anchor)
│ })
│
└── scheduler.enqueueTasks(tasks) queueTask のタスクをキューに追加
ここから、画面を表示更新出来る程度の createRoot を作る場合の超最小構成を考えてみます。
new TypedEventTarget, container.addEventListener('error', ...)
上から見ていくと、削れそうだなーってなります。
再レンダリング時にフォーカスが保持出来なくなる可能性が高かったり、レンダリングエラーをキャッチ出来なくなったりしそうですが、そんなものは画面が表示できることを確認した後で困れば良いかなって思います。
Scheduler
順ですが、一番実装カロリーが高そうかつ、画面を表示する程度なら削れるんじゃない?となる大きいやつは Scheduler の機構かなと思います。
実装を見てみたものでは、およそやっていることは以下のようなものでした。
-
enqueue。コンポーネントを再レンダリング予約する -
enqueueTasks。タスクを配列追加する -
flush。renderComponentによって差分比較・DOM更新を実行する。その他。 -
captureLayoutSnapshots / applyLayoutAnimations。FLIPアニメーションis何?どれ?何の機能....? -
queueMicrotask。タスクの非同期処理
flush 内の renderComponent は実行したいですが、ほかは後からでいいかな、というものに見えます。
diffVNodes
- 前回なし → 全部新規挿入
- 型が違う → 古いのを消して新しいのを挿入
- テキスト同士 →
textContentを更新 - DOM要素同士 → 属性と子を差分更新
- コンポーネント同士 →
renderComponentで再レンダー -
Fragment同士 → 子の配列を差分更新
全部 insert で良くないですか?と思いました。
何かしらのたびに全画面全要素全置換を走らせることになるので、 input 要素の値とかは吹っ飛んじゃいそうですね。こう考えると、本当に偉大。
insert
巨大かつ繊細な実装なので、気を付けて削りたいです。
が、基本的に本家の実装ではSSR済のDOMの再利用や、SVG要素の作成、diffChildren を介しての子要素の注入、 anchor 、 handle 等、ユーザーにストレスを与えないための工夫からRemix独自のコンテキストまでが入り混みあっています。
SSR済の要素の利用(cursor)でなく、 insert の再帰実行、ベタな appendChild でだいぶ安直な実装が出来そうです。
完成系
createRoot(container)
▼
render(element)
▼
toVNode(element)
▼
diffVNodes(curr, next, container)
└── curr === null
▼
insert(next)
└── curr !== null
▼
replace(next)
▼
insert
こちらをコードにしたものが以下です。
動作を確認。
さいごに
スクロール位置の保持、再レンダリングの要素の比較、またRemix3のレンダリングのタイミングについては、独特の実装かつ気付きを得られるものが多かった所感です。
今回は端折った実装を最後にやった程度でしたが、今回の成果物をベースにほかの機構・機能も盛り込んでいってみるのが楽しそうかなと思いました。


