实现自己的React

559 阅读17分钟

写在前面:

React在前端的世界里有着举足轻重的地位。对于绝大部分React开发者,React只是一个工具,用就是了,既然一个工具能够工作得很好,并不需要知道这个工具内部的零件如何组成的。如果我们跳出使用者的视角,从设计者的角度来重新认识React,你也许会对司空见惯的React有不一样的体会,也能学习到源码的精彩之处。本文基于《build your own React》一书,从源码角度出发实现一个简单的React,敲开新世界的大门。

总述

在忽略源码里一些优化和不重要的情况下,功能文章会按照真实的 React 架构重写 React。另外,文章是基于 React 16.8 的,因此代码是基于 hook 来写 React,不存在和 class 相关的内容。

在重写react的过程中,我们将用到以下内容:

  • 1: The createElement Function
  • 2: The render Function
  • 3: Concurrent Mode
  • 4: Fibers
  • 5: Render and Commit Phases
  • 6: Reconciliation
  • 7: Function Components
  • 8: Hooks 我们先通过一个及其简单的例子回顾一些基本概念,主要是关于React、JSX、DOM元素相关的部分,代码如下:
    //定义React element, JSX语句
    const element = <h1 title="foo">Hello</h1>
    //获取DOM元素节点
    const container = document.getElementById("root")
    //将React element渲染到容器中
    ReactDOM.render(element,container)

第一行我们定义了 JSX 元素。这不是合法的 JavaScript 代码,因此我们需要将其替换成合法的 JavaScript 代码。

JSX 通过构建工具 Babel 转换成 JS。这个转换过程很简单:将标签中的代码替换成 createElement,并把标签名、参数和子节点作为参数传入。React.createElement 验证入参并生成了一个对象。因此我们将其替换成如下代码:

    // const element = <h1 title="foo">Hello</h1>
    const element = React.createElement(
        "h1",
        {title : "foo"},
        "Hello"
    )
   //React.createElement(): 根据指定的第一个参数创建一个React元素,
   //第一个参数是必填,传入的是似HTML标签名称,eg: ul, li;
   //第二个参数是选填,表示的是属性,eg: className
   //第三个参数是选填, 子节点,eg: 要显示的文本内容
   //React.createElement( 
   //  type, 
   //  [props], 
   //  [...children] 
   // )

一个 JSX 元素就是一个带有 type 和 props 等属性的对象:

  • type属性就是一个字符串,值是JSX中的标签名,它指定了DOM节点的类型。type最终会被当做tagName传给document.createElement创建HTML元素,当然它也可以是个函数,这放在后面再讨论。
  • props是一个对象,从JSX属性中接收所有key、value,并且有一个特别的属性children。在上面例子中children是一个字符串,但在一般情况下,他会是一个由元素组成的数组。由此element的结构就变成了一棵树。 所以最终element结果是一个对象:
    const element = {
        type:'h1',
        props:{
            title:"foo",
            children:"Hello",
        }
    }

需要另外处理的代码是ReactDOM.render。React是在render函数里改变DOM的,这里我们先手动更新一下DOM。

    const element = {
        type: "h1",
        props: {
            title: "foo",
            children: "Hello",
        },
    }
    const container = document.getElementById("root")
    //首先我们根据传入的 `type` 创建了一个 node
    //为了避免混淆,用 “element” 来代指 React Element, 用 “node” 来代指 DOM Element
    const node = document.createElement(element.type)
    node["title"] = element.props.title
    //接下来创建 node 的子节点。在这个例子中,子节点是字符串,因此我们需要创建一个 text 节点
    const text = document.createTextNode("")
    text["nodeValue"] = element.props.children
    //最后我们把 `textNode` 添加到 `h1` 里,把 `h1` 添加到 `container` 里
    node.append(text)
    container.append(node)

我们可以发现,在不使用React的情况下,我们成功渲染了和React相同的内容。其实react底层也是类似这样是实现的!下面具体来介绍。

