手写实现 React(JSX、渲染、Fiber、Hooks、协调器)

180 阅读14分钟

我们将从头开始逐步重写 React(基于React16.8)。按照真实的 React 代码架构进行操作,但没有所有的优化和非必要的功能。

从头开始,这些都是我们会逐步添加到我们版本的 React 中的所有内容:

  • 第一步:createElement 函数
  • 第二步:render 函数
  • 第三步:并发模式
  • 第四步:Fiber
  • 第五步:渲染和提交阶段
  • 第六步:协调
  • 第七步:函数组件
  • 第八步:Hooks

第0步: 回顾

在开始之前,我们先来回顾一些基本概念。如果你已经对 React、JSX 和 DOM 元素的工作方式有了很好的理解,可以跳过这一步。

我们将使用这个只有三行代码的 React 应用程序进行演示。

const element = <h1 title="foo">Hello</h1>

const container = document.getElementById("root")

ReactDOM.render(element, container)
  • 第一行定义了一个 React 元素
  • 第二行从 DOM 中获取了一个节点
  • 第三行将 React 元素渲染到容器中。

让我们删除所有与 React 有关的代码,并替换为普通的 JavaScript 代码。

元素

在第一行中,我们使用 JSX 定义了元素。它甚至不是有效的 JavaScript 代码,因此为了将其替换为普通的 JS 代码,我们首先需要将其转换为有效的 JS 代码。

const element = React.createElement(
    "h1",
    { title: "foo" },
    "Hello"
)

JSX 是由构建工具(如 Babel)将其转换为 JS 的。转换通常很简单:用 createElement 方法代替标签中的代码,将标签名称、属性和子元素作为参数传递。

React.createElement 从其参数中创建一个对象。除了一些验证之外,这就是它所做的全部。因此,我们可以放心地用其输出替换函数调用。

这就是元素,一个具有两个属性的对象:type 和 props(它还有更多属性,但我们只关心这两个)。

const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}
  • type 是指定要创建的 DOM 节点类型的字符串,它是您在想要创建 HTML 元素时传递给 document.createElement 的标记名称。它也可以是一个函数,但我们将在第七步中处理。

  • props 是另一个对象,它具有 JSX 属性中的所有键和值。它还有一个特殊的属性:children。

  • children 是一个字符串,但通常它是包含更多元素的数组。这就是为什么元素也是树的原因。

render

我们需要替换的另一部分 React 代码是对 ReactDOM.render 的调用。

render 是 React 更改 DOM 的地方,因此让我们自己进行更新。

  • 首先,我们使用元素类型创建一个 node*,在这种情况下是 h1。然后,我们将所有元素 props 分配给该节点。这里只有标题。为了避免混淆,我将使用“element”来指代 React 元素,“node”来指代 DOM 元素。
const node = document.createElement(element.type)

node["title"] = element.props.title
  • 接下来,我们创建子节点的节点。我们只有一个字符串作为子元素,因此我们创建一个文本节点。使用 textNode 而不是设置 innerText 将允许我们以后以相同的方式处理所有元素。注意我们是如何设置 nodeValue的,跟 h1 标题一样,直接赋值就行。
const text = document.createTextNode("")

text["nodeValue"] = element.props.children
  • 最后,我们将 textNode 添加到 h1 中,然后将 h1 添加到容器中。
const container = document.getElementById("root")
node.appendChild(text)
container.appendChild(node)

现在我们拥有了与之前相同的应用程序,但是没有使用 React。

第1步:createElement函数

递归调用React.createElement

让我们再用另一个应用程序重新开始。这次,我们将用我们自己的 React 版本替换 React 代码。 我们将从编写自己的 createElement 函数开始。让我们将 JSX 转换为 JS,以便我们可以看到 createElement 的调用。

const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)
const container = document.getElementById("root")
ReactDOM.render(element, container)

正如我们在上一步中所看到的,一个元素是一个带有类型和属性的对象。我们的函数需要做的唯一一件事情就是创建这个对象。

const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b")
)

其实就是对象属性的拼装

我们使用展开运算符来处理属性,使用剩余参数语法来处理子元素,这样子元素就始终是一个数组。

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] }
}

