阅读 224

打造一个你自己的 React

打造你自己的 React —— Didact

参考原文 Build your own React (pomb.us)

我们将遵循 React 源码的体系结构,但不不带所有的优化和非必要的特性,一步一步地从零开始重写 React。原文作者将这个简版的 React 命名为 Didact

本项目基于 React 16.8,可以使用 hook写法,不带任何 class 组件相关的内容。

将重写一个我们自己的 React 分为以下几个步骤:

  1. createElement 函数实现
  2. render(渲染) 函数实现
  3. Concurrent Mode(并发模式)实现
  4. Fibers 实现
  5. Render(渲染)和 Commit(提交)阶段实现
  6. Reconciliation(协调)实现
  7. Function Components(函数组件)实现
  8. Hooks 实现

Step Zero:回顾

首先,我们回顾一些基本概念。搞清楚 React、JSX 和 DOM 元素是如何工作的。

下面是三行 React 代码:

const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
复制代码

第一行代码定义了一个 React 元素;第二行代码获取 DOM 节点;第三行代码将 React 元素渲染到 DOM 中。

下面我们用普通 JavaScript 来实现。首先是第一行,这根本就不是有效的 JS 语法,JSX 通过像 Babel 这样的构建工具被转换为 JS。转换的过程非常简单,就是将标签中的代码替换为调用 createElement 函数来生成标签元素以及其属性和子元素等,如下所示。

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

React.createElement 可以创建一个对象,如下所示,在创建之前可能会有一些校验。因此,我们可以安全地用函数输出替换函数调用。

const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}
复制代码

这就是一个元素,一个具有多个属性的对象,现在我们主要关注 typeprops 这两个属性。

  • type 是一个字符串,它指定了我们要创建的 DOM 节点的类型,是传给 document.createElement 函数的 tagName 参数。它也可以是一个函数,这个在后面函数组件的中会详细介绍。
  • props 是一个对象,它包含了 JSX 属性的所有键和值。它还有一个特殊的属性 children
  • children 在这个例子中是一个字符串,它通常是一个具有更多元素的数组。这就是为什么元素也是树的原因。

第二行代码就是简单的 JS 代码,我们略过。

第三行代码 ReactDOM.render 需要实现。render 是 React 修改 DOM 的地方,下面我们来实现一下。

  • 首先,使用 React 创建的元素来生成 DOM 节点。
  • 然后,设置节点属性。
  • 再然后,创建子节点,这里的子节点就是一个字符串(这里不使用 innerText 是因为 createTextNode 的适用性更广可以处理非字符串类型的子节点)。
  • 最后,把创建好的子节点和节点分别加到其父节点和容器中
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)
复制代码

现在我们有了和以前相同的应用程序,但没有使用 React。

完整代码如下所示:

const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}

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 函数

以下面这段 JSX 代码为例:

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

Babel 会将上面这段 JSX 代码转成 JS 代码(此处我们不需要关心 Babel 是如何将上面的代码转成下面代码的),如下:

const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b")
)
const container = document.getElementById("root")
ReactDOM.render(element, container)
复制代码

我们来实现一下 createElement 函数。

从上面的例子我们可以看出来,createElement函数最后其实是生成了一个对象,于是我可以下面的代码来表示我们入参和返回的情况。

// 从第三个参数开始,都是 children
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children,
    },
  }
}
复制代码

对于 children 数组我还需要做一些处理,因为我们还需要把子元素中可能会包含的字符串或者数字之类的非对象,因此,我们需要将这些内容包装在一个特殊类型中 TEXT_ELEMENT

当没有子的时候,React 不会包装这些非对象的值或者创建一个空数组,但是我们会创建,因为我们就是图代码简单,而不图高性能,代码如下:

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 的 createElement 函数,现在我们就来将他换个名字 Didact。在写 JSX 的时候加上一个注解,此时 Babel 在解析下面代码的时候就会变成使用 Didact 的 createElement函数。

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

解析出来的新的完整代码如下:

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: [],
    },
  }
}

const Didact = {
  createElement,
}

const element = Didact.createElement(
  "div",
  { id: "foo" },
  Didact.createElement("a", null, "bar"),
  Didact.createElement("b")
)
const container = document.getElementById("root")
ReactDOM.render(element, container)
复制代码

Step II render 函数

下面是写一个我们自己的 ReactDOM.render函数。