构造自己的React

我们换个例子,这次使用我们自己写的React来完成页面渲染。上面一节讲过构造自己的React需要关注8个点,我们先从createElement开始吧!

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

1、The createElement Function

我们需要先把JSX转换成JS,我们需要关注createElement是怎么被调用的。element是一个带有type和props的对象,因此createElement 函数需要做的就是创建这样一个对象。

    const element = React.createElement(
        "div",
        {id:"foo"},
        React.createElement('a',null,'bar'),
        React.createElement('b')
    )
    
    //对props使用...操作符,对入参中的children使用剩余参数, 这样children参数永远是数组.
    function createElement(type,props,...children){
        return {
            type,
            props:{
                ...props,
                children
            }
        }
    }

children数组里面可能存在一些基本类型,比如string、number等。实际上React对于一个基本值的子元素,不会创建空数组,而是有着自己的处理逻辑。这里为了简化代码,我们对所有不是对象的值创建一个特殊类型 TEXT_ELEMENT,请注意我们的实现和 React 有差异,毕竟在这里我们只想要简单的代码而不是完美的代码。因此我们给上面的createElement函数进行进一步改造:

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

我们定义好了自己的createElement函数,我们把 Didact 作为我们自己写的库名,并使用Didact.createElement方法来编译JSX:

    const Didact = {
            createElement,
           }
    // const element = Didact.createElement(
    //        "div",
    //       { id: "foo" },
    //        Didact.createElement("a", null, "bar"),
    //        Didact.createElement("b")
    //      )
    /** @jsx Didact.createElement */
    //由于上面的注释,babel 会将 JSX 编译成Didact.createElement我们需要的函数
    const element = (
            <div id="foo">
            <a>bar</a>
            <b />
            </div>
          )

2、the render function

下面编写ReactDOM.render函数。我们由易到难,首先暂时只关心如何在DOM上添加东西,之后再考虑更新和删除:

    ...
    function render(element,container){
        //TODO create dom nodes
    }
    const Didact = {
        createElement,
        render
    }
     /** @jsx Didact.createElement */
    const element = (
            <div id="foo">
            <a>bar</a>
            <b />
            </div>
          )
    const container = document.getElementById("root")
    Didact.render(element, container)

如果只考虑DOM添加元素的话,我们很容易想到render应该做什么,就是先根据element 中的 type 属性创建 DOM 节点,再将新节点添加到容器中:

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

其中我们需要对每一个子节点递归的做相同的处理,使得子节点都能被添加到DOM中,渲染出来:

    function render(element, container) {
        const dom = document.createElement(element.type)
        element.props.children.forEach(child =>
            render(child, dom)
        )
        container.appendChild(dom)
    }

这里需要注意一点就是elemen的值可能是一个普通文本,即类型是TEXT_ELEMENT,此时我们需要创建一个text节点,就像我们开头做的那样。

    const dom = element.type == "TEXT_ELEMENT" ? 
        document.createTextNode("")
        : document.createElement(element.type)

最后我们需要把element上的属性都添加到相应的元素上:

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

至此我们写出了一个可以将JSX渲染成DOM的库了,一下是完整的代码:

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

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

    const Didact = {
      createElement,
      render
    };

    /** @jsx Didact.createElement */
    const element = (
      <div style="background: salmon">
        <h1>Hello  前端小鹿</h1>
        <h2 style="text-align:right">from Didact</h2>
      </div>
    );
    const container = document.getElementById("root");
    Didact.render(element, container);

效果如下:

image.png nice!目前我们自己打造的React可以正常渲染了!

3、Concurrent Mode