纯文本元素

子元素数组也可以包含原始值,如字符串或数字。因此,我们将不是对象的所有内容都包装在自己的元素内,并为它们创建一个特殊的类型:TEXT_ELEMENT。React 不会包装原始值,也不会在没有子元素时创建空数组,但我们这样做是为了简化代码。对于我们的库而言,我们更喜欢简单的代码而不是高性能的代码。

{
  ...
  children: children.map(child =>
    typeof child === "object"
      ? child
      : createTextElement(child)
  ),
}

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}

换成我们自己的react

我们仍然使用 React 的 createElement。

为了替换它,让我们给我们的库起个名字。我们需要一个听起来像 React 但也暗示了它的教学目的的名字。

我们称之为 Didact。

const Didact = {
  createElement,
}

const element = Didact.createElement(
  "div",
  { id: "foo" },
  Didact.createElement("a", null, "bar"),
  Didact.createElement("b")
)

JSX

但我们仍然想在这里使用 JSX。我们如何告诉 babel 使用 Didact 的 createElement 而不是 React 的呢?

如果我们有像这样的注释,当 babel 转换 JSX 时,它将使用我们定义的函数。

/** @jsx Didact.createElement */
const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)

第2步:render函数

render就是创建dom

接下来,我们需要编写我们自己的 ReactDOM.render 函数。目前,我们只关心将内容添加到 DOM 中。稍后我们再处理更新和删除。

function render(element, container) {
  // TODO create dom nodes
}

const Didact = {
  createElement,
  render,
}

Didact.render(element, container)

递归创建

我们首先使用元素类型创建 DOM 节点,然后将新节点附加到容器中。

接下来,我们对每个子元素递归执行相同的操作。

function render(element, container) {
  const dom = document.createElement(element.type)

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

  container.appendChild(dom)
}

文本和属性

我们还需要处理文本元素,如果元素类型是 TEXT_ELEMENT,则创建一个文本节点而不是常规节点。

在这里我们需要做的最后一件事是将元素属性赋值给节点。

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 上试试吧。

第3步:同步模式

但是,在我们开始添加更多代码之前,我们需要进行重构。这个递归调用存在一个问题。

function render(element, container) {
  ...
  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 包。但对于这个用例而言,它在概念上是相同的。

  • requestIdleCallback 中 还给我们提供了一个截止时间参数。我们可以使用它来检查直到浏览器需要重新渲染之前还有多少时间。

  • 要开始使用循环,我们需要设置第一个工作单元,然后编写 performUnitOfWork 函数,该函数不仅执行工作,而且返回下一个工作单元。

第4步:Fiber架构

Fiber结构

假设我们想要渲染以下元素树:

Didact.render(
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>,
  container
)

image.png

在 render 中,我们将创建根 Fiber 并将其设置为 nextUnitOfWork。其余的工作将在 performUnitOfWork 函数中完成,我们将对每个 Fiber 执行以下三件事:

  • 将元素添加到 DOM 中
  • 创建元素子级的 Fibers
  • 选择下一个工作单元

这个数据结构的目标之一是使找到下一个工作单元变得容易。这就是为什么每个 Fiber 都有一个链接到它的第一个子级、下一个兄弟和父级的原因。

Fiber的过程

当我们完成了对一个 Fiber 的工作时,如果它有一个子级,那么该 Fiber 将成为下一个工作单元。 image.png 从我们的例子来看,当我们完成对 div Fiber 的工作后,下一个工作单元将是 h1 Fiber。

如果 Fiber 没有子级,则使用它的兄弟作为下一个工作单元。

例如,在完成 p Fiber 的工作后,我们移到 a Fiber。

如果 Fiber 没有子级也没有兄弟,则进入“叔叔”:父级的兄弟。例如例子中的 a 和 h2 fibers。

此外,如果父级没有兄弟,则我们会通过父级向上移动,直到找到具有兄弟或到达根的父级为止。如果我们到达了根,这意味着我们已经完成了此渲染的所有工作。

代码实现

现在让我们将其转换为代码。首先,让我们从 render 函数中删除此代码。

props元素换为fiber树

原来的代码如此:

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

let nextUnitOfWork = null

将被修改为处理fiber树:

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

初始化Fiber为根节点

在render函数中,我们将 nextUnitOfWork 设置为 Fiber 树的根。

function render(element, container) {
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],
    },
  }
}