目前我们只关注向 DOM 添加一些东西的功能,后面再处理更新和删除操作。

步骤如下:

  1. 首先,创建 DOM 节点,并添加到根节点上。
  2. 然后,我们递归地对每个子节点做同样的操作。
  3. 再然后,我们还需要处理 TEXT_ELEMENT类型的节点。
  4. 最后,我们需要配置每个节点的属性。

实现代码如下:

function render(element, container) {
  const dom = document.createElement(element.type)
  
  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)
}

const Didact = {
  createElement,
  render,
}
复制代码

到此,我们就实现了一个可以将 JSX 渲染为 DOM 的库。可以去 codesandbox 看一下效果。

Step III Concurrent Mode

在此,添加更多代码之前我们需要对之前的代码进行一个重构。

因为前面用到了递归,一旦开始递归渲染,我们就无法中途停下来,只能等整个的元素树渲染完才能停止。如果元素树很大,那么它就可以使主线程阻塞。如果浏览器需要做高优先级的事情,比如处理用户输入或保持动画平滑等,就必须等到渲染完成。

所以我们需要将渲染过程分解为小单元,完成每个单元后,如果需要执行其他操作,我们将让浏览器中断渲染。

我们使用 requestIdleCallbackMDN 链接)来实现循环。你可以把 requestIdleCallback当成 setTimeout,但不同的是,它不需要我们去设定一个超时时间,浏览器会在主线程空闲的时候去运行回调。

现在 React 其实已经不使用这个 API 了,而是使用 scheduler调度),但对于我们现在的情况,它在概念上是一样的。

requestIdleCallback还给了我们一个截止日期参数。我们可以使用它来检查我们有多少时间可以用来操控浏览器。

这个功能还不稳定,官方的说法是需要等到 React 18 才会出稳定版本

下面我们来写一下任务单元循环相关的函数:

循环开始,我们需要设置第一个任务单元,然后编写一个performUnitOfWork函数,它不仅能执行任务,还返回下一个任务单元。

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
}
复制代码

Step IV Fibers

为了更方便地管理任务单元,我们需要一种特殊的数据结构,那就是Fiber Treefiber 的本质就是一个链表。

每一个元素都用 fiber来封装,每一个fiber就是一个任务单元。

下面我们来举个例子,渲染下面的元素树:

Didact.render(
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>,
  container
)
复制代码

渲染过程中,我们先创建根 fiber,并将其设置为 nextUnitOfWork。其余的任务将在performUnitOfWork函数去执行,每个 fiber 需要做三件事:

  1. 将元素添加到 DOM 上;
  2. 为该元素的子创建 fiber对象;
  3. 切换到下一个任务单元;

设计 fiber这个数据结构的目的之一就是为了方便找到下一个任务单元。这就是为什么每一个 fiber 都与它的第一个子、下一个兄弟元素和父元素都要关联上。

fiber1.png

  • 当我们执行完一个 fiber时,如果它有一个子,那么其子 fiber 就会成为下一个任务单元。从我们上面的例子来看,当我们完成对div 的 fiber的任务时,下一个任务单元将是 h1 的 fiber

  • 如果当前 fiber 没有子了,我们就会用其兄弟元素作为下一个任务单元。p 的 fiber 没有子了,我就会去执行 a 的 fiber

  • 如果 fiber既没有子也没了兄弟,那么就会去找其“叔叔”(它兄弟的父),就像 a 和 h2 都会这样。

  • 如果父 fiber 也没有兄弟了我们会不断地向上去找父的父,一直找到根节点。这也意味着所有渲染任务都已经完成了。

下面来重构代码,首先是把 render 函数中创建 DOM 的部分抽成一个新的函数 createDom

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
}
复制代码

改造 render 函数,它被调用的时候会赋值给全局变量 nextUnitOfWork,这样 Step III 中的 workLoop 函数就能执行起来了。

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

下面来完成 Step III 中 TODO 的 performUnitOfWork 函数

function performUnitOfWork(fiber) {
  // add dom node
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }
  
  // create new fibers
  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++
  }

  // return next unit of work
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}
复制代码

Step V Render and Commit 阶段

上面代码中这一句有一个问题。每次处理一个元素时,我们都会向 DOM 添加一个新节点。而且,浏览器可能会在我们完成对整个树的渲染之前中断我们的任务。在这种情况下,用户将看到不完整的 UI,这是我们不期望的。

