一个前端老狗对Vue和React的对比总结(架构理念、最佳实践 、脑图、源码级别10w+文)

1,223 阅读1小时+

前言

2025年了,在 openAI 刚刚发布 o3, cursor 大行其道,AGI 实现的前夜,我想对我们用了多年的老两样 vue React 进行一个全面的总结。不管以后是不是大前端,AI驱动工程师,还是不需要再在简历上写‘熟练掌握Vue和react’,这都会成为未来硅基世界的数据类里我们努力过的证明和痕迹。

文前求赞,写得很辛苦,如果对您有帮助,麻烦点赞、收藏、评论三连。谢谢了!🥲🥲🥲

1. 前端框架的心智模型

1.1 什么是前端框架?有什么用?

在我看来,无论是react还是vue,它们的本质都是”构建UI的库“。目的是为了让开发者不用再去操作DOM,提升开发、渲染效率,拓展项目的跨平台能力。

那这类”构建UI的库“,主要提供了2个核心功能:

  • 基于状态的声明式渲染:UI = f(state)
  • 提供组件化开发

再围绕这些核心功能,我们随着应用的升级和复杂度的提升,响应的在核心功能外逐步的去提供解决方案。

  • 路由管理方案:当页面由简单页面变为SPA页面,那必然需要路由管理方案。vue是Vue-Router,react是React-router
  • 状态管理方案:当组件越来越多,数据流转越来越复杂,需要对数据或者状态进行管理。Vue提供了vuexpinia;react提供了reduxreact-reduxzustand等等
  • 其他管理方案:除了路由和状态管理,还有很多其他功能管理方案,如构建支持,数据流方案等等...

除了核心功能外,其他解决方案vue或者react本身是不支持的。vue和react本身只专注,基于状态(数据)的声明式渲染这一件事,只不过内部的实现机制有差异。

回到本节的问题,什么是前端框架? 一句话总结:前端框架是专注于基于状态的声明式渲染以及其他附加解决方案的总和称之为前端框架

有什么用呢? 在早期jquery阶段,我们需要手动操作dom,或者在更早期,前后端不分离的时代,根本不需要前端的库。因为前端页面多是多页面的,简单的,代码量不高的,前端还没形成一个和后端分庭抗礼的生态。 那随着前端的复杂度和重要度的提高,我们编写前端代码越来越吃力,帮助我们提效的前端的库应运而生。

1.2 react和vue的共同理念

我们先不去探究如何去使用一个框架,这个大家已经非常熟练了。我们剥开现象看本质。聊一聊react和vue的实现原理,了解它们如何实现上述的核心功能和解决方案的。他们有什么共同的实现理念?

vue和react的实现方案其实很像,都是都是用户写模板DSL(jsx和template),再借助编译器(vue自己实现编译器,react借助babel)转化为渲染函数,渲染函数调用生成vdom,然后再diff,最后操作dom API 渲染到界面上。 其中不同的地方,便是各自内部的状态更新机制不同。一个是SetSate,一个是defineProperty和proxy,一个主动触发渲染,一个数据劫持加响应式。

分析两个框架共性的部分,能够帮助我们更好的理解这两个框架的架构思想。但是如果没有阅读过源码的同学,可以翻到后面看源码的整体分析。

1.3 虚拟DOM

1.3.1 什么是虚拟dom?

一句话总结:虚拟dom是一个js对象,它描述了dom结构,保存在内存中。

虚拟DOM最早是由react团队提出的,英文名Virtual DOM,可以说这是一种编程思想,通过js对象,去描述页面UI的层次结构。

1.3.2 为什么要使用虚拟DOM?

主要是两个点:

  • 比DOM更小、更简单、更快
  • 可以渲染到不同平台

比dom更小、更简单、更快

首先,虚拟dom是一个js对象。在浏览器的执行环境里,操作一个js对象就是天然要比操作一个dom要快得多。 因为js引擎和渲染引擎是在不同线程中运行的,因此js引擎沟通渲染引擎需要付出很高成本。 第二,虚拟dom当然比真实dom更简单,真实dom上麻麻多的属性,虚拟dom不需要一比一复制,自然结构简单得多。

可以渲染到不同平台

由于虚拟dom是一个js对象,包含了UI的结构信息,因此对于不同平台的"真实dom",自然也能操作不同平台的API进行响应的实现。

例如在浏览器:document.creatElement就是一个变成真实dom的API; 在Native 宿主环境使用对应的reactNative API

1.3.3 虚拟dom真的更快?

事实真的是如上所述吗?虚拟dom真的比真实dom更快? 很多人忽略了一点。虚拟dom的快其实是有前提的。虚拟dom再快,你最后,不也得渲染成真实dom吗?你不也得操作dom吗?

的确,虚拟dom最终还是得操作真实dom。因此虚拟dom的快,并不体现在第一次渲染,而是体现在更新阶段。

据调查表明,开发人员在不使用虚拟dom的时候,相比于使用原生API,他们更倾向于使用innerHTML


//原生API操作DOM:
const div = document.createElement("div");
const content = document.createTextNode("aleeeeex gogogo 914131");
div.appendChild(content);
document.body.appendChild(div);

//innerHTML
document.body.innerHTML = `<div>aleeeeex gogogo 914131</div>`;

我们对比innerHTML和虚拟dom的执行消耗

  • 在第一次渲染,对比执行消耗,虚拟dom和传统的写法并不具备优势。虚拟dom的优势体现在更新阶段。

    innerHTML:

    • 解析字符串<div>aleeeeex gogogo 914131</div>

    • 创建对应DOM节点。

    虚拟dom:

    • 创建js对象

    • 创建对应dom节点

  • 在更新阶段,虚拟dom体现的优势在于,它不需要全量更新,创建全量dom节点,自然就高效得多

    innerHTML:

    • 解析字符串<div>aleeeeex gogogo 914131</div>

    • 销毁原来

    • 创建对应DOM节点。

    虚拟dom:

    • 创建js对象

    • diff(比对确认更改的部分)

    • 修改对应dom节点

1.3.4 react中的虚拟DOM

1.3.4.1 react 虚拟DOM的生成

整体流程: JSX --> createElement ---> 虚拟DOM对象

在react中,通过 JSX 来描述UI,JSX最终会被解析成createElement函数,该函数生成虚拟DOM

image.png

React.createElement 会返回一个虚拟 DOM 对象,类似于:

const element = {
  $$typeof: Symbol.for("react.element"), // 表示这是一个 React 元素
  type: "h1",                           // 表示元素类型
  key: null,                            // 可选的 key 属性,用于列表中的元素
  ref: null,                            // 可选的 ref 属性,用于引用 DOM 或组件实例
  props: {                              // 元素的属性
    className: "title",                 // class 属性在 React 中为 className
    children: "Hello, React!"           // 子节点,文本内容
  },
  _owner: null                          // 用于调试和跟踪 React 元素的创建者
};

在源码中 createElement 方法如下(看不懂可以先略过):

在下面的代码中,最终返回的 element 对象就是我们所说的虚拟 DOM 对象。react官方倾向于叫react元素

//createElement源码

/**
 *
 * @param {*} type 元素类型 h1
 * @param {*} config 属性对象 {id : "aa"}
 * @param {*} children 子元素 hello
 * @returns
 * <h1 id="aa">hello</h1>
 */
export function createElement(type, config, children) {
  let propName;

  const props = {};

  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  // 说明有属性
  if (config != null) {
    // ...
    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }
  // 经历了上面的 if 之后,所有的属性都放到了 props 对象上面
  // props ==> {id : "aa"}

  // children 可以有多个参数,这些参数被转移到新分配的 props 对象上
  // 如果是多个子元素,对应的是一个数组
  const childrenLength = arguments.length - 2;
  if (childrenLength === 1) {
    props.children = children;
  } else if (childrenLength > 1) {
    const childArray = Array(childrenLength);
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    // ...
    props.children = childArray;
  }

  // 添加默认的 props
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }
  // ...
  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props
  );
}

const ReactElement = function (type, key, ref, self, source, owner, props) {
    // 该对象就是最终向外部返回的 vdom(也就是用来描述 DOM 层次结构的 JS 对象)
  const element = {
    // 让我们能够唯一地将其标识为 React 元素
    $$typeof: REACT_ELEMENT_TYPE,

    // 元素的内置属性
    type: type,
    key: key,
    ref: ref,
    props: props,

    // 记录负责创建此元素的组件。
    _owner: owner,
  };
  // ...
  return element;
};
1.3.4.2 react 虚拟DOM的更新(React Diff)
  1. 构建虚拟 DOM 树
    React 使用 React.createElement 将 JSX 转换为虚拟 DOM 对象(React 元素),形成一棵虚拟 DOM 树。

  2. 触发更新机制
    当组件的 stateprops 发生变化时,React 会触发更新流程:

    • 调用 render 方法,生成新的虚拟 DOM 树。
    • 将新旧虚拟 DOM 树进行对比。
  3. 对比虚拟 DOM 树(Diff 算法) React 的 Diff 算法优化了传统 O(n³) 的树对比,采用分层递归策略,将时间复杂度降低为 O(n)。具体过程如下:

    • 分层比较:React 仅对同一层级的节点进行比较,不跨层级。

    • 节点类型判断

      • 如果节点类型相同,保留当前节点,仅对属性和子节点递归更新。
      • 如果节点类型不同,直接移除旧节点,添加新节点。
    • Key 优化:通过 key 标识节点,提高节点复用率,减少不必要的操作。

  4. 生成更新队列
    在对比过程中,React 会生成一系列更新操作(增、删、改节点),并存入更新队列。

  5. 批量更新真实 DOM
    React 将更新操作应用到真实 DOM,尽量减少操作次数:

    • 使用 requestAnimationFrame 等技术合并多次更新。
    • 将所有更新操作打包为一次 DOM 操作。
  6. 触发生命周期钩子
    更新完成后,React 调用组件的 componentDidUpdate 或 React Hook 中的 useEffect 钩子,通知开发者更新结束。

1.3.5 Vue中的虚拟DOM

1.3.5.1 vue虚拟DOM的生成

整体流程: 模板语法 --> createElement ---> 虚拟DOM对象

在 Vue 中,通过模板 (template) 来声明 UI,Vue 的模板会被编译为渲染函数 (render function,vue2是createElement函数,vue3是h函数)。渲染函数执行后返回虚拟 DOM 树。

// 模板
<template>
  <div id="app">
    <h1>Hello, {{ name }}</h1>
  </div>
</template>

// 编译后的渲染函数
render() {
  return h('div', { id: 'app' }, [
    h('h1', null, `Hello, ${this.name}`)
  ]);
}

//渲染函数生成的虚拟dom对象
{
  tag: 'div',         // 标签名
  data: { id: 'app' }, // 属性
  children: [          // 子节点
    {
      tag: 'h1',
      data: null,
      children: ['Hello, John']
    }
  ],
  text: undefined,     // 文本内容
  elm: null,           // 对应的真实 DOM 节点
  key: undefined,      // 节点的唯一标识
}

Vue 使用 h 函数 (Vue 3) 或 createElement 函数 (Vue 2) 来创建虚拟 DOM 节点。

  • Vue 2: 使用 createElement 创建虚拟节点。

    render: function(createElement) { 
        return createElement( 
            'div', // 标签名 
            { attrs: { id: 'app' } }, // 属性对象
            [ // 子节点数组 
                createElement( 
                    'h1',
                    null,
                    ['Hello, ' + this.name] 
                )
            ]
        );
    }
    
  • Vue 3: 使用 h 函数。

    import { h } from 'vue';
    const App = {
      render() {
        return h('div', { id: 'app' }, [
          h('h1', null, `Hello, ${this.name}`)
        ]);
      }
    }
    
1.3.5.2 vue 虚拟 DOM 的更新 (Diff 算法)

Vue 的虚拟 DOM 更新主要通过以下步骤实现:

  1. 新旧虚拟 DOM 的对比

    • Vue 遍历新旧虚拟 DOM 树,找出需要更新的节点,进行最小化的 DOM 操作。
  2. Patch 流程

    • Vue 中的 patch 函数负责将虚拟 DOM 的变化映射到真实 DOM。
  3. 优化策略

    • 使用 key 来标识节点,减少不必要的更新。
    • 静态节点标记:Vue 会跳过不需要更新的静态节点。
1.3.5.3 vue虚拟DOM的代码核心(简化版)
function createElement(tag, data = {}, children = []) {
  return {
    tag,         // 标签
    data,        // 属性和事件
    children,    // 子节点
    key: data.key, // key 值
    elm: null    // 对应的真实 DOM
  };
}

function patch(oldVNode, newVNode) {
  if (!oldVNode) {
    // 挂载新节点
    return createElm(newVNode);
  } else if (!newVNode) {
    // 删除旧节点
    removeElm(oldVNode.elm);
  } else if (sameVNode(oldVNode, newVNode)) {
    // 更新节点
    updateVNode(oldVNode, newVNode);
  } else {
    // 替换节点
    replaceElm(oldVNode.elm, newVNode);
  }
}

1.3.6 Vue 和 React 虚拟 DOM 的比较

特性ReactVue
创建虚拟 DOMReact.createElementh 函数(Vue 3)
更新机制全局 diff局部 diff,优化静态节点
数据绑定单向数据流双向数据绑定 + 单向数据流
模板支持不支持模板,使用 JSX支持模板,也支持 render 函数
性能优化依赖于 key 和 diff 算法依赖 key,静态节点标记优化
diff机制同层对比同层对比
触发更新机制主动触发响应式系统自动触发

通过上述内容可以看出,Vue 和 React 的虚拟 DOM 在设计上有许多相似之处,但 Vue 的模板和响应式系统使其在某些场景下开发效率更高。

1.4 Diff

在上一节,我们简要对比了react和Vue的diff过程,这一节,我们继续讲一讲Diff这件事。

react中的Diff

react中的diff发生在reconciler的组件更新阶段。 当第一次渲染完成,就会产生fiber树。更新的时候,就会那新的jsx对象(虚拟dom)和旧的fiberNode节点进行一个对比,再决定如何来产生新的fiberNode,目标是尽可能的复用已有节点。