let nextUnitOfWork = null

workLoop循环处理根节点

然后,当浏览器准备好时,它将调用我们的 workLoop,我们将开始处理根节点。

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
}

首先创建fiber.dom

首先,我们创建一个新节点并将其附加到 DOM 中。

我们在 fiber.dom 属性中跟踪 DOM 节点。

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
}

每个子元素创建一个Fiber

然后对于每个子元素,我们创建一个新的 Fiber。

function performUnitOfWork(fiber) {
  // ...
  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,
    }
  }
  // ...
}

设置子级或兄弟级节点

然后,我们将其添加到 Fiber 树中,将其设置为子级或兄弟级,具体取决于它是否是第一个子元素。

function performUnitOfWork(fiber) {
  // ...
  if (index === 0) {
    fiber.child = newFiber
  } else {
    prevSibling.sibling = newFiber
  }
  prevSibling = newFiber
  index++
// ...
}

搜索工作单元的顺序

最后,我们搜索下一个工作单元。首先尝试使用子级,然后是兄弟级,然后是父级的兄弟,以此类推。

function performUnitOfWork(fiber) {
  // ...
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
  // ...
}

这就是我们的 performUnitOfWork 函数。

第5步:Render 和 Commit 阶段

渲染过程中修改树会中断

我们这里还有另一个问题。

每次处理元素时,我们都会向 DOM 添加一个新节点。而且,请记住,在我们完成渲染整个树之前,浏览器可能会中断我们的工作。在这种情况下,用户将看到不完整的 UI。我们不希望出现这种情况。

function performUnitOfWork(fiber) {
  // ...
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }
  // ...
}

因此,我们需要从这里删除突变 DOM 的部分。

function render(element, container) {
  wipRoot = {
  // ...
  nextUnitOfWork = wipRoot
}

// ...
let wipRoot = null

完成一个工作单元再提交修改fiber(保证一次任务的树是稳定的)

一旦我们完成了所有工作(我们知道这点是因为没有下一个工作单元),我们将整个 Fiber 树提交到 DOM。

function commitRoot() {
  // TODO add nodes to dom
}

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

批量commit

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

第6步:协调

保存上一次的fiber树

到目前为止,我们只向 DOM 添加了内容,但是更新或删除节点怎么办?

这就是我们现在要做的事情,我们需要比较我们在 render 函数中收到的元素和我们上次提交到 DOM 的 Fiber 树。

因此,我们需要在完成 commit 后保存对“上次提交到 DOM 的 Fiber 树”的引用。我们称之为 currentRoot。

我们还将 alternate 属性添加到每个 Fiber 中。该属性是与旧 Fiber 相关联的链接,旧 Fiber 是我们在上一个提交阶段中提交到 DOM 中的 Fiber。

function commitRoot() {
  // commitWork(wipRoot.child)
  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

提取协调代码

现在让我们从 performUnitOfWork 中提取创建新 Fiber 的代码。

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

  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
  }
}

…到一个新的 reconcileChildren 函数。

function performUnitOfWork(fiber) {
  // if (!fiber.dom) {
  //   fiber.dom = createDom(fiber)
  // }

  const elements = fiber.props.children
  reconcileChildren(fiber, elements)

  if (fiber.child) {
    return fiber.child
  }
  // let nextFiber = fiber
  // while (nextFiber) {
  //   if (nextFiber.sibling) {
  //     return nextFiber.sibling
  //   }
  //   nextFiber = nextFiber.parent
  // }
}

