React 原理分析(一) —— React 设计思想

7,683 阅读11分钟

React 理念

我们认为,React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。

作为一个构建用户页面的JS库,React最近的几次大版本更新都围绕着快速 快速响应 这个目标, 不断优化用户的使用体验。 以下面例子为例,我们渲染 4500 个 span 标签。

class App extends React.Component {
  state = { number: 0 }
  render(){
    const { number } = this.state;
    return  (
      <div>
        {
          Array.from(new Array(4500)).map((item, index) => {
            return <span id={index}>{number}</span>
          })
        }  
      </div>
    )
  }
}

img (旧版本渲染 4500 个span )

img (新版本渲染 4500 个 span )

可以看到使用旧版本的React在渲染 4500个span 时 JS 连续执行了 140ms 才完成。 而我们都知道 JS 是单线程的,执行大量运算的占用 JS 的同时,也必然会造成页面的卡顿。也就 违背了 React 快速响应 的理念。

而新版本 React 渲染大量的 span 标签时,将一个巨大的渲染任务拆成了许多 微型个task(5ms 左右)。React 会在浏览器的每一帧(16ms)中执行一个 task, 随后将剩余时间留出完成页面的其他操作。这样就解决了浏览器页面的卡顿问题。

React 是如何实现上面的功能的呢? 我想主要是以下几点:

  • 将之前的 Virtual DOM 替换成 全新的 Fiber 结构
  • 基于 Fiber架构Scheduel 实现了异步可中断的更新
  • React 内部的 优先级及Lane 机制出色的完成了 React 中各种任务的调度。

本文是我 学习React源码 后的第一篇文章。我会以尽量易懂的例子为大家介绍上面几点的具体含义。并在后面的文章中进行源码级别的讲解。

传统的 VDOM

先简单介绍一下VDOM。vdom是虚拟DOM(Virtual DOM)的简称,指的是用JS模拟的DOM结构,将DOM变化的对比放在JS层来做。VDOM的优势在于将 DOM 的 diff 操作放到 JS 内存中,同时可以做到 DOM 的复用。减少不必要的重绘从而提高效率。

我们来简单思考一个将 VDOM 渲染成 DOM 的 diff 流程。

img

React 会从根节点开始不断的 diff 新旧 VDOM,如果节点相同,则继续 递归 diff 子节点。如果不相同,将旧 DOM 的旧 DOM 全部删除,并渲染出新的 DOM 。这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。

使用 Virtual DOM 的缺点在于使用了深度优先的 递归 方式来比较 DOM 与其对应的子节点。一旦进入递归,便无法暂停。如果遇到了 VDOM 树十分庞大( 如上文 4500 个 span ) 的情况,浏览器线程便一直被 diff 函数所阻塞住。

最后简单总结一下 React15 使用的 VDOM 更新流程。 VDOM 的更新流程可以理解如下 :从根节点开始以深度优先的方式 DIFF 新老节点, 找到差异后立刻在对应的 DOM 上进行更新。

全新的 Fiber 架构

关于 Fiber 架构,后续会产出 3-5 篇文章进行讲解。

为了解决 VDOM 不可中断的问题, React 使用了全新的 Fiber 架构, 用 Fiber 代替 Vnode 。为了实现 异步可中断 的更新使用了双缓存机制

Fiber 是什么

Fiber 也是一种 DOM 结构的 JS 实现。你可以将其理解为 React 使用 Fiber 机构后对 VDOM 的一种全新定义。 Fiber 其实也是一种 VDOM。举个例子, 组件 APP 的定义如下。

function App() {
  return (
    <div>
      i am
      <span>KaSong</span>
    </div>
  )
}

在 Fiber 中,使用 child 属性代表 该节点的字节点。 Sibling 表示兄弟节点, return 表示父节点。这样的结构有利于实现 Fiber 结构中可中断的遍历。 img

Fiber 架构中的更新流程

对比 React15 中 VDOM 的 更新流程, 为了解决 diff 流程不可中断的问题,Fiber 架构中将整个更新流程分为了两个阶段。

  1. Render阶段: 异步可中断的 diff 新老 DOM,找到差异后并不及时立刻更新。而是对该 Fiber 节点打上一个tag(Update, Placement, Delete)
  2. Commit阶段: 遍历存在 tag 的 Fiber ,根据 tag 的类型执行对应的 DOM 更新。

img

新架构的优势在于将 diff 和 渲染的流程分开。并基于 Schedule 实现异步可中断,解决了 复杂运算 大量占用 JS线程 的问题。

双缓存机制

