[译]Build your own react

date
Mar 4, 2021
slug
build-your-own-react
status
Published
tags
React
翻译
summary
type
Post
MindMap
notion image
 
我们将一步步从头重写 React。整个步骤将会遵循真实的 React 代码架构,但不包含所有的优化和非必要的功能。
我们从头开始构建的 React 包含以下内容:
  1. 第一步:createElement函数
  1. 第二步:render函数
  1. 第三步:Concurrent Mode
  1. 第四步:Fibers
  1. 第五步:Render 与 Commit 两大阶段(Phases)
  1. 第六步:调和算法 Reconciliation
  1. 第七步:函数组件 Function Components
  1. 第八步:Hooks
 
 

Step Zero: 回顾

首先让我们回顾一些基本概念。如果你已经对 React、JSX 和 DOM 元素的工作方式有了很好的了解,则可以跳过此步骤。
const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
我们将使用以上这个仅包含三行代码的 React App。
  • 第一行:定义了一个 React element。
  • 第二行:从 DOM 中获取了一个 DOM node。
  • 第三行:将 React element 渲染到 DOM node 上。
接下来让我们把 React 代码替换成原生 JavaScript 代码。
 
在第一行中的 React element 是由 JSX 定义的。这其实不是合法的 JavaScript 代码,首先我们需要把它替换成合法的 JavaScript 代码。
JSX 转换为 JS 代码的过程由 Babel 之类的构建工具来完成。转换过程通常很简单:使用 createElement 函数的调用来替换 Tag 内的代码,并将 Tag 名、props 和 children 作为参数传入:
const element = React.createElement(
  "h1",
  { title: "foo" },
  "Hello"
)
 
除了一下参数校验,React.createElement所做的就是根据传入的参数创建了一对象。因此我们可以安全地将函数调用代码替换为其输出:
const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}
以上就是一个 React Element ,一个具有两个属性(type与props)的对象。(嗯,它其实具有更多的属性,但在这里我们只关心这两个。
type 是一个字符串,用于指定我们要创建的 DOM node 的类型,它就是创建 HTML 元素
时传递给 document.createElementtagName 。它也可以是一个函数,这部分我们将留在 Step VII 中介绍。
props 是另一个对象,它具有 JSX attributes 中的所有键值对。它还有一个特殊的属性: children
children在这个例子中是一个字符串,但它通常是一个包含更多 elements 的数组。这就是为什么 elements 也是 trees 的原因。
 
我们需要替换的另一部分 React 代码是对ReacDOM.render 的调用。
render 函数是 React 改变 DOM 的地方,所以在这里我们手动实现一下 DOM 的更新:

const node = document.createElement(element.type)
node["title"] = element.props.title
首先我们使用 element type 属性创建了一个 DOM 节点 ,在这个例子中是 h1
然后我们将所有 element props 分配到这个 DOM 节点 中,在这里是只有一个 title。
💡
为了避免混淆,我将使用元素(element)来指代 React elements,节点(node)来指代 DOM elements。
接下来我们为 children 创建 DOM 节点。我们只有一个字符串作为 children,所以我们将创建一个文本节点。
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
使用 textNode 而不是设置 innerText ,我们会在接下来的步骤中以相同的规则方式对待所有 React Element。这个过程与给 h1 设置 title prop 类似,这就像是字符串中带有这样一个 props: {nodeValue: "hello"}
在最后,我们将textNode 添加至 h1,将 h1 添加至 container
node.appendChild(text)
container.appendChild(node)
 
现在我们得到了一个与之前相同的 App,只不过没有用 React 特定的代码。
const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}

const container = document.getElementById("root")

const node = document.createElement(element.type)
node["title"] = element.props.title

const text = document.createTextNode("")
text["nodeValue"] = element.props.children

node.appendChild(text)
container.appendChild(node)

Step I: createElement

让我们从另一个 App 重新开始。这次,我们将用自己的 React 版本替换原有的 React 代码。
const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)
const container = document.getElementById("root")
ReactDOM.render(element, container)
我们将从编写自己的 createElement 函数开始。

JSX ⇒ JS

让我们将 JSX 转换为 JS,这样我们就能实现 createElement 函数的调用了。
在之前的步骤中我们可以看到,一个 element 是一个含有 type 和 props 的对象。我们这个函数唯一要做的是就是创建这个对象
const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b")
);
 