function reconcileChildren(wipFiber, elements) {

旧的Fiber与新的元素调和

这里我们将把旧的Fiber与新的元素调和。

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

我们同时迭代旧Fiber(wipFiber.alternate)的子节点和我们想要调和的元素数组。

如果我们忽略迭代数组和链表所需的所有样板代码,我们在这个while循环中需要关注的是最重要的内容:oldFiber和element。element是我们想要呈现到DOM中的内容,而oldFiber则是上次呈现的内容。

我们需要比较它们以查看是否有任何变化需要应用于DOM

比较新旧Fiber树

为了比较它们,我们使用类型:

  • 如果旧Fiber和新元素具有相同的类型,则可以保留DOM节点并仅使用新属性进行更新。

  • 如果类型不同并且有一个新元素,则意味着我们需要创建一个新的DOM节点。

  • 如果类型不同并且存在旧Fiber,则需要移除旧节点。

在这里,React还使用键(keys)来实现更好的调和。例如,它会检测到元素数组中的子元素交换位置的情况。

const sameType =
  oldFiber &&
  element &&
  element.type == oldFiber.type

if (sameType) {
  // TODO update the node
}
if (element && !sameType) {
  // TODO add this node
}
if (oldFiber && !sameType) {
  // TODO delete the oldFiber's node
}

相同类型时,我们创建一个新的Fiber

当旧Fiber和元素具有相同类型时,我们创建一个新的Fiber,保留旧Fiber中的DOM节点以及元素中的属性。

我们还会为Fiber添加一个新属性:effectTag。稍后在提交阶段,我们将使用此属性。

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",
  }
}

需要创建新DOM节点的元素

然后对于需要创建新DOM节点的元素,我们使用PLACEMENT effect tag标记新Fiber。

if (element && !sameType) {
  newFiber = {
    type: element.type,
    props: element.props,
    dom: null,
    parent: wipFiber,
    alternate: null,
    effectTag: "PLACEMENT",
  }
}

删除节点

对于需要删除节点的情况,我们没有新Fiber,因此将effect tag添加到旧Fiber中。

但是,在将Fiber树提交到DOM时,我们是从正在进行的根节点(work in progress root)进行的,这里没有旧Fiber。

if (oldFiber && !sameType) {
  oldFiber.effectTag = "DELETION"
  deletions.push(oldFiber)
}

因此,我们需要一个数组来跟踪我们要删除的节点。

function render(element, container) {
  deletions = []
}

let deletions = null

然后,在将更改提交到DOM时,我们还使用该数组中的Fiber。

function commitRoot() {
  deletions.forEach(commitWork)
}

处理新的effectTag

好的,现在让我们更改commitWork函数以处理新的effectTag。

  • 如果是PLACEMENT,执行新增操作
  • 如果是DELETION(删除),我们执行相反的操作,即删除子节点。
  • 如果是UPDATE(更新),我们需要使用已更改的props更新现有的DOM节点。
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)
}

dom更新

我们将在updateDom函数中完成此操作。

属性

我们将旧Fiber的props与新Fiber的props进行比较,删除已经不存在的props,并设置新的或更改的props。

const isProperty = key => key !== "children"
const isNew = (prev, next) => key =>
  prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
  // 删除旧的属性
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = ""
    })

  // 设置新的或者更新属性
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name]
    })
}

事件

如果事件处理程序发生了更改,我们将其从节点中删除。然后我们添加新的事件处理程序。

const isEvent = key => key.startsWith("on")
const isProperty = key =>
  key !== "children" && !isEvent(key)
  
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]
    )
  })

  // 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上尝试使用调和版本。

第7步:函数组件

下一步我们需要添加的是对函数组件的支持。

新建一个函数组件示例

首先,让我们更改示例。我们将使用这个简单的函数组件,它返回一个h1元素。

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

请注意,如果我们将JSX转换为JS,则为:

function App(props) {
  return Didact.createElement(
    "h1",
    null,
    "Hi ",
    props.name
  )
}
const element = Didact.createElement(App, {
  name: "foo",
})

函数组件的不同

函数组件有两个不同之处:

  1. 函数组件的Fiber没有DOM节点
  2. 组件的子节点不是直接从props中获取,而是通过运行函数来获得

我们检查Fiber的类型是否为函数,并根据这一点进入不同的更新函数。

在updateHostComponent中,我们与之前一样。

function performUnitOfWork(fiber) {
  const isFunctionComponent =
    fiber.type instanceof Function
  if (isFunctionComponent) {
    updateFunctionComponent(fiber)
  } else {
    updateHostComponent(fiber)
  }
  
  // ...
}