双缓冲机制是React管理更新工作的一种手段,也是提升用户体验的重要机制。双缓存机制的主要概念为当React开始更新工作之后,会在 JS内存 中保存两棵 JS树。 分别为 current 树和 workInProgress 树。

  • current树: 为当前页面渲染的DOM 结构对应的 DOM 树。
  • workInProgress树: 由 current 树复制出来的树。diff 过程中对 fiber 的操作主要在 workInProgress 树中进行。 Commit 阶段开始时, fiberRootNode节点 current 指针指向左边的的 Root 树,此时左边的 Root 树为 current 树。 currentalternate指向的为 workInProgress 树。 示意如下 img

当 commit 阶段即将结束时, React 会通过 WorkInProgress 树重新渲染一次 DOM。 并将 fiberRootNodecurrent 节点指向右边的树,这样就切换了双缓存机制中的 current 树和 workInProgress 树。

function commitRootImpl(root, renderPriorityLevel) {
    root.current = finishedWork;
} 

同时,双缓存机制的一个重要作用为将 DOM 的计算放在了 workInProgress 树中。在current 树保存了浏览器当前渲染的 VDOM 。 可以做到新老 DOM 的无缝切换。

基于 Schedule 实现的异步可中断

这里只是一个 Schedule 的简单原理分析, 后续会输出一片专门的文章进行讲解。

从上面的介绍可知, Fiber 结构的核心在于实现了commit阶段的异步可中断。那什么是异步可中断呢?这里以一个简单的例子进行解释。

假设想使用 JS 计算从 1 - 3000 的相加结果。 但是在 JS 运算中, 相加是一个非常 "耗时" 的操作,每次执行相加操作都需要使用 1ms 的时间。造成在计算过程中 JS 进程被占用,没法响应其他的事件造成了卡顿。

const add = (a, b) => {
    sleep(1);
    return
}

let count = 0;
for(let i = 1; 1 <= 100; i++){
    count = add(count, i);
}

React 内部实现了一个 Schedule, 在一个浏览器渲染帧中使用 5ms 进行运算,其余时间用于进行 点击时间处理等其他操作。 这里参考 React Schedule 思路实现了add 函数

const syncSleep = (time: number) =>  {
  const start = new Date().valueOf();
  while (new Date().valueOf() - start < time) {}
}



// 简易实现的 add 
const add = (a: number, b: number) => {
    syncSleep(1);
    return a + b;
}



// 实现一个 累加函数 ,当计算未达到边界状态时, 返回下一个计算函数。 否则返回 null 
const Accumulate = () => {
    let count = 0;
    let i = 1;
    let ac = () => {
        if(i <= 200){
            console.log(i)
            count =  add(count, i);
            i++;
            return ac
        }else{
            // 任务结束 
            console.log("count:", count)
        }
        return null
    }
    return ac;
}

// 利用 MessageChannel 的 onMessage 触发 JS 的宏任务
// 为什么使用 利用 MessageChannel 而不使用 setTimeout 是因为 setTimeout 的 4ms bug
// https://juejin.cn/post/6846687590616137742 , 而 4ms 在一个 JS 浏览器渲染帧中
// 影响是比较大的

const channel = new MessageChannel();

// 当前 JS 浏览器渲染帧中任务的过期时间
let expireTime!: number;

// 累加函数的下一个任务
let nextTask!: Function; = Accumulate()

const workLoop = (task: Function) => {

    let taskForNextTime = task;
    // 如果当前时间大于过期时间,结束 while 循环
    while(new Date().valueOf() < expireTime && task){
        taskForNextTime = task();
    }
    return taskForNextTime;
}

// handleWorkStart 会在一个新的 JS 浏览器渲染帧 触发
const handleWorkStart = () => {
    console.log("一个新的 JS 浏览器渲染帧")
    // 设置一个过期时间, 过期时间为当前时间 +5 ,
    // new Date().valueOf() 的表现不好,实际应该使用 performance 
    expireTime = new Date().valueOf() + 5;

    // 执行 workLoop,如果任务完成,nextTask 为 null ,否则为一个可执行的函数
    nextTask = workLoop(nextTask);

    // 如果还有任务未完成,使用 postMessage,
    // 会在下一个 JS 浏览器渲染帧继续触发 handleWorkStart
    if(nextTask){
        channel.port2.postMessage(null)
    }
}

// 收到 postMessage 时触发 handleWorkStart
channel.port1.onmessage = handleWorkStart

// 模拟触发一次 累加任务
channel.port2.postMessage(null)

如果你把 Accumulate函数替换成 React 中处理 Fiber节点的对应函数,那么这就变成了一个 React Schedule 的简单实现。