// 这段代码需要删除
if (fiber.parent) {
  fiber.parent.dom.appendChild(fiber.dom)
}
复制代码

所以我们需要删除这句修改 DOM 的代码。然后,我们需要一直追踪 fiber树的根节点。我们将这种方式叫做 work in progress root,简称 wipRoot

一旦我们完成所有任务(我们知道因为没有下一个任务单元了),我们就将整个fiber树提交(commit)给 DOM。我们在 commitRoot函数中实现这个过程,递归地将所有节点添加到 DOM 中。

function commitRoot() {
  // add nodes to dom
  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)
复制代码

Step VI Reconciliation

到目前为止,我们只向 DOM 中添加了一些东西,但是如何更新和删除节点呢?。

这就是当前阶段要做的事情,我们需要将 render 函数上接收到的元素和我们最新的 fiber树进行比较。

因此 ,我们需要在完成 commit 的时候将最新的 fiber树的引用保存下来。我们把它称之为 currentRoot

我们还需要给每一个fiber添加一个属性 alternate,此属性记录着旧fiber的引用,就是我们上次提交的那个 fiber

deletions是用来追踪我们想要删除的节点的数组。

function commitRoot() {
  // add nodes to dom
  commitWork(wipRoot.child)
  // 增加这一句
  currentRoot = wipRoot
  wipRoot = null
}

function commitWork(fiber) {
  // 同上,省略
}

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    // 增加属性
    alternate: currentRoot,
  }
  // 新增
  deletions = []
  nextUnitOfWork = wipRoot
}

let nextUnitOfWork = null
// 新增定义
let currentRoot = null
let wipRoot = null
// 新增定义
let deletions = null
复制代码

下面,将 performUnitOfWork函数中创建新 fiber 的代码提取到 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
  }
}
复制代码

reconcileChildren函数中,我们将协调(reconcile)旧fiber和新的元素。

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

我们并不去对比旧fiber和新元素的所有细节,我们使用类型来进行比较(这就是 React 独创的 diff 算法)。具体方法如下:

  1. 如果旧的 fiber 和 新的元素类型相同,我们可以保留 DOM 节点并只需使用新的属性更新即可
  2. 如果类型不同并且有新的元素类型不同,则意味着我们需要创建一个新的 DOM 节点
  3. 如果类型不同并且还存在旧 fiber,我们需要移除旧节点

第二、三点可能同时满足,也可能只满足其中一项。

React 也使用 key 来优化 reconciliation 过程。例如,当子元素改变元素数组中的位置的时候它会检测。

下面来完善上述三个方法:

  1. 当旧的 fiber 和 新的元素类型相同的时候,我们创建一个新的 fiber,保留旧fiber的DOM 节点和新元素的属性。我们还需要添加给 fiber这个数据结构添加一个新属性:effectTag,我们将在commit阶段使用这个属性。这里我们的属性值是 UPDATE
  2. 然后,对于需要新增加一个 DOM 节点的情况,我们给新fiber标记上一个 PLACEMENT
  3. 在需要删除节点的情况下,我们给旧fiber增加一个 DELETION 的标记。
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
    
    // compare oldFiber to element
    const sameType =
      oldFiber &&
      element &&
      element.type == oldFiber.type

    if (sameType) {
      // update the node
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      }
    }
    if (element && !sameType) {
      // add this node
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      }
    }
    if (oldFiber && !sameType) {
      // delete the oldFiber's node
      oldFiber.effectTag = "DELETION"
      deletions.push(oldFiber)
    }
    
    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }
    
    if (index === 0) {
      fiber.child = newFiber
    } else if (element) {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }
}
复制代码

接下来,我们需要对commit阶段进行改造,处理新属性effectTags

  • effectTagPLACEMENT,我们和之前一样,把fiber.dom添加到其父节点上
  • effectTagUPDATE,我们需要更新已存在的 DOM 节点的属性
  • effectTagDELETION,我们删除子节点
function commitRoot() {
  // 增加对需要移除的节点的处理
  deletions.forEach(commitWork)
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

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)
}
复制代码

接下来我们来实现一下updateDom

我们会比较旧fiber和新fiber的属性,去掉已经不存在的属性设置新的或者改变的属性。

注意:像事件监听这种以”on“开头的特殊属性,我们需要用不同的方式处理。

