阅读 448

手把手带你写一个Mini 版的React

还记得我上次给大家安利的build ur own react那篇文章吗, 其实有些同学和我说那个是上万字还全是英文的,有些看不太明白, 于是我...总结了一个中文的版本(根据自己的对react的理解联合上部分觉得他文中说的比较好的一些地方), 希望能够对大家有所帮助

顺便再安利一下这篇文章, 针不戳, 有能力的同学可以看了我这篇中文文档再去看看这篇英文文档: pomb.us/build-your-…

那么, let's go


这篇文章的一个核心目的是带着大家基于react的一个源码架构, 我们来从0实现一个自己的mini版本的react, 为啥是mini呢, 因为抓大放小, 抓的大就是我们把react的核心功能都实现一遍, 放的小就是我们把一些react在源码中做的性能优化和一些平时我们用得少甚至不会去用的功能给忽略掉

本篇博文要实现的功能是基于React16.8存在的

我们要实现的一个功能大致如下:

  • createElement 函数
  • render 函数
  • Concurrent Mode & Fiber
  • render阶段和commit阶段
  • Reconciliation(react中的diff算法)

基本回顾

这一块主要是和大家一起回顾一下React, JSX, Dom的一个基本工作模式, 如果你觉得自己对这块已经比较熟了, 那你完全可以跳过这一节直接进入下一节的阅读

来看一个例子

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

上面的代码完全是基于React架构的一个实现, 他实现了将一个div元素渲染进idroot的真实dom容器中的功能

我们现在不用react, 就用原生JS来实现这个功能, 你可以停下想想, 你会怎么实现

// 比较nice的方式就是我们可以直接用一个对象来描述一个JSX表达式
// 当然不限制你的想象力, 只要你有任何方式可以描述出上面的JSX表达式
// 最终能够渲染出来, 那都是OK的
const element = {
    type: "div",
    props: {
        title: "foo"
    },
    children: ["hello, div"], // 这个children为啥是一个array, 因为你想啊, 我们是可以直接在div中写什么span标签, a标签的, 这个时候我们就必须用array来形容他了
}

const container = document.getElementById("root"); // 这个本身原生JS就提供了支撑, 所以我们压根没必要进行转换

// render方法的作用就是将element渲染进container中, 我们暂且不实现render方法本身
// 我们详细如果我们自己要达到将element渲染进container中要怎么做
const divDom = document.createElement(element.type); // 这样我们就创建了一个div节点
divDom.setAttribute("title", "foo"); // 将属性打上去, 其实你可以直接使用div["title"] = "foo"的形式

const textNode = document.createTextNode();
textNode.nodeValue = element.children[0];

divDom.appendChild(textNode);

container.appendChild(divDom);
复制代码

OK, 到了这一步, 上面的代码就给我们不用React但是实现和react一样的功能提供了技术支撑, 这也是一个简单的基本回顾, 下面我们可以正式进入正题了

createElement函数

createElement函数之前,我们应该要知道一点, 就是我们书写的JSX表达式最终都会被babel编译成createElement的形式

// 比如我有一行JSX表达式如下:
const element = (<div className="wrapper">hello, wrapper</div>);

// 他最终会被babel编译成如下形式
const element = React.createElement("div", { class: "wrapper" }, "hello wrapper");

// 至于你要说他的怎么编译的, 他还能咋编译, 字符串替换呗, 这个咱就暂且不论
复制代码

通过之前的回顾认知代码我们应该也基本了解了, 其实本质上来说, 最终React和真实dom的一个连接点就是我们需要拥有一个具备type和其他更多属性的一个对象, 而createElement就是要给我们提供一个element描述对象

那我们怎么去设计这个函数呢? 你可以停下来想想自己的思路

// 我们先固定参数
// type: 要创建上面说的一个对象, 我们需要知道当前的节点类型
// props: 当前节点上都有什么属性, 是一个对象
// children: 你可以看到我使用了收集运算符, 那就意味着后续的所有剩余参数我都要
// 收集进children作为他的元素存在, 而这样我们也保证了children永远是一个数组
// 当然你也可以强行约束用户手动给你传递一个children, 那这样你的createElement
// 就固定永远只有三个参数了, 不同的写法都可以
function createElement(type, props, ...children) {
    
    // 然后我们根据参数将所有的属性返回出去
    // 我这里是把children又塞入了props对象里, 这个也没所谓的
    // 你想放哪放哪, 只要保证这个返回的对象里有children就ok拉
    return {
        type,
        props: {
            ...props,
            children
        }
    }
}
复制代码

那么我们来尝试写个复杂一点的结构, 看看会不会有什么问题