由于react采用的是fiber架构,因此,他是一个链表的形式链接父子和兄弟节点。react中的diff,采用的是同层比较,使用key和节点的type来判断节点能否复用。

react的diff分为单节点和多节点的Diff,而多节点的diff又分为2轮遍历。

  • 单节点Diff

    判断key和type是否相同,相同则复用,如果不同则无法复用,同时删除兄弟节点

  • 多节点Diff

    • 第一轮遍历

      • 如果新旧节点key和type相同,可以复用
      • 如果key同,type不同,生成权限fiber,老fiber放到deletions数组统一删除
      • key和type不同,结束遍历
    • 第二轮遍历

      • 剩下旧节点,旧的添加到deletions数组统一删除

      • 剩下新jsx元素,创建新的fiberNode

      • 新旧皆有,把剩余老的节点放入map,遍历剩余的新节点,从map中找能够复用的,能找到就复用,不能找到,就新增。遍历完了map还有剩下,那么也是放到deletetions中统一删除

vue中的Diff

vue采用的是双端对比的diff,也是同层比较, 根据sameVnode函数来判断新旧vNode是不是可以复用。

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

sameVnode 的逻辑非常简单,如果两个 vnode 的 key 不相等,则是不同的;否则继续判断对于同步组件,则判断 isCommentdatainput 类型等是否相同,对于异步组件,则判断 asyncFactory 是否相同。

对新旧vNode进行比对,有三种情况:

  • 新旧节点不同:创建新节点,删除旧的节点
  • 新旧节点相同:采用双端对比算法

所谓双端,指的是在新旧子节点的数组中,各用两个指针指向头尾的节点,在遍历的过程中,头尾两个指针同时向中间靠拢。

因此在新子节点数组中,会有两个指针,newStartIndex 和 newEndIndex 分别指向新子节点数组的头和尾。在旧子节点数组中,也会有两个指针,oldStartIndex 和 oldEndIndex 分别指向旧子节点数组的头和尾。

每遍历到一个节点,就尝试进行双端比较:「新前 vs 旧前」、「新后 vs 旧后」、「新后 vs 旧前」、「新前 vs 旧后」,如果匹配成功,更新双端的指针。比如,新旧子节点通过「新前 vs 旧后」匹配成功,那么 newStartIndex += 1,oldEndIndex -= 1。

如果新旧子节点通过「新后 vs 旧前」匹配成功,还需要将「旧前」对应的 DOM 节点插入到「旧后」对应的 DOM 节点之前。如果新旧子节点通过「新前 vs 旧后」匹配成功,还需要将「旧后」对应的 DOM 节点插入到「旧前」对应的 DOM 节点之前。

而vue2.0 diff的流程如下:

  • 对比头头、尾尾、头尾、尾头是否可以复用,如果可以复用,就进行节点的更新或移动操作。

  • 如果经过四个端点的比较,都没有可复用的节点,则将就的子序列保存为节点 key 为 key ,index 为 value 的 map 。

  • 拿新的一组子节点的头部节点去 map 中查找,如果找到可复用的节点,则将相应的节点进行更新,并将其移动到头部,然后头部指针右移。

  • 然而,拿新的一组子节点中的头部节点去旧的一组子节点中寻找可复用的节点,并非总能找到,这说明这个新的头部节点是新增节点,只需要将其挂载到头部即可。

  • 经过上述处理,最后还剩下新的节点就批量新增,剩下旧的节点就批量删除。

react 和 Vue 的 Diff原理不同的原因

vue采用的是双端 diff,这需要向前查找节点,而react的每个 FiberNode 节点上都没有反向指针,即前一个 FiberNode 通过 sibling 属性指向后一个 FiberNode,只能从前往后遍历,而不能反过来,因此该算法无法通过双端搜索来进行优化。

本节拆分到react源码解析和Vue源码解析中单独讲解

1.5 状态管理

参见我的另外一篇文章:我理解的前端状态管理:从Flux到Redux再到Pinia说开去

1.6 模板

我们前面讲过,vue和react采用不同的领域模型来描述UI页面

  • JSX
  • 模板

React 中使用的是 JSX,Vue 中使用的是模板来描述界面

1.6.1 JSX 历史来源

JSX 最早起源于 React 团队在 React 中所提供的一种类似于 XML 的 ES 语法糖:

const element = <h1>Hello</h1>

经过 Babel 编译之后,就会变成:

// React v17 之前
var element = React.createElement("h1", null, "Hello");

// React v17 之后
var jsxRuntime = require("react/jsx-runtime");
var element = jsxRuntime.jsx("h1", {children: "Hello"});

无论是 17 之前还是 17 之后,执行了代码后会得到一个对象:

{
  "type": "h1",
  "key": null,
  "ref": null,
  "props": {
    "children": "Hello"
  },
  "_owner": null,
  "_store": {}
}

这个其实就是大名鼎鼎的虚拟 DOM。

React 团队认为,UI 本质上和逻辑是有耦合部分的:

  • 在 UI 上面绑定事件
  • 数据变化后通过 JS 去改变 UI 的样式或者结构

作为一个前端工程师,JS 是用得最多,所以 React 团队思考屏蔽 HTML,整个都用 JS 来描述 UI,因为这样做的话,可以让 UI 和逻辑配合得更加紧密,所以最终设计出来了类 XML 形式的 JS 语法糖

由于 JSX 是 JS 的语法糖(本质上就是 JS),因此可以非常灵活的和 JS 语法组合使用,例如:

  • 可以在 if 或者 for 当中使用 jsx
  • 可以将 jsx 赋值给变量
  • 可以将 jsx 当作参数来传递,当然也可以在一个函数中返回一段 jsx
function App({isLoading}){
  if(isLoading){
    return <h1>loading...</h1>
  }
  return <h1>Hello World</h1>;
}

这种灵活性就使得 jsx 可以轻松的描述复杂的 UI,如果和逻辑配合,还可以描述出复杂 UI 的变化。

使得 React 社区的早期用户可以快速实现各种复杂的基础库,丰富社区生态。又由于生态的丰富,慢慢吸引了更多的人来参与社区的建设,从而源源不断的形成了一个正反馈。

1.6.2 模板的历史来源

模板的历史就要从后端说起。

在早期前后端未分离的时候,最流行的方案就是使用模板引擎,模板引擎可以看作是在正常的 HTML 上面进行挖坑(不同的模板引擎语法不一样),挖了坑之后,服务器端会将数据填充到挖了坑的模板里面,生成对应的 html 页面返回给客户端。

image-20211103140631869

所以在那个时期前端人员的工作,主要是 html、css 和一些简单的 js 特效(轮播图、百叶窗...),写好的 html 是不能直接用的,需要和后端确定用的是哪一个模板引擎,接下来将自己写好的 html 按照对应模板引擎的语法进行挖坑

image-20211103143319523

不同的后端技术对应的有不同的模板引擎,甚至同一种后端技术,也会对应很多种模板引擎,例如:

  • JavaJSP、Thymeleaf、Velocity、Freemarker
  • PHPSmarty、Twig、HAML、Liquid、Mustache、Plates
  • PythonpyTenjin、Tornado.template、PyJade、Mako、Jinja2
  • node.jsJade、Ejs、art-template、handlebars、mustache、swig、doT

下面列举几个模板引擎代码片段

twig 模板引擎

基本语法
{% for user in users %}
    * {{ user.name }}
{% else %}
    No users have been found.
{% endfor %}

指定布局文件
{% extends "layout.html" %}
定义展示块
{% block content %}
    Content of the page...
{% endblock %}

blade 模板引擎

<html>
    <head>
        <title>应用程序名称 - @yield('title')</title>
    </head>
    <body>
       @section('sidebar')
            这是 master 的侧边栏。
        @show

        <div class="container">
            @yield('content')
        </div>
    </body>
</html>

EJS 模板引擎

<h1>
    <%=title %>
</h1>
<ul>
    <% for (var i=0; i<supplies.length; i++) { %>
    <li>
        <a href='supplies/<%=supplies[i] %>'>
            <%= supplies[i] %>
        </a>
    </li>
    <% } %>
</ul>

这些模板引擎对应的模板语法就和 Vue 里面的模板非常的相似。

现在随着前后端分离开发的流行,已经没有再用模板引擎的模式了,后端开发人员只需要书写数据接口即可。但是如果让一个后端人员来开前端的代码,那么 Vue 的模板语法很明显对于后端开发人员来讲要更加亲切一些。

最后我们做一个总结,虽然现在前端存在两种方式:JSX 和模板的形式都可以描述 UI,但是出发点是不同

模板语法的出发点是,既然前端框架使用 HTML 来描述 UI,那么我们就扩展 HTML,让 HTML 种能够描述一定程度的逻辑,也就是“从 UI 出发,扩展 UI,在 UI 中能够描述逻辑”。

JSX 的出发点,既然前端使用 JS 来描述逻辑,那么就扩展 JS,让 JS 也能描述 UI,也就是“从逻辑出发,扩展逻辑,描述 UI”。

这两者虽然都可以描述 UI,但是思路或者说方向是完全不同的,从而造成了整体框架架构上面也是不一样的。

1.6.3 总结

在 React 中,使用 JSX 来描述 UI。因为 React 团队认为UI 本质上与逻辑存在耦合的部分,作为前端工程师,JS 是用的最多的,如果同样使用 JS 来描述 UI,就可以让 UI 和逻辑配合的更密切。

使用 JS 来描述页面,可以更加灵活,主要体现在:

  • 可以在 if 语句和 for 循环中使用 JSX
  • 可以将 JSX 赋值给变量
  • 可以把 JSX 当作参数传入,以及在函数中返回 JSX

而模板语言的历史则需要从后端说起。早期在前后端未分离时代,后端有各种各样的模板引擎,其本质是扩展了 HTML,在 HTML 中加入逻辑相关的语法,之后在动态的填充数据进去。如果单看 Vue 中的模板语法,实际上和后端语言中的各种模板引擎是非常相似的。

总结起来就是:

模板语法的出发点是,既然前端框架使用 HTML 来描述 UI,那么就扩展 HTML 语法,使它能够描述逻辑,也就是“从 UI 出发,扩展 UI,在 UI 中能够描述逻辑”。

而 JSX 的出发点是,既然前端使用 JS 来描述逻辑,那么就扩展 JS 语法,让它能够描述 UI,也就是“从逻辑出发,扩展逻辑,描述 UI”。

虽然这两者都达到了同样的目的,但是对框架的实现产生了不同的影响。

2. react源码解析

什么是react?

React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式

react是一个轻量化的库,是纯js实现的,几乎可以说是没有依赖其他库。 优点是比较灵活,拓展性好,侵入性较低(侵入性指的是改变原有架构的深度),为什么vue侵入性比react高?因为vue比react多做了一些魔法,也就是帮用户多做了一些工作。使得必须要按照vue规定的语法来写代码,这就叫侵入性高。例如vue会自动帮你做defineproperty,又例如你要写v-for vif v-html,但是react就是直接写js。

缺点是由于灵活,做的工作少,那就容易产生烂代码。vue就比较傻瓜式嘛,上手也简单,牺牲灵活来换取更好的代码。

React的特点

  • 轻量:React的开发版所有源码(包含注释)仅3000多行
  • 原生:所有的React的代码都是用原生JS书写而成的,不依赖其他任何库
  • 易扩展:React对代码的封装程度较低,也没有过多的使用魔法,所以React中的很多功能都可以扩展。
  • 不依赖宿主环境:React只依赖原生JS语言,不依赖任何其他东西,包括运行环境。因此,它可以被轻松的移植到浏览器、桌面应用、移动端。
  • 渐近式:React并非框架,对整个工程没有强制约束力。这对与那些已存在的工程,可以逐步的将其改造为React,而不需要全盘重写。
  • 单向数据流:所有的数据自顶而下的流动
  • 用JS代码声明界面
  • 组件化
  • 虚拟DOM
  • diff算法

2.1 react整体架构

我们前面讲过,react和vue一样,本质都是从领域模型DSL ---> 渲染函数再到虚拟DOM ---> Diff ---> 渲染到页面的过程。

在React v15以及之前的架构称之为 Stack 架构,从 v16 开始,React 重构了整体的架构,新的架构被称之为 Fiber 架构。

2.1.1 旧架构的问题

我们讲 react 是构建快速响应的大型 Web 应用程序的首选方式,那是什么制约一个应用的快速响应呢? 主要有如下2点:

  • 当需要执行大量计算或者设备本身性能不足的时候,就会占用浏览器的渲染时间,导致掉帧,卡顿,本质是CPU的瓶颈。
  • 当I/O操作发生的时候,需要等待数据返回后再进行后续操作,等待的时候无法快速响应,这是I/O的瓶颈。

而老的 Stack 架构, React v16 版本之前,进行两颗虚拟 DOM 树的对比的时候,需要涉及到遍历dom树结构,这个时候只能使用递归,而且这种递归是不能够打断的,一条路走到黑,从而造成了 JS 执行时间过长。计算会消耗大量的时间,造成页面掉帧卡顿。这是一个难以改变的CPU瓶颈。

image.png 另外一个制约快速响应的的 I/O 瓶颈主要是网络延迟。

网络延迟是一种客观存在的现象,那么如何减少这种现象对用户的影响呢?React 团队给出的答案是:将人机交互的研究成果整合到 UI 中。

用户对卡顿的感知是不一样的,输入框哪怕只有轻微的延迟,用户也会认为很卡,假设是加载一个列表,哪怕 loading 好几秒,用户也不会觉得卡顿。

对于 React 来讲,所有的操作都是来自于自变量的变化导致的重新渲染,我们只需要针对不同的操作赋予不同的优先级即可。

具体来说,主要包含以下三个点:

  • 为不同操作造成的“自变量变化”赋予不同的优先级

  • 所有优先级统一调度,优先处理“最高优先级的更新”

  • 如果更新正在进行(进入虚拟 DOM 相关工作),此时有“更高优先级的更新”产生的话,中段当前的更新,优先处理高优先级更新

要实现上面的这三个点,就需要 React 底层能实现:

  • 用于调度优先级的调度器
  • 调度器对应的调度算法
  • 支持可中断的虚拟 DOM 的实现

所以不管是解决 CPU 的瓶颈还是 I/O 的瓶颈,底层的诉求都是需要实现 time slice