function createDom(fiber) {
  const dom =
    fiber.type === "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type)

  updateDom(dom, {}, fiber.props)
  
  return dom
}

const isEvent = key => key.startsWith("on")
const isProperty = key =>
  key !== "children" && !isEvent(key)
const isNew = (prev, next) => key =>
  prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
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(协调或者叫调和)是指将 Virtual DOM 与真实 DOM 通过如 ReactDOM 等类库同步的过程。

Step VII Function Components

这一步是让我们的框架支持函数组件。

首先,让我们改变一下示例。我们将写一个简单的函数组件,它返回一个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",
})
const container = document.getElementById("root")
Didact.render(element, container)
复制代码

函数组件有两点不同:

  • 函数组件中的 fiber 没有 DOM 节点
  • 函数组件的子是来自函数执行结果,而不是直接从 props 中获取他们

现在,我们需要检查一下 fiber 的类型是不是一个函数,并根据此情况来进行处理。

updateHostComponent 函数和以前处理一样。

updateFunctionComponent 函数中,我们需要运行函数来获取子节点的内容。

以上面的示例为例,这里的 fiber.typeApp 函数,我们运行它的时候就会返回 h1元素。

然后,一旦我们获取到了子节点,reconciliation 就可以像之前一样去执行了。

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 updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  reconcileChildren(fiber, fiber.props.children)
}

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

还有一个需要修改是 commitWork 函数。

因为函数组件的存在,我们可能会出现 fiber 没有对应原生的 DOM 节点的情况(比如 App 不是一个原生的 HTML 标签)。

这样,我们就需要沿着 fiber 树往上找,直到找到具有原生 DOM 节点的 fiber

删除的时候我也需要沿着 fiber 树一直往下找,直到找到具有原生 DOM 节点的子节点。

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  
  // 修改此处逻辑
  let domParentFiber = fiber.parent
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent
  }
  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") {
    // 修改
    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

最后一步是给函数组件增加 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)
复制代码

Counter组件内容调用了 useState

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

首先,我们设置 work in progress fiber,也就是 wipFiber

我们还需要在 fiber 中添加了一个 hooks 数组,以支持在同一组件中多次调用 useState。我们会追踪当前 hooks 的索引。

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 了。我们使用 hook 的索引来检查 fiberalternate 属性。

如果我们已经有了一个 hook,我们会将 state 从原来的 hook 复制到新 hook,如果没有,则初始化 state

然后,我们将新的 hook 添加到 fiber 中,将 hook 的索引增加1,并返回 state

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 函数(对于Counter 示例,此操作是将 state 递增1的函数)。

我们将该操作放入到 hookqueue 属性中。

然后,我们执行类似于在 render 函数中所做的事情,将一个新的 work in progress root 设置为下一个任务单元,这样任务循环就可以启动一个新的渲染阶段。

我们在下次渲染组件时执行此操作,我们从原来的 hook 队列中获取所有操作函数,然后将它们逐个应用到新的 hook 状态,因此当我们返回 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

完整代码可以在 codesandbox 或者 github 上查看。

结语

首先致敬原创作者创造这篇文章帮助我们了解 React 是如何工作的。这篇文章的目标之一是让你更容易深入到更深的 React 代码。这就是为什么我们几乎到处都使用相同的变量和函数名称。

例如,如果你在真正的响应应用程序中的一个函数组件中添加了一个断点,则调用堆栈应显示:

  • workLoop
  • performUnitOfWork
  • updateFunctionComponent

我们没有包含很多 React 的特性和优化。例如,这些和 React 不同:

  • 我们在渲染阶段遍历了整个树。而 React 遵循一些规则,跳过没有改变的整个子树
  • 我在提交阶段也遍历了整棵树。而 React 保存了一个链表,整个链表里面存着改变过的 fiber,只会访问这些 fiber
  • 我们每次构建一个新的的 work in progress tree的时候,我们会给每个 fiber 创建一个新的对象。而 React 会从之前的树去回收 fiber
  • 我们在渲染阶段收到新的更新时,将会丢弃work in progress tree 并重新从 root 开始。而 React 会对每个更新用时间戳进行标记,使用它来决定哪个更新具有更高的优先级。

当然并不止上述这些点。

我们还可以轻松地添加一些功能:

文章分类
前端
文章标签