// 我们有一个JSX表达式如下
const element = (<div className="wrapper">
  <span class="title">我是标题</span>
  <input placeholder="请输入文字" />
</div>);

// 根据预期的想法, 上面的代码会被babel转换成如下形式
createElement("div", { class: "wrapper" }, 
        React.createElement("span", { class: "title" }, "我是标题"),
        React.createElement("input", { placeholder: "请输入文字" }));
复制代码

其实我们是可以看出一点问题的, 那么就是我们的createElement函数的children去收集到的子节点,他既可以是createElement创建的对象, 又可以是一个原始值(Primitive Value)比如Number或者String, 这样就会造成一个小问题, 日后我们在遍历children的值的时候, 都不能放心的去确定children的子元素是不是一个对象, 需要去做逻辑判定if (Object.getPrototypeOf 子元素) === Object.prototype, 这样很烦, 所以我们最好是在createElement函数里就给他搞定了, 让children中的所有元素不管你给我的是啥, 我存的就是一个对象

// 我新建一个createTextNode的方法, 他专门来为我们生成文本节点
// 我们知道只有文本节点才可能是原始值吧, 这里你可以好好想一想
function createTextNode(textValue) {
    return {
        type: "text",
        props: {
            nodeValue: textValue,
            children: []
        }
    }
}

// 然后我们稍稍改动一下我们的createElement方法
function createElement(type, props, ...children) {
    return {
        type,
        props: {
            ...props,
            children: children.map(child => Object.getPrototypeOf(child) === Object.prototype ? child : 
                    createTextNode(child))
        }
    }
}
复制代码

我们建立一个自己的myOwnReact文件夹, 创建一个createElement.js, render.jsindex.js文件夹, 分别把我们代码加进去, 后续我们就会引入我们自己的myOwnReact, 同时因为本身编译JSX是babel协助react去做的一件事情, 所以我们这里不会对babel怎么去编译JSX做过多的描述(其实我们压根不会使用JSX, 我们会假设已经通过了babel的编译变成了createElement函数了)。

// myOwnReact/createElement
export function createTextNode(textValue) {
    return {
        type: "text",
        props: {
            nodeValue: textValue,
            children: []
        }
    }
}

export function createElement(type, props, ...children) {
    return {
        type,
        props: {
            ...props,
            children: children.map(child => Object.getPrototypeOf(child) === Object.prototype ? child : createTextNode(child))
        }
    }
}
复制代码
// myOwnReact/index.js
export { default as createElement } from "./createElement.js"
import createElement from "./createElement"


export default {
    createElement
}

复制代码

render函数

接下来, 我们就该编写我们的render函数了

其实render函数的作用就是帮助我们将createElement创建的对象渲染成真实dom

在此之前, 我们需要写一个utils.js来为我们提供一些工具方法

//  /myOwnReact/utils.js
export function checkIsTextNode(node) { // 该方法用来检测一个通过createElement创建出来的节点是不是文本节点
  return node.type === "text";
}
复制代码
// /myOwnReact/render.js

import { checkIsTextNode } from "./utils.js";

// render方法他接受两个参数:
// 1. element: 通过createElement创建的元素对象
// 2. container: 真实的dom容器
function render(element, container) {
    const isTextNode = checkIsTextNode(element);
    // 首先我们要通过根element的type来创建一个真实节点 
    // 但是我们需要区分一下节点类型, 如果是text节点的话我们就不要创建element了
    const rootDom = isTextNode ? document.createTextNode("") : document.createElement(element.type);
    
    //  这里我们还是要区分一下文本节点和dom节点, 因为文本节点是没有setAttribute方法的
    // 我们需要将文本节点直接给到文本节点的nodeValue
    // 当然其实在一开始我们创建文本节点的时候你就可以将nodeValue作为参数传递进去了
    // 只不过我们上面传的是空串, 这个看个人喜好了
    if (isTextNode) {
        rootDom.nodeValue = props.nodeValue;
    } else {
        // 如果是dom节点, 我们要讲所有的props添加到该真实rootDom上, 但是除了children
        const { children = [], ...restProps } = element.props;
        const attrs = Object.keys(restProps);
        attrs.forEach(k => domElement.setAttribute(k, restProps[k]));

        // 递归子元素
        children.forEach(child => render(child, domElement));
    }
    
    container.appendChild(rootDom)
}

复制代码

至此我们的render方法就写完了

同样我们需要在上面的index.js中做一个具名导出

// /myOwnReact/index.js
...
export { default as render } from "./render.js"
import render from "./render.js"

export {
    ...,
    render
}
...
复制代码

