18
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

株式会社ACCESSAdvent Calendar 2019

Day 24

TypeScript で自分だけの React を作る

Last updated at Posted at 2019-12-24

この記事は 株式会社 ACCESS Advent Calendar 2019 24 日目の記事です。

先週、@Momijinn (全くの別人でした。失礼しました。) 社内のとあるエンジニアに「Build your own React」という記事の存在を教えてもらいました。React の内部実装を把握するためにスクラッチで自分の手を使って実装する手順を紹介している記事です。

元記事は JavaScript で記述されていたのですが、そのまま写経するのもつまらないので TypeScript に書き換えながら実装してみました。元記事の部分的な日本語訳や実装しながら得た知見をこの記事で紹介したいと思います。
この記事は実装の最終形を機能ごとに分割して説明しますが、元記事は React を構成する重要な要素を順番に紹介しています。深い理解を得たい方は元記事を読みながら自分の手で実装することがおすすめです。
自分が実装したコードは こちら で公開してます。

今回実装した React の API は

動作環境
requestIdleCallbackString.prototype.startsWith が動作するブラウザであることが前提です。

JSX (TSX) と DOM

公式 でも言及されていますが、JSX の記述は React.createElement のシンタックスシュガーです。JSX を使用した記述は下記例のように書き換えられます。

JSX
const hello = <div id="hoge">Hello {this.props.toWhat}</div>;
JavaScript
const hello = React.createElement('div', { id: 'hoge' }, `Hello ${this.props.toWhat}`)

React.crateElement の引数は下記のようになっています。

JavaScript
React.createElement(
  type,         // タグ名の文字列
  [props],      // タグに付与する属性
  [...children] // 子要素
)

では TypeScript で React.createElement を実装してみます。

TypeScript
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を実装します。

TypeScript
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 を追加します。

TypeScript
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は差分検出を実装することを見据えて分割して呼び出せるようになっています。

TypeScript
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 よりも高速」だそうです。お楽しみに。

18
4
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
18
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?