Concurrrent Mode是react fiber里重要的概念,主要是负责任务的调度,使得页面在大量计算更新的情况下显示不再那么卡顿。我们可以想想一下页面在大量 DOM 节点同时更新的情况下,react会出现延迟很严重的现象,具体表现为交互/渲染的卡顿现象。再比如大批量的异步IO操作也会阻塞页面更新等等。react里Concurrent Mode的出现就是为了解决这个问题的,这里我们从代码层面来讲,关于更详细的Concurrent Mode内容,网上资料很多哈,(比如:zhuanlan.zhihu.com/p/109971435…

我们首先来回顾一下上面的代码,大家有没有发现上面问题?没错!render函数里面用到了递归。我们遇到递归就会情不自禁的想起栈溢出、性能等问题。在我们的render函数里,一旦开始渲染,我们是没有办法终止这个过程的,直到元素被完全的渲染出来推出递归。如果DOM树很大,可能会对主线程进行阻塞。这意味着浏览器的一些高优先级任务会一直等待渲染完成,如:用户输入,保证动画顺畅。这样就造成了页面及交互卡顿的现象。

那如何处理这个问题呢?原理其实也很简单,就是将整个大任务分成一些小任务,每当我们完成其中一个小任务后就把控制权交给浏览器,让浏览器判断是否有更高优先级的任务需要完成。如果有高优任务就先执行高优任务,比如用户输入等操作,如果没有高优任务则继续执行当下的任务。我们先使用requestIdleCallback 作为一个循环。window.requestIdleCallback作用是在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。你可以把 requestIdleCallback 类比成 setTimeout,只不过这次是浏览器来决定什么时候运行回调函数,而不是 settimeout 里通过我们指定的一个时间。浏览器会在主线程有空闲的时候运行回调函数。

    ...
    function render(element, container){...}
    ...
    // 下一个工作单元
    let nextUnitOfWork = null
    /**
     * workLoop 工作循环函数
     * @param {deadline} 截止时间
     */
    function workLoop(deadline) {
      // 是否应该停止工作循环函数
      let shouldYield = false
      // 如果存在下一个工作单元,且没有优先级更高的其他工作时,循环执行
      while (nextUnitOfWork && !shouldYield) {
        nextUnitOfWork = performUnitOfWork(
          nextUnitOfWork
        )
        // 如果截止时间快到了,停止工作循环函数
        shouldYield = deadline.timeRemaining() < 1
      }
      // 通知浏览器,空闲时间应该执行 workLoop
      requestIdleCallback(workLoop)
    }
    // 通知浏览器,空闲时间应该执行 workLoop
    requestIdleCallback(workLoop)
    // 执行单元事件,并返回下一个单元事件
    function performUnitOfWork(nextUnitOfWork) {
      // TODO
    }
    ...

我们需要注意,React 并不是用 requestIdleCallback 的。它使用自己编写的 scheduler package。 但两者概念上是相同的

根据上述代码我们知道,需要先设置渲染的第一个任务单元,然后开始循环。另外performUnitOfWork函数不仅需要执行每一个小任务单元,而且还需要返回下一个任务单元

4、Fibers

Fibers是React 16之后增加的新特性(React fiber是什么),这东西具体是什么不在本文的讨论范围内,毕竟都开始写代码了想必你已经多多少少对此有点了解。如果不太明白的同学,请自行百度哈。

前文说到 Concurrent Mode用来执行大任务分解之后的小任务,而且performUnitOfWork还需要返回下一个任务单元。这说明所有的任务单元之间是有联系的,存在一种数据结构把这些单元组织起来,这种数据结构就是fiber树。每一个 element 都是一个 fiber,每一个 fiber 都是一个任务单元。 来看一个例子,我们想要渲染下面的DOM树:

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

在上一小节里我们创建根fiber(即nextUnitOfWork)作为第一个任务单元,剩下的单元则是通过performUnitOfWork函数完成并返回的。每一个fiber节点完成三件事:

  1. 把 element 添加到 DOM 上
  2. 为该 fiber 节点的子节点新建 fiber
  3. 挑出下一个任务单元 这个数据结构的其中一个目的是为了更方便地找到下一个任务单元。因此每个 fiber 都会指向它的第一个子节点、它的下一个兄弟节点 和 父节点。因此例子中我们要渲染的DOM结构对应的fiber树是下面这个样子:

当我们处理完了一个 fiber 节点之后。它的 child fiber 节点将作为下一个任务单元。在这个例子中,紧接着 div fiber 节点的是 h1 fiber 节点。

  1. 如果这个 fiber 没有 child,那么它的兄弟节点(sibling)会作为下一个任务单元。在这个例子中,紧接着 p fiber 节点任务完成后,我们需要处理 a fiber 节点,因为 p 节点没有 child 节点。
  2. 如果一个 fiber 既没有 child 又没有 sibling,它的 “uncle” 节点(父节点的兄弟)将作为下一个任务单元。在这个例子中 a 对应的 “uncle” 节点是 h2
  3. 如果 parent 节点没有 silbing,就继续找父节点的父节点,直到该节点有 sibling,或者直到达到根节点。到达根节点意味着完成了整个树的 render。 总结下来就是先找子节点,没有子节点就找兄弟节点,如果也没有兄弟节点就找uncle节点(父节点的兄弟节点),如果还是没有,就继续往上找祖先节点,直到祖先节点有兄弟节点。如果一直回溯到根节点那就表示整棵树render完成。

理论层面是讲完了,那么代码怎么写呢?肯定是要对原来的render函数改造:

    ...
    //原来的render函数
    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
  ...
  
  
  //改造后的函数 
  //改造点有两个:1、把创建DOM节点的代码提取出来,封装成一个函数,提高复用性;
  //2、render函数中我们把nextUnitOfWork置为 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){
      nextUnitOfWork = {
          dom:container,
          props:{
              children:[element],
          }
      }
  }
  let nextUnitOfWork = null
  