到这里, 我们可以创建一个index.html, 然后书写如下代码, 我们可以看看页面中是不是出现了我们想要的结果

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Build Ur Own React</title>
</head>
<body>
  <div id="root"></div>
  <script type="module">
    import { createElement, render } from "./index.js"

    const element = createElement("div", { class: "wrapper" }, 
    createElement("span", { class: "title" }, "我是标题"), createElement("div", { class: "content" }, "我是内容"));

    console.log("element", element);

    render(element, document.getElementById("root"));

  </script>
</body>
</html>
复制代码

打开live server, 我们可以看到页面中呈现的效果和浏览器的对element的打印结果如下:

2021-07-11-10-16-55.png

我们可以看到, 我们的标签已经成功在html文档中渲染了, 同时浏览控制台也打印出了我们递归创建的节点。

Concurrent Mode & Fiber

我们来探讨一个一个问题: 当上面的render方法开始执行以后, 我们能中断render方法的执行吗?

很显然, 答案是no, 那么这样会造成一个什么问题呢

一旦我们开始这个"渲染"操作, 在渲染完整个真实的dom树之前我们都不能中断这个渲染. 那如果我们的虚拟dom树特别的庞大, 那么对应的渲染成真实dom树要花的时间就越长, 我们都知道JS是单线程的, 那么这个渲染操作就会一直在主线程中进行, 并阻塞后续任务的进行, 我们假设有一个场景, 我们最开始渲染了一个input输入框, 然后这个时候后续还有一大堆东西需要渲染并且渲染了十多秒, 那这十多秒内, 用户在input框输入了东西以后, 他虽然可以被事件监听线程发现, 但是主线程会甩他吗, 是不会的, 所以这就是问题所在

解决这个问题, React的思路是这样的:

  • 将整个渲染过程分解成n多个小单元
  • 当每一个小单元渲染完毕以后, 我们就看看现在是否有交互啊, 有没有其他需要中断渲染的操作啊, 如果有的话, 那就中断渲染, 如果没有的话就进行下一个单元的渲染

我们需要改造一下我们的render.js文件

我们现在在render方法里是直接进行了渲染, 而且一渲染就直接递归把他的所有子节点都跟着渲染了, 这样肯定是不OK的, 根据我们上面的说法, 我们需要将一项庞大的渲染工作拆分成小而独立的UI单元, 这样渲染起来比较轻松

同时在更新render.js这个文件之前, 我建议大家先去看看requestIdleCallback这个函数: developer.mozilla.org/zh-CN/docs/…

简单说的话就是这个函数会帮你去计算浏览器栈中当前有没有需要执行的高优先级任务(比如用户的输入和动画响应等), 我们就可以直接通过这个函数来协助我们完成对用户输入等高优先级操作的响应

然后我们再来简单说一说Fiber, 我们都知道Vue中有虚拟dom对把, 而React中也有虚拟dom, 只不过他的名字叫做Fiber

// 我们通过createElement创建的对象还不是一个虚拟dom哦, 他只是一个基本的描述对象

// 简单来说fiber就是一种数据结构
const Fiber = {
    type: null, // 该fiber节点对应的标签类型
    parent: null, // 父级fiber节点
    sibling: null, // 兄弟fiber节点
    child: null, // 自己fiber节点
    dom: null, // 该fiber节点对应的真实dom元素
    props: {}, // 该fiber节点的所有属性
    effectTag: null, // 该fiber节点对应的更新状态, 在更新阶段会用到
}

// 里面的每个属性都有自己的用途, 随着我们的书写后续你就知道了
复制代码
import { checkIsTextNode } from "./utils.js";


let nextUnitOfWork = null; // 我们假设这个变量中存储的就是每一次需要渲染的UI单元, 我们通过不断变动这个变量的值来控制本次渲染的究竟是什么

// 我们现在知道, render他的任务其实还是渲染一整个dom树, 但是我们要改变一下策略, 我们通过render来开启一项自动工作的调度任务
// 该调度任务会源源不断的帮助我们进行dom的渲染, 就像流水线上的一条业务线一样, 但是该调度任务会在没有东西可以渲染的时候停下来
// 同时也会在需要停止的时候停下来(什么时候需要停止? 比如上面我们说的用户高优先级的输入响应操作)
export default function render(element, container) {
    // 我们现在把调度开关的开启决断于 nextUnitOfWork有没有值
    // 所以我们要开启调度, 那么就给nextUnitOfWork赋值
    nextUnitOfWork = { // nextUnitOfWork其实就是一个fiber节点了
        type: null,
        dom: container,
        parent: null, // 父级fiber节点
        sibling: null, // 兄弟fiber节点
        child: null, // 他的子fiber节点
        effectTag: "placement", // 这是在后续更新阶段会使用到的一个fiber标记, placement表示新增节点
        props: {
            children: [element]
        }
    }
}