children 的处理

我们使用展开运算符(spread operator)来处理 props,剩余参数语法(rest parameter syntax)处理children。这样一来 children 就永远都是数组了。
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children,
    },
  }
}
举个例子, createElement("div") 返回:
{
  "type": "div",
  "props": { "children": [] }
}
createElement("div", null, a) 返回:
{
  "type": "div",
  "props": { "children": [a] }
}
createElement("div", null, a, b) 返回
{
  "type": "div",
  "props": { "children": [a, b] }
}
 
 
children 数组也可以包含字符串和数字这样的原始类型。所以我们为所有不是对象的内容创建一个独立的元素,并为其创建一个特殊的类型: TEXT_ELEMENT
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
			children: children.map(child =>
        typeof child === "object"
          ? child
          : createTextElement(child)
      ),
    },
  }
}

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}
React 并不会为原始类型进行包装,或者在没有 children 时创建一个空数组,我们这样做是为了简化我们的代码,对于我们的库来讲,我们更偏向于简单的代码而非高性能代码。
 
目前我们扔在使用 React 的 createElement 函数。为了替换它,我们将为我们的库取个名。我们需要一个听起来像 React 同时又能暗示它的教学目的。
我们称它为 Didact。
const Didact = {
  createElement,
}

const element = Didact.createElement(
  "div",
  { id: "foo" },
  Didact.createElement("a", null, "bar"),
  Didact.createElement("b")
)
但是我们仍让想在这里使用 JSX。我们该如何告诉 babel 去使用 Didact的createElement 而非 React 的呢?
如果我们有这样的注释,当 babel 转译 JSX 时,它将使用我们定义的功能。
/** @jsx Didact.createElement */
const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)
 
 

Step II: render

创建 DOM node

接下来,我们需要编写自己版本 ReactDOM.render 函数。目前,我们只需要关心向 DOM 中添加内容。我们稍后会处理更新和删除。
function render(element, container) {
	// TODO create dom nodes
}

const Didact = {
  createElement,
	render,
}
我们首先使用 element type 创建 DOM 节点,然后将新节点附加到 container 中。
function render(element, container) {
  const dom = document.createElement(element.type)

  container.appendChild(dom)
}
接下来递归地为每个 child 做同样的事。
function render(element, container) {
  const dom = document.createElement(element.type)

	element.props.children.forEach(child =>
    render(child, dom)
  )

  container.appendChild(dom)
}
 
同样的,我们需要处理文本元素,如果 element type 为 TEXT_ELEMENT ,我们将创建一个文本节点而非一个普通的节点:
function render(element, container) {
	const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)

	element.props.children.forEach(child =>
    render(child, dom)
  )

  container.appendChild(dom)
}
 
我们在这里要做的最后一件事是将 element props 分配给 DOM node:
function render(element, container) {
	const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)

	const isProperty = key => key !== "children"
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    })

	element.props.children.forEach(child =>
    render(child, dom)
  )

  container.appendChild(dom)
}
 
就是这样。现在,我们有了一个可以将 JSX 渲染到 DOM 的库。
codesandbox 上可以进行尝试。

Step III: Concurrent Mode

在我们开始实现功能之前我们需要一次重构。
element.props.children.forEach(child =>
  render(child, dom)
)
这里的递归调用便是症结所在。
一旦我们开始渲染,在整棵 element tree 渲染完成之前程序是不会停止的。如果这棵 element tree 过于庞大,它有可能会阻塞主进程太长时间。如果浏览器需要做类似于用户输入或者保持动画流畅这样的高优先级任务,则必须等到渲染完成为止。
 
因此,我们将渲染工作分成几个小部分,在完成每个单元后,如果需要执行其他操作,我们将让浏览器中断渲染。
// element.props.children.forEach(child =>
//  render(child, dom)
// )

let nextUnitOfWork = null

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(nextUnitOfWork) {
  // TODO
}
我们使用 requestIdleCallback 构建循环。你可以把 requestIdleCallback 当作是一个 setTimeout ,但在这里浏览器将在主线程空闲时进行回调,而不是指定回调何时运行。
 