当浏览器有空闲的时候,会调用 workLoop 我们就开始遍历整颗树。

    /**
     * workLoop 工作循环函数
     * @param {deadline} 截止时间
     */
    function workLoop(deadline) {
      // 是否应该停止工作循环函数
      let shouldYield = false
      // 如果存在下一个工作单元,且没有优先级更高的其他工作时,循环执行
      while (nextUnitOfWork && !shouldYield) {
        nextUnitOfWork = performUnitOfWork(
          nextUnitOfWork
        )
        // 如果截止时间快到了,停止工作循环函数
        shouldYield = deadline.timeRemaining() < 1
      }
      // 通知浏览器,空闲时间应该执行 workLoop
      requestIdleCallback(workLoop)
    }
    // 通知浏览器,空闲时间应该执行 workLoop
    requestIdleCallback(workLoop)
    // 执行单元事件,并返回下一个单元事件
    function performUnitOfWork(nextUnitOfWork) {
      // TODO add dom node 把 element 添加到 DOM 上
      // TODO create new fibers 为该 fiber 节点的子节点新建 fiber
      // TODO return next unit of work 返回下一个工作单元
    }

接下来重点就是写performUnitOfWork这个函数了,函数里面干三件事情,那么函数自然分为三个部分:

  1. 首先创建 fiber 对应的 DOM 节点,并将它添加(append)到父节点的 DOM 上。其中我们通过 fiber.dom 这个属性来维护创建的 DOM 节点;
  2. 为每个子节点创建对应的新的 fiber 节点【这里要特别注意区分三个不同的节点实体,element(通过 createElement创建的 react element),它是一个对象、react element经过渲染得到真正的节点,DOM node(最终生成对应的 DOM 节点)、fiber node(从element 到 DOM 节点的中间产物,用于时间切片)】,然后根据是否是第一个子节点,来设置父节点的 child 属性的指向,或者上一个节点的 sibling 属性的指向;
  3. 最后找到下一个工作单元。这个查找顺序前文已经说过了,先试试 child 节点,再试试 sibling 节点,再试试 uncle 节点
    // 执行单元事件,并返回下一个单元事件
    function performUnitOfWork(fiber) {
      // TODO add dom node 把 element 添加到 DOM 上
      if (!fiber.dom) {
        fiber.dom = createDom(fiber)
      }
      if (fiber.parent) {
        fiber.parent.dom.appendChild(fiber.dom)
      }
      // TODO create new fibers 为该 fiber 节点的子节点新建 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++
      }
      // TODO 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
      }
    }