// 那么我们势必是需要一个玩意去感知nextUnitOfWork是不是有值了, 来写个workLoop方法
// 这个deadline是啥玩意: 他就是requestIdleCallback给我们传的一个参数, 我们等会会用这个参数来
// 决定我们是否需要停止下一个单元的渲染
function workLoop(deadline) {
    let shouldYield = false; // 我们是否需要停止渲染的一个flag, false就是不需要, true就是需要停止了
    while (nextUnitOfWork && !shouldYield) {
        // 只要当前要处理的工作对象有值 而且 系统没有让我们停下来(shouldYield为false), 那我们就一直
        // 执行任务
        // performUnitOfWork是我们下面会补的一个函数
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
        // deadline是一些关于浏览器闲暇情况的一个参数, 它里面有一个方法timeRemaining, 该方法
        // 的调用会返回一个毫秒数, 代表浏览器当前闲置的一个剩余的估计时间, 比如当浏览器有任务要过来了, 他就会知道
        // 并且他会大概计算一下这个任务过来还要大概多久时间, 所以当这个时间不多的时候, 我们需要把渲染任务停止, 、
        // 让浏览器去做他该做的事情
        shouldYield = deadline.timeRemaining() < 1;
    }
    // 当停止以后浏览器其实就有空去响应用户的操作了, 但是我们这里还是要记住需要源源不断的开启监听
    requestIdleCallback(workLoop);
}

function createDom(fiber) {
  // 我们会根据传递进来的fiber节点, 然后构建出属于该fiber节点的唯一的真实dom
  const isTextNode = checkIsTextNode(fiber);
  const domElement = isTextNode ? document.createTextNode("") : document.createElement(fiber.type);

  // 同理, 如果是文本节点, 我们就直接将nodeValue进行赋值
  if (isTextNode) domElement.nodeValue = fiber.props.nodeValue;
  else {
    // 否则就对props进行赋值
    const { children = [], ...attrs } = fiber.props;

    const keys = Object.keys(attrs);

    keys.forEach(k => {
      domElement.setAttribute(k, attrs[k]);
    })

    // 注意: 我们这里不处理子元素, 我们只处理他本人
  }
  return domElement;
}



