2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Remix3 の createRoot をモデルに仮想DOMを使ったレンダリングを完全に理解する

2
Posted at

はじめに

お疲れ様です。
Remix3 という言葉について、いまいち心当たりが無く「Umm....」状態の方は以下記事のリンクの章を読んでいただければ面白いかなと思います。

仮想DOMを用いたレンダリングは vuereact を筆頭に広く使われています。しかし、これらのライブラリは機能拡張に伴い実装が複雑化しており、レンダリングの仕組み自体を学ぶハードルが高くなっています。
Remix3React という巨大な依存関係、複雑性から脱却し、Web標準に準拠した新たなエコシステムを開発しています。

Remix3 の現在の実装は、レンダリングの仕組み自体のミニマムな学習教材として適していると考え、本記事ではRemix3 のコンポーネントの描画の機構について解いてみようかと思っております。

仮想DOM.png

sandbox

下地として、 Remix3 のコンポーネントが表示できる状態を作っておきます。

バンドラーは vite 、テンプレートに react を指定し、ごちゃっていきます。

image.png

@vitejs/plugin-react を残しほかの React 関連のライブラリを依存性から削除しました。
@vitejs/plugin-reactjsxexport しているランタイムであれば jsxImportSource に指定可能であるため、HMRの機構等の便利機能にタダ乗りが可能です。

@remix-run/component0.4.0 だけ依存関係に追加して、画面だけ表示出来るようになった状態が以下です。

import { createRoot } from '@remix-run/component'

function App() {
  return () => (
    <div>test</div>
  )
}

createRoot(document.getElementById('root')!).render(<App />)

今回は、この createRoot の自作を図ってみます。

Remix側の実装の確認

@remix-run/component0.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 />)

こんな感じになりそうですね。

README
## 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 に送信します。
また、 eventTargetrender の返り値を受け取ります。

  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 />)

されるため、 eventTargetroot に代入されるオブジェクトであると解釈出来そうです。

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 = []
    }
  }
  1. flushScheduled をクリア
  2. scheduled Mapbatch にコピーし、 scheduled をクリアする
  3. batch 内の各コンポーネントの親 DOM 配下で、レイアウトアニメーション対象の要素に「更新予定」フラグを立てる(markLayoutSubtreePending
  4. FLIPアニメーション用に、フラグが立った要素の現在位置・サイズを記録する(FLIPアニメーション用....? captureLayoutSnapshots
  5. 現在のフォーカス要素と選択範囲(カーソル位置等)を保存する(documentState.capture()
  6. batch を配列に変換し、 noScheduledAncestor キャッシュを用意する
  7. renderComponent によって差分比較・DOM更新を実行する
  8. 保存しておいたフォーカスと選択範囲を復元する(DOM 書き換えで失われるため)
  9. 記録した変更前の位置と変更後の位置を比較し、FLIPレイアウトアニメーションを適用する(FLIPアニメーション....?)
  10. tasks 配列内のタスクを順に実行する(connect、イベント登録、ユーザーの queueTask 等
  11. 全タスク完了後、 tasks を空配列にリセットする

FLIPアニメーションがピンと来ていませんが。
おおまか、 scheduledtasks の実行。 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 後、 elementtask の形式で受け取ります。
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 から受け取った tasksscheduler.enqueueTasks してタスク配列に追加。


(こうのほうが良くねえかなあって思った)

  // let committed = next as CommittedComponentNode
    let committed = {
    ...next,
    _content: content,
    _handle: handle,
    _parent: vParent,
  }

insert

diffVNodescurr === 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
}
  1. document.createElement でDOM要素を作成する(SVGなら createElementNS
  2. diffHostProps でpropsをDOMの属性に設定する(className → class 等の変換含む)
  3. diffChildren で子要素を再帰的に insert する
  4. setupHostNodeconnect コールバック、イベントリスナー(on prop)、 enter アニメーションをスケジューラのタスクとして登録する
  5. 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.createElementdocument.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。タスクを配列追加する
  • flushrenderComponent によって差分比較・DOM更新を実行する。その他。
  • captureLayoutSnapshots / applyLayoutAnimations。FLIPアニメーションis何?どれ?何の機能....?
  • queueMicrotask。タスクの非同期処理

flush 内の renderComponent は実行したいですが、ほかは後からでいいかな、というものに見えます。

diffVNodes

  • 前回なし → 全部新規挿入
  • 型が違う → 古いのを消して新しいのを挿入
  • テキスト同士 → textContent を更新
  • DOM要素同士 → 属性と子を差分更新
  • コンポーネント同士 → renderComponent で再レンダー
  • Fragment 同士 → 子の配列を差分更新

全部 insert で良くないですか?と思いました。
何かしらのたびに全画面全要素全置換を走らせることになるので、 input 要素の値とかは吹っ飛んじゃいそうですね。こう考えると、本当に偉大。

insert

巨大かつ繊細な実装なので、気を付けて削りたいです。
が、基本的に本家の実装ではSSR済のDOMの再利用や、SVG要素の作成、diffChildren を介しての子要素の注入、 anchorhandle 等、ユーザーにストレスを与えないための工夫からRemix独自のコンテキストまでが入り混みあっています。
SSR済の要素の利用(cursor)でなく、 insert の再帰実行、ベタな appendChild でだいぶ安直な実装が出来そうです。

完成系

createRoot(container)
    ▼
  render(element)
    ▼
  toVNode(element)
    ▼
  diffVNodes(curr, next, container) 
    └── curr === null
         ▼
       insert(next)
    └── curr !== null
         ▼
       replace(next)
    ▼
  insert

こちらをコードにしたものが以下です。

動作を確認。

image.png

さいごに

スクロール位置の保持、再レンダリングの要素の比較、またRemix3のレンダリングのタイミングについては、独特の実装かつ気付きを得られるものが多かった所感です。
今回は端折った実装を最後にやった程度でしたが、今回の成果物をベースにほかの機構・機能も盛り込んでいってみるのが楽しそうかなと思いました。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?