至此performUnitOfWork 部分的工作完成了。

5、Render and Commit Phases

目前为止感觉上面的代码很完美是不是?请看performUnitOfWork函数中的这部分代码:

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

这里存在一个问题,我们一边遍历 element,一边生成新的 DOM 节点并且添加到其父节点上。 在完成整棵树的渲染前,浏览器还要中途阻断这个过程。 那么用户就有可能看到渲染未完全的 UI。我们肯定不想让这个事情发生。因此我们把修改 DOM 节点的这部分代码从performUnitOfWork中移出去。我们把修改 DOM 这部分内容记录在 fiber tree 上,通过追踪这颗树来收集所有 DOM 节点的修改,这棵树叫做 wipRoot(work in progress root, WIP)。

相应的代码还要改两个地方:

  1. 定义wipRoot,并在render里把初始化的wipRoot作为fiber根节点赋值给nextUnitOfWork。
  2. workLoop里要处理wipRoot,因为你把处理DOM节点的部分从performUnitOfWork中移走,得找个地方加回去吧,不然怎么挂载DOM节点。没错,就这WookLoop里处理,一旦完成了 wipRoot 这颗树上的所有任务(next unit of work 为 undefined),我们把整颗树的变更提交(commit)到实际的 DOM 上。这个提交操作都在 commitRoot 函数中完成。我们递归地将所有节点添加到 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) {
        ...
      }
      //多加了这个条件判断,处理把完整wipRoot树的变更提交到实际的DOM上
      if (!nextUnitOfWork && wipRoot) {
        commitRoot()
      }
      requestIdleCallback(workLoop)
    }
    function commitRoot() {
        // TODO 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)
    }
    ...
    ...

6、Reconciliation

至今我们只完成了 添加 东西到 DOM 上这个操作,更新删除 node 节点呢?这就需要我们比较 render 中新接收的 element 生成的 fiber 树和上次提交到 DOM 的 fiber 树。因为只有比较新老两棵树才能知道更新或者删除了哪些元素,这比较好理解。需要保存”上次提交到 DOM 节点的 fiber 树” 的”引用”(reference),我们称之为 currentRoot。另外在每一个 fiber 节点上添加 alternate 属性用于记录旧 fiber 节点(上一个 commit 阶段使用的 fiber 节点)的引用。于是在代码中添加上currentRoot相关部分:

    ...
    ...
    function commitRoot() {
      commitWork(wipRoot.child)
      //++currentRoot
      currentRoot = wipRoot
      wipRoot = null
    }
   function commitWork(fiber) {
      ...
   }
   function render(element, container) {
      wipRoot = {
        dom: container,
        props: {
          children: [element],
        },
         //++alternate
        alternate: currentRoot,
      }
      nextUnitOfWork = wipRoot
   }
    let nextUnitOfWork = null
     //++currentRoot
    let currentRoot = null
    let wipRoot = null
    ...
    ...

为了进一步简化代码以及方便后续处理currentRoot和wiproot两棵树,我们把 performUnitOfWork 中创建新 fiber 节点的代码抽出来,封装成一个函数体,名字叫reconcileChildren

    function performUnitOfWork(fiber) {
      ...
      const elements = fiber.props.children
      reconcileChildren(fiber, elements)
      if (fiber.child) {
        return fiber.child
      }
      ...
    }
    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++
      }
    }