function performUnitOfWork(fiber) {
    // 这个方法我们要做的事情就几个:

    // 1. 根据当前的fiber节点给他创建对应的真实dom节点
    fiber.dom == null && (fiber.dom = createDom(fiber))
    // 2. 将nextUnitOfWork的dom推入到父级的dom中
    if (fiber.parent) {
        // 如果该fiber节点有父级fiber元素, 那我们就可以将该fiber推入到父级节点中去
        fiber.parent.dom.appendChild(fiber.dom)
    }

    // 3. 开始构建该fiber的一些兄弟节点, 子节点的关系
    const elements = fiber.props.children;

    let index = 0;
    let prevFiber = null; // 这是我们用来维护整个fiber链表的一个索引入口
    // 请注意哈: 这个elements里面装的可全都是通过createElement创建的描述对象哈
    while (index < elements.length) {
        let newFiber = {
            type: elements[index].type,
            props: elements[index].props,
            parent: fiber, // 他的父级节点是不是就是此次的fiber节点, 这个仔细屡一下
            child: null,
            sibling: null,
            effectTag: "placement"
        }

        // 然后其实我们本次的fiber节点还没有child属性吧
        if (index === 0) fiber.child = newFiber;
        else {
            prevFiber.sibling = newFiber;
        }
         prevFiber = newFiber;
         index ++;
    }

    // 4. 我们还需要将下一次调度的nextUnitOfWork返回出去吧, 来保证每次都有新fiber节点可以被渲染
    if (fiber.child) return fiber.child;
    let nextFiber = fiber;
    while (nextFiber) {
    if (nextFiber.sibling) {
        return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
    }
}

// 注意哦: 这里我们通过requestIdleCallback直接开启调度任务
requestIdleCallback(workLoop); 
复制代码

render phase & commit phase

不知道大家有没有考虑另外一个问题, 我们现在会将整个ui的渲染拆分成很多个小的UI单元进行逐步渲染, 假设中途用户有一个输入, 我们假设这个输入的JS处理逻辑为10秒, 那么这10秒内UI都将不会继续渲染, 则用户看到的是一个残缺的UI界面, 这完全不是我们想要的, 所以我们需要改变一下我们的方针

我们现在不一个一个的塞入dom容器了, 我们将整个fiber tree全部构建完毕以后(代表着这个时候没有任何JS逻辑处理了), 我们再将整个树直接塞入真实dom里(依靠的就是fiber节点中的dom), 我们之前为什么说用户的响应会不及时, 是因为我们在渲染的时候会有非常多的JS逻辑操作, 而我们将dom塞进真实的容器中, 这消耗的时间远没有上面多, 所以我们更希望这样, 那么这里就涉及到两个概念:

  • render phase: 代表我们进行JS逻辑处理和构建整个fiber 树的阶段, 在这个阶段如果有用户响应必须由我们处理(特别是在更新的时候), 那么我们将停止目前的工作, 直接去处理优先级更高的用户响应等操作
  • commit phase: 代表我们整个fiber tree已经构建完毕了, 正在往真实dom容器里塞入, 这个时候我们是不会去管用户的交互和优先级的, 整个过程不可中断

那么我们就又得改改我们的render.js

// 1. 首先我们现在的每一次nextUnitOfWork都是会不断变化的, 所以我们压根拿不到根节点
// 而在render中对nextUnitOfWork的赋值一定是根节点
...
let nextUnitOfWork = null;
// 我们再多加一个变量
let wipRoot = null; // 代表整个fiber tree的根节点的引用, 因为我们知道要保存一棵树的引用保存他的根节点就OK了

...

function render(element, container) {
    // 我们需要更新一下render 方法
    wipRoot = {
        dom: container,
        parent: null,
        sibling: null,
        child: null,
        props: {
            children: [element],
        },
        effectTag: "placement",
        type: null
    }

    nextUnitOfWork = wipRoot;
}
...

// 我们的workLoop方法里我们可以用来提交整个fiber tree
function workLoop(deadline) {
    ...

    // 为什么workLoop里是可以的哈, 主要是因为我们知道, 每一次nextUnitOfWork的值的变换其实都是在workLoop里进行处理的
    // 所以我们只能在workLoop里去感知现在的fiber tree是不是已经构建完了
    
    // 我们直接在while 循环的下面进行判断
    // 如果这个时候我们nextUtilOfWork为null了, 代表整个fiber tree已经构建完毕了, 所以我们要做的就是直接进入commit phase
    if (!nextUnitOfWork && wipRoot) {
        // 为啥一定得是nextUnitOfWork为null才行哈, 主要是因为就算不为空也可能会走到这里来
        // 因为中断渲染的时候, 这个时候nextUnitOfWork一定还有值, 但是呢 他又一定会走进这个流程
        // 我们始终只希望一点, 就是整个fiber 树确定构建完了 我们才会进行提交
        commitRoot();
    }

     // 当停止以后浏览器其实就有空去响应用户的操作了, 但是我们这里还是要记住需要源源不断的开启监听
    requestIdleCallback(workLoop);

    ...
}

// 同时新增以下两个方法, 这两个方法都比较简单, 我就不说了哈
function commitRoot() {
    commitWork(wipRoot.child); // 因为wipRoot一定是container这个dom嘛, 所以我们直接从子元素开始提交
    wipRoot = null; 
}

function commitWork(fiber) {
    if (!fiber) return;
    fiber.parent.dom.appendChild(fiber.dom);
    commitWork(fiber.child);
    commitWork(fiber.sibling);
}

复制代码

Reconciliation

大家一定听说过Vue的一个diff算法吧, 同样作为MVVM框架, React也具备自己内部比对虚拟dom的一个算法, 他叫做Reconciliation

主要的这个流程就是我们需要在每一次更新的时候去对上一次保存的虚拟dom树进行一个比较, 从而决定我们是有哪些节点更新, 哪些节点被删除, 又有哪些新增的节点

我们再次对我们的render.js文件下手了

// 1. 首先我们在全局加一个currentRoot用来保存之前的fiber tree, 同时加一个deleteGroup来保存本次比对需要删除的dom元素
...
let nextUnitOfWork = null;
let wipRoot = null;
let currentRoot = null; // 我们要保存的本次的整个fiber Tree
let deleteGroup = []; // 被删除的fiber集合


// 然后我们需要在commitRoot里加一些代码
function commitRoot() {
    // 因为这里我们是会把wipRoot清空的, 所以在清空之前我们一定要保存一下引用
    ...
    currentRoot = wipRoot;
    wipRoot = null;
    ...
}
...


// 然后就是我们要更新一下我们performUnitOfWork函数
function performUnitOfWork(fiber) {
    ...
    // 2. 开始构建该fiber的一些兄弟节点, 子节点的关系
    const elements = fiber.props.children;

    // 我们之前在这里写了一大块处理子节点fiber的东西, 我们都不要了, 直接拎出去
    // 代码看着怪恶心的
    reconciliationChildren(fiber, elements);
    
    ...
}


// 然后编写我们的reconciliationChildren方法
// 我们定义一个对子元素进行diff比较的方法
function reconciliateChildren(wipRoot, elements) {
    
    // 1. 首先我们拿到最近保存的一个虚拟dom树
    const oldFiber = wipRoot.alternate && wipRoot.alternate.child;



    let index = 0;
    let prevFiber = null; // 这是我们用来维护整个fiber链表的一个索引入口
    // 请注意哈: 这个elements里面装的可全都是通过createElement创建的描述对象哈

    // 因为我们这里要进行逐层比对, 而且会对oldFiber进行多次值的修改, 所以我们并不能够以
    // index < elements.length为结束手段, 因为如果elements.length没有了
    // 但是oldFiber还有 那其实代表的是最新的fiber节点里做了删除操作
    while (index < elements.length || oldFiber != null) {

        const el = elements[index];

        // 如果本次oldFiber和新的el类型相同, 我们就要留存一部分信息以节约性能
        const isSameType = el && oldFiber && el.type === oldFiber.type; 

        let newFiber = null;

        if (isSameType) {
            // 代表是更新阶段
            newFiber = {
                type: oldFiber.type,
                parent: wipRoot,
                sibling: null,
                child: null,
                props: el.props,
                alternate: oldFiber,
                effectTag: "update",
                dom: oldFiber.dom
            }
        } else if (oldFiber && !isSameType) {
            // 代表做了删除
            oldFiber.effectTag = "delete"; 
            deleteGroup.push(oldFiber); // 往本次被删除的集合中添加一个oldFiber

        } else if (el && !isSameType) {
            // 代表做了新增
            newFiber = {
                type: elements[index].type,
                props: elements[index].props,
                parent: wipRoot, // 他的父级节点是不是就是此次的fiber节点, 这个仔细屡一下
                child: null,
                sibling: null,
                effectTag: "placement",
                dom: null
            }
        }
        // 然后其实我们本次的fiber节点还没有child属性吧
        if (index === 0) wipRoot.child = newFiber;
        else {
            prevFiber.sibling = newFiber;
        }
        prevFiber = newFiber;
        index ++;
    }
}

// 有了上面的reconciliationChildren 方法来比对虚拟dom以后, 其实我们在commitWork的时候就需要区分一下状态了
function commitWork(fiber) {
    if (!fiber) return;
    if (fiber.effectTag === "placement") {
        // 新增
        fiber.parent && fiber.parent.dom.appendChild(fiber.dom);
    } else if (fiber.effectTag === "update") {
        updateDom(dom, fiber.alternate.props, fiber.props, );
    } else if (fiber.effectTag === "delete") {
        fiber.parent.dom.removeChild(fiber.dom);
    }
    commitWork(fiber.child);
    commitWork(fiber.sibling);
}

function updateDom(dom, prevProps, nextProps) {
    // 1. 首先我要看的是有没有被移除的属性
    const withoutChildrenPrevProps = Object.keys(prevProps).filter(k => k !== "children");
    const withoutChildrenNextProps = Object.keys(nextProps).filter(k => k !== "children");

    // 我要做的就是把旧的属性全部遍历一遍, 如果旧的有 新的直接没有了就remove掉
    // 否则就是更新掉
    withoutChildrenPrevProps.forEach(k => {
        if (k.startsWith("on")) {
            // 这代表是事件啊, 事件得悠着点
            const legalEventName = k.toLowerCase().substring(2); // 我们知道React里是以onClick这种来标注事件的, 我们只需要小写的click
            // 事件其实也分移除还是更新
            if(!(k in withoutChildrenNextProps)) {
                // 代表都没有了 我还留着干嘛啊
                dom.removeEventListener(legalEventName, prevProps[k]);
            } else {
                // 直接绑定
                dom.addEventListener(legalEventName, nextProps[k]);
            }


        } else if (!(k in withoutChildrenNextProps)) {
            // 如果在新的属性里都没有这个key了, 直接拜拜
            dom[k] = "";
        } else {
            // 到这里就一定是更新阶段, 全部以新的为主, 当然你也可以进行深层优化比较
            dom[k] = nextProps[k];
        }
    })
}

复制代码

到此为止, 我们的reconciliation阶段也完成了

我们最后的render.js文件代码如下:

import { checkIsTextNode } from "./utils.js";


let nextUnitOfWork = null; // 我们假设这个变量中存储的就是每一次需要渲染的UI单元, 我们通过不断变动这个变量的值来控制本次渲染的究竟是什么
let wipRoot = null; // 代表我最后要提交的整个fiber tree
let currentRoot = null; // 我们要保存的本次的整个fiber Tree
let deleteGroup = []; // 被删除的fiber集合

// 我们现在知道, render他的任务其实还是渲染一整个dom树, 但是我们要改变一下策略, 我们通过render来开启一项自动工作的调度任务
// 该调度任务会源源不断的帮助我们进行dom的渲染, 就像流水线上的一条业务线一样, 但是该调度任务会在没有东西可以渲染的时候停下来
// 同时也会在需要停止的时候停下来(什么时候需要停止? 比如上面我们说的用户高优先级的输入响应操作)
export default function render(element, container) {
    // 我们现在把调度开关的开启决断于 nextUnitOfWork有没有值
    // 所以我们要开启调度, 那么就给nextUnitOfWork赋值
    wipRoot = { // nextUnitOfWork其实就是一个fiber节点了
        type: null,
        dom: container,
        parent: null, // 父级fiber节点
        sibling: null, // 兄弟fiber节点
        child: null, // 他的子fiber节点
        effectTag: "placement", // 这是在后续更新阶段会使用到的一个fiber标记, placement表示新增节点
        props: {
            children: [element]
        },
        alternate: currentRoot, // 我们在根节点处也留存一下我们上一次的fiber树
    }

    deleteGroup = []; // 每次render我们都应该置空被删除的fiber数组

    nextUnitOfWork = wipRoot;
}


// 那么我们势必是需要一个玩意去感知nextUnitOfWork是不是有值了, 来写个workLoop方法
// 这个deadline是啥玩意: 他就是requestIdleCallback给我们传的一个参数, 我们等会会用这个参数来
// 决定我们是否需要停止下一个单元的渲染
function workLoop(deadline) {

    let shouldYield = false; // 我们是否需要停止渲染的一个flag, false就是不需要, true就是需要停止了
    while (nextUnitOfWork && !shouldYield) {
        // 只要当前要处理的工作对象有值 而且 系统没有让我们停下来(shouldYield为false), 那我们就一直
        // 执行任务
        // performUnitOfWork是我们下面会补的一个函数
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
        // deadline是一些关于浏览器闲暇情况的一个参数, 它里面有一个方法timeRemaining, 该方法
        // 的调用会返回一个毫秒数, 代表浏览器当前闲置的一个剩余的估计时间, 比如当浏览器有任务要过来了, 他就会知道
        // 并且他会大概计算一下这个任务过来还要大概多久时间, 所以当这个时间不多的时候, 我们需要把渲染任务停止, 、
        // 让浏览器去做他该做的事情
        shouldYield = deadline.timeRemaining() < 1;
    }

    // 如果这个时候我们nextUtilOfWork为null了, 代表整个fiber tree已经构建完毕了, 所以我们要做的就是直接进入commit phase
    if (!nextUnitOfWork && wipRoot) {
        // 为啥一定得是nextUnitOfWork为null才行哈, 主要是因为就算不为空也可能会走到这里来
        // 因为中断渲染的时候, 这个时候nextUnitOfWork一定还有值, 但是呢 他又一定会走进这个流程
        // 我们始终只希望一点, 就是整个fiber 树确定构建完了 我们才会进行提交
        commitRoot();
    }

    // 当停止以后浏览器其实就有空去响应用户的操作了, 但是我们这里还是要记住需要源源不断的开启监听
    requestIdleCallback(workLoop);
}

function commitRoot() {
    deleteGroup.forEach(commitWork); // 看看有没有被删除东西
    commitWork(wipRoot.child); // 因为wipRoot一定是container这个dom嘛, 所以我们直接从子元素开始提交

    // 跑不掉的一定是在commit阶段对本次的一个虚拟dom树进行一个留存
    currentRoot = wipRoot;

    wipRoot = null; 
}

function commitWork(fiber) {
    if (!fiber) return;
    if (fiber.effectTag === "placement") {
        // 新增
        fiber.parent && fiber.parent.dom.appendChild(fiber.dom);
    } else if (fiber.effectTag === "update") {
        updateDom(dom, fiber.alternate.props, fiber.props, );
    } else if (fiber.effectTag === "delete") {
        fiber.parent.dom.removeChild(fiber.dom);
    }
    commitWork(fiber.child);
    commitWork(fiber.sibling);
}

function updateDom(dom, prevProps, nextProps) {
    // 1. 首先我要看的是有没有被移除的属性
    const withoutChildrenPrevProps = Object.keys(prevProps).filter(k => k !== "children");
    const withoutChildrenNextProps = Object.keys(nextProps).filter(k => k !== "children");

    // 我要做的就是把旧的属性全部遍历一遍, 如果旧的有 新的直接没有了就remove掉
    // 否则就是更新掉
    withoutChildrenPrevProps.forEach(k => {
        if (k.startsWith("on")) {
            // 这代表是事件啊, 事件得悠着点
            const legalEventName = k.toLowerCase().substring(2); // 我们知道React里是以onClick这种来标注事件的, 我们只需要小写的click
            // 事件其实也分移除还是更新
            if(!(k in withoutChildrenNextProps)) {
                // 代表都没有了 我还留着干嘛啊
                dom.removeEventListener(legalEventName, prevProps[k]);
            } else {
                // 直接绑定
                dom.addEventListener(legalEventName, nextProps[k]);
            }


        } else if (!(k in withoutChildrenNextProps)) {
            // 如果在新的属性里都没有这个key了, 直接拜拜
            dom[k] = "";
        } else {
            // 到这里就一定是更新阶段, 全部以新的为主, 当然你也可以进行深层优化比较
            dom[k] = nextProps[k];
        }
    })
}

function createDom(fiber) {
  // 我们会根据传递进来的fiber节点, 然后构建出属于该fiber节点的唯一的真实dom
  const isTextNode = checkIsTextNode(fiber);
  const domElement = isTextNode ? document.createTextNode("") : document.createElement(fiber.type);

  // 同理, 如果是文本节点, 我们就直接将nodeValue进行赋值
  if (isTextNode) domElement.nodeValue = fiber.props.nodeValue;
  else {
    // 否则就对props进行赋值
    const { children = [], ...attrs } = fiber.props;

    const keys = Object.keys(attrs);

    keys.forEach(k => {
      domElement.setAttribute(k, attrs[k]);
    })

    // 注意: 我们这里不处理子元素, 我们只处理他本人
  }
  return domElement;
}



function performUnitOfWork(fiber) {
    // 这个方法我们要做的事情就几个:

    // 1. 根据当前的fiber节点给他创建对应的真实dom节点
    fiber.dom == null && (fiber.dom = createDom(fiber))

    // 2. 开始构建该fiber的一些兄弟节点, 子节点的关系
    const elements = fiber.props.children;

    reconciliateChildren(fiber, elements);

    // 3. 我们还需要将下一次调度的nextUnitOfWork返回出去吧, 来保证每次都有新fiber节点可以被渲染
    if (fiber.child) return fiber.child;
    let nextFiber = fiber;
    while (nextFiber) {
        if (nextFiber.sibling) {
            return nextFiber.sibling;
        }
        nextFiber = nextFiber.parent;
    }
}

// 我们定义一个对子元素进行diff比较的方法
function reconciliateChildren(wipRoot, elements) {
    
    // 1. 首先我们拿到最近保存的一个虚拟dom树
    const oldFiber = wipRoot.alternate && wipRoot.alternate.child;



    let index = 0;
    let prevFiber = null; // 这是我们用来维护整个fiber链表的一个索引入口
    // 请注意哈: 这个elements里面装的可全都是通过createElement创建的描述对象哈

    // 因为我们这里要进行逐层比对, 而且会对oldFiber进行多次值的修改, 所以我们并不能够以
    // index < elements.length为结束手段, 因为如果elements.length没有了
    // 但是oldFiber还有 那其实代表的是最新的fiber节点里做了删除操作
    while (index < elements.length || oldFiber != null) {

        const el = elements[index];

        // 如果本次oldFiber和新的el类型相同, 我们就要留存一部分信息以节约性能
        const isSameType = el && oldFiber && el.type === oldFiber.type; 

        let newFiber = null;

        if (isSameType) {
            // 代表是更新阶段
            newFiber = {
                type: oldFiber.type,
                parent: wipRoot,
                sibling: null,
                child: null,
                props: el.props,
                alternate: oldFiber,
                effectTag: "update",
                dom: oldFiber.dom
            }
        } else if (oldFiber && !isSameType) {
            // 代表做了删除
            oldFiber.effectTag = "delete"; 
            deleteGroup.push(oldFiber); // 往本次被删除的集合中添加一个oldFiber

        } else if (el && !isSameType) {
            // 代表做了新增
            newFiber = {
                type: elements[index].type,
                props: elements[index].props,
                parent: wipRoot, // 他的父级节点是不是就是此次的fiber节点, 这个仔细屡一下
                child: null,
                sibling: null,
                effectTag: "placement",
                dom: null
            }
        }
        // 然后其实我们本次的fiber节点还没有child属性吧
        if (index === 0) wipRoot.child = newFiber;
        else {
            prevFiber.sibling = newFiber;
        }
         prevFiber = newFiber;
         index ++;
    }
}

// 注意哦: 这里我们通过requestIdleCallback直接开启调度任务
requestIdleCallback(workLoop); 
复制代码

ok, 你基本已经实现了一个小的react的生态, 从createElementrenderfiber, 再到render phase & commit phase, 再到reconciliation 你可以思考一下函数组件和类组件应该要怎么处理, 下一次我会出一篇博客专门聊聊他们, 希望本篇博客能够对你有所帮助喽

文章分类
前端
文章标签