2.1.1 新架构的解决方案

在 React v16 版本之前,采用的是 Stack 架构,所有任务只能同步进行,无法被打断,这就导致浏览器可能会出现丢帧的现象,表现出卡顿。React 为了解决这个问题,从 v16 版本开始从架构上面进行了两大更新:

  • 引入 Fiber
  • 新增了 Scheduler
2.1.1.1 CPU瓶颈的解决方案

从 React v16 开始,官方团队正式引用了 Fiber 的概念,这是一种通过链表来描述 UI 的方式,本质上你也可以看作是一种虚拟 DOM 的实现。

与其将 “Virtual DOM” 视为一种技术,不如说它是一种模式,人们提到它时经常是要表达不同的东西。在 React 的世界里,术语 “Virtual DOM” 通常与 React 元素 关联在一起,因为它们都是代表了用户界面的对象。而 React 也使用一个名为 “fibers” 的内部对象来存放组件树的附加信息。上述二者也被认为是 React 中 “Virtual DOM” 实现的一部分。

Fiber 本质上也是一个对象,但是和之前 React 元素不同的地方在于对象之间使用链表的结构串联起来,child 指向子元素,sibling 指向兄弟元素,return 指向父元素。

image.png 使用链表这种结构,有一个最大的好处就是在进行整颗树的对比(reconcile)计算时,这个过程是可以被打断。 在发现一帧时间已经不够,不能够再继续执行 JS,需要渲染下一帧的时候,这个时候就会打断 JS 的执行,优先渲染下一帧。渲染完成后再接着回来完成上一次没有执行完的 JS 计算。

image.png

2.1.1.2 I/O瓶颈的解决方案

前端框架的渲染公式是 UI = f(state):

  • 根据自变量的变化计算出 UI
  • 根据 UI 变化执行具体的宿主环境的 API

从 React v16 开始引入了 Scheduler(调度器),用来调度任务的优先级。

在React v16之前:

  • Reconciler(协调器):vdom 的实现,根据自变量的变化计算出 UI 的变化
  • Renderer(渲染器):负责将 UI 的变化渲染到宿主环境

从 React v16 开始,多了一个组件:

  • Scheduler(调度器):调度任务的优先级,高优先级的任务会优先进入到 Reconciler
  • Reconciler(协调器):vdom 的实现,根据自变量的变化计算出 UI 的变化
  • Renderer(渲染器):负责将 UI 的变化渲染到宿主环境

新架构中,Reconciler 的更新流程也从之前的递归变成了“可中断的循环过程”。

//核心源码
function workLoopConcurrent{
  // 如果还有任务,并且时间切片还有剩余的时间
  while(workInProgress !== null && !shouldYield()){
    performUnitOfWork(workInProgress);
  }
}

function shouldYield(){
  // 当前时间是否大于过期时间
  // 其中 deadline = getCurrentTime() + yieldInterval
  // yieldInterval 为调度器预设的时间间隔,默认为 5ms
  return getCurrentTime() >= deadline;
}

每次循环都会调用 shouldYield 判断当前的时间切片是否有足够的剩余时间,如果没有足够的剩余时间,就暂停 reconciler 的执行,将主线程还给渲染流水线,进行下一帧的渲染操作,渲染工作完成后,再等待下一个宏任务进行后续代码的执行。(这里没看懂,可以先到后面看源码)

2.2 react渲染流程

现代前端框架都可以总结为一个公式:

UI = f(state)

上面的公式还可以进行一个拆分:

  • 根据自变量(state)的变化计算出 UI 的变化

  • 根据 UI 变化执行具体的宿主环境的 API

对应到react:

  • const state = reconcile(update); // 通过 reconciler 计算出最新的状态
  • const UI = commit(state); // 根据上一步计算出来的 state 渲染出 UI

也就是react的两大阶段:

  • render 阶段:调合虚拟 DOM,计算出最终要渲染出来的虚拟 DOM
  • commit 阶段:根据上一步计算出来的虚拟 DOM,渲染具体的 UI

image.png

  • 调度器(Scheduer):调度任务,为任务排序优先级,让优先级高的任务先进入到 Reconciler
  • 协调器(Reconciler):生成 Fiber 对象,收集副作用,找出哪些节点发生了变化,打上不同的 flags,著名的 diff 算法也是在这个组件中执行的。
  • 渲染器(Renderer):根据协调器计算出来的虚拟 DOM 同步的渲染节点到视图上。

接下来我们来看一个例子:

export default () => {
  const [count, updateCount] = useState(0);
  return (
    <ul>
      <button onClick={() => updateCount(count + 1)}>乘以{count}</button>
      <li>{1 * count}</li>
      <li>{2 * count}</li>
      <li>{3 * count}</li>
    </ul>
  );
}

当用户点击按钮时,首先是由 Scheduler 进行任务的协调,render 阶段(虚线框内)的工作流程是可以随时被以下原因中断:

  • 有其他更高优先级的任务需要执行

  • 当前的 time slice 没有剩余的时间

  • 发生了其他错误

注意上面 render 阶段的工作是在内存里面进行的,不会更新宿主环境 UI,因此这个阶段即使工作流程反复被中断,用户也不会看到“更新不完整的UI”。

当 Scheduler 调度完成后,将任务交给 Reconciler,Reconciler 就需要计算出新的 UI,最后就由 Renderer 同步进行渲染更新操作。

如下图所示:

2.2.1 调度器

在 React v16 版本之前,采用的是 Stack 架构,所有任务只能同步进行,无法被打断,这就导致浏览器可能会出现丢帧的现象,表现出卡顿。React 为了解决这个问题,从 v16 版本开始从架构上面进行了两大更新:

  • 引入 Fiber
  • 新增了 Scheduler

Scheduler 在浏览器的原生 API 中实际上是有类似的实现的,这个 API 就是 requestIdleCallback

MDN:developer.mozilla.org/zh-CN/docs/…

虽然每一帧绘制的时间约为 16.66ms,但是如果屏幕没有刷新,那么浏览器会安排长度为 50ms 左右的空闲时间。

为什么是50ms?

根据研究报告表明,用户操作之后,100ms以内的响应给用户的感觉都是瞬间发生,也就是说不会感受到延迟感,因此将空闲时间设置为 50,浏览器依然还剩下 50ms 可以处理用户的操作响应,不会让用户感到延迟。

虽然浏览器有类似的 API,但是 React 团队并没有使用该 API,因为该 API 存在兼容性问题。因此 React 团队自己实现了一套这样的机制,这个就是调度器 Scheduler。

后期 React 团队打算单独发行这个 Scheduler,这意味着调度器不仅仅只能在 React 中使用,凡是有涉及到任务调度需求的项目都可以使用 Scheduler。

2.2.2 协调器

协调器是 render 阶段的第二阶段工作,类组件或者函数组件本身就是在这个阶段被调用的。

根据 Scheduler 调度结果的不同,协调器起点可能是不同的

  • performSyncWorkOnRoot(同步更新流程)

  • performConcurrentWorkOnRoot(并发更新流程)

// performSyncWorkOnRoot 会执行该方法
function workLoopSync(){
  while(workInProgress !== null){
    performUnitOfWork(workInProgress)
  }
}
// performConcurrentWorkOnRoot 会执行该方法
function workLoopConcurrent(){
  while(workInProgress !== null && !shouldYield()){
    performUnitOfWork(workInProgress)
  }
}

新的架构使用 Fiber(对象)来描述 DOM 结构,最终需要形成一颗 Fiber tree,这不过这棵树是通过链表的形式串联在一起的。

workInProgress 代表的是当前的 FiberNode。

performUnitOfWork 方法会创建下一个 FiberNode,并且还会将已创建的 FiberNode 连接起来(child、return、sibling),从而形成一个链表结构的 Fiber tree。

如果 workInProgress 为 null,说明已经没有下一个 FiberNode,也就是说明整颗 Fiber tree 树已经构建完毕。

上面两个方法唯一的区别就是是否调用了 shouldYield方法,该方法表明了是否可以中断。

performUnitOfWork在创建下一个 FiberNode 的时候,整体上的工作流程可以分为两大块:

  • 递阶段

  • 归阶段

递阶段

递阶段会从 HostRootFiber 开始向下以深度优先的原则进行遍历,遍历到的每一个 FiberNode 执行 beginWork 方法。该方法会根据传入的 FiberNode 创建下一级的 FiberNode,此时可能存在两种情况:

  • 下一级只有一个元素,beginWork 方法会创建对应的 FiberNode,并于 workInProgress 连接
<ul>
  <li></li>
</ul>

这里就会创建 li 对应的 FiberNode,做出如下的连接:

LiFiber.return = UlFiber;
  • 下一级有多个元素,这是 beginWork 方法会依次创建所有的子 FiberNode 并且通过 sibling 连接到一起,每个子 FiberNode 也会和 workInProgress 连接
<ul>
  <li></li>
  <li></li>
  <li></li>
</ul>

此时会创建 3 个 li 对应的 FiberNode,连接情况如下:

// 所有的子 Fiber 依次连接
Li0Fiber.sibling = Li1Fiber;
Li1Fiber.sibling = Li2Fiber;

// 子 Fiber 还需要和父 Fiber 连接
Li0Fiber.return = UlFiber;
Li1Fiber.return = UlFiber;
Li2Fiber.return = UlFiber;

由于采用的是深度优先的原则,因此无法再往下走的时候,会进入到归阶段。

归阶段

归阶段会调用 completeWork 方法来处理 FiberNode,做一些副作用的收集。

当某个 FiberNode 执行完了 completeWork 方法后,如果存在兄弟元素,就会进入到兄弟元素的递阶段,如果不存在兄弟元素,就会进入父 FiberNode 的归阶段。

function performUnitOfWork(fiberNode){
  // 省略 beginWork 
  if(fiberNode.child){
    performUnitOfWork(fiberNode.child);
  }
  // 省略 CompleteWork 
  if(fiberNode.sibling){
    performUnitOfWork(fiberNode.sibling);
  }
}

最后我们来看一张图:

2.2.3 渲染器

Renderer 工作的阶段被称之为 commit 阶段。该阶段会将各种副作用 commit 到宿主环境的 UI 中。

相较于之前的 render 阶段可以被打断,commit 阶段一旦开始就会同步执行直到完成渲染工作。

整个渲染器渲染过程中可以分为三个子阶段:

  • BeforeMutation 阶段
  • Mutation 阶段
  • Layout 阶段

2.2.4 整体流程脑图

whiteboard_exported_image.png

思维脑图没办法粘贴到这里,需要原图可以一键三连关注我后私信我要😄

2.3 Fiber双缓冲

2.3.1 Fiber是什么?

实际上,我们可以从三个维度来理解 Fiber:

  • 是一种架构,称之为 Fiber 架构
  • 是一种数据类型
  • 动态的工作单元

是一种架构,称之为 Fiber 架构

在 React v16之前,使用的是 Stack Reconciler,因此那个时候的 React 架构被称之为 Stack 架构。从 React v16 开始,重构了整个架构,引入了 Fiber,因此新的架构也被称之为 Fiber 架构,Stack Reconciler 也变成了 Fiber Reconciler。各个FiberNode之间通过链表的形式串联起来:

function FiberNode(tag, pendingProps, key, mode) {
  // ...

  // 周围的 Fiber Node 通过链表的形式进行关联
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  // ...
}

是一种数据类型

Fiber 本质上也是一个对象,是在之前 React 元素基础上的一种升级版本。每个 FiberNode 对象里面会包含 React 元素的类型、周围链接的FiberNode以及 DOM 相关信息:

function FiberNode(tag, pendingProps, key, mode) {
  // 类型
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null; // 映射真实 DOM

  // ...
}

动态的工作单元

在每个 FiberNode 中,保存了本次更新中该 React 元素变化的数据,还有就是要执行的工作(增、删、更新)以及副作用的信息:

function FiberNode(tag, pendingProps, key, mode) {
  // ...

  // 副作用相关
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags;
  this.deletions = null;
        // 与调度优先级有关  
  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  // ...
}

2.3.2 Fiber双缓冲

2.3.2.1 双缓冲理念

Fiber 架构中的双缓冲工作原理类似于显卡的工作原理。

显卡分为前缓冲区和后缓冲区。首先,前缓冲区会显示图像,之后,合成的新的图像会被写入到后缓冲区,一旦后缓冲区写入图像完毕,就会前后缓冲区进行一个互换,这种将数据保存在缓冲区再进行互换的技术,就被称之为双缓冲技术。

Fiber 架构同样用到了这个技术,在 Fiber 架构中,同时存在两颗 Fiber Tree,一颗是真实 UI 对应的 Fiber Tree,可以类比为显卡的前缓冲区,另外一颗是在内存中构建的 FiberTree,可以类比为显卡的后缓冲区。

在 React 源码中,很多方法都需要接收两颗 FiberTree:

function cloneChildFibers(current, workInProgress){
  // ...
}

current 指的就是前缓冲区的 FiberNode,workInProgress 指的就是后缓冲区的 FiberNode。

两个 FiberNode 会通过 alternate 属性相互指向:

current.alternate = workInProgress;
workInProgress.alternate = current;
2.3.2.2 双缓冲的执行流程

接下来我们从首次渲染(mount)和更新(update)这两个阶段来看一下 FiberTree 的形成以及双缓存机制:

mount 阶段

首先最顶层有一个 FiberNode,称之为 FiberRootNode,该 FiberNode 会有一些自己的任务:

  • Current Fiber Tree 与 Wip Fiber Tree 之间的切换
  • 应用中的过期时间
  • 应用的任务调度信息

现在假设有这么一个结构:

<body>
  <div id="root"></div>
</body>
function App(){
  const [num, add] = useState(0);
  return (
          <p onClick={() => add(num + 1)}>{num}</p>
  );
}
const rootElement = document.getElementById("root");
ReactDOM.createRoot(rootElement).render(<App />);

当执行 ReactDOM.createRoot 的时候,会创建如下的结构:

此时会有一个 HostRootFiber,FiberRootNode 通过 current 来指向 HostRootFiber。

接下来进入到 mount 流程,该流程会基于每个 React 元素以深度优先的原则依次生成 wip FiberNode,并且每一个 wipFiberNode 会连接起来,如下图所示:

生成的 wip FiberTree 里面的每一个 FiberNode 会和 current FiberTree 里面的 FiberNode进行关联,关联的方式就是通过 alternate。但是目前 currentFiberTree里面只有一个 HostRootFiber,因此就只有这个 HostRootFiber 进行了 alternate 的关联。

当 wip FiberTree生成完毕后,也就意味着 render 阶段完毕了,此时 FiberRootNode就会被传递给 Renderer(渲染器),接下来就是进行渲染工作。渲染工作完毕后,浏览器中就显示了对应的 UI,此时 FiberRootNode.current 就会指向这颗 wip Fiber Tree,曾经的 wip Fiber Tree 它就会变成 current FiberTree,完成了双缓存的工作:

update 阶段

点击 p 元素,会触发更新,这一操作就会开启 update 流程,此时就会生成一颗新的 wip Fiber Tree,流程和之前是一样的

新的 wip Fiber Tree 里面的每一个 FiberNode 和 current Fiber Tree 的每一个 FiberNode 通过 alternate 属性进行关联。

当 wip Fiber Tree 生成完毕后,就会经历和之前一样的流程,FiberRootNode 会被传递给 Renderer 进行渲染,此时宿主环境所渲染出来的真实 UI 对应的就是左边 wip Fiber Tree 所对应的 DOM 结构,FiberRootNode.current 就会指向左边这棵树,右边的树就再次成为了新的 wip Fiber Tree

这个就是 Fiber双缓存的工作原理。

另外值得一提的是,开发者是可以在一个页面创建多个应用的,比如:

ReactDOM.createRoot(rootElement1).render(<App1 />);
ReactDOM.createRoot(rootElement2).render(<App2 />);                                                                                 ReactDOM.createRoot(rootElement3).render(<App3 />);

在上面的代码中,我们创建了 3 个应用,此时就会存在 3 个 FiberRootNode,以及对应最多 6 棵 Fiber Tree 树。

那为什么要做这样的双缓存呢?

因为浏览器的每一帧时间是固定的,当你等到上一帧结束后再去计算并渲染,会卡顿,那如果你在上一帧一开始就渲染,但是把渲染树保留到内存中,等到该帧渲染结束后再直接替换,那就会没有卡顿

2.4 MessageChannel

2.4.1 messagechannel是什么?

MessageChannel 接口本身是用来做消息通信的,允许我们创建一个消息通道,通过它的两个 MessagePort 来进行信息的发送和接收。 基本使用如下:

<div>
  <input type="text" id="content" placeholder="请输入消息">
</div>
<div>
  <button id="btn1">给 port1 发消息</button>
  <button id="btn2">给 port2 发消息</button>
</div>
const channel = new MessageChannel();
// 两个信息端口,这两个信息端口可以进行信息的通信
const port1 = channel.port1;
const port2 = channel.port2;
btn1.onclick = function(){
  // 给 port1 发消息
  // 那么这个信息就应该由 port2 来进行发送
  port2.postMessage(content.value);
}
// port1 需要监听发送给自己的消息
port1.onmessage = function(event){
  console.log(`port1 收到了来自 port2 的消息:${event.data}`);
}

btn2.onclick = function(){
  // 给 port2 发消息
  // 那么这个信息就应该由 port1 来进行发送
  port1.postMessage(content.value);
}
// port2 需要监听发送给自己的消息
port2.onmessage = function(event){
  console.log(`port2 收到了来自 port1 的消息:${event.data}`);
}

而scheduler调度器,内部的实现机制就是使用messagechannel来做的。 之前,我们有说过 scheduler 是用来调度任务,调度任务需要满足两个条件:

  • JS 暂停,将主线程还给浏览器,让浏览器能够有序的重新渲染页面
  • 暂停了的 JS(说明还没有执行完),需要再下一次接着来执行

那么这里自然而然就会想到事件循环,我们可以将没有执行完的 JS 放入到任务队列,下一次事件循环的时候再取出来执行。

那么,如何将没有执行完的任务放入到任务队列中呢?

那么这里就需要产生一个任务(宏任务),这里就可以使用 MessageChannel,因为 MessageChannel 能够产生宏任务。

为什么不选择 setTimeout

以前要创建一个宏任务,可以采用 setTimeout(fn, 0) 这种方式,但是 react 团队没有采用这种方式。

这是因为 setTimeout 在嵌套层级超过 5 层,timeout(延时)如果小于 4ms,那么则会设置为 4ms。

这个你可以参阅 HTML 规范:html.spec.whatwg.org/multipage/t…

If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.

为什么没有选择 requestAnimationFrame

因为requestAnimationFrame在重新渲染之前,只能够执行一次。但我们的需求是,我们包装一个任务,只要没到重新渲染时间,就可以一直从任务队列里获取任务执行,而不是只执行一次。

而且 requestAnimationFrame 还会有一定的兼容性问题,safari 和 edge 浏览器是将 requestAnimationFrame 放到渲染之后执行的,chrome 和 firefox 是将 requestAnimationFrame 放到渲染之前执行的,所以这里存在不同的浏览器有不同的执行顺序的问题。

为什么没有选择包装成一个微任务?

因为微任务队列会在清空整个队列之后才会结束。那么微任务会在页面更新前一直执行,直到队列被清空,达不到将主线程还给浏览器的目的。

2.5 调度器:Scheduler

2.5.1 Scheduler调度普通任务

Scheduler 的核心源码位于 packages/scheduler/src/forks/Scheduler.js

scheduleCallback

该函数的主要目的就是用调度任务,该方法的分析如下:

let getCurrentTime = () => performance.now();

// 有两个队列分别存储普通任务和延时任务
// 里面采用了一种叫做小顶堆的算法,保证每次从队列里面取出来的都是优先级最高(时间即将过期)
var taskQueue = []; // 存放普通任务
var timerQueue = []; // 存放延时任务

var maxSigned31BitInt = 1073741823;

// Timeout 对应的值
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

/**
 *
 * @param {*} priorityLevel 优先级等级
 * @param {*} callback 具体要做的任务
 * @param {*} options { delay: number } 这是一个对象,该对象有 delay 属性,表示要延迟的时间
 * @returns
 */
function unstable_scheduleCallback(priorityLevel, callback, options) {
  // 获取当前的时间
  var currentTime = getCurrentTime();

  var startTime;
  // 整个这个 if.. else 就是在设置起始时间,如果有延时,起始时间需要添加上这个延时
  if (typeof options === "object" && options !== null) {
    var delay = options.delay;
    // 如果设置了延时时间,那么 startTime 就为当前时间 + 延时时间
    if (typeof delay === "number" && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
  } else {
    startTime = currentTime;
  }

  var timeout;
  // 根据传入的优先级等级来设置不同的 timeout
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT;
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT;
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT;
      break;
    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT;
      break;
  }
  // 接下来就计算出过期时间
  // 计算出来的时间有些比当前时间要早,绝大部分比当前的时间要晚一些
  var expirationTime = startTime + timeout;

  // 创建一个新的任务
  var newTask = {
    id: taskIdCounter++, // 任务 id
    callback, // 该任务具体要做的事情
    priorityLevel, // 任务的优先级别
    startTime, // 任务开始时间
    expirationTime, // 任务的过期时间
    sortIndex: -1, // 用于后面在小顶堆(这是一种算法,可以始终从任务队列中拿出最优先的任务)进行排序的索引
  };
  if (enableProfiling) {
    newTask.isQueued = false;
  }

  if (startTime > currentTime) {
    // This is a delayed task.
    // 说明这是一个延时任务
    newTask.sortIndex = startTime;
    // 将该任务推入到 timerQueue 的任务队列中
    push(timerQueue, newTask);
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // 进入此 if,说明 taskQueue 里面的任务已经执行完毕了
      // 并且从 timerQueue 里面取出一个最新的任务又是当前任务
      // All tasks are delayed, and this is the task with the earliest delay.

      // 下面的 if.. else 就是一个开关
      if (isHostTimeoutScheduled) {
        // Cancel an existing timeout.
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      // Schedule a timeout.
      // 如果是延时任务,调用 requestHostTimeout 进行任务的调度
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    // 说明不是延时任务
    newTask.sortIndex = expirationTime; // 设置了 sortIndex 后,可以在任务队列里面进行一个排序
    // 推入到 taskQueue 任务队列
    push(taskQueue, newTask);
    if (enableProfiling) {
      markTaskStart(newTask, currentTime);
      newTask.isQueued = true;
    }
    // Schedule a host callback, if needed. If we're already performing work,
    // wait until the next time we yield.
    // 最终调用 requestHostCallback 进行任务的调度
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    }
  }

  // 向外部返回任务
  return newTask;
}

该方法主要注意以下几个关键点:

  • 关于任务队列有两个,一个 taskQueue,另一个是 timerQueue,taskQueue 存放普通任务,timerQueue 存放延时任务,任务队列内部用到了小顶堆的算法,保证始终放进去(push)的任务能够进行正常的排序,回头通过 peek 取出任务时,始终取出的是时间优先级最高的那个任务
  • 根据传入的不同的 priorityLevel,会进行不同的 timeout 的设置,任务的 timeout 时间也就不一样了,有的比当前时间还要小,这个代表立即需要执行的,绝大部分的时间比当前时间大。

  • 不同的任务,最终调用的函数不一样

    • 普通任务:requestHostCallback(flushWork)

    • 延时任务:requestHostTimeout(handleTimeout, startTime - currentTime);

requestHostCallback 和 schedulePerformWorkUntilDeadline
/**
 * 
 * @param {*} callback 是在调用的时候传入的 flushWork
 * requestHostCallback 这个函数没有做什么事情,主要就是调用 schedulePerformWorkUntilDeadline
 */
function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  // scheduledHostCallback ---> flushWork
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline(); // 实例化 MessageChannel 进行后面的调度
  }
}


//往下是requesHostCallback外部的代码,注意了不要看混淆
let schedulePerformWorkUntilDeadline; // undefined
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  // https://github.com/facebook/react/issues/20756
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // 大多数情况下,使用的是 MessageChannel
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // setTimeout 进行兜底
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}
  • requestHostCallback 主要就是调用了 schedulePerformWorkUntilDeadline

  • schedulePerformWorkUntilDeadline 一开始是 undefiend,根据不同的环境选择不同的生成宏任务的方式

performWorkUntilDeadline
let startTime = -1;
const performWorkUntilDeadline = () => {
  // scheduledHostCallback ---> flushWork
  if (scheduledHostCallback !== null) {
    // 获取当前的时间
    const currentTime = getCurrentTime();
    // Keep track of the start time so we can measure how long the main thread
    // has been blocked.
    // 这里的 startTime 并非 unstable_scheduleCallback 方法里面的 startTime
    // 而是一个全局变量,默认值为 -1
    // 用来测量任务的执行时间,从而能够知道主线程被阻塞了多久
    startTime = currentTime;
    const hasTimeRemaining = true; // 默认还有剩余时间

    // If a scheduler task throws, exit the current browser task so the
    // error can be observed.
    //
    // Intentionally not using a try-catch, since that makes some debugging
    // techniques harder. Instead, if `scheduledHostCallback` errors, then
    // `hasMoreWork` will remain true, and we'll continue the work loop.
    let hasMoreWork = true; // 默认还有需要做的任务
    try {
      // scheduledHostCallback ---> flushWork(true, 开始时间): boolean
      // 如果是 true,代表工作没做完
      // false 代表没有任务了
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
    } finally {
      if (hasMoreWork) {
        // If there's more work, schedule the next message event at the end
        // of the preceding one.
        // 那么就使用 messageChannel 进行一个 message 事件的调度,就将任务放入到任务队列里面
        schedulePerformWorkUntilDeadline();
      } else {
        // 说明任务做完了
        isMessageLoopRunning = false;
        scheduledHostCallback = null; // scheduledHostCallback 之前为 flushWork,设置为 null
      }
    }
  } else {
    isMessageLoopRunning = false;
  }
  // Yielding to the browser will give it a chance to paint, so we can
  // reset this.
  needsPaint = false;
};
  • 该方法实际上主要就是在调用 scheduledHostCallback(flushWork),调用之后,返回一个布尔值,根据这个布尔值来判断是否还有剩余的任务,如果还有,就是用 messageChannel 进行一个宏任务的包装,放入到任务队列里面
flushWork
/**
 *
 * @param {*} hasTimeRemaining 是否有剩余的时间,一开始是 true
 * @param {*} initialTime 做这一个任务时开始执行的时间
 * @returns
 */
function flushWork(hasTimeRemaining, initialTime) {
  // ...
  try {
    if (enableProfiling) {
      try {
        // 核心实际上是这一句,调用 workLoop
        return workLoop(hasTimeRemaining, initialTime);
      } catch (error) {
        // ...
      }
    } else {
      // 核心实际上是这一句,调用 workLoop
      return workLoop(hasTimeRemaining, initialTime);
    }
  } finally {
    // ...
  }
}

flushWork 主要就是在调用 workLoop

workLoop
/**
 *
 * @param {*} hasTimeRemaining 是否有剩余的时间,一开始是 true
 * @param {*} initialTime 做这一个任务时开始执行的时间
 * @returns
 */