我们改造reconcileChildren这个函数,使得它调和(reconcile)旧的 fiber 节点 和新的 react elements。怎么调和呢?就是在迭代整个 react elements 数组的同时我们也会迭代旧的 fiber 节点(wipFiber.alternate),代码里对新老节点都进行相应的处理。下面对reconcileChildren改造:

    function reconcileChildren(wipFiber, elements) {
      let index = 0
      //获取上次commit的fiber树
      let oldFiber = wipFiber.alternate && wipFiber.alternate.child
      let prevSibling = null
      //在循环里处理新、旧fiber节点
      while (
        index < elements.length ||
        oldFiber != null
      ) {
        const element = elements[index]
        let newFiber = null
        // TODO compare oldFiber to element
        ...
        }
   }

如果我们忽略掉同时迭代数组和对应的link中的一些标准模板,我们就剩下两个最重要的东西: oldFiber 和 elementelement 是我们想要渲染到 DOM 上的东西,oldFiber 是我们上次渲染 fiber 树. 我们需要比较这两者之间的差异,看看需要在 DOM 上应用哪些改变。既然牵扯到比较两棵树,就有相应的比较逻辑,具体来说:

  • 对于新旧节点类型相同的情况,我们可以复用旧的 DOM,仅修改上面的属性
  • 如果类型不同,意味着我们需要创建一个新的 DOM 节点
  • 如果类型不同,并且旧节点存在的话,需要把旧节点的 DOM 给移除 这三个比较规则有点像简化版diff算法的味道。我们需要注意的是,React是使用 key 这个属性来优化 reconciliation 过程的。比如, key 属性可以用来检测 elements 数组中的子组件是否仅仅是更换了位置。 因此我们在使用react时要注意节点上的key属性,要独一无二,这样会提高react的性能哦! 规则确定了,那么我开始完善代码函数reconcileChildrenTODO compare oldFiber to element 的部分:
    // TODO compare oldFiber to element
    const sameType =
      oldFiber &&
      element &&
      element.type == oldFiber.type
      
    //新旧节点类型相同  
    if (sameType) {
      // TODO update the node
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE"
      };
    }
    
    //新旧节点类型不相同,需要创建新节点
    if (element && !sameType) {
      // TODO add this node
       newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT"
      };
    }
    
    //新旧节点类型不相同,并且存在旧节点,需要移除旧节点
    if (oldFiber && !sameType) {
      // TODO delete the oldFiber's node
      oldFiber.effectTag = "DELETION";
      deletions.push(oldFiber);
    }
  • 当新的 element 和旧的 fiber 类型相同, 我们对 element 创建新的 fiber 节点,并且复用旧的 DOM 节点,但是使用 element 上的 props。另外我们需要在生成的fiber上添加新的属性:effectTag。在 commit 阶段(commit phase)会用到它。
  • 对于需要生成新 DOM 节点的 fiber,我们需要标记其为 PLACEMENT
  • 对于需要删除的节点,我们并不会去生成 fiber,因此我们在旧的fiber上添加标记。

需要注意的是,当我们提交(commit)整颗 fiber 树(wipRoot)的变更到 DOM 上的时候,并不会遍历旧 fiber。那就需要记录下fiber树的变更,因此我们需要一个数组去保存要移除的 dom 节点,因此我们初始化声明一个deletions变量用于后续保存DOM的变更(其实是移除DOM):

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

之后我们提交变更到 DOM 上的时候,需要把这个数组中的 fiber 的变更给提交上去,同时我们对 commitWork 函数略作修改来处理我们新添加的 effectTags:

    function commitRoot() {
    //提交 fiber 的变更
      deletions.forEach(commitWork)
      commitWork(wipRoot.child)
      currentRoot = wipRoot
      wipRoot = null
    }
    function commitWork(fiber) {
      if (!fiber) {
        return
      }
      const domParent = fiber.parent.dom
      
      //如果 fiber 节点有我们之前打上的 `PLACEMENT` 标,即新建的DOM节点
      //那么在其父 fiber 节点的 DOM 节点上添加该 fiber 的 DOM
      if (
        fiber.effectTag === "PLACEMENT" &&
        fiber.dom != null
      ) {
        domParent.appendChild(fiber.dom)
      }
      //如果有"DELETION"标则删除节点
      else if (fiber.effectTag === "DELETION") {
        domParent.removeChild(fiber.dom)
      }
      //如果有"UPDATE"标则更新节点的属性值即可,可以复用原来的节点
      else if (
        fiber.effectTag === "UPDATE" && fiber.dom != null
      ) {
        updateDom(
          fiber.dom,
          fiber.alternate.props,
          fiber.props
        )

      commitWork(fiber.child)
      commitWork(fiber.sibling)
    }

接下来实现我们的定义的updateDom函数:

    //比较新老 fiber 节点的属性, 移除、新增或修改对应属性
    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) {
      // 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]
        })
    }