Schedule 是一个 React 中相对独立的一个模块。与 React 无关,是一种通用的设计思想。如果遇到了利用JS 进行大规模运算阻塞浏览器线程的时候,可以考虑一下Schedule的实现并加以改造。

React 内部的优先级

未命名文件 (3).png (React 优先级的比较的流程图)

在 React 里,如 ClassComponent中的setState/forceUpdatefunctionComponent 中的 useState/useReducer 都可以触发一次组件的 render。React 使用了 Update 的数据结构兼容这些情况,上方提到的方法内部都创建一个 Update 对象。

export type Update<State> = {
  eventTime: number,
  lane: Lane,
  tag: 0 | 1 | 2 | 3,
  payload: any,
  callback: (() => mixed) | null,
  next: Update<State> | null,
};

Update 中在 lane 存储当前更新的优先级。Update 利用二进制将优先级分为以下 31 种。从优先级的名字以及对应的位来看,越靠右优先级越高。且高优先级的 Update 会打断低优先级的 Update。

export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;

export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;

export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000001;

export const SyncBatchedLane: Lane = /*                 */ 0b0000000000000000000000000000010;

export const InputDiscreteHydrationLane: Lane = /*      */ 0b0000000000000000000000000000100;

const InputDiscreteLanes: Lanes = /*                    */ 0b0000000000000000000000000011000;

const InputContinuousHydrationLane: Lane = /*           */ 0b0000000000000000000000000100000;

const InputContinuousLanes: Lanes = /*                  */ 0b0000000000000000000000011000000;

export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000100000000;

export const DefaultLanes: Lanes = /*                   */ 0b0000000000000000000111000000000;

const TransitionHydrationLane: Lane = /*                */ 0b0000000000000000001000000000000;

const TransitionLanes: Lanes = /*                       */ 0b0000000001111111110000000000000;

const RetryLanes: Lanes = /*                            */ 0b0000011110000000000000000000000;

export const SomeRetryLane: Lanes = /*                  */ 0b0000010000000000000000000000000;

export const SelectiveHydrationLane: Lane = /*          */ 0b0000100000000000000000000000000;

const NonIdleLanes = /*                                 */ 0b0000111111111111111111111111111;

export const IdleHydrationLane: Lane = /*               */ 0b0001000000000000000000000000000;

const IdleLanes: Lanes = /*                             */ 0b0110000000000000000000000000000;

export const OffscreenLane: Lane = /*                   */ 0b1000000000000000000000000000000;

举个例子,有以下 demo 。在componentDidMount 1000ms 时触发 this.setState({ number: 1 }), 后在 1040 ms后触发一次 btn 的 click 事件。

class App extends React.Component {
  state = { number: 0 }
  btnRef = null;
  handleClick = () => {
    this.setState((prev) => ({ number: prev.number + 2 }))
  }
  componentDidMount(){
    setTimeout(() => {
      this.setState({ number: 1 })
    },1000)
    setTimeout(() => {
      console.log("btn click")
      this.btnRef.click();
    }, 1040 )
  }

  render(){
    const { number } = this.state;
    return  (
      <div>
          <button 
           ref={(ref) => { this.btnRef = ref }} 
           onClick={this.handleClick}
          > 
           add 2
          </button>
          <div> 
            {
              Array.from(new Array(4500)).map((item, index) => {
                return <span id={index}>{number}</span>
              })
            }  
          </div>
        </div>
    )
  }
}

流程如下:

  1. 1000 ms 时触发 setState , 创建一个 laneDefaultLanes: 二进制值为 0b0000000000000000000111000000000Update1
  2. 浏览器 Update1 对应的更新。
  3. 1040 ms 触发 onclick 事件,创建一个 laneInputDiscreteLanes: 二进制值为0b0000000000000000000000000011000Update2
  4. 此时 1000ms 时创建的 Update 还没有处理完。比较优先级发现Update2 优先级高于 Update1。放弃 Update1,优先先处理 1040ms 触发的 Update2
  5. 完成 Update2 后, 浏览器调度剩余的任务, 处理 Update1

React 内部的优先级让 React 能优先处理更为紧急的事情,如点击事件/输入时间就应该优先与普通的时间处理 这非常符合 React 快速响应的目标!关于内部优先级的实际实现会在后面详细讲解。

参考链接

  1. React技术揭秘: react.iamkasong.com/
  2. React原理解析系列文章: juejin.cn/post/691707…
  3. React Scheduler 为什么使用 MessageChannel 实现: juejin.cn/post/695380…