现在React 不再使用requestIdleCallback ,而是使用 scheduler package。但是对于此用例,两者在概念上是相同的。
requestIdleCallback 还为我们提供了 deadline 参数。我们可以用它来检查在浏览器需要再次控制之前我们有多少时间。
在 2019 年 11 月份,Concurrent Mode 在 React 还未真正稳定。稳定版本的循环更像是这样:
while (nextUnitOfWork) {    
  nextUnitOfWork = performUnitOfWork(   
    nextUnitOfWork  
  ) 
}
要开始循环检查之前,我们需要设置第一个工作单元,然后编写 performUnitOfWork 函数。该函数不仅会执行工作单元,还会返回下一个工作单元。

Step IV: Fibers

为了组织各个工作单元,我们需要一个数据结构:fiber tree。
我们将为每一个 element 分配一个 fiber,而每个 fiber 将成为一个工作单元。

Fiber 的执行

举个例子,假设我们像渲染这样一个 element tree:
Didact.render(
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>,
  container
)
render 函数中我们将会创建 root fiber,将其设置为 nextUnitOfWork。剩下的工作将在 performUnitOfWork 中进行,在那里我们将为每个 fiber 做三件事:
  1. 将 element 添加至 DOM
  1. 为 element 的 children 创建 fiber
  1. 选出下一个工作单元
notion image
设计这个数据结构的目标之一是:使查找下一个工作单元变得更加容易。这就是为什么每一个 Fiber 都会链接到其第一个子节点,下一个兄弟姐妹节点和其父节点。(在下文,用childsiblingparent 分别指代子节点、兄弟姐妹节点和父节点)。
 
当我们完成对 Fiber 的工作时,如果它有 child ,那么这个 Fiber 会被当作是下一个工作单元。
在我们的示例中,当我们完成 div fiber 时,下一个工作单元将是 div fiber。
 
如果该 fiber 没有 child ,我们会把这个 fiber 的兄弟姐妹节点当作是下一个工作单元。
在我们的示例中, p fiber 并没有 child,下一个工作单元将是 a fiber。
 
如果该 fiber 既没有 child 也没有 sibling ,那我们会寻找它的「叔叔节点」:其parentsibling 。就像这个例子中的 ah2
 
同样的,如果parent没有sibling ,我们将不断检查父节点的父节点,直到找到有siblingparent节点,或者直接找到根节点 root 位置。如果达到根节点,则意味着我们以及完成了此次渲染的所有工作。
 