但是有一些属性值比较特殊,它们是事件监听,如果属性值以 “on” 作为前缀,我们需要以不同的方式来处理这个属性,所以需要对上面代码再改动一版,判断一下是不是监听事件,如果是监听事件那么需要在updateDom里做特殊处理:

     //比较新老 fiber 节点的属性, 移除、新增或修改对应属性
     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]);
        });
      // Remove old properties
      ...
      // Set new or changed properties
      ...
      // 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]
          )
        })
    }

这个部分有点长,但是也是react渲染更新核心部分,请耐心仔细阅读哈~

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

还记得上一节中performUnitOfWork函数长什么样子吧:

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

我们需要注意,函数组件有些特殊,特殊点在于:

  • 函数组件的 fiber 没有 DOM 节点
  • 并且子节点由函数运行得来而不是直接从 props 属性中获取 因此这个函数不适用于函数组件呀。怎么办?改造呗。。。
    function performUnitOfWork(fiber) {
    //当 fiber 类型为函数时,我们使用不同的函数来进行 diff
      const isFunctionComponent =
        fiber.type instanceof Function
      if (isFunctionComponent) {
      //处理函数组件
        updateFunctionComponent(fiber)
      } else {
      //原来的处理逻辑
        updateHostComponent(fiber)
      }
     ...
    }
    //用于从函数组件中生成子组件
    function updateFunctionComponent(fiber) {
        //这里的 fiber.type 是 App 函数,运行这个函数会返回 h1 element(react JSX element)
      const children = [fiber.type(fiber.props)]
      //一旦我们拿到了这个子节点,剩下的调和(reconciliation)工作和之前一致,我们不需要修改任何东西了
      reconcileChildren(fiber, children)
    }
    //同原来的逻辑
    function updateHostComponent(fiber) {
      if (!fiber.dom) {
        fiber.dom = createDom(fiber)
      }
      reconcileChildren(fiber, fiber.props.children)
    }

另外我们需要修改 commitWork 函数,当我们的 fiber 没有 DOM 的时候需要修改两个东西:

  1. 首先找 DOM 节点的父节点的时候我们需要往上遍历 fiber 节点,直到找到有 DOM 节点的 fiber 节点。函数组件没有 DOM 节点,在实际的 DOM 寻找父子节点等操作中需要被跳过。
  2. 移除节点也同样需要找到该 fiber 下第一个有 DOM 节点的 fiber 节点 对应代码如下:
    function commitWork(fiber) {
      if (!fiber) {
        return
      }
      //变化一 遍历找到有DOM节点的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 (
        fiber.effectTag === "UPDATE" &&
        fiber.dom != null
      ) {
        updateDom(
          fiber.dom,
          fiber.alternate.props,
          fiber.props
        )
      } else if (fiber.effectTag === "DELETION") {
        //变化二:移除第一个带有DOM节点的后代fiber节点
        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)
      }
   }

8、Hooks

最后一步,给我们的函数组件添加状态。让我们把例子改成经典的计数组件。每点击一次,状态自增一:

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

代码里我们使用 Didact.setState 来读取和修改计数器的值。我们在这里调用 Counter 函数,并且在这个函数中调用 useState