function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  // 该方法实际上是用来遍历 timerQueue,判断是否有已经到期了的任务
  // 如果有,将这个任务放入到 taskQueue
  advanceTimers(currentTime);
  // 从 taskQueue 里面取一个任务出来
  currentTask = peek(taskQueue);
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // This currentTask hasn't expired, and we've reached the deadline.
      // currentTask.expirationTime > currentTime 表示任务还没有过期
      // hasTimeRemaining 代表是否有剩余时间
      // shouldYieldToHost 任务是否应该暂停,归还主线程
      // 那么我们就跳出 while
      break;
    }
    // 没有进入到上面的 if,说明这个任务到过期时间,并且有剩余时间来执行,没有到达需要浏览器渲染的时候
    // 那我们就执行该任务即可
    const callback = currentTask.callback; // 拿到这个任务
    if (typeof callback === "function") {
        // 说明当前的任务是一个函数,我们执行该任务

      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      if (enableProfiling) {
        markTaskRun(currentTask, currentTime);
      }
      // 任务的执行实际上就是在这一句
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      if (typeof continuationCallback === "function") {
        // If a continuation is returned, immediately yield to the main thread
        // regardless of how much time is left in the current time slice.
        // $FlowFixMe[incompatible-use] found when upgrading Flow
        currentTask.callback = continuationCallback;
        if (enableProfiling) {
          // $FlowFixMe[incompatible-call] found when upgrading Flow
          markTaskYield(currentTask, currentTime);
        }
        advanceTimers(currentTime);
        return true;
      } else {
        if (enableProfiling) {
          // $FlowFixMe[incompatible-call] found when upgrading Flow
          markTaskCompleted(currentTask, currentTime);
          // $FlowFixMe[incompatible-use] found when upgrading Flow
          currentTask.isQueued = false;
        }
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
    } else {
      // 直接弹出
      pop(taskQueue);
    }
    // 再从 taskQueue 里面拿一个任务出来
    currentTask = peek(taskQueue);
  }
  // Return whether there's additional work
  if (currentTask !== null) {
    // 如果不为空,代表还有更多的任务,那么回头外部的 hasMoreWork 拿到的就也是 true
    return true;
  } else {
    // taskQueue 这个队列是空了,那么我们就从 timerQueue 里面去看延时任务
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    // 没有进入上面的 if,说明 timerQueue 里面的任务也完了,返回 false,回头外部的 hasMoreWork 拿到的就也是 false
    return false;
  }
}
  • workLoop 首先有一个 while 循环,该 while 循环保证了能够从任务队列中不停的取任务出来
 while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
 ){
   // ...
 }
  • 当然,不是说一直从任务队列里面取任务出来执行就完事儿,每次取出一个任务后,我们还需要一系列的判断
if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
 ) {
  break;
}
  • currentTask.expirationTime > currentTime 表示任务还没有过期

  • hasTimeRemaining 代表是否有剩余时间

  • shouldYieldToHost 任务是否应该暂停,归还主线程

  • 如果进入 if,说明因为某些原因不能再执行任务,需要立即归还主线程,那么我们就跳出 while

shouldYieldToHost
function shouldYieldToHost() {
    // getCurrentTime 获取当前时间
    // startTime 是我们任务开始时的时间,一开始是 -1,之后任务开始时,将任务开始时的时间复值给了它
  const timeElapsed = getCurrentTime() - startTime;
  // frameInterval 默认设置的是 5ms
  if (timeElapsed < frameInterval) {
    // 主线程只被阻塞了一点点时间,远远没达到需要归还的时候
    return false;
  }
  // 如果没有进入上面的 if,说明主线程已经被阻塞了一段时间了
  // 需要归还主线程
  if (enableIsInputPending) {
    if (needsPaint) {
      // There's a pending paint (signaled by `requestPaint`). Yield now.
      return true;
    }
    if (timeElapsed < continuousInputInterval) {
      // We haven't blocked the thread for that long. Only yield if there's a
      // pending discrete input (e.g. click). It's OK if there's pending
      // continuous input (e.g. mouseover).
      if (isInputPending !== null) {
        return isInputPending();
      }
    } else if (timeElapsed < maxInterval) {
      // Yield if there's either a pending discrete or continuous input.
      if (isInputPending !== null) {
        return isInputPending(continuousOptions);
      }
    } else {
      // We've blocked the thread for a long time. Even if there's no pending
      // input, there may be some other scheduled work that we don't know about,
      // like a network event. Yield now.
      return true;
    }
  }

  // `isInputPending` isn't available. Yield now.
  return true;
}
  • 首先计算 timeElapsed,然后判断是否超时,没有的话就返回 false,表示不需要归还,否则就返回 true,表示需要归还。

  • frameInterval 默认设置的是 5ms

advanceTimers
function advanceTimers(currentTime) {
  // Check for tasks that are no longer delayed and add them to the queue.
  // 从 timerQueue 里面获取一个任务
  let timer = peek(timerQueue);
  // 遍历整个 timerQueue
  while (timer !== null) {
    if (timer.callback === null) {
      // 这个任务没有对应的要执行的 callback,直接从这个队列弹出
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      // 进入这个分支,说明当前的任务已经不再是延时任务
      // 我们需要将其转移到 taskQueue
      pop(timerQueue);
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer); // 推入到 taskQueue
      // ...
    } else {
      return;
    }
    // 从 timerQueue 里面再取一个新的进行判断
    timer = peek(timerQueue);
  }
}
  • 该方法就是遍历整个 timerQueue,查看是否有已经过期的方法,如果有,不是说直接执行,而是将这个过期的方法添加到 taskQueue 里面。

2.5.2 Scheduler调度延时任务

unstable_scheduleCallback
function unstable_scheduleCallback(priorityLevel,callback,options){
  //...
  if (startTime > currentTime) {
    // 调度一个延时任务
    requestHostTimeout(handleTimeout, startTime - currentTime);
  } else {
    // 调度一个普通任务
    requestHostCallback(flushWork);
  }
}
  • 可以看到,调度一个延时任务的时候,主要是执行 requestHostTimeout
requestHostTimeout
// 实际上在浏览器环境就是 setTimeout
const localSetTimeout = typeof setTimeout === 'function' ? setTimeout : null;

/**
 * 
 * @param {*} callback 就是传入的 handleTimeout
 * @param {*} ms 延时的时间
 */
function requestHostTimeout(callback, ms) {
  taskTimeoutID = localSetTimeout(() => {
    callback(getCurrentTime());
  }, ms);
  /**
   * 因此,上面的代码,就可以看作是
   * id = setTimeout(function(){
   *    handleTimeout(getCurrentTime())
   * }, ms)
   */
}

可以看到,requestHostTimeout 实际上就是调用 setTimoutout,然后在 setTimeout 中,调用传入的 handleTimeout

handleTimeout
/**
 *
 * @param {*} currentTime 当前时间
 */