我们把上述理论转换为代码:
  1. 首先我们删除 render 函数中的原有代码。将创建 DOM node 的部分代码抽离处理,稍后进行填充:
    1. function createDom(fiber) {
        const dom =
          fiber.type == "TEXT_ELEMENT"
            ? document.createTextNode("")
            : document.createElement(fiber.type)
      
        const isProperty = key => key !== "children"
        Object.keys(fiber.props)
          .filter(isProperty)
          .forEach(name => {
            dom[name] = fiber.props[name]
          })
      
        return dom
      }
      
      function render(element, container) {
        // TODO set next unit of work
      }
      
      let nextUnitOfWork = null
  1. render 函数中,我们将 nextUnitOfWork 设置为 Fiber Tree 的根节点:
    1. function render(element, container) {
        nextUnitOfWork = {
          dom: container,
          props: {
            children: [element],
          },
        }
      }
      
      let nextUnitOfWork = null
  1. 接下来,当浏览器准备好的时候,它将会调用我们的 workLoop 函数,从根节点开始执行 performUnitOfWork
    1. function workLoop(deadline) {
        let shouldYield = false
        while (nextUnitOfWork && !shouldYield) {
          nextUnitOfWork = performUnitOfWork(
            nextUnitOfWork
          )
          shouldYield = deadline.timeRemaining() < 1
        }
        requestIdleCallback(workLoop)
      }
      
      requestIdleCallback(workLoop)
      
      function performUnitOfWork(fiber) {
        // TODO add dom node
        // TODO create new fibers
        // TODO return next unit of work
      }
  1. 首先,我们创建一个 node 节点然后将其添加至 DOM,将这个 DOM node 保存在 fiber.dom 属性中以持续跟踪。
    1. function performUnitOfWork(fiber) {
        if (!fiber.dom) {
          fiber.dom = createDom(fiber)
        }
      
        if (fiber.parent) {
          fiber.parent.dom.appendChild(fiber.dom)
        }
      
        // TODO create new fibers
        // TODO return next unit of work
      }
  1. 接着,为每一个chid创建一个新的 fiber
    1. function performUnitOfWork(fiber) {
        if (!fiber.dom) {
          fiber.dom = createDom(fiber)
        }
      
        if (fiber.parent) {
          fiber.parent.dom.appendChild(fiber.dom)
        }
      
      	const elements = fiber.props.children
        let index = 0
        let prevSibling = null
      	
        while (index < elements.length) {
          const element = elements[index]
      
          const newFiber = {
            type: element.type,
            props: element.props,
            parent: fiber,
            dom: null,
          }
        }
        // TODO return next unit of work
      }
  1. 将其添加到 Fiber Tree 中,它是 child 还是 sibling ,取决于它是否是第一个 child
    1. function performUnitOfWork(fiber) {
        if (!fiber.dom) {
          fiber.dom = createDom(fiber)
        }
      
        if (fiber.parent) {
          fiber.parent.dom.appendChild(fiber.dom)
        }
      
      	const elements = fiber.props.children
        let index = 0
        let prevSibling = null
      	
        while (index < elements.length) {
          const element = elements[index]
      
          const newFiber = {
            type: element.type,
            props: element.props,
            parent: fiber,
            dom: null,
          }
        }
      
      	if (index === 0) {
            fiber.child = newFiber
          } else {
            prevSibling.sibling = newFiber
          }
      
          prevSibling = newFiber
          index++
        }
        // TODO return next unit of work
      }
  1. 最后,我们选出下一个工作单元。首先寻找 child ,其次 sibling ,然后是 uncleparentsibling)。
    1. function performUnitOfWork(fiber) {
        if (!fiber.dom) {
          fiber.dom = createDom(fiber)
        }
      
        if (fiber.parent) {
          fiber.parent.dom.appendChild(fiber.dom)
        }
      
        const elements = fiber.props.children
        let index = 0
        let prevSibling = null
      
        while (index < elements.length) {
          const element = elements[index]
      
          const newFiber = {
            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
        }
      }
      以上就是我们的 performUnitOfWork 函数。

      Step V: Render and Commit Phases

      💡
      将「添加节点至 DOM」这个动作延迟至所有节点 render 完成。这个动作也被称为 commit。

      为什么要分阶段

      现在我们又有另一个问题。
      每当我们在处理一个 React element 时,我们都会添加一个新的节点到 DOM 中,而浏览器在渲染完成整个树之前可能会中断我们的工作。在这种情况下,用户将会看不到完整的 UI。

      如何分阶段

      所以我们需要删除那部分对 DOM 进行修改的代码:
      function performUnitOfWork(fiber) {
        if (!fiber.dom) {
          fiber.dom = createDom(fiber)
        }
      
       // if (fiber.parent) {
       //   fiber.parent.dom.appendChild(fiber.dom)
       // }
      //...
      }
      相反地,我们会跟踪 Fiber Tree 的根节点。我们称它为「进行中的 root」—— wipRoot。
      function render(element, container) {
        wipRoot = {
          dom: container,
          props: {
            children: [element],
          },
        }
        nextUnitOfWork = wipRoot
      }
      
      let nextUnitOfWork = null
      let wipRoot = null
      一旦完成所有工作(直到没有 nextUnitOfWork ),我们便将整个 Fiber Tree 交给 DOM。
      function commitRoot() {
        commitWork(wipRoot.child)
        wipRoot = null
      }
      
      function commitWork(fiber) {
        if (!fiber) {
          return
        }
        const domParent = fiber.parent.dom
        domParent.appendChild(fiber.dom)
        commitWork(fiber.child)
        commitWork(fiber.sibling)
      }
      
      function render(element, container) {
        wipRoot = {
          dom: container,
          props: {
            children: [element],
          },
        }
        nextUnitOfWork = wipRoot
      }
      
      let nextUnitOfWork = null
      let wipRoot = null
      
      function workLoop(deadline) {
        let shouldYield = false
        while (nextUnitOfWork && !shouldYield) {
          nextUnitOfWork = performUnitOfWork(
            nextUnitOfWork
          )
          shouldYield = deadline.timeRemaining() < 1
        }
      
        if (!nextUnitOfWork && wipRoot) {
          commitRoot()
        }
      
        requestIdleCallback(workLoop)
      }
      
      requestIdleCallback(workLoop)
      我们将这个步骤在 commitRoot 函数中完成。在这里我们将所有节点递归附加到 DOM 中。

      Step VI: Reconciliation

      💡
      更新和删除节点的过程:调和 Reconciliation
      目前我们只做了「添加节点至 DOM」这个动作,那么更新或删除节点是怎么实现的呢?
      这就是我们现在要做的,我们需要将在 render 函数上接收到的 elements 与我们提交给 DOM 的最后一棵 Fiber Tree 进行比较。

      保存当前渲染的 Fiber Tree

      因此,在完成 commit 之后,我们需要对「最后一次 commit 到 DOM 的一棵 Fiber Tree」 的引用进行保存。我们称它为 currentRoot 。同时我们也对每个 Fiber 添加了一个 alternate 属性。这个属性是对旧 Fiber 的链接,这个旧 Fiber 是我们在在上一个 commit phase 向 DOM commit 的 Fiber。
      function commitRoot() {
        commitWork(wipRoot.child)
      	// currentRoot: 最后一次 commit 到 DOM 的一棵 Fiber Tree
        currentRoot = wipRoot
        wipRoot = null
      }
      
      function commitWork(fiber) {
        if (!fiber) {
          return
        }
        const domParent = fiber.parent.dom
        domParent.appendChild(fiber.dom)
        commitWork(fiber.child)
        commitWork(fiber.sibling)
      }
      
      function render(element, container) {
        wipRoot = {
          dom: container,
          props: {
            children: [element],
          },
          alternate: currentRoot,
        }
        nextUnitOfWork = wipRoot
      }
      
      let nextUnitOfWork = null
      let currentRoot = null
      let wipRoot = null
       

      Reconcile

      Reconcile 的过程会在执行工作单元时完成。
      现在我们把 performUnitOfWork 中用来创建新 Fiber 的部分代码抽离成一个新的 reconcileChildren 函数。
      function reconcileChildren(wipFiber, elements) {
        let index = 0
        let prevSibling = null
      
        while (index < elements.length) {
          const element = elements[index]
      
          const newFiber = {
            type: element.type,
            props: element.props,
            parent: wipFiber,
            dom: null,
          }
      
          if (index === 0) {
            wipFiber.child = newFiber
          } else {
            prevSibling.sibling = newFiber
          }
      
          prevSibling = newFiber
          index++
        }
      }
      
      在这里我们将会旧 Fibers 与新 elements进行调和(reconcile):
      function reconcileChildren(wipFiber, elements) {
        let index = 0
        let oldFiber =
          wipFiber.alternate && wipFiber.alternate.child
        let prevSibling = null
      
        while (
          index < elements.length ||
          oldFiber != null
        ) {
          const element = elements[index]
          let newFiber = null
      
          // TODO compare oldFiber to element
      		....
      	}
      }
       
      我们使用 type 属性对它们进行比较:
      • 如果老的 Fiber 和新的 element 拥有相同的 type,我们可以保留 DOM 节点并仅使用新的 Props 进行更新。这里我们会创建一个新的 Fiber 来使 DOM 节点与旧的 Fiber 保持一致,而props 与新的 element 保持一致。
        • 我们还向 Fiber 中添加了一个新的属性 effectTag ,这里的值为 UPDATE 。为稍后我们将在 commit 阶段使用这个属性。
      • 如果两者的 type 不一样并且有一个新的 element,这意味着我们需要创建一个新的 DOM 节点。
        • 在这种情况下,我们会用 PLACEMENT effect tag 来标记新的 Fiber。
      • 如果两者的 type 不一样,并且有一个旧的 Fiber,我们需要删除旧节点。
        • 在这种情况下,我们没有新的 Fiber,所以我们会把 DELETIONeffect tag 添加到旧 Fiber 中。
      // 对于新旧 element 的处理
      const sameType =
            oldFiber &&
            element &&
            element.type == oldFiber.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) // 这里使用了一个数组来追踪我们想要删除的 node
          }
      在这里 React 使用了 keys ,这样可以更好地实现 reconciliation 。例如,它会检测 children 何时更改 element 数组中的位置。
       

      变更 commitWork 以处理不同类型的变化

      接下来,当我们要 commit 这些变更到 DOM 时,我们就会用到 deletions 这个数组中的 fibers。
      function commitRoot() {
        deletions.forEach(commitWork)
        commitWork(wipRoot.child)
        currentRoot = wipRoot
        wipRoot = null
      }
       
      为了处理前面定义的各种 effectTags,我们也需要对 commitWork 函数进行变更:
      function commitWork(fiber) {
      if (!fiber) {
          return
        }
        const domParent = fiber.parent.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") {
          domParent.removeChild(fiber.dom)
        }
      
        commitWork(fiber.child)
        commitWork(fiber.sibling)
      }
      • PLACEMENT :这个 DOM 节点添加到父 Fiber 的节点上
      • DELETION:删除这个 child
      • UPDATE :使用最新的 props 来更新现有的 DOM 节点
        • 这部分动作将有 updateDOM 函数来完成:我们将旧 Fiber 的props 与 新 Fiber 的 props 可进行比较,删除旧的 props,并设置新的或者变更之后的 props。
        • 针对 event listener 这种特殊的 prop,我们将以不同的方式处理:如果 event listener 发生了变更我们会把它从 node 中移除,然后设置一个新的 event listener。
        • function updateDom(dom, prevProps, nextProps) {
            //Remove old or changed event listeners
            Object.keys(prevProps)
              .filter(isEvent)
              .filter(
                key =>
                  !(key in nextProps) ||
                  isNew(prevProps, nextProps)(key)
              )
              .forEach(name => {
                const eventType = name
                  .toLowerCase()
                  .substring(2)
                dom.removeEventListener(
                  eventType,
                  prevProps[name]
                )
              })
          
            // Remove old properties
            Object.keys(prevProps)
              .filter(isProperty)
              .filter(isGone(prevProps, nextProps))
              .forEach(name => {
                dom[name] = ""
              })
          
            // Set new or changed properties
            Object.keys(nextProps)
              .filter(isProperty)
              .filter(isNew(prevProps, nextProps))
              .forEach(name => {
                dom[name] = nextProps[name]
              })
          
            // Add event listeners
            Object.keys(nextProps)
              .filter(isEvent)
              .filter(isNew(prevProps, nextProps))
              .forEach(name => {
                const eventType = name
                  .toLowerCase()
                  .substring(2)
                dom.addEventListener(
                  eventType,
                  nextProps[name]
                )
              })
          }
           
          codesandbox 上可以试用带有 Reconciliation 的最新版本。`

Step VII: Function Components

下一个我们要增添的是对 Function Components 的支持。
首先让我们更改样例,将 JSX 声明修改为 Function Component:
/** @jsx Didact.createElement */
function App(props) {
  return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
const container = document.getElementById("root")
Didact.render(element, container)
Function Component 在两种方面存在差异:
  1. 来自 Function Component 的 Fiber 并没有 DOM node
  1. children 从运行函数中而来,而非直接从 props 中获取
 

Function Component 的判断

我们检查 fiber.type 是否是 function ,根据不同的结果来使用不同的更新函数。
function performUnitOfWork(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
  }
}

function updateFunctionComponent(fiber) {
  // TODO
}

function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  reconcileChildren(fiber, fiber.props.children)
}
updateFunctionComponent 中,我们执行函数以获取children 。一旦我们拿到了 children ,reconciliation 的过程其实是一样的。
function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

Commit 时需要寻找 DOM node

由于来自 Function Component 的 Fiber 并没有 DOM node,我们需要修改的是 commitWork 这个函数:
  1. 修改寻找 DOM 父节点的逻辑:顺着 Fiber Tree 向上找直到找到有 DOM node 的 Fiber。
  1. 当删除节点是,我们也需要向下寻找知道找到有 child 的 DOM node。
function commitWork(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)
}

function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom)
  } else {
    commitDeletion(fiber.child, domParent)
  }
}

Step VIII: Hooks

最后一步,既然我们有了 Function Components,那么我们还可以添加 Hooks。
首先把我们的 App 改写成一个传统的 Counter Component,使用自定义的 useState 来变更状态:
const Didact = {
  createElement,
  render,
  useState,
}

/** @jsx Didact.createElement */
function Counter() {
  const [state, setState] = Didact.useState(1)
  return (
    <h1 onClick={() => setState(c => c + 1)}>
      Count: {state}
    </h1>
  )
}
const element = <Counter />
const container = document.getElementById("root")
Didact.render(element, container);

准备工作

💡
定义 hook 需要用到的全局变量;引入 Fiber 进行调度工作
回顾一下,Function Component 是在 updateFunctionComponent 函数中被调用的,那么同样我们也会在这个函数中调用 useState。
function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

function useState(initial) {
  // TODO
}
我们需要在调用 Function Component 之前初始化一些全局变量,以便可以再 useState 函数中使用它们。
首先我们把要执行的工作添加至正在执行的 Fiber (wipFiber);再给 Fiber 添加一个 hooks 数组,以支持在同意组建中多次调用 useState。同时我们还能跟踪当前 Hook 的索引。
let wipFiber = null
let hookIndex = null

function updateFunctionComponent(fiber) {
  wipFiber = fiber
  hookIndex = 0
  wipFiber.hooks = []
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}
 

useState 内部逻辑

每当 Function Component 调用 useState 时,会检查是否有旧的 hook。我们使用 hook 的索引在 fiber 的 alternate 属性中进行查询。
如果存在旧的 hook,那么我们将 state 从旧 hook 中复制到新的 hook;否则我们将初始化 state。
然后我们将向 Fiber 添加新的 hook,同时索引也递增加1,并返回状态。
function useState(initial) {
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex]
  const hook = {
    state: oldHook ? oldHook.state : initial,
  }

  wipFiber.hooks.push(hook)
  hookIndex++
  return [hook.state]
}
 
useState 还应该返回一个用于更新 state 的函数。因此我们定义了一个 setState 函数,该函数接受变更 state 的动作。
我们将这些动作添加添加至我们给 hook 对应的队列中。
接下来的操作与我们在 render 函数中所做的类似,将新的工作进行中的 root 设置为下一个工作单元,以便 work loop 可以开始新的渲染阶段。
function useState(initial) {
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex]
  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [],
  }

  const setState = action => {
    hook.queue.push(action)
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
    nextUnitOfWork = wipRoot
    deletions = []
  }

  wipFiber.hooks.push(hook)
  hookIndex++
  return [hook.state, setState]
}
 
对于存储在 hook.queue 中的 actions,我们将在下一次渲染该组件时进行执行。
首先从旧 hook 中拿到所有 actions,并将它们逐个应用到新 hook 中的 state 中。所以当我们返回 state 时,该 state 已经被更新了。
function useState(initial) {
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex]
  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [],
  }

	const actions = oldHook ? oldHook.queue : []
  actions.forEach(action => {
    hook.state = action(hook.state)
  })

  const setState = action => {
    hook.queue.push(action)
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
    nextUnitOfWork = wipRoot
    deletions = []
  }

  wipFiber.hooks.push(hook)
  hookIndex++
  return [hook.state, setState]
}
 
就这样。我们已经构建了自己版本的 React。你可以在 codesandboxGithub 上体验。
 

Epilogue

除了帮助你了解 React 的工作原理外,本文的目的之一是使你更轻松地深入 React 源代码。这就是为什么我们几乎在所有地方都使用相同的变量和函数名的原因。
例如,如果你在 Function Component 中打断点,你能看到以下调用栈:
  • workLoop
  • performUnitOfWork
  • updateFunctionComponent
我们并没有把所有 React 的功能和优化点包含进来。例如:
  • 在 Didact 中,我们在 render 阶段遍历整棵树,而 React 会根据一些标记来跳过一些没有发生变化的子树。
  • 我们还在 commit 阶段遍历整棵树。React 仅保留有影响的 Fiber 链接列表,并且也仅只访问这些 Fiber。
  • 每当我们构建一个新的 WIP Tree 时,我们会为每个 Fiber 创建新的对象。而 React 会从旧的 Fiber Tree 中回收 Fiber。
  • 当 Didact 在 render 阶段收到新的更新时,它会丢弃 WIP Tree 中的工作,并重新从根节点开始。而 React 使用有效期时间戳来标记每次更新,并用它来决定哪个更新具有更高的优先级。
  • ...
 
另外,你还可以添加更多的特性:
  1. style prop 的处理
  1. flatten children 数组
  1. useEffect hook
  1. reconciliation by key

© Sytone 2021 - 2024