在调用函数组件前需要初始化一些全局变量。我们需要在 useState 函数中用到这些全局变量,代码改动主要是初始化定义一些变量,以及在updateFunctionComponent中给变量赋值,另外需要定义一个useState函数用于改变状态:

    ...
    ...
    let wipFiber = null
    let hookIndex = null
    function updateFunctionComponent(fiber) {
      wipFiber = fiber
      hookIndex = 0
      wipFiber.hooks = []
      const children = [fiber.type(fiber.props)]
      reconcileChildren(fiber, children)
    }
    function useState(initial) {
      // TODO
    }
    ...
    ...

首先我们设置 work in progress fiber。在对应的 fiber 上加上 hooks 数组以支持我们在同一个函数组件中多次调用 useState。然后我们记录当前 hook 的序号。(用过hooks的同学应该熟悉这一块)。

当函数组件调用 useState,我们校验 fiber 对应的 alternate 字段下的旧 fiber 是否存在旧 hook。hook 的序号用以记录是该组件下的第几个 useState。如果存在旧 hook,我们将旧 hook 的值拷贝一份到新的 hook。 如果不存在,将 state 初始化。然后在 fiber 上添加新 hook,自增 hook 序号,返回状态。

useState 还需要返回一个可以更新状态的函数,我们定义 setState,它接收一个 action参数。(在 Counter 的例子中, action 是自增state 的函数)。我们将 action 推入刚才添加的 hook 里的队列。之后和之前在 render 函数中做的一样,我们将 wipRoot 设置为当前 fiber,之后我们的调度器会帮我们开始新一轮的渲染的。

这里需要注意的是,我们并没有立即运行 action,在下一次渲染的时候,我们才会对 action 进行消费,我们把所有的 action 从旧的 hook 队列中取出,然后将其一个个调用,直到回去新的 hook state 值,最后返回的 state 就已经是更新好的。

    function useState(initial) {
    //检查是否存在旧hook,用于初始化state
      const oldHook =
        wipFiber.alternate &&
        wipFiber.alternate.hooks &&
        wipFiber.alternate.hooks[hookIndex]
      //给fiber添加hook
      const hook = {
        state: oldHook ? oldHook.state : initial,
        queue: [],
      }
      const actions = oldHook ? oldHook.queue : []
      actions.forEach(action => {
        hook.state = action(hook.state)
      })
      //setState用于更新状态
      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!!!源码见本文末尾的附件部分。

总结

我们通过一步步的写代码,最终实现了自己的迷你react。之所以说迷你,是因为我们并没有涉及很多 React 的功能和优化,一些细节点我们也是做了简化,毕竟react真实的源码更多,也更加考虑性能等问题。但这些差异的存在并不能说明我们的代码没有意义,不实用。相反,我们通过巧妙的方法,精炼的实现了react,并且思路和大体流程是和react一致的!通过这篇文章你可以更容易地深入 React 源码。

写作不易,且看且珍惜。如果感觉对你有帮助,实不相瞒,想要个赞👍。

参考资料

附件

源代码:

    // import React from "react";
    // import ReactDOM from "react-dom";
    // import React, { Component } from 'react';
    // import { render } from "react-dom";

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

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

    function commitRoot() {
      deletions.forEach(commitWork);
      commitWork(wipRoot.child);
      currentRoot = wipRoot;
      wipRoot = null;
    }

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

    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;

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

      if (!nextUnitOfWork && wipRoot) {
        commitRoot();
      }

      requestIdleCallback(workLoop);
    }

    requestIdleCallback(workLoop);

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

    let wipFiber = null;
    let hookIndex = null;

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

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

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

    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;

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

        if (oldFiber) {
          oldFiber = oldFiber.sibling;
        }

        if (index === 0) {
          wipFiber.child = newFiber;
        } else if (element) {
          prevSibling.sibling = newFiber;
        }

        prevSibling = newFiber;
        index++;
      }
    }

    const Didact = {
      createElement,
      render,
      useState
    };

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

    /*
    Didact.render is deprecated since React 0.14.0, 
    use ReactDOM.render instead (react/no-deprecated)eslint
    */

代码演示:React & Didact