function updateFunctionComponent(fiber) {
  // TODO
}

function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  reconcileChildren(fiber, fiber.props.children)
}

在updateFunctionComponent中,我们运行该函数以获取子节点。

对于我们的示例,这里的fiber.type是App函数,当我们运行它时,它返回一个h1元素。

然后,一旦我们有了子节点,调和过程的处理方式与之前相同,我们不需要在那里进行任何更改。

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

修改commit函数

我们需要更改的是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)
}

现在我们有了没有DOM节点的Fiber,因此我们需要更改两件事。 首先,为了找到DOM节点的父级,我们需要沿着Fiber树向上查找,直到找到带有DOM节点的Fiber。

function commitWork(fiber) {
  // ...
  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 (
}

当删除一个节点时,我们也需要持续查找,直到找到带有DOM节点的子节点。

function commitWork(fiber) {
  // ...
  // } else if (fiber.effectTag === "DELETION") {
    commitDeletion(fiber, domParent)
  // }

}

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

第8步:Hooks

最后一步。现在我们有了函数组件,让我们也添加状态(state)。

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)

让我们将示例更改为经典的计数器组件。每次单击它都会将状态值增加1。

请注意,我们使用Didact.useState来获取和更新计数器值。

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

function useState(initial) {
  // TODO
}

在调用函数组件之前,我们需要初始化一些全局变量,以便在useState函数内部使用它们。

首先设置进行中的Fiber。

我们还为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时,我们检查是否有旧的hook。使用钩子索引检查Fiber的alternate。

如果我们有旧的hook,则将状态从旧的hook复制到新的hook中,否则我们初始化状态。

然后我们将新的hook添加到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还应该返回一个更新状态的函数,因此我们定义了一个setState函数,它接收一个操作(对于Counter示例,该操作是将状态增加1的函数)。

我们将该操作推送到我们添加到hook的队列中。

然后我们执行类似于渲染函数中所做的操作,将新的work in progress root设置为下一个工作单元,以便工作循环可以开始进行新的渲染阶段。

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]

但是我们还没有运行这个操作。

下一次渲染组件时,我们会从旧的钩子队列中获取所有操作,然后逐个应用到新的钩子状态上,因此当我们返回状态时,它已经更新了。

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

好的,这就是全部。我们已经构建了自己的React版本。

你可以在codesandbox上进行测试。

结语

除了帮助你了解React的工作原理外,本文还旨在使您更轻松地深入研究React代码库。这就是我们几乎在所有地方使用相同的变量和函数名称的原因。

例如,如果您在真实的React应用程序中的一个函数组件中添加断点,调用栈应该会显示:

workLoop performUnitOfWork updateFunctionComponent

我们没有包括许多React功能和优化。例如,这些是React不同之处的一些内容:

在Didact中,在渲染阶段期间我们遍历整个树。React通过遵循某些提示和启发式算法来跳过未发生任何变化的整个子树。 我们在提交阶段也遍历整个树。React保留了一个只有具有影响的fiber的链接列表,并且仅访问这些fiber。 每当我们构建新的工作进展树时,我们为每个fiber创建新对象。React从先前的树中回收fiber。 当Didact在渲染阶段接收到新的更新时,它会丢弃正在进行中的树,然后从根开始重新开始。React使用到期时间戳标记每个更新,并使用它来决定哪个更新具有更高的优先级。 等等...

还有一些功能可以轻松添加:

使用对象作为样式prop 扁平化子数组 useEffect钩子 通过键进行调和

如果您向Didact添加了任何这些或其他功能,请向GitHub存储库发送拉取请求,以便其他人可以查看。

感谢阅读!

如果您想发表评论、点赞或分享这篇文章,可以使用此推文:

不得不构建一个新的博客和一些工具,以便以我想要的格式发表这篇文章。花了一些时间,但终于准备好了!

📢 DIY指南更新:从头构建React ✨ t.co/RfGrl8ARYz pic.twitter.com/3kih0xLHIu

— Rodrigo Pombo (@pomber) November 13, 2019 本文翻译自link