function handleTimeout(currentTime) {
  isHostTimeoutScheduled = false;
  // 遍历timerQueue,将时间已经到了的延时任务放入到 taskQueue
  advanceTimers(currentTime);

  if (!isHostCallbackScheduled) {
    if (peek(taskQueue) !== null) {
      // 从普通任务队列中拿一个任务出来
      isHostCallbackScheduled = true;
      // 采用调度普通任务的方式进行调度
      requestHostCallback(flushWork);
    } else {
      // taskQueue任务队列里面是空的
      // 再从 timerQueue 队列取一个任务出来
      // peek 是小顶堆中提供的方法
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) {
        // 取出来了,接下来取出的延时任务仍然使用 requestHostTimeout 进行调度
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}
  • handleTimeout 里面主要就是调用 advanceTimers 方法,该方法的作用是将时间已经到了的延时任务放入到 taskQueue,那么现在 taskQueue 里面就有要执行的任务,然后使用 requestHostCallback 进行调度。如果 taskQueue 里面没有任务了,再次从 timerQueue 里面去获取延时任务,然后通过 requestHostTimeout 进行调度。

2.5.3 scheduler流程图

Scheduler 这一块儿大致的流程图如下:

Scheduler思维导图 whiteboard_exported_image.png

思维脑图没办法粘贴到这里,需要原图可以一键三连关注我后私信我要😄

2.5.4 总结

scheduler任务的调度过程大概就是先对每个任务排优先级,然后再利用最小堆去存储。保证后续每次拿出来的任务都是最先优先级的。然后把任务放到延时队列和非延时任务队列中去。先执行非延时任务队列,如果系统需要渲染了,那就打断,先渲染。判断依据是shouldYieldToHost。打断的时候,依然把剩下要执行的任务包装成messagechannel宏任务,以便浏览器渲染后继续执行。这里这个任务调度器就解决了之前架构的两个问题:

  • 浏览器卡,js占用太多时间阻塞渲染的问题(现在可以随时打断),
  • 任务优先级的问题(最小堆)

2.6 协调器:Reconciler

Reconciler(协调器) 是 Render 阶段的第二阶段工作,整个工作的过程可以分为“递”和“归”:

  • 递:beginWork
  • 归:completeWork

2.6.1 beginWork工作流程

beginWork 方法主要是根据传入的 FiberNode 创建下一级的 FiberNode。

主要是创建workInProgressFiber,使用 v-dom和current fiber对比,向下生成新的fiberNode,期间会执行函数组件、类组件、diff子节点,打上不同effectTag等。

对于组件,执行部分生命周期,执行render,执行函数,得到最新的vdom(jsx对象),向下遍历调和chidren,打上不同effectTag,标志这个元素是增还是删还是改。

整个 beginWork 方法的流程如下图所示:

首先在 beginWork 中,会判断当前的流程是 mount(初次渲染)还是update(更新),判断的依据就是 currentFiberNode 是否存在

if(current !== null){
  // 说明 CurrentFiberNode 存在,应该是 update
} else {
  // 应该是 mount
}

如果是 update,接下来会判断 wipFiberNode 是否能够复用,如果不能够复用,那么 update 和 mount 的流程大体上一致:

  • 根据 wip.tag 进行不同的分支处理

  • 根据 reconcile 算法生成下一级的 FiberNode(diff 算法)

无法复用的 update 流程和 mount 流程大体一致,主要区别在于是否会生成带副作用标记 flags 的 FiberNode

beginWork 方法的代码结构如下:

// current 代表的是 currentFiberNode
// workInProgress 代表的是 workInProgressFiberNode,后面我会简称为 wip FiberNode
function beginWork(current, workInProgress, renderLanes) {
  // ...
  if(current !== null) {
    // 进入此分支,说明是更新
  } else {
    // 说明是首次渲染
  }
  
  // ...
  
  // 根据不同的 tag,进入不同的处理逻辑
  switch (workInProgress.tag) {
    case IndeterminateComponent: {
      // ...
    }
    case FunctionComponent : {
      // ...
    }
    case ClassComponent : {
      // ...
    }
  }
}

关于 tag,在 React 源码中定义了 28 种 tag:

export const FunctionComponent = 0;
export const ClassComponent = 1;
export const IndeterminateComponent = 2; // Before we know whether it is function or class
export const HostRoot = 3; // Root of a host tree. Could be nested inside another node.
export const HostPortal = 4; // A subtree. Could be an entry point to a different renderer.
export const HostComponent = 5;
export const HostText = 6;
export const Fragment = 7;
// ...

不同的 FiberNode,会有不同的 tag

  • HostComponent 代表的就是原生组件(div、span、p)

  • FC 在 mount 的时候,对应的 tag 为 IndeterminateComponent,在 update 的时候就会进入 FunctionComponent

  • HostText 表示的是文本元素

根据不同的 tag 处理完 FiberNode 之后,根据是mount 还是 update 会进入不同的方法:

  • mount:mountChildFibers
  • update:reconcileChildFibers

这两个方法实际上都是一个叫 ChildReconciler 方法的返回值:

var reconcileChildFibers = ChildReconciler(true);
var mountChildFibers = ChildReconciler(false);

function ChildReconciler(shouldTrackSideEffects) {}

也就是说,在 ChildReconciler 方法内容,shouldTrackSideEffects 是一个布尔值

  • false:不追踪副作用,不做 flags 标记,因为你是 mount 阶段

  • true:要追踪副作用,做 flags 标记,因为是 update 阶段

在 ChildReconciler 方法内部,就会根据 shouldTrackSideEffects 做一些不同的处理:

function placeChild(newFiber, lastPlacedIndex, newIndex){
  newFiber.index = newIndex;
  
  if(!shouldTrackSideEffects){
    // 说明是初始化
    // 说明不需要标记 Placement
    newFiber.flags |= Forked;
    return lastPlacedIndex
  }
  // ...
  // 说明是更新
  // 标记为 Placement
  newFiber.flags |= Placement;
  
}

可以看到,在 beginWork 方法内部,会做一些 flags 标记(主要是在 update 阶段),这些 flags 标记主要和元素的位置有关系:

  • 标记 ChildDeletion,这个是代表删除操作
  • 标记 Placement,这是代表插入或者移动操作

总结

在 beginWork 会根据是 mount 还是 update 有着不一样的流程。

如果当前的流程是 update,则 WorkInProgressFiberNode 存在对应的 CurrentFiberNode,接下来就判断是否能够复用。

如果无法复用 CurrentFiberNode,那么 mount 和 update 的流程大体上是一致的:

  • 根据 wip.tag 进入“不同类型元素的处理分支”
  • 使用 reconcile 算法生成下一级 FiberNode(diff 算法)

两个流程的区别在于“最终是否会为生成的子 FiberNode 标记副作用 flags”

在 beginWork 中,如果标记了副作用的 flags,那么主要与元素的位置相关,包括:

  • 标记 ChildDeletion,代表删除操作

  • 标记 Placement,代表插入或移动操作

个人疑问:fibernode和虚拟dom和react元素的区别是什么?

2.6.2 completeWork 工作流程

completework本质就是根据beginwork的时候打上的effectTag,创建effectList(也就是所有改变的vdom结构形成的链表),构建真实dom,但是还没有挂载到界面上。

前面所介绍的 beginWork,是属于“递”的阶段,该阶段的工作处理完成后,就会进入到 completeWork,这个是属于“归”的阶段。

与 beginWork 类似,completeWork 也会根据 wip.tag 区分对待,流程上面主要包括两个步骤:

  • 创建元素或者标记元素的更新
  • flags 冒泡

整体流程图如下:

mount 阶段

在 mount 流程中,首先会通过 createInstance 创建 FiberNode 所对应的 DOM 元素:

function createInstance(type, props, rootContainerInstance, hostContext, internalInstanceHandle){
  //...
  if(typeof props.children === 'string' || typeof props.chidlren === 'number'){
    // children 为 string 或者 number 时做一些特殊处理
  }
  
  // 创建 DOM 元素
  const domElement = createElement(type, props, rootContainerInstance, parentNamespace);
  
  //...
  return domElement;
}

接下来会执行 appendAllChildren,该方法的作用是将下一层 DOM 元素插入到通过 createInstance 方法所创建的 DOM 元素中,具体的逻辑如下:

  • 从当前的 FiberNode 向下遍历,将遍历到的第一层 DOM 元素类型(HostComponent、HostText)通过 appendChild 方法插入到 parent 末尾

  • 对兄弟 FiberNode 执行步骤 1

  • 如果没有兄弟 FiberNode,则对父 FiberNode 的兄弟执行步骤 1

  • 当遍历流程回到最初执行步骤 1 所在层或者 parent 所在层时终止

相关的代码如下:

appendAllChildren = function(parent, workInProgress, ...){
  let node = workInProgress.child;
  
  while(node !== null){
    // 步骤 1,向下遍历,对第一层 DOM 元素执行 appendChild
    if(node.tag === HostComponent || node.tag === HostText){
      // 对 HostComponent、HostText 执行 appendChild
      appendInitialChild(parent, node.stateNode);
    } else if(node.child !== null) {
      // 继续向下遍历,直到找到第一层 DOM 元素类型
      node.child.return = node;
      node = node.child;
      continue;
    }
    // 终止情况 1: 遍历到 parent 对应的 FiberNode
    if(node === workInProgress) {
      return;
    }
    // 如果没有兄弟 FiberNode,则向父 FiberNode 遍历
    while(node.sibling === null){
      // 终止情况 2: 回到最初执行步骤 1 所在层
      if(node.return === null || node.return === workInProgress) {
        return;
      }
      node = node.return
    }
    // 对兄弟 FiberNode 执行步骤 1
    node.sibling.return = node.return;
    node = node.sibling;
  }
}

appendAllChildren 方法实际上就是在处理下一级的 DOM 元素,而且在 appendAllChildren 里面的遍历过程会更复杂一些,会多一些判断,因为 FiberNode 最终形成的 FiberTree 的层次和最终 DOMTree 的层次可能是有区别:

function World(){
  return <span>World</span>
}

<div>
        Hello
  <World/>
</div>

在上面的代码中,如果从 FiberNode 的角度来看,Hello 和 World 是同级的,但是如果从 DOM 元素的角度来看,Hello 就和 span 是同级别的。因此从 FiberNode 中查找同级的 DOM 元素的时候,经常会涉及到跨 FiberNode 层级进行查找。

接下来 completeWork 会执行 finalizeInitialChildren 方法完成属性的初始化,主要包含以下几类属性:

  • styles,对应的方法为 setValueForStyles 方法

  • innerHTML,对应 setInnerHTML 方法

  • 文本类型 children,对应 setTextContent 方法

  • 不会再在 DOM 中冒泡的事件,包括 cancel、close、invalid、load、scroll、toggle,对应的是 listenToNonDelegatedEvent 方法

  • 其他属性,对应 setValueForProperty 方法

该方法执行完毕后,最后进行 flags 的冒泡。

总结一下,completeWork 在 mount 阶段执行的工作流程如下:

  • 根据 wip.tag 进入不同的处理分支

  • 根据 current !== null 区分是 mount 还是 update

  • 对应 HostComponent,首先执行 createInstance 方法来创建对应的 DOM 元素

  • 执行 appendChildren 将下一级 DOM 元素挂载在上一步所创建的 DOM 元素下

  • 执行 finalizeInitialChildren 完成属性初始化

  • 执行 bubbleProperties 完成 flags 冒泡

update 阶段

上面的 mount 流程,完成的是属性的初始化,那么这个 update 流程,完成的就是属性更新的标记

updateHostComponent 的主要逻辑是在 diffProperties 方法里面,这个方法会包含两次遍历:

  • 第一次遍历,主要是标记更新前有,更新没有的属性,实际上也就是标记删除了的属性

  • 第二次遍历,主要是标记更新前后有变化的属性,实际上也就是标记更新了的属性

相关代码如下:

function diffProperties(domElement, tag, lastRawProps, nextRawProps, rootContainer){
  // 保存变化属性的 key、value
  let updatePayload = null;
  // 更新前的属性
  let lastProps;
  // 更新后的属性
  let nextProps;
  
  //...
  // 标记删除“更新前有,更新后没有”的属性
  for(propKey in lastProps){
    if(nextProps.hasOwnProperty(propKey) || !lastProps.hasOwnProperty(propKey) || lastProps[propKey] == null){
      continue;
    }
    
    if(propKey === STYLE){
      // 处理 style
    } else {
      //其他属性
      (updatePayload = updatePayload || []).push(propKey, null);
    }
  }
  
  // 标记更新“update流程前后发生改变”的属性
  for(propKey in lastProps){
    let nextProp = nextProps[propKey];
    let lastProp = lastProps != null ? lastProps[propKey] : undefined;
    
    if(!nextProps.hasOwnProperty(propKey) || nextProp === lastProp || nextProp == null && lastProp == null){
      continue;
    }
    
    if(propKey === STYLE) {
      // 处理 stlye
    } else if(propKey === DANGEROUSLY_SET_INNER_HTML){
      // 处理 innerHTML
    } else if(propKey === CHILDREN){
      // 处理单一文本类型的 children
    } else if(registrationNameDependencies.hasOwnProperty(propKey)) {
      if(nextProp != null) {
        // 处理 onScroll 事件
      } else {
        // 处理其他属性
      }
    }
  }
  //...
  return updatePayload;
}

所有更新了的属性的 key 和 value 会保存在当前 FiberNode.updateQueue 里面,数据是以 key、value 作为数组相邻的两项的形式进行保存的

export default ()=>{
  const [num, updateNum] = useState(0);
  return (
    <div
            onClick = {()=>updateNum(num + 1)}
      style={{color : `#${num}${num}${num}`}}
      title={num + ''}
    ></div>
  );
}

点击 div 元素触发更新,那么这个时候 style、title 属性会发生变化,变化的数据会以下面的形式保存在 FiberNode.updateQueue 里面:

["title", "1", "style", {"color": "#111"}]

并且,当前的 FiberNode 会标记 Update:

workInProgress.flags |= Update;
flags冒泡

我们知道,当整个 Reconciler 完成工作后,会得到一颗完整的 wipFiberTree,这颗 wipFiberTree 是由一颗一颗 FiberNode 组成的,这些 FiberNode 中有一些标记了 flags,有一些没有标记,现在就存在一个问题,我们如何高效的找到散落在这颗 wipFiberTree 中有 flag 标记的 FiberNode,那么此时就可以通过 flags 冒泡。

我们知道,completeWork 是属于归的阶段,整体流程是自下往上,就非常适合用来收集副作用,收集的相关的代码如下:

let subtreeFlags = NoFlags;

// 收集子 FiberNode 的子孙 FiberNode 中标记的 flags
subtreeFlags |= child.subtreeFlags;
// 收集子 FiberNode 中标记的 flags
subtreeFlags |= child.flags;
// 将收集到的所有 flags 附加到当前 FiberNode 的 subtreeFlags 上面
completeWork.subtreeFlags |= subtreeFlags;

这样的收集方式,有一个好处,在渲染阶段,通过任意一级的 FiberNode.subtreeFlags 都可以快速确定该 FiberNode 以及子树是否存在副作用从而判断是否需要执行和副作用相关的操作。

早期的时候,React 中实际上并没有使用 subtreeFlags 来通过 flags 冒泡收集副作用,而是使用的 effect list(链表)来收集的副作用,使用 subtreeFlags 有一个好处,就是能确定某一个 FiberNode 它的子树的副作用。

总结

completeWork 会根据 wip.tag 区分对待,流程大体上包括如下的两个步骤:

  • 创建元素(mount)或者标记元素更新(update)

  • flags 冒泡

completeWork 在 mount 时的流程如下:

  • 根据 wip.tag 进入不同的处理分支

  • 根据 current !== null 区分是 mount 还是 update

  • 对应 HostComponent,首先执行 createInstance 方法来创建对应的 DOM 元素

  • 执行 appendChildren 将下一级 DOM 元素挂载在上一步所创建的 DOM 元素下

  • 执行 finalizeInitialChildren 完成属性初始化

  • 执行 bubbleProperties 完成 flags 冒泡

completeWork 在 update 时的主要是标记属性的更新。

  • updateHostComponent 的主要逻辑是在 diffProperties 方法中,该方法包括两次遍历:
  • 第一次遍历,标记删除“更新前有,更新后没有”的属性
  • 第二次遍历,标记更新“update流程前后发生改变”的属性

无论是 mount 还是 update,最终都会进行 flags 的冒泡。

flags 冒泡的目的是为了找到散落在 WorkInProgressFiberTree 各处的被标记了的 FiberNode,对“被标记的 FiberNode 所对应的 DOM 元素”执行 flags 对应的 DOM 操作。

FiberNode.subtreeFlags 记录了该 FiberNode 的所有子孙 FiberNode 上被标记的 flags。而每个 FiberNode 经由如下操作,便可以将子孙 FiberNode 中标记的 flags 向上冒泡一层。

Fiber 架构的早期版本并没有使用 subtreeFlags,而是使用一种被称之为 Effect list 的链表结构来保存“被标记副作用的 FiberNode”。

但在 React v18 版本中使用了 subtreeFlags 替换了 Effect list,原因是因为 v18 中的 Suspense 的行为恰恰需要遍历子树。

2.7 Diff

Render 阶段会生成 Fiber Tree,所谓的 diff 实际上就是发生在这个阶段,这里的 diff 指的是 current FiberNode 和 JSX 对象之间进行对比,然后生成新的的 wip FiberNode。

除了 React 以外,其他使用到了虚拟 DOM 的前端框架也会有类似的流程,比如 Vue 里面将这个流程称之为patch。

diff 算法本身是有性能上面的消耗,在 React 文档中有提到,即便采用最前沿的算法,如果要完整的对比两棵树,那么算法的复杂度都会达到 O(n^3),n 代表的是元素的数量,如果 n 为 1000,要执行的计算量会达到十亿量级的级别。

因此,为了降低算法的复杂度,React 为 diff 算法设置了 3 个限制:

  • 限制一:只对同级别元素进行 diff,如果一个 DOM 元素在前后两次更新中跨越了层级,那么 React 不会尝试复用它

  • 限制二:两个不同类型的元素会产生不同的树。比如元素从 div 变成了 p,那么 React 会直接销毁 div 以及子孙元素,新建 p 以及 p 对应的子孙元素

  • 限制三:开发者可以通过 key 来暗示哪些子元素能够保持稳定

更新前:

<div>
  <p key="one">one</p>
  <h3 key="two">two</h3>
</div>

更新后

<div>
  <h3 key="two">two</h3>
  <p key="one">one</p>
</div>

如果没有 key,那么 React 就会认为 div 的第一个子元素从 p 变成了 h3,第二个子元素从 h3 变成了 p,因此 React 就会采用限制二的规则。

但是如果使用了 key,那么此时的 DOM 元素是可以复用的,只不过前后交换了位置而已。

接下来我们回头再来看限制一,对同级元素进行 diff,究竟是如何进行 diff ?整个 diff 的流程可以分为两大类:

  • 更新后只有一个元素,此时就会根据 newChild 创建对应的 wip FiberNode,对应的流程就是单节点 diff

  • 更新后有多个元素,此时就会遍历 newChild 创建对应的 wip FiberNode 已经它的兄弟元素,此时对应的流程就是多节点 diff

单节点 diff

单节点指的是新节点为单一节点,但是旧节点的数量是不一定

单节点 diff 是否能够复用遵循以下的流程:

  • 判断 key 是否相同

    • 如果更新前后没有设置 key,那么 key 就是 null,也是属于相同的情况
    • 如果 key 相同,那么就会进入到步骤二
    • 如果 key 不同,就不需要进入步骤,无需判断 type,结果直接为不能复用(如果有兄弟节点还会去遍历兄弟节点)
  • 如果 key 相同,再判断 type 是否相同

    • 如果 type 相同,那么就复用
    • 如果 type 不同,无法复用(并且兄弟节点也一并标记为删除)

更新前

<ul>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>

更新后

<ul>
  <p>1</p>
</ul>

这里因为没有设置 key,所以会被设为 key 是相同的,接下来就会进入到 type 的判断,此时发现 type 不同,因此不能够复用。

既然这里唯一的可能性都已经不能够复用,会直接标记兄弟 FiberNode 为删除状态。

如果上面的例子中,key 不同只能代表当前的 FiberNode 无法复用,因此还需要去遍历兄弟的 FiberNode

下面我们再来看一些示例

示例一

更新前

<div>one</div>

更新后

<p>one</p>

没有设置 key,那么可以认为默认 key 就是 null,更新前后两个 key 是相同的,接下来就查看 type,发现 type 不同,因此不能复用。

示例二

更新前

<div key="one">one</div>

更新后

<div key="two">one</div>

更新前后 key 不同,不需要再判断 type,结果为不能复用

示例3

更新前

<div key="one">one</div>

更新后

<p key="two">one</p>

更新前后 key 不同,不需要再判断 type,结果为不能复用

示例4

更新前

<div key="one">one</div>

更新后

<div key="one">two</div>

首先判断 key 相同,接下来判断 type 发现也是相同,这个 FiberNode 就能够复用,children 是一个文本节点,之后将文本节点更新即可。

多节点 diff

所谓多节点 diff,指的是新节点有多个。

React 团队发现,在日常开发中,对节点的更新操作的情况往往要多余对节点“新增、删除、移动”,因此在进行多节点 diff 的时候,React 会进行两轮遍历:

  • 第一轮遍历会尝试逐个的复用节点
  • 第二轮遍历处理上一轮遍历中没有处理完的节点
第一轮遍历

第一轮遍历会从前往后依次进行遍历,存在三种情况:

  • 如果新旧子节点的key 和 type 都相同,说明可以复用

  • 如果新旧子节点的 key 相同,但是 type 不相同,这个时候就会根据 ReactElement 来生成一个全新的 fiber,旧的 fiber 被放入到 deletions 数组里面,回头统一删除。但是注意,此时遍历并不会终止

  • 如果新旧子节点的 key 和 type 都不相同,结束遍历

示例一

更新前

<div>
  <div key="a">a</div>
  <div key="b">b</div>
  <div key="c">c</div>
  <div key="d">d</div>
</div>

更新后

<div>
  <div key="a">a</div>
  <div key="b">b</div>
  <div key="e">e</div>
  <div key="d">d</div>
</div>

首先遍历到 div.key.a,发现该 FiberNode 能够复用

继续往后面走,发现 div.key.b 也能够复用

接下来继续往后面走,div.key.e,这个时候发现 key 不一样,因此第一轮遍历就结束了

示例二

更新前

<div>
  <div key="a">a</div>
  <div key="b">b</div>
  <div key="c">c</div>
  <div key="d">d</div>
</div>

更新后

<div>
  <div key="a">a</div>
  <div key="b">b</div>
  <p key="c">c</p>
  <div key="d">d</div>
</div>

首先和上面的一样,div.key.a 和 div.key.b 这两个 FiberNode 可以进行复用,接下来到了第三个节点,此时会发现 key 是相同的,但是 type 不相同,此时就会将对应的旧的 FiberNode 放入到一个叫 deletions 的数组里面,回头统一进行删除,根据新的 React 元素创建一个新的 FiberNode,但是此时的遍历是不会结束的

接下来继续往后面进行遍历,遍历什么时候结束呢?

  • 到末尾了,也就是说整个遍历完了
  • 或者是和示例一相同,可以 key 不同

第二轮遍历

如果第一轮遍历被提前终止了,那么意味着有新的 React 元素或者旧的 FiberNode 没有遍历完,此时就会采用第二轮遍历

第二轮遍历会处理这么三种情况:

  • 只剩下旧子节点:将旧的子节点添加到 deletions 数组里面直接删除掉(删除的情况)
  • 只剩下新的 JSX 元素:根据 ReactElement 元素来创建 FiberNode 节点(新增的情况)
  • 新旧子节点都有剩余:会将剩余的 FiberNode 节点放入一个 map 里面,遍历剩余的新的 JSX 元素,然后从 map 中去寻找能够复用的 FiberNode 节点,如果能够找到,就拿来复用。(移动的情况)

如果不能找到,就新增呗。然后如果剩余的 JSX 元素都遍历完了,map 结构中还有剩余的 Fiber 节点,就将这些 Fiber 节点添加到 deletions 数组里面,之后统一做删除操作

  • 只剩下旧子节点

更新前

<div>
  <div key="a">a</div>
  <div key="b">b</div>
  <div key="c">c</div>
  <div key="d">d</div>
</div>

更新后

<div>
  <div key="a">a</div>
  <div key="b">b</div>
</div>

遍历前面两个节点,发现能够复用,此时就会复用前面的节点,对于 React 元素来讲,遍历完前面两个就已经遍历结束了,因此剩下的FiberNode就会被放入到 deletions 数组里面,之后统一进行删除

  • 只剩下新的 JSX 元素

更新前

<div>
  <div key="a">a</div>
  <div key="b">b</div>
</div>

更新后

<div>
  <div key="a">a</div>
  <div key="b">b</div>
  <div key="c">c</div>
  <div key="d">d</div>
</div>

根据新的 React 元素新增对应的 FiberNode 即可。

  • 新旧子节点都有剩余

更新前

<div>
  <div key="a">a</div>
  <div key="b">b</div>
  <div key="c">c</div>
  <div key="d">d</div>
</div>

更新后

<div>
  <div key="a">a</div>
  <div key="c">b</div>
  <div key="b">b</div>
  <div key="e">e</div>
</div>

首先会将剩余的旧的 FiberNode 放入到一个 map 里面

接下来会继续去遍历剩下的 JSX 对象数组,遍历的同时,从 map 里面去找有没有能够复用

如果在 map 里面没有找到,那就会新增这个 FiberNode,如果整个 JSX 对象数组遍历完成后,map 里面还有剩余的 FiberNode,说明这些 FiberNode 是无法进行复用,直接放入到 deletions 数组里面,后期统一进行删除。

双端对比算法

所谓双端,指的是在新旧子节点的数组中,各用两个指针指向头尾的节点,在遍历的过程中,头尾两个指针同时向中间靠拢。

因此在新子节点数组中,会有两个指针,newStartIndex 和 newEndIndex 分别指向新子节点数组的头和尾。在旧子节点数组中,也会有两个指针,oldStartIndex 和 oldEndIndex 分别指向旧子节点数组的头和尾。

每遍历到一个节点,就尝试进行双端比较:「新前 vs 旧前」、「新后 vs 旧后」、「新后 vs 旧前」、「新前 vs 旧后」,如果匹配成功,更新双端的指针。比如,新旧子节点通过「新前 vs 旧后」匹配成功,那么 newStartIndex += 1,oldEndIndex -= 1。

如果新旧子节点通过「新后 vs 旧前」匹配成功,还需要将「旧前」对应的 DOM 节点插入到「旧后」对应的 DOM 节点之前。如果新旧子节点通过「新前 vs 旧后」匹配成功,还需要将「旧后」对应的 DOM 节点插入到「旧前」对应的 DOM 节点之前。

实际上在 React 的源码中,解释了为什么不使用双端 diff

function reconcileChildrenArray(
returnFiber: Fiber,
 currentFirstChild: Fiber | null,
 newChildren: Array<*>,
 expirationTime: ExpirationTime,
): Fiber | null {
    // This algorithm can't optimize by searching from boths ends since we
    // don't have backpointers on fibers. I'm trying to see how far we can get
    // with that model. If it ends up not being worth the tradeoffs, we can
    // add it later.

    // Even with a two ended optimization, we'd want to optimize for the case
    // where there are few changes and brute force the comparison instead of
    // going for the Map. It'd like to explore hitting that path first in
    // forward-only mode and only go for the Map once we notice that we need
    // lots of look ahead. This doesn't handle reversal as well as two ended
    // search but that's unusual. Besides, for the two ended optimization to
    // work on Iterables, we'd need to copy the whole set.

    // In this first iteration, we'll just live with hitting the bad case
    // (adding everything to a Map) in for every insert/move.

    // If you change this code, also update reconcileChildrenIterator() which
    // uses the same algorithm.

将上面的注视翻译成中文如下:

由于双端 diff 需要向前查找节点,但每个 FiberNode 节点上都没有反向指针,即前一个 FiberNode 通过 sibling 属性指向后一个 FiberNode,只能从前往后遍历,而不能反过来,因此该算法无法通过双端搜索来进行优化。

React 想看下现在用这种方式能走多远,如果这种方式不理想,以后再考虑实现双端 diff。React 认为对于列表反转和需要进行双端搜索的场景是少见的,所以在这一版的实现中,先不对 bad case 做额外的优化。

总结

diff 计算发生在更新阶段,当第一次渲染完成后,就会产生 Fiber 树,再次渲染的时候(更新),就会拿新的 JSX 对象(vdom)和旧的 FiberNode 节点进行一个对比,再决定如何来产生新的 FiberNode,它的目标是尽可能的复用已有的 Fiber 节点。这个就是 diff 算法。

在 React 中整个 diff 分为单节点 diff 和多节点 diff。

所谓单节点是指新的节点为单一节点,但是旧节点的数量是不一定的。

单节点 diff 是否能够复用遵循如下的顺序:

  1. 判断 key 是否相同
  • 如果更新前后均未设置 key,则 key 均为 null,也属于相同的情况
  • 如果 key 相同,进入步骤二
  • 如果 key 不同,则无需判断 type,结果为不能复用(有兄弟节点还会去遍历兄弟节点)
  1. 如果 key 相同,再判断 type 是否相同
  • 如果 type 相同,那么就复用
  • 如果 type 不同,则无法复用(并且兄弟节点也一并标记为删除)

多节点 diff 会分为两轮遍历:

第一轮遍历会从前往后进行遍历,存在以下三种情况:

  • 如果新旧子节点的key 和 type 都相同,说明可以复用
  • 如果新旧子节点的 key 相同,但是 type 不相同,这个时候就会根据 ReactElement 来生成一个全新的 fiber,旧的 fiber 被放入到 deletions 数组里面,回头统一删除。但是注意,此时遍历并不会终止
  • 如果新旧子节点的 key 和 type 都不相同,结束遍历

如果第一轮遍历被提前终止了,那么意味着还有新的 JSX 元素或者旧的 FiberNode 没有被遍历,因此会采用第二轮遍历去处理。

第二轮遍历会遇到三种情况:

  • 只剩下旧子节点:将旧的子节点添加到 deletions 数组里面直接删除掉(删除的情况)
  • 只剩下新的 JSX 元素:根据 ReactElement 元素来创建 FiberNode 节点(新增的情况)
  • 新旧子节点都有剩余:会将剩余的 FiberNode 节点放入一个 map 里面,遍历剩余的新的 JSX 元素,然后从 map 中去寻找能够复用的 FiberNode 节点,如果能够找到,就拿来复用。(移动的情况)

如果不能找到,就新增呗。然后如果剩余的 JSX 元素都遍历完了,map 结构中还有剩余的 Fiber 节点,就将这 些 Fiber 节点添加到 deletions 数组里面,之后统一做删除操作

整个 diff 算法最最核心的就是两个字“复用”。

React 不使用双端 diff 的原因:

由于双端 diff 需要向前查找节点,但每个 FiberNode 节点上都没有反向指针,即前一个 FiberNode 通过 sibling 属性指向后一个 FiberNode,只能从前往后遍历,而不能反过来,因此该算法无法通过双端搜索来进行优化。

React 想看下现在用这种方式能走多远,如果这种方式不理想,以后再考虑实现双端 diff。React 认为对于列表反转和需要进行双端搜索的场景是少见的,所以在这一版的实现中,先不对 bad case 做额外的优化。

2.8 渲染器:renderer

整个 React 的工作流程可以分为两大阶段:

  • Render 阶段

    • Schedule
    • Reconcile
  • Commit 阶段

注意,Render 阶段的行为是在内存中运行的,这意味着可能被打断,也可以被打断,而 commit 阶段则是一旦开始就会同步执行直到完成。

commit 阶段整体可以分为 3 个子阶段:

  • BeforeMutation 阶段
  • Mutation 阶段
  • Layout 阶段

整体流程图如下:

每个阶段,又分为三个子阶段:

  • commitXXXEffects
  • commitXXXEffects_begin
  • commitXXXEffects_complete

所分成的这三个子阶段,是有一些共同的事情要做的

commitXXXEffects

该函数是每个子阶段的入口函数,finishedWork 会作为 firstChild 参数传入进去,相关代码如下:

function commitXXXEffects(root, firstChild){
  nextEffect = firstChild;
  // 省略标记全局变量
  commitXXXEffects_begin();
  // 省略重置全局变量
}

因此在该函数中,主要的工作就是将 firstChild 赋值给全局变量 nextEffect,然后执行 commitXXXEffects_begin

commitXXXEffects_begin

向下遍历 FiberNode。遍历的时候会遍历直到第一个满足如下条件之一的 FiberNode:

  • 当前的 FiberNode 的子 FiberNode 不包含该子阶段对应的 flags
  • 当前的 FiberNode 不存在子 FiberNode

接下来会对目标 FiberNode 执行 commitXXXEffects_complete 方法,commitXXXEffects_begin 相关代码如下:

function commitXXXEffects_begin(){
  while(nextEffect !== null) {
    let fiber = nextEffect;
    let child = fiber.child;
    
    // 省略该子阶段的一些特有操作
    
    if(fiber.subtreeFlags !== NoFlags && child !== null){
      // 继续向下遍历
      nextEffect = child;
    } else {
      commitXXXEffects_complete();
    }
  }
}

commitXXXEffects_complete

该方法主要就是针对 flags 做具体的操作了,主要包含以下三个步骤:

  • 对当前 FiberNode 执行 flags 对应的操作,也就是执行 commitXXXEffectsOnFiber
  • 如果当前 FiberNode 存在兄弟 FiberNode,则对兄弟 FiberNode 执行 commitXXXEffects_begin
  • 如果不存在兄弟 FiberNode,则对父 FiberNode 执行 commitXXXEffects_complete

相关代码如下:

function commitXXXEffects_complete(root){
  while(nextEffect !== null){
    let fiber = nextEffect;
    
    try{
      commitXXXEffectsOnFiber(fiber, root);
    } catch(error){
      // 错误处理
    }
    
    let sibling = fiber.sibling;
    
    if(sibling !== null){
      // ...
      nextEffect = sibling;
      return
    }
    
    nextEffect = fiber.return;
  }
}

总结一下,每个子阶段都会以 DFS 的原则来进行遍历,最终会在 commitXXXEffectsOnFiber 中针对不同的 flags 做出不同的处理。

2.8.1 BeforeMutation 阶段

BeforeMutation 阶段的主要工作发生在 commitBeforeMutationEffects_complete 中的 commitBeforeMutationEffectsOnFiber 方法,相关代码如下:

function commitBeforeMutationEffectsOnFiber(finishedWork){
  const current = finishedWork.alternate;
  const flags = finishedWork.falgs;
  
  //...
  // Snapshot 表示 ClassComponent 存在更新,且定义了 getSnapsshotBeforeUpdate 方法
  if(flags & Snapshot !== NoFlags) {
    switch(finishedWork.tag){
      case ClassComponent: {
        if(current !== null){
          const prevProps = current.memoizedProps;
          const prevState = current.memoizedState;
          const instance = finishedWork.stateNode;
          
          // 执行 getSnapsshotBeforeUpdate
          const snapshot = instance.getSnapsshotBeforeUpdate(
                  finishedWork.elementType === finishedWork.type ? 
            prevProps : resolveDefaultProps(finishedWork.type, prevProps),
            prevState
          )
        }
        break;
      }
      case HostRoot: {
        // 清空 HostRoot 挂载的内容,方便 Mutation 阶段渲染
        if(supportsMutation){
          const root = finishedWork.stateNode;
          clearCOntainer(root.containerInfo);
        }
        break;
      }
    }
  }
}

上面代码的整个过程中,主要是处理如下两种类型的 FiberNode:

  • ClassComponent:执行 getSnapsshotBeforeUpdate 方法

  • HostRoot:清空 HostRoot 挂载的内容,方便 Mutation 阶段进行渲染

2.8.2 Mutation 阶段

对于 HostComponent,Mutation 阶段的主要工作就是对 DOM 元素及进行增、删、改

2.8.2.1 删除 DOM 元素

删除 DOM 元素相关代码如下:

function commitMutationEffects_begin(root){
  while(nextEffect !== null){
    const fiber = nextEffect;
    // 删除 DOM 元素
    const deletions = fiber.deletions;
    
    if(deletions !== null){
      for(let i=0;i<deletions.length;i++){
        const childToDelete = deletions[i];
        try{
          commitDeletion(root, childToDelete, fiber);
        } catch(error){
          // 省略错误处理
        }
      }
    }
    
    const child = fiber.child;
    if((fiber.subtreeFlags & MutationMask) !== NoFlags && child !== null){
      nextEffect = child;
    } else {
      commitMutationEffects_complete(root);
    }
  }
}

删除 DOM 元素的操作发生在 commitMutationEffects_begin 方法中,首先会拿到 deletions 数组,之后遍历该数组进行删除操作,对应删除 DOM 元素的方法为 commitDeletion。

commitDeletion 方法内部的完整逻辑实际上是比较复杂的,原因是因为在删除一个 DOM 元素的时候,不是说删除就直接删除,还需要考虑以下的一些因素:

  • 其子树中所有组件的 unmount 逻辑

  • 其子树中所有 ref 属性的卸载操作

  • 其子树中所有 Effect 相关 Hook 的 destory 回调的执行

假设有如下的代码:

<div>
        <SomeClassComponent/>
  <div ref={divRef}>
          <SomeFunctionComponent/>
  </div>
</div>

当你删除最外层的 div 这个 DOM 元素时,需要考虑:

  • 执行 SomeClassComponent 类组件对应的 componentWillUnmount 方法

  • 执行 SomeFunctionComponent 函数组件中的 useEffect、useLayoutEffect 这些 hook 中的 destory 方法

  • divRef 的卸载操作

整个删除操作是以 DFS 的顺序,遍历子树的每个 FiberNode,执行对应的操作。

2.8.2.2插入、移动 DOM 元素

上面的删除操作是在 commitMutationEffects_begin 方法里面执行的,而插入和移动 DOM 元素则是在 commitMutationEffects_complete 方法里面的 commitMutationEffectsOnFiber 方法里面执行的,相关代码如下:

function commitMutationEffectsOnFiber(finishedWork, root){
  const flags = finishedWork.flags;

  // ...
  
  const primaryFlags = flags & (Placement | Update | Hydrating);
  
  outer: switch(primaryFlags){
    case Placement:{
      // 执行 Placement 对应操作
      commitPlacement(finishedWork);
      // 执行完 Placement 对应操作后,移除 Placement flag
      finishedWork.falgs &= ~Placement;
      break;
    }
    case PlacementAndUpdate:{
      // 执行 Placement 对应操作
      commitPlacement(finishedWork);
      // 执行完 Placement 对应操作后,移除 Placement flag
      finishedWork.falgs &= ~Placement;
      
      // 执行 Update 对应操作
      const current = finishedWork.alternate;
      commitWork(current, finishedWork);
      break;
    }
      
    // ...
  }
  

}

可以看出, Placement flag 对应的操作方法为 commitPlacement,代码如下:

function commitPlacement(finishedWork){
  // 获取 Host 类型的祖先 FiberNode
  const parentFiber = getHostParentFiber(finishedWork);
  
  // 省略根据 parentFiber 获取对应 DOM 元素的逻辑
  
  let parent;
  
  // 目标 DOM 元素会插入至 before 左边
  const before = getHostSibling(finishedWork);
  
  // 省略分支逻辑
  // 执行插入或移动操作
  insertOrAppendPlacementNode(finishedWork, before, parent);
}

整个 commitPlacement 方法的执行流程可以分为三个步骤:

  • 从当前 FiberNode 向上遍历,获取第一个类型为 HostComponent、HostRoot、HostPortal 三者之一的祖先 FiberNode,其对应的 DOM 元素是执行 DOM 操作的目标元素的父级 DOM 元素
  • 获取用于执行 parentNode.insertBefore(child, before) 方法的 “before 对应的 DOM 元素”
  • 执行 parentNode.insertBefore 方法(存在 before)或者 parentNode.appendChild 方法(不存在 before)

对于“还没有插入的DOM元素”(对应的就是 mount 场景),insertBefore 会将目标 DOM 元素插入到 before 之前,appendChild 会将目标DOM元素作为父DOM元素的最后一个子元素插入

对于“UI中已经存在的 DOM 元素”(对应 update 场景),insertBefore 会将目标 DOM 元素移动到 before 之前,appendChild 会将目标 DOM 元素移动到同级最后。

因此这也是为什么在 React 中,插入和移动所对应的 flag 都是 Placement flag 的原因。(可能面试的时候会被问到)

2.8.2.3 更新 DOM 元素

更新 DOM 元素,一个最主要的工作就是更新对应的属性,执行的方法为 commitWork,相关代码如下:

function commitWork(current, finishedWork){
  switch(finishedWork.tag){
    // 省略其他类型处理逻辑
    case HostComponent:{
      const instance = finishedWork.stateNode;
      if(instance != null){
        const newProps = finishedWork.memoizedProps;
        const oldProps = current !== null ? current.memoizedProps : newProps;
        const type = finishedWork.type;
        
        const updatePayload = finishedWork.updateQueue;
        finishedWork.updateQueue = null;
        if(updatePayload !== null){
          // 存在变化的属性
          commitUpdate(instance, updatePayload, type, oldProps, newProps, finishedWork);
        }
      }
      return;
    }
  }
}

之前有讲过,变化的属性会以 key、value 相邻的形式保存在 FiberNode.updateQueue ,最终在 FiberNode.updateQueue 里面所保存的要变化的属性就会在一个名为 updateDOMProperties 方法被遍历然后进行处理,这里的处理主要是处理如下的四种数据:

  • style 属性变化

  • innerHTML

  • 直接文本节点变化

  • 其他元素属性

相关代码如下:

function updateDOMProperties(domElement, updatePayload, wasCustomComponentTag, isCustomComponentTag){
  for(let i=0;i< updatePayload.length; i+=2){
    const propKey = updatePayload[i];
    const propValue = updatePayload[i+1];
    if(propKey === STYLE){
      // 处理 style
      setValueForStyle(domElement, propValue);
    } else if(propKey === DANGEROUSLY_SET_INNER_HTML){
      // 处理 innerHTML
      setInnerHTML(domElement, propValue);
    } else if(propsKey === CHILDREN){
      // 处理直接的文本节点
      setTextContent(domElement, propValue);
    } else {
      // 处理其他元素
      setValueForProperty(domElement, propKey, propValue, isCustomComponentTag);
    }
  }
}

当 Mutation 阶段的主要工作完成后,在进入 Layout 阶段之前,会执行如下的代码来完成 FiberTree 的切换:

root.current = finishedWork;

2.8.3 Layout 阶段

有关 DOM 元素的操作,在 Mutation 阶段已经结束了。

在 Layout 阶段,主要的工作集中在 commitLayoutEffectsOnFiber 方法中,在该方法内部,会针对不同类型的 FiberNode 执行不同的操作:

  • 对于 ClassComponent:该阶段会执行 componentDidMount/Update 方法

  • 对于 FunctionComponent:该阶段会执行 useLayoutEffect 的回调函数

2.8.4 总结

整个 commit 可以分为三个子阶段

  • BeforeMutation 阶段

  • Mutation 阶段

  • Layout 阶段

每个子阶段又可以分为 commitXXXEffects、commitXXXEffects_beigin 和 commitXXXEffects_complete

其中 commitXXXEffects_beigin 主要是在做遍历节点的操作,commitXXXEffects_complete 主要是在处理副作用

BeforeMutation 阶段整个过程主要处理如下两种类型的 FiberNode:

  • ClassComponent,执行 getSnapsshotBeforeUpdate 方法

  • HostRoot,清空 HostRoot 挂载的内容,方便 Mutation 阶段渲染

对于 HostComponent,Mutation 阶段的工作主要是进行 DOM 元素的增、删、改。当 Mutation 阶段的主要工作完成后,在进入 Layout 阶段之前,会执行如下的代码完成 Fiber Tree 的切换。

Layout 阶段会对遍历到的每个 FiberNode 执行 commitLayoutEffectOnFiber,根据 FiberNode 的不同,执行不同的操作,例如:

  • 对于 ClassComponent,该阶段执行 componentDidMount/Update 方法
  • 对于 FunctionComponent,该阶段执行 useLayoutEffect callback 方法

2.9 其他

2.9.1 lane模型

在 React 中有一套独立的粒度更细的优先级算法,这就是 lane 模型。

这是一个基于位运算的算法,每一个 lane 是一个 32 bit Integer,不同的优先级对应了不同的 lane,越低的位代表越高的优先级。

早期的 React 并没有使用 lane 模型,而是采用的的基于 expirationTime 模型的算法,但是这种算法耦合了“优先级”和“批”这两个概念,限制了模型的表达能力。优先级算法的本质是“为 update 排序”,但 expirationTime 模型在完成排序的同时还默认的划定了“批”。

使用 lane 模型就不存在这个问题,因为是基于位运算,所以在批的划分上会更加的灵活。

2.9.2 react中的事件

在 React 中,有一套自己的事件系统,如果说 React 用 FiberTree 这一数据结构是用来描述 UI 的话,那么事件系统则是基于 FiberTree 来描述和 UI 之间的交互。

对于 ReactDOM 宿主环境,这套事件系统由两个部分组成:

(1)SyntheticEvent(合成事件对象)

SyntheticEvent 是对浏览器原生事件对象的一层封装,兼容主流浏览器,同时拥有与浏览器原生事件相同的 API,例如 stopPropagation 和 preventDefault。SyntheticEvent 存在的目的是为了消除不同浏览器在 “事件对象” 间的差异。

(2)模拟实现事件传播机制

利用事件委托的原理,React 基于 FiberTree 实现了事件的捕获、目标、冒泡的流程(类似于原生事件在 DOM 元素中传递的流程),并且在这套事件传播机制中加入了许多新的特性,例如:

  • 不同事件对应了不同的优先级
  • 定制事件名
  • 例如事件统一采用如 “onXXX” 的驼峰写法
  • 定制事件行为
  • 例如 onChange 的默认行为与原生 oninput 相同

2.9.3 Hooks原理

首先 Hook 是一个对象,大致有如下的结构:

const hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null
}

  不同类型的 hook,hook 的 memoizedState 中保存了不同的值,例如:

  • useState:对于 const [state, updateState] = useState(initialState),memoizedState 保存的是 state 的值

  • useEffect:对于 useEffect( callback, [...deps] ),memoizedState 保存的是 callback、[...deps] 等数据

  一个组件中的 hook 会以链表的形式串起来,FiberNode 的 memoizedState 中保存了 Hooks 链表中的第一个 Hook

3. vue源码解析

3.1 vue的心智模型

vue是什么?vue解决了什么问题?

是一套渐进式的框架? mvvm模型? 一个跨平台的框架? 沟通链接了数据和视图?只需关注数据?

帮助开发人员实现更简便的开发,提高开发人员的开发效率和代码维护性?

引用官方的说法:

Vue 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。但 Vue 并不只是一个简单的视图库,通过与一系列周边工具的配合,它也可以轻易地构建大型应用。

同react一样,vue本质是一个构建页面的框架,也遵循UI=f(state)的公式。和react区别在于表达UI的不同,内部状态管理的不同。但本质就是帮开发者完成了渲染的工作,使得开发者专注业务构建。更精简的说,就是开发者只用关注状态(数据),不需要关注视图。

vue是怎么实现的?

vue框架其实主要分为两部分:数据响应式模板引擎的编译和渲染

  • 在一开始就要做数据响应式(发生变化可感知,并且自动渲染)和数据绑定到视图中,做一个初始化。

  • 二是编译得到渲染函数(渲染函数未来执行生成页面)。

我的理解是后端的数据以及用户触发的数据可以动态驱动view视图的变化,视图自动变化依赖于对模板语法的编译及渲染(渲染内部包含了虚拟dom的生成及diff比对),实现更高效及可靠的编程模式。

而模板引擎是Vue里最核心的能力。

在模板引擎还没有出现的时候,前端需要手动更新前端页面的内容,需要维护一大堆的 HTML 和变量拼接的动态内容,虽然 jQuery 的出现提升了 DOM 元素的操作性,但依然难以避免代码的可读性、可维护性上存在的一些问题。

Before:

监听操作 -> 获取数据变量 -> 使用数据拼接成 HTML 模板 -> 将 HTML 内容塞到页面对应的地方 -> 将 HTML 片段内需要监听的点击等事件进行绑定。

Now:

在模板里用插值表达式{{}}v-bind绑定变量来展示,同时配合v-ifv-for这些内置指令,就可以很方便地写出可读性和维护性都很不错的代码,然后通过渲染引擎转化成响应的虚拟dom(包含了ast语法树的解析),经过一系列高效的处理后,就可以自动生成对应的真实dom,并插入到页面中。

这一套逻辑的大致的内容主要是:

数据响应式:

  • object.defineProperty()
  • proxy

模板引擎的渲染逻辑是:

模板 ---> 编译成渲染函数 ---> 渲染函数执行生成虚拟dom ---> 新旧虚拟dom之间进行比对 ---> 生成一个最新虚拟dom ---> 虚拟dom创建生成真实dom添加到html中。

中间通过watcher和dep来建立数据和渲染的桥梁。

我们来看一个流程图

在new Vue的过程中

1.先对data数据进行监听,然后每个key生成一个dep实例,这个dep内部维护管理后续的watcher(这个watcher内部包含的就是组件的更新方法,包含了虚拟dom的生成及diff比对等)。

2.当数据监听完成后,就会执行挂载,挂载时就在内部编译指令(执行时机不同),实例化watcher,跟dep产生关联。视图的初始化和更新都在watcher中实现。

vue2.0和vue3.0有什么不同

vue2.0中存在

  • 代码架构问题
  • 性能优化的空间
  • 大型项目中的可维护性
  • 浏览器的发展真的需要兼容低版本吗

针对这些问题 vue3进行了升级

这部分内容待完善...

3.2 vue2.0 核心源码

3.2.1 核心流程思维脑图

还是先放一张呕心沥血的脑图:vue2.0核心运行流程细节,配合源码的注释

image.png

思维脑图没办法粘贴到这里,需要原图可以一键三连关注我后私信我要😄

暂未写完,敬请期待

3.3 vue3.0 核心源码

暂未写完,敬请期待