万字长文剖析React一路升级下的动机

1,718 阅读48分钟

React 18于2022 年 3 月 29 日正式发布,正式开启了react并发更新时代,从 React 16 Fiber的出现到 React 18正式开启并发更新,这中间经历了好多次版本更新,这篇文章将梳理 React 16到React 18的几次重要变更,以及变更背后的原因。

React 16 Fiber的出现

React 16之前版本调度、渲染效率不高,新版本引入新机制进行全面优化。 React框架内部的运作可以分为 3 层:

  • Virtual DOM(虚拟DOM) 层,描述页面长什么样。
  • Reconciler(协调) 层,负责调用组件生命周期方法,进行 Diff 运算等。
  • Renderer(渲染) 层,根据不同的平台,渲染出相应的页面,比较常见的是 ReactDOM 和 ReactNative。

旧版本的痛点

Reconciler 层是调度任务的核心,旧版本的调度方式中,当我们调用setState更新页面的时候,React 会用递归的方式遍历整颗组件数的所有节点,对比新旧虚拟DOM树,找出需要变动的节点,然后再更新 UI。整个过程是一气呵成,不能被打断的。如果页面元素很多,整个过程占用的时机就可能超过 16 毫秒,就容易出现卡顿掉帧的现象。

1.png 这种遍历是递归调用,执行栈会越来越深,而且不能中断,中断后就不能恢复了。递归如果非常深,就会十分卡顿。传统的方法存在不能中断和执行栈太深的问题。

为什么需要Fiber

因此,为了解决以上的痛点问题,React希望能够彻底解决主线程长时间占用问题,于是引入了 Fiber 来改变这种不可控的现状,把渲染/更新过程拆分为一个个小块的任务,通过合理的调度机制来调控时间,指定任务执行的时机,从而降低页面卡顿的概率,提升页面交互体验。通过Fiber架构,让reconcilation过程变得可被中断。适时地让出CPU执行权,可以让浏览器及时地响应用户的交互。

React16中使用了 Fiber,但是 Vue 是没有 Fiber 的,为什么呢?原因是二者的优化思路不一样:

  1. Vue 是基于 template 和 watcher 的组件级更新,Vue使用模版语法,可以在编译时对确定的模版作出优化,比如一些不会变化的静态节点不会参与diff过程。 watcher 的组件级更新,当发生变更时 vue 可以知道具体是哪个组件需要更新,把每个更新任务分割得足够小,不需要使用到 Fiber 架构,将任务进行更细粒度的拆分。
  2. ReactJS写法太过灵活,使他在编译时优化方面先天不足,且 React 不管在哪里调用 setState,都是从根节点开始更新的,更新任务还是很大,需要使用到 Fiber 将大任务分割为多个小任务,可以中断和恢复,不阻塞主进程执行高优先级的任务。

Fiber是什么

Fiber 可以理解为是一种数据结构,也可以理解为是一个执行单元。

一种数据结构

Fiber 可以理解为是一种数据结构,React Fiber 就是采用链表实现的,每个Fiber保存了节点处理的上下文信息,因为是手动实现的,所以更为可控,我们可以保存在内存中,随时中断和恢复。每个 Virtual DOM 都可以表示为一个 fiber,如下图所示,每个节点都是一个 fiber。一个 fiber包括了 child(第一个子节点)、sibling(兄弟节点)、return(父节点)等属性,React Fiber 机制的实现,就是依赖于以下的数据结构。

2.png

一个执行单元

Fiber 可以理解为一个执行单元,每次执行完一个执行单元,react 就会检查现在还剩多少时间,如果没有时间则将控制权让出去。Fiber 可以被理解为划分一个个更小的执行单元,它是把一个大任务拆分为了很多个小块任务,一个小块任务的执行必须是一次完成的,不能出现暂停,但是一个小块任务执行完后可以移交控制权给浏览器去响应用户,从而不用像之前一样要等那个大任务一直执行完成再去响应用户。

任务调度与任务优先级

任务的拆分、执行、挂起、恢复以及高优先级任务插队是 react 更新任务的核心。

拆分

每一个dom元素就是一个Fiber,而一个 Fiber 可以理解为一个执行单元,所以一次更新任务被拆分成了以Fiber为单位的小任务。

执行、挂起、恢复

假设用户调用 setState 更新组件, 这个待更新的任务会先放入队列中, 然后通过 requestIdleCallback 请求浏览器调度:浏览器有空闲时就会来执行任务,每执行完一个执行单元,就检查一下剩余时间是否充足以及是否有剩余的执行单元,如果没有了任务则退出,如果时间充足且有剩余的任务就执行下一个执行单元,反之则停止执行,记录下一次要执行的执行单元,等下一次有执行权时恢复执行。使用方法如下:window.requestIdleCallback(callback)callback就是更新函数,会接收到默认参数 deadline ,其中包含了以下两个属性:

  • didTimeout 返回 callback 任务是否超时
  • timeRamining 返回当前帧还剩多少时间供用户使用

requestIdleCallback调度fiber更新任务的伪代码如下:

let firstFiber
let nextFiber = firstFiber
let shouldYield = false
//firstFiber->firstChild->sibling
function performUnitOfWork(nextFiber){
  //...
  return nextFiber.next
}

function workLoop(deadline){
  while(nextFiber && deadline.timeReaming > 1){
          nextFiber = performUnitOfWork(nextFiber)
        }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

任务优先级

React 16 利用 expirationTimes 模型来实现任务优先级,每一种不同的任务会分配一个过期时间,过期时间 = 每种任务计算出一个常量 + 任务的触发时间,任务的具体优先级计算公式为 优先级 = 一个很大的常量 - 过期时间,得到的值越大,优先级越高。

// MAX_SIGNED_31_BIT_INT为最大31 bit Interger
// currentTime 为当前时间,updatePriority 为一个常量
update.expirationTime = MAX_SIGNED_31_BIT_INT - (currentTime + updatePriority);

react 定义了 5 种优先级

  • Immediate(updatePriority = -1) 这个优先级的任务会同步执行, 或者说要马上执行且不能中断
  • UserBlocking(updatePriority = 250ms) 这些任务一般是用户交互的结果, 需要即时得到反馈
  • Normal (updatePriority = 5000ms) 应对哪些不需要立即感受到的任务,例如网络请求
  • Low (updatePriority = 10000ms) 这些任务可以放后,但是最终应该得到执行. 例如分析通知
  • Idle (没有超时时间) 一些没有必要做的任务 (e.g. 比如隐藏的内容), 可能会被饿死

例如,高优先级更新u1、低优先级更新u2的updatePriority分别为0、250,则

MAX_SIGNED_31_BIT_INT - (currentTime + 0) > MAX_SIGNED_31_BIT_INT - (currentTime + 200)

// 即
u1.expirationTime > u2.expirationTime;

代表u1优先级更高。

expirationTime算法的原理简单易懂:每次都选出所有更新中**「优先级最高的」**。

优先级批次

除此之外,还有个问题需要解决:如何表示批次

批次是什么?考虑如下例子:

// 定义状态num
const [num, updateNum] = useState(0);

// ...某些修改num的地方
// 修改的方式1
updateNum(3);
// 修改的方式2
updateNum(num => num + 1);

两种 修改状态的方式 都会创建更新,区别在于:

  • 第一种方式,不需考虑更新前的状态,直接将状态num修改为3
  • 第二种方式,需要基于 更新前的状态 计算新状态

由于第二种方式的存在,更新之间可能有连续性。所以 expirationTime算法 计算出一个优先级后,组件render时实际参与更新当前状态的值的是:

计算出的优先级对应更新 + 与该优先级相关的其他优先级对应更新

这些相互关联,有连续性的更新被称为一个批次batch)。expirationTime算法计算 批次 的方式也简单粗暴:优先级大于某个值(priorityOfBatch)的更新都会划为同一批次。

const isUpdateIncludedInBatch = priorityOfUpdate >= priorityOfBatch;

expirationTime算法保证了render异步可中断、且永远是最高优先级的更新先被处理。

Fiber 与浏览器交互流程

React Fiber 与浏览器的核心交互流程如下

3.jpeg

react 弃用 requestIdleCallback

但是 react 并没有用 requestIdleCallback 来执行 Fiber 更新任务,主要原因有两点

  • 兼容性不够好
  • 不会和帧对齐,且屏幕刷新率只有20FPS

出现空闲时段的场景:

  1. 浏览器一帧渲染所用时间小于屏幕刷新率(对于具有60Hz 的设备,一帧间隔应该小于16ms)时间,到下一帧渲染渲染开始时出现的空闲时间,对于这种场景 requestIdleCallback 能和帧对齐,且刷新率有60FPS,但是如果一帧里没有空余时间则会出现掉帧。

7.png 2. 当浏览器没有可渲染的任务,主线程一直处于空闲状态,事件队列为空。为了避免在不可预测的任务(例如用户输入的处理)中引起用户可察觉的延迟,这些空闲周期的长度应限制为最大值 50ms 也就是timeRemaining最大不超过50(也就是20fps),当空闲时段结束时,可以调度另一个空闲时段,如果它保持空闲,那么空闲时段将更长,后台任务可以在更长时间段内发生。

9.png

requestIdleCallback工作只有20FPS,一般对用户来感觉来说,需要到60FPS才是流畅的, 即一帧时间为 16.7 ms,所以这也是react团队自己实现requestIdleCallback的原因。

requestIdleCallback polyfill 实现

实现大致思路是在requestAnimationFrame获取一桢的开始时间 rafTime,那么,结束时间点(deadlineTime) = 开始时间点(rafTime) + 一帧用时16.667ms, 并触发一个基于MessageChannelpostMessage,同时更新 Fiber 的回调函数监听 onmessage 事件,当浏览器绘制完毕,进入下一个事件循环就会执行更新 Fiber 的回调函数,执行回调函数时记录当前时间,那么,一帧的剩余时间(timeRemaining) = deadlineTime - Date().now(),如果timeRemaining > 0,则执行更新任务,更新任务用一个 while() 循环来执行,循环条件就是是否还有剩余时间,每执行完一个 Fiber 任务就判断剩余时间是否还够,如果还够就执行下一个任务,不够了会先判断是否还有剩余的 Fiber 任务,如果没有了就将回调函数设为null,结束任务,如果还有任务,则触发一个postMessage宏任务,跳出循环让出控制权。

如果不支持MessageChannel的话,就会去用 setTimeout 来执行,只是退而求其次的办法。浏览器在执行 setTimeout() 和 setInterval() 时,会设定一个最小的时间阈值,一般是 4ms。MessageChannel没有这个延时。

requestAnimationFrame + MessageChannel 实现 requestIdleCallback代码如下

// 计算出当前帧 结束时间点 
var deadlineTime 
// 保存任务 
var callback 
// 建立通信 
var channel = new MessageChannel() 
var port1 = channel.port1var port2 = channel.port2; 
 
// 接收并执行宏任务 
port2.onmessage = () => { 
    // 判断当前帧是否还有空闲,即返回的是剩下的时间 
    const timeRemaining = () => deadlineTime - Date().now(); 
    const _timeRemain = timeRemaining(); 
    // 有空闲时间 且 有回调任务 
    if (_timeRemain > 0 && callback) { 
        const deadline = { 
            timeRemaining, // 计算剩余时间 
            didTimeout: _timeRemain < 0 // 当前帧是否完成 
        } 
        // 执行回调 
        callback(deadline) 
    } 
} 
 
window.requestIdleCallback = function (cb) { 
    requestAnimationFrame(rafTime => { 
        // 结束时间点 = 开始时间点 + 一帧用时16.667ms 
        deadlineTime = rafTime + 16.667 
        // 保存任务 
        callback = cb 
        // 发送个宏任务 
        port1.postMessage(null); 
    }) 
} 

两个阶段的拆分

除了Fiber 工作单元的拆分,两阶段的拆分也是一个非常重要的改造,在此之前都是一边Diff一边提交的。先来看看这两者的区别:

  • 协调阶段 可以认为是 Diff 阶段, 这个阶段可以被中断, 这个阶段会找出所有节点变更,例如节点新增、删除、属性变更等等, 这些变更React 称之为'副作用(Effect)',在协调阶段如果时间片用完,React就会选择让出控制权。因为协调阶段执行的工作不会导致任何用户可见的变更,所以在这个阶段让出控制权不会有什么问题。
  • 提交阶段 将上一个阶段计算出来的需要处理的**副作用(Effects)**一次性执行了。这个阶段必须同步执行,不能被打断

状态更新的时序

在 React 得到控制权后,应该优先处理高优先级的任务。也就是说正在处理的任务可能会被中断,在恢复时会让位给高优先级任务,原本中断的任务可能会被放弃或者重做。但是如果不按顺序执行任务,可能会导致前后的状态不一致。 比如低优先级任务将 a 设置为0,而高优先级任务将 a 递增1, 两个任务的执行顺序会影响最终的渲染结果。因此要让高优先级任务插队, 首先要保证状态更新的时序

解决办法是: 所有更新任务按照顺序插入一个队列, 状态必须按照插入顺序进行计算,但任务可以按优先级顺序执行,例如:

4.png 红色表示高优先级任务。要计算它的状态必须基于前序任务计算出来的状态, 从而保证状态的最终一致性

5.png 最终红色的高优先级任务 C 执行时的状态值是a=5,b=3. 在恢复控制权时,会按照优先级先执行 C, 前面的A、 B暂时跳过,虽然A、 B任务暂时跳过,但是会执行他们的状态。

上面被跳过任务不会被移除,在执行完高优先级任务后它们还是会被执行的。因为不同的更新任务影响的节点树范围可能是不一样的,举个例子 ab 可能会影响 Foo 组件树,而 c 会影响 Bar 组件树。所以为了保证视图的最终一致性, 所有更新任务都要被执行。

6.png

  1. 首先 C 先被执行,它更新了 Foo 组件
  2. 接着执行 A 任务,它更新了Foo 和 Bar 组件,由于 C 已经以最终状态a=5, b=3更新了Foo组件,这里可以做一下性能优化,直接复用C的更新结果, 不必触发重新渲染。因此 A 仅需更新 Bar 组件即可。
  3. 接着执行 B,同理可以复用 Foo 更新结果。

道理讲起来都很简单,React Fiber 实际上非常复杂,不管执行的过程怎样拆分、以什么顺序执行,最重要的是保证状态的一致性视图的一致性

react Fiber 出现以后让 react 有了可中断更新的能力,在 react 17 以前 react 依然是同步更新的(react 17有个实验版本,通过ReactDOM.createRoot(rootNode).render(<App />)创建的应用是并发更新),也就是协调过程不可中断,表现和 react 16之前一样。

React 16.3 生命周期的变更

从React16.3之后,React 团队就对生命周期进行了调整,React16.3 之前的生命周期如下:

之前的生命周期.png

React16.3之后的生命周期如下:

之后的生命周期.png

通过对比可以发现之前的生命周期钩子函数componentWillMount,componentWillReceiveProps,componentWillUpdate 被废弃,新增了 static getDerivedStateFromProps(newProps,prevState)getSnapshotBeforeUpdate(prevProps,prevSteate),之所以废弃掉三个生命周期是因为原来(React v16.0前)的生命周期在React v16推出Fiber之后就不合适了,因为如果要开启async rendering,组件在更新过程中有可能会被暂停和恢复更新,所以执行时机在 render 函数之前的所有钩子函数,都有可能被执行多次。 如果在这些钩子函数里做副作用操作,比如发起请求,事件监听等可能会导致内存泄漏,这三个生命周期方法在 v17 以前仍然保留,新增了带 UNSAFE_ 前缀的3个方法,v16.x版本中,新旧的方法依旧都可以使用,但是使用不带UNSAFE_前缀的方法,将提示被弃用的警告。

新增了两个钩子函数具体使用如下:

  • static getDerivedStateFromProps(newProps,prevState):在 render() 方法之前调用,并且在初始挂载和后续更新时调用,返回值对象用作更新 state,如果不需要则返回null。由于是静态方法,无法在静态方法内调用组件的实例方法,比如要请求接口数据时迫使开发者在 componentDidMount、componentDidUpdate里发起异步请求。
  • getSnapshotBeforeUpdate(prevProps,prevSteate):在 render 方法之后调用,它的触发时机是 React 进行 DOM 更新前的“瞬间”,即在进入 commit 阶段“瞬间”调用,在此获取到的 DOM 信息比 componentWillUpdate 更加可靠,因为如果有更高优先级的任务,当前任务会被挂起,到当前任务再次被执行时可能会经历比较长的过程,那么当前任务执行前和执行时的DOM结构可能有较大变化,所以 componentWillUpdate 在执行前和执行时的上下文有可能有较大变化。此外,它的返回值会作为第三个参数传入 componentDidUpdate(prevProps, prevState, snapshot)prevProps, prevState 表示更新前的属性和状态。

React 16.8 Hooks 的出现

当我们要学习一个新事物的时候,我们应该做的第一件事就是问自己两个问题:

  1. 旧的东西有什么痛点?
  2. 新的东西能解决什么问题?

类组件的缺陷

我们最初在写类组件时一定遇到过如下的问题:

  • 令人困惑的 this:比如当在DOM元素里绑定一个事件函数时,函数里的 this 会被替换为DOM元素。
change() {
   console.log(this.state.xxxx);
}
<button onClick={this.change}> 

点击 button 的时候会报错,报 state 没有定义,因为执行 change 函数时函数里的 this 会被替换成指向 button 对象,不过这个问题也很容易解决,用箭头函数 change = () => { console.log(this.state.xxxx) } 或在构造函数里用 bind 绑定 this,使用方式:this.change = this.change.bind(this)。

  • 生命周期钩子函数的缺陷:不适合side effect(副作用)逻辑的管理
  1. 迫使相关的逻辑分散到不同的生命周期中,可读性和复用性差,比如 componentDidMount 和 componentDidUpdate 里可能处理相同的副作用,比如当组件初次挂载之后需要请求数据,父组件传进来的属性变了之后又要请求新的数据,还有 componentWillUnmount 可能需要取消 componentDidMount 和 componentDidUpdate 里的监听。
  2. 同一个生命周期函数放置很多互不关联的side effect逻辑,比如在 componentDidMount 里可能同时有获取数据请求、数据监听、获取 DOM 属性值等互不关联的副作用逻辑,而且随着组件的功能变得越来越复杂,这些不关联而又放在一起的代码只会变得越来越多,于是你的组件逐渐变得难以测试和维护。
  • 复用(非可视)逻辑的问题:在 hooks 出现之前,类组件通常用 HOC 或 renderProps 来复用共享组件状态逻辑,缺点如下:
  1. 高阶组件的开发对开发者不友好,开发者(特别是初级开发者)需要花费一段时间才能搞懂其中的原理并且适应它的写法。
  2. 高阶组件之间组合性差,由于要为组件添加不同的功能,我们要为同一个组件嵌套多个高阶组件,例如这样的代码:
withAuth(withRouter(withUserStatus(UserDetail)))

这种嵌套写法的高阶组件可能会导致很多问题,其中一个就是props丢失的问题,例如withAuth传递给UserDetail的某个prop可能在withUserStatus组件里面丢失或者被覆盖了,且容易发生wrapper hell(嵌套地狱)

HOC嵌套地狱的副本.jpeg

Hooks能解决哪些问题

hooks的出现就是用来解决以上类组件面临的问题,实际上就两句话:易维护,易复用。

  • 函数组件没有this的困惑:hooks 出现后可以用函数组件替代类组件,类组件里的生命周期钩子,状态变更等 hooks 在函数组件里都可以做到,那么在函数组件里自然就没有了 this 的困惑。
  • 副作用逻辑管理:useEffect 可以实现 componentDidMount、componentDidUpdate、componentWillUnmount 钩子里的逻辑,且可以用多个 useEffect 来管理不同种类的副作用,非常适合副作用的管理。
  • 组件间非可视状态逻辑复用:可以用自定义 hook 来实现组件间共享状态(非可视)逻辑的问题,可以将共享状态逻辑封装在 自定义hook 中,那么当组件需要这段共享逻辑时直接引入 自定义hook 即可,不同的共享逻辑可以封装在不同的自定义 hook 中,非常方便组件状态的共享,既易于理解,也不会有 HOC 包装地狱的问题。

函数组件和类组件本质的区别

talk is cheap ,我们先看两个代码片段:

在类组件中

class Index extends React.Component<any,any>{
    constructor(props){
        super(props)
        this.state={
            number:0
        }
    }
    handerClick=()=>{
       for(let i = 0 ;i<5;i++){
           setTimeout(()=>{
               this.setState({ number:this.state.number+1 })
               console.log(this.state.number)
           },1000)
       }
    }

    render(){
        return <div>
            <button onClick={ this.handerClick } >num++</button>
        </div>
    }
}

上面的打印是什么?

再来看看函数组件中:

function Index(){
    const [ num ,setNumber ] = React.useState(0)
    const handerClick=()=>{
        for(let i=0; i<5;i++ ){
           setTimeout(() => {
                setNumber(num+1)
                console.log(num)
           }, 1000)
        }
    }
    return <button onClick={ handerClick } >{ num }</button>
}

上面的打印是什么?

在第一个例子🌰打印结果: 1 2 3 4 5

在第二个例子🌰打印结果: 0 0 0 0 0

第一个类组件中,由于执行上setState没有在react正常的函数执行上下文上执行,而是setTimeout中执行的,批量更新条件被破坏。原理这里我就不讲了(可以参考我的另一篇文章 setState()是同步还是异步),所以可以直接获取到变化后的state

但是在无状态组件中,似乎没有生效。原因很简单,在class状态中,通过一个实例化的class,去维护组件中的各种状态;但是在function组件中,没有一个状态去保存这些信息,每一次函数上下文执行,所有变量,常量都重新声明,执行完毕,再被垃圾机制回收。所以如上,无论setTimeout执行多少次,都是在初始的函数上下文执行,此时 setNumber(num + 1)console.log(num)中的 num = 0不会变,由于在 setTimeout 环境下,所以setNumber先执行,等更新完毕,真实DOM 生成之后执行console.log(num),此时的 num 依然是初始上下文函数中的 num = 0,所以打印出来的为 0,需要注意的是最终界面呈现的 num 值为 1,也就是 JSX中的 num 为更新后的 num 。

hooks 实现原理

在讲解 hooks 原理前先提两个问题:

  1. 当写多个同类型的 hooks 时是如何保证唯一性的,如写多个 useState 或 多个 useEffect
  2. 为什么 hooks 不能写在条件语句里?

初始化阶段

在组件初始化的时候,每一次hooks执行,如useState(),useRef(),都会调用mountWorkInProgressHook,这个函数会产生一个hook对象,并形成链表结构,绑定在当前组件对应的 Fiber(也称为 workInProgress) 对象的 memoizedState 属性上,每个 hook 对象又有如下属性:

  • memoizedState:useState中保存 state 值 | useEffect 中 保存着 effect 对象和 deps | useMemo 中 保存的是缓存的值和 deps | useRef 中保存的是 ref 对象。
  • baseQueue : usestate和useReducer中 保存最新的更新队列
  • baseState :usestate和useReducer一次更新中 ,产生的最新state值。
  • queue :保存待更新队列 pendingQueue ,更新函数 dispatch 等信息,批量更新时更新队列会保存在此,更新时会合并到 baseQueue
  • next: 指向下一个 hook 对象。

对于effect副作用钩子,会把当前需要执行的副作用以链表的形式绑定在当前组件对应的 Fiber 对象的 updateQueue 属性上,等组件更新完毕后依次执行副作用。

假设一个组件中调用了 useState、useMemo、useRef、useEffect,那么最终形成的结构如下图:

hooks链表.png

从图中可以知道,当多个同类型的 hook 通过链表可以确保唯一性,知道每个hooks关系之后,我们应该理解了,为什么不能条件语句中,声明hooks

我们用一幅图表示如果在条件语句中声明会出现什么情况发生。

如果我们将其中的一个 useRef 放入条件语句中,

let curRef  = null
if(isFisrt){
  curRef = useRef(null)
}

条件hook.png

因为一旦在条件语句中声明hooks,在下一次函数组件更新,hooks链表结构,将会被破坏,current树的memoizedState缓存hooks信息,和当前workInProgress不一致,如果涉及到读取state等操作,就会发生异常。

更新阶段

首先会以当前 current 组件树为基础复制一份 workInProgress 树,并将 current 树上的 hooks 信息复制过来。各 hooks 工作如下:

  • useState:首先会将更新的 state 值与之前的 state 值做浅比较,如果相等则不做更新(与类组件的 setState() 不同,默认只要调用 setState() 就会做更新),如果不相等,如果符合批量更新条件(在 react 上下文中),会将更新队列放在 pendingQueue,更新时会合并到 baseQueue,更新后 baseState 存放更新后的 state,并将 baseState 赋给 memoizedState 。⚠️调用 useState 返回的第二个参数就是一个更新函数,类组件的 setState 函数实质上最后也是调用了这个更新函数,他们的批量更新条件和效果是一样的。
  • useEffect:判断两次 deps 是否相等,如过不相等会给当前副作用函数打上一个标签,等组件更新完毕后 react 会通过标签来判断,是否执行当前的 effect 函数。
  • useMemo:判断两次 deps是否相等,如果不相等证明依赖项发生改变,那么执行 useMemo 的第一个参数(函数),得到新的值,然后重新赋值给 hook.memoizedState,如果相等则直接取 hook.memoizedState。
  • useRef:就是返回了缓存下来的值,也就是无论函数组件怎么执行,执行多少次,hook.memoizedState 内存中都指向了一个对象,可以将其理解为该函数的一个全局变量,类似于在 class 中使用实例字段的方式。

至于其他的几个 hook 是类似的,例如 useReducer 和 useState 类似,useCallback 和 useMemo 类似。

React 17 正式版

React 17发布日志上说这次版本最大的特点就是无新特性,但是仔细研究后还是有很多东西值得学习的。

任务优先级算法更新

React 16可中断更新可以解决以下问题:

  1. 组件树逻辑复杂导致更新时卡顿(因为组件render变为可中断
  2. 重要的交互更快响应(因为不同交互产生更新优先级不同)

这些问题统称为CPU密集型问题

在前端,还有一类问题也会影响体验,那就是 请求数据或懒加载造成的等待 。这类问题被称为IO密集型问题

为了解决IO密集型问题React提出了Suspense

React.Suspense 可以指定加载指示器(loading indicator),以防其组件树中的某些子组件尚未具备渲染条件。在未来,我们计划让 Suspense 处理更多的场景,如数据获取等。

如今,懒加载组件是 <React.Suspense> 支持的唯一用例。

考虑如下代码:

// 该组件是动态加载的
const Sub = React.lazy(() => import(url));

const App = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const t = setInterval(() => {
      setCount(count => count + 1);
    }, 1000);
    return () => clearInterval(t);
  }, []);
  
  return (
    <>
      <Suspense fallback={<div>loading...</div>}>
        <Sub count={count} />
      </Suspense>
      <div>count is {count}</div>
    </>
  );
};

其中:

  • 每过一秒会触发一次更新,将状态count更新为count => count + 1
  • Sub是一个懒加载组件,在组件 Sub 被加载回来之前,包裹SubSuspense会渲染fallback

假设组件Sub三秒后被加载,理想情况下,在加载前后UI会依次显示为:

// 初始状态
<div>loading...</div>
<div>count is 0</div>

// Sub组件加载第1秒
<div>loading...</div>
<div>count is 1</div>

// Sub组件加载第2秒
<div>loading...</div>
<div>count is 2</div>

// Sub组件加载第3秒
<div>loading...</div>
<div>count is 3</div>

// Sub组件加载成功后
<div class=“sub”>I am sub, request success, count is 4</div>
<div>count is 4</div>

从用户的视角观察,有两个任务在并发执行:

  1. 加载Sub的任务(观察第一个div的变化)
  2. 改变count的任务(观察第二个div的变化)

Suspense带来了 多任务并发执行 的直观感受。

expirationTimes 模型的bug

那么Suspense对应更新的优先级是高还是低呢?

当加载成功后,合理的逻辑应该是 尽快展示成功后的UI 。所以Suspense对应更新应该是高优先级更新。那么,在示例中共有两类更新:

  1. Suspense对应的高优IO更新,简称u0
  2. 每秒产生的低优先级CPU更新,简称u1u2u3

expirationTime算法下:

// u0优先级远大于u1、u2、u3...
u0.expirationTime >> u1.expirationTime > u2.expirationTime > …

u0优先级最高,则u1及之后的更新都需要等待u0执行完毕后再进行。

u0需要等待 加载成功 才能执行。所以,加载成功前后UI会依次显示为:

// 初始状态
<div>loading...</div>
<div>count is 0</div>

// Sub组件加载第1秒
<div>loading...</div>
<div>count is 0</div>

// Sub组件加载第2秒
<div>loading...</div>
<div>count is 0</div>

// Sub组件加载第3秒
<div>loading...</div>
<div>count is 0</div>

// Sub组件加载成功
<div class=“sub”>I am sub, request success, count is 4</div>
<div>count is 4</div>

从用户的视角观察,第二个div被卡住了3秒后突然变为4。

所以,只考虑CPU密集型场景的情况下,高优先级更新先执行 的算法并无问题。

但考虑IO密集型场景的情况下,高优先级IO更新会阻塞低优先级CPU更新,这显然是不对的。

所以expirationTime算法并不能很好支持并发更新。

出现bug的原因

expirationTime算法最大的问题在于:expirationTime字段耦合了 优先级批次 这两个概念,限制了模型的表达能力。

这导致高优IO更新不会与低优CPU更新划为同一 批次 。那么低优CPU更新就必须等待高优IO更新处理完后再处理。

如果不同更新能根据实际情况灵活划分 批次 ,就不会产生这个bug

Lane优先级算法

新的调度算法被称为Lane(车道),他是如何定义 优先级批次 呢?

对于优先级,一个lane就是一个32bit Interger,最高位为符号位,所以最多可以有31个位参与运算。

不同优先级对应不同lane,越低的位代表越高的优先级,比如:

// 对应SyncLane,为最高优先级
0b0000000000000000000000000000001
// 对应InputContinuousLane
0b0000000000000000000000000000100
// 对应DefaultLane
0b0000000000000000000000000010000
// 对应IdleLane
0b0100000000000000000000000000000
// 对应OffscreenLane,为最低优先级
0b1000000000000000000000000000000

批次 则由lanes定义,一个lanes同样也是一个32bit Interger,代表 一到多个lane的集合 ,该整数所有二进制位为 1 对应的优先级任务都将被执行。例如 lanes 为 17 (10001)时,表示将并行更新SyncLane(值为 1)和DefaultLane(值为 16)的任务,这两个任务属于同一批次。

可以用位运算很轻松的将多个lane划入同一个批次

// 要使用的批次
let lanesForBatch = 0;

const laneA = 0b0000000000000000000000001000000;
const laneB = 0b0000000000000000000000000000001;

// 将laneA纳入批次中
lanesForBatch |= laneA;
// 将laneB纳入批次中
lanesForBatch |= laneB;
// lanesForBatch = 0b0000000000000000000000001000001
// 新的优先级为 0b0000000000000000000000001000001
// 更新时会将各个位上的 1 对应的任务一同更新,也就是一个批次

上文提到的Suspensebug是由于expirationTime算法不能灵活划定批次导致的。

lanes就完全没有这种顾虑,任何想划定为同一 批次优先级(lane)都能用位运算轻松搞定。

全新的 JSX 转换

React 17以前,React中如果使用JSX,则必须像下面这样导入React,否则会报错,这是因为旧的 JSX 转换会把 JSX 转换为React.createElement(...) 调用。

// 必须要加
import React from 'react';
export default function App(props) {
  return <div>app </div>;
}

React 16 利用 babel-loader 会预编译 JSXReact.createElement(...),React 需要在 createElement 里做动态children的拼接。

而React 17带来了改变,可以让我们单独使用 JSX 而无需引入 React。这是因为新的 JSX 转换 不会将 JSX 转换为 React.createElement ,而是自动从 React 的 package 中引入新的入口函数并调用,开发者可以不依赖于React的导入。

另外此次升级不会改变 JSX 语法,旧的 JSX 转换也将继续工作。

事件委托的变更

在 React 16 或更早版本中,React 会由于事件委托对大多数事件执行 document.addEventListener()。但是一旦你想要局部使用React,那么React中的事件会影响全局。尤其在微前端中,不同的子工程可能会相互影响。

React 17 不再将事件添加在document 上,而是添加到渲染 React 树的根 DOM 容器中。

下图形象描述了这次的变更,图片来自React官网

17事件系统.png

去除事件池

在 React 17 以前,如果想要用异步的方式使用事件 e,则必须先调用 e.persist() 才可以,这是因为 React 在事件池中重用了不同事件的事件源对象,以提高性能,并将所有事件字段在它们之前设置为 null

React 17 之前的调用方式:

handerClick = (e) => { 
    console.log(e.target) // button 
    // 必需要加 e.persist() 
    e.persist() 
    setTimeout(()=>{ 
      console.log(e.target) // button 
    },0) 
}

React 17 之后的调用方式:

handerClick = (e) => { 
    console.log(e.target) // button 
    setTimeout(()=>{ 
      console.log(e.target) // button 
    },0) 
}

React 17 实验版(并发更新的尝试)

React 17实验版本是一个过渡版本,为 React 18 正式启用并发更新做铺垫,来看看 React 17 做了哪些工作?它与正式发布的 React 18 有什么不同?

React有多少种架构

可以从架构角度来概括下,当前一共有两种架构:

  1. 采用不可中断的递归方式更新的Stack Reconciler(老架构)
  2. 采用可中断的遍历方式更新的Fiber Reconciler(新架构)

新架构可以选择是否开启并发更新,所以当前市面上所有React版本一定属于如下一种情况:

  1. 老架构(v15及之前版本)
  2. 新架构,未开启并发更新,与情况1行为一致(v16、v17默认属于这种情况)
  3. 新架构,未开启并发更新,但是启用了一些新功能(比如Automatic Batching)(v17 实验版 )
  4. 新架构,开启并发更新(v17 实验版 )

理想与现实的差距

React团队的愿景是:

使用老版本的开发者可以逐步升级到新版,即从情况1、2、3向情况4升级。

但是这中间存在极大的阻力,因为情况4的React一些行为异于情况1、2、3。

比如如下三个生命周期函数在情况4的React下是“不安全的”:

  • componentWillMount
  • componentWillReceiveProps
  • componentWillUpdate

贸然升级可能造成老代码不兼容。

渐进升级第一步

渐进升级方案的第一步是规范代码。

v16.3新增了StrictMode,对开发者编写的不符合并发更新规范的代码作出提示,逐步引导开发者写出规范代码。

比如,使用上述不安全的生命周期函数时会产生如下报错信息:

生命周期.png

渐进升级第二步

下一步,React团队让不同情况的React可以在同一个页面共存,借此可以让情况4的React逐步渗入原有的项目。

具体做法是提供三种开发模式:

  1. Legacy模式,通过ReactDOM.render(<App />, rootNode)创建的应用遵循该模式。默认关闭StrictMode,表现同情况2
  2. Blocking模式,通过ReactDOM.createBlockingRoot(rootNode).render(<App />)创建的应用遵循该模式,作为从LegacyConcurrent过渡的中间模式,默认开启StrictMode,表现同情况3
  3. Concurrent模式,通过ReactDOM.createRoot(rootNode).render(<App />)创建的应用遵循该模式,默认开启StrictMode,表现同情况4

官网的一张图片很直观的说明了三种模式

模式.png

最新的渐进升级策略

时间前进到2021年6月8日,v18工作组成立。

在与社区进行大量沟通后,React团队意识到当前的渐进升级策略存在两方面问题。

原因一

首先,由于模式影响的是整个应用,所以无法在同一个应用中完成渐进升级。

举个例子,开发者将应用中ReactDOM.render改为ReactDOM.createBlockingRoot,从Legacy模式切换到Blocking模式,这会自动开启StrictMode

此时,整个应用的并发不兼容警告都会上报,开发者还是需要修改整个应用。

从这个角度看,并没有起到渐进升级的目的。

原因二

其次,React团队发现:开发者从新架构中获益,更多是由于使用了并发特性Concurrent Feature)。

并发特性指开启并发更新后才能使用的特性,比如:

  • useDeferredValue
  • useTransition

所以,可以默认情况下仍使用同步更新,在使用了并发特性后再开启并发更新

在v18中运行如下代码:

const App = () => {
  const [count, updateCount] = useState(0);
  const [isPending, startTransition] = useTransition();

  const onClick = () => {
    // 使用了并发特性useTransition
    startTransition(() => {
      // 本次更新是并发更新
      updateCount((count) => count + 1);
    });
  };
  return <h3 onClick={onClick}>{count}</h3>;
};

由于updateCountstartTransition的回调函数中执行(使用了并发特性),所以updateCount会触发并发更新

如果updateCount没有作为startTransition的回调函数执行,那么updateCount将触发默认的同步更新

结论

所以,在v18中,不再有三种模式,而是以是否使用并发特性作为是否开启并发更新的依据。

具体来说,在v18中统一使用ReactDOM.createRoot创建应用。

当不使用并发特性时,表现如情况3。使用并发特性后,表现如情况4。

React 18并发更新的开启

在上节 React 17 实验版中我们已经得到了结论:在v18中统一使用ReactDOM.createRoot创建应用,当不使用并发特性时,更新仍然是同步更新(不可中断更新),且默认是批量更新,当使用并发特性后,为并发更新,下面聊聊 React 18 的一些新特性。

Automatic Batching

批处理是 react 将多个状态更新分组到一个渲染中以获得更好的性能。react18 之前只能在react 事件处理程序中批处理更新。默认情况下,Promise、setTimeout、本机事件处理程序或任何其他事件内部的更新不会在 React 中批处理。

React 18 自动使用自动批处理,这些更新将自动批处理:

 //示例一:react17会render两次,react18只需要render一次
 const handleClick = () => {
  Promise.resolve().then(() => {
    setC1((c) => c + 1);
  });
  setC2((c) => c + 1);
};

那么,如果我不想要批处理呢?

flushSync

官方提供了一个 API flushSync用于退出批处理

// 会更新两次
function handleClick() {
  flushSync(() => {
    setC1((c) => c + 1);
  });
  setC2((c) => c + 1);
}

flushSync 会以函数为作用域,函数内部的多个 setState 仍然为批量更新,这样可以精准控制哪些不需要的批量。

componentDidUpdate() {
  console.log('didmount');
}

handleClick = () => {
  setTimeout(() => {
      console.log('开始运行setTimeout')
      flushSync(() => {
          this.setState({}, () => {
              console.log('更新1')
          })
          this.setState({}, () => {
              console.log('更新2')
          })
      });
      this.setState({}, () => {
          console.log('更新3')
      })
      this.setState({}, () => {
          console.log('更新4')
      })
      console.log('结束运行setTimeout')
  });
  console.log('结束运行')
}
// flushSync 将四个 setState() 分为了两组:更新1和更新2为一组、更新3和更新4为一组
// 打印顺序如下:
// 开始运行setTimeout
// didmount
// 更新1
// 更新2
// 结束运行setTimeout
// didmount
// 更新3
// 更新4

实现原理

自动批处理的实现在React18中是基于 优先级 的,用 lane模型 来进行优先级的控制。lane用来弥补expirationTime 的缺陷,它首先说明这个任务是个什么任务(确定优先级,确定lane值)  ,其次说明哪些任务应该被 batching 到一起做(lane相同即batching 到一起做)  。然后通过lanes确定哪些并行更新。关于 lane 模型 可以参见 React 17正式版 这一小节中的 Lane 优先级算法

批处理实现的核心在于当相同优先级的更新发生时,并不会生成新的任务,而是复用上一次的任务,从而实现合并。

flushSync 实现原理更简单,它将内部更新的优先级强制指定为SyncLane,即指定为同步优先级,具体效果就是每一次更新时都会同步的执行渲染。

Transitions

过渡是 React 18中的一个新概念,用于区分紧急和非紧急更新。紧急更新反映了直接交互,例如键入、单击、按下等。非紧急(过渡)更新将 UI 从一个视图转换到另一个视图。

打字、点击或按下等紧急更新需要立即响应,以符合我们对物理对象行为方式的直觉。否则用户会觉得“不对劲”。但是,过渡是不同的,因为用户不希望在屏幕上看到每个中间值。

下面我们来看一个例子:当滑块滑动时,下方的图表会一起更新,然而图表更新是一个CPU密集型操作,比较耗时。由于阻塞了渲染导致页面失去响应,用户能够非常明显的感受到卡顿。

transtion.png

实际上,当我们拖动滑块的时候,需要做两次更新:

// 紧急更新
setSliderValue(input); 
// 非紧急更新
setGraphValue(input);

startTransition

包装在 startTransition 中的更新被视为非紧急更新,也就是它的优先级被降低,如果出现更紧急的更新(如点击或按键),则会中断。默认的更新被视为紧急更新,也就是没有开启 并发特性 时的更新为同步更新,是不可中断的,表现和 React 18 之前一样。

import { startTransition } from 'react';
// 紧急更新
setSliderValue(input);
// 非紧急更新
startTransition( () => {
    // Transition: Show the results
    setGraphValue(input);
});

使用后效果:

流畅.png

为什么不是 setTimeout

上述的问题能够把 setGraphValue 的更新包装在 setTimeout 内部,像如下这样:

import { startTransition } from 'react';
// 紧急更新
setSliderValue(input);
// 非紧急更新
setTimeout( () => {
    // Transition: Show the results
    setGraphValue(input);
});

这里通过 setTimeout ,把更新放在 setTimeout 内部,那么我们都知道 setTimeout 是属于延时器任务,它不会阻塞浏览器的正常绘制,浏览器会在下次空闲时间执行 setTimeout 。

通过 setTimeout 确实可以让输入状态好一些,但是由于 setTimeout 本身也是一个宏任务,而每一次 input 触发 onchange 事件也是宏任务,所以 setTimeout 还是会影响页面的交互体验。相当于是将setGraphValue(input) 更新任务对页面的阻塞推迟到了下一个事件循环。

通过对比,startTransition 相比于 setTimeout 的优势在于:

对于渲染并发的场景下,setTimeout 仍然会使页面卡顿。因为超时后,还会执行 setTimeout 的任务,它们与用户交互同样属于宏任务,所以仍然会阻止页面的交互。那么 transition 就不同了,在 conCurrent mode 下,startTransition 是可以中断渲染的 ,所以它不会让页面卡顿,React 让这些任务,在浏览器空闲时间执行,所以上述输入 input 内容时,startTransition 会优先处理 input 值的更新,而之后才是图形的渲染。

为什么不是节流防抖

那么我们再想一个问题,为什么不是节流和防抖。首先节流和防抖能够解决卡顿的问题吗?答案是一定的,在没有 transition 这样的 api 之前,就只能通过防抖节流来处理这件事。接下来用防抖处理一下。

const setGraphValueDebounce = useMemo(() => debounce((value) => setGraphValue(value), 1000), []);
onChange = (e) => {
  setSliderValue(e.target.value);
  /* 通过防抖处理后的 setGraphValue 函数。 */
  setGraphValueDebounce(e.target.value);
}

通过防抖处理后,基本上已经不影响 input(滑块移动) 输入了。但是面临一个问题就是 图形 视图改变的延时时间变长了。那么 transition 和节流防抖 本质上的区别是:

  • 一方面,节流防抖 本质上也是 setTimeout ,只不过控制了执行的频率,其原理就是让 render 次数减少了。而 transitions 和它相比,并没有减少渲染的次数。
  • 另一方面,节流和防抖需要有效掌握 Delay Time 延时时间,如果时间过长,那么给人一种渲染滞后的感觉,如果时间过短,那么就类似于 setTimeout(fn,0) 还会造成前面的问题。而 startTransition 就不需要考虑这么多。

什么是 useTranstion

上面介绍了 startTransition ,又讲到了过渡任务,本质上过渡任务有一个过渡期,在这个期间当前任务本质上是被中断的,那么在过渡期间,应该如何处理呢,或者说告诉用户什么时候过渡任务处于 pending 状态,什么时候 pending 状态完毕。

为了解决这个问题,React 提供了一个带有 isPending 状态的 hooks —— useTransitionuseTransition 执行返回一个数组。数组有两个状态值:

  • 第一个是,当处于过渡状态的标志——isPending
  • 第二个是一个方法,可以理解为上述的 startTransition。可以把里面的更新任务变成过渡任务。
import { useTransition } from 'react' 

/* 使用 */
const  [ isPending , startTransition ] = useTransition()

那么当任务处于悬停状态的时候,isPending 为 true,可以作为用户等待的 UI 呈现。比如:

{ isPending  &&  < Spinner  / > }

什么是 useDeferredValue

useDeferredValue 可以让状态滞后派生。useDeferredValue 的实现效果也类似于 transtion,当迫切的任务执行后,再得到新的状态,而这个新的状态就称之为 DeferredValue

useDeferredValue 和上述 useTransition 本质上有什么异同呢?

相同点:

  • useDeferredValue 本质上内部实现与 useTransition 一样都是标记成了过渡更新任务。

不同点:

  • useTransition 是把 startTransition 内部的更新任务变成了过渡任务,而 useDeferredValue 是把原值通过过渡任务得到新的值,这个值作为延时状态。一个是处理一段逻辑,另一个是生产一个新的状态
  • useDeferredValue 还有一个不同点就是这个任务,本质上在 useEffect 内部执行,而 useEffect 内部逻辑是异步执行的 ,所以它一定程度上更滞后于 useTransitionuseDeferredValue = useEffect + transtion

举个例子来更好的理解 useDeferredValue

export default function App(){
    const [ value ,setInputValue ] = React.useState('')
    // query 是个延时状态,依赖 query 更新的组件会被标记为一个过渡任务,只有等紧急更新完成后 query 才能获取新的 value 值
    const query = React.useDeferredValue(value)
    const handleChange = (e) => {
        // 改变 vlaue 的值是紧急更新
        setInputValue(e.target.value)
    }
    return  <div>
     <button>useDeferredValue</button>
    <input onChange={handleChange}
        placeholder="输入搜索内容"
        value={value}
    />
    <!--NewList组件更新依赖 query 值,会被标记为过渡任务,只有等紧急更新完成后浏览器有空闲了才更新-->
   <NewList  query={query} />
   </div>
}

Suspense SSR 架构

在 React 18 之前,SSR 的过程是下面这样的:

  1. 浏览器发起请求
  2. 服务器接受请求,根据请求的 url 找到需要服务端渲染的组件
  3. 调用接口,获取组件的数据,通过 props 或 context 将数据注入到组件
  4. react 将组件的虚拟 DOM 转成 html 字符串,在这个过程中只有执行时机在 render 方法前的钩子函数才会执行
  5. 数据注水(将获取到的组件数据注入到页面,使浏览器能访问到),通常将数据放到 DOM 里,例如:
 <textarea id="ssrTextInitData" style="display:none;">${JSON.stringify(fetchResult)}</textarea>
  1. 将编译打包后的 javascript, css 等静态资源和步骤 4、5 产生的 html 片段放到 html 模板里
  2. 服务器将组装好的 html 模板返回到浏览器端
  3. 浏览器端渲染出 html 模版里经过服务端渲染好的组件,此时在浏览器端能看到界面了,但是界面是不可交互的,因为在服务端渲染出的只是一个“干燥”的 DOM 结构,没有事件绑定。
  4. 浏览器加载 javascript等静态资源(对于具有一定规模的应用程序,大部分的加载时间将用于下载应用程序代码),加载完成后,浏览器接管页面。
  5. 数据脱水,react 将 html 模版里的组件数据取出,并传给组件,进行浏览器端渲染
  6. react 将浏览器端渲染的组件和在服务端渲染的组件进行对比,即双端对比,如果相同则浏览器不会再渲染到界面,直接复用服务端渲染的结果,同时添加事件绑定等交互,让页面变得可交互,这个过程叫 hydrate(水合,就像是用事件处理程序当作 “水” 来浇灌 “干燥” 的 HTML),如果双端对比结果不相同则浏览器会将组件重新渲染到屏幕上,此时屏幕上可能会出现闪烁。
  7. 此后页面的交互就是一个纯 SPA 应用

上述整个过程可以参考一个大神写的一个简易的 SSR 框架

SSR 有点像 “魔术”。它不能使你的应用程序更快地完全可交互。相反,它让你更快地展示你的应用程序的非交互式版本。

React 18 之前的 SSR 问题

SSR 的整个过程是一个串行的,总结一下就是:获取数据(服务器)→ 渲染成 HTML(服务器)→ 加载代码(客户端)→ hydration(客户端)。任何一个阶段都不能在前一个阶段结束之前开始。

  1. 服务端生成 HTML 字符串之前必须先获取到组件所需的数据

在目前的 API 中,当你渲染到 HTML 时,你必须已经在服务器上为你的组件准备好所有的数据。这意味着你必须在服务器上收集所有的数据,然后才能开始向客户端发送任何 HTML。这样是很低效的。

  1. 客户端必须要先加载完所有的 JavaScript,然后才能对服务端渲染的组件hydration

在 JavaScript 代码加载后, React 将 HTML “hydrate” 并使其具有交互性。 客户端在渲染组件时将 “走” 过服务器生成的 HTML,并将事件处理程序绑定到该 HTML 上。为了使其发挥作用,组件在浏览器中生成的树必须与服务器生成的树相匹配。否则 React 就不能 “匹配它们!” 这样做的一个非常不幸的后果是,必须在客户端加载所有组件的 JavaScript,才能开始对任何组件进行 hydration

  1. 在组件变得可交互前,必须 hydrate 完所有组件

React 一次性完成树的 hydration。这意味着,一旦它开始 hydrate,React 就不会停止 hydration 的过程,直到它为整个树完成 hydration。因此,必须等待所有的组件被 hydrated,才能与任何组件进行交互。

如何解决这些问题

SSR 存在的问题的根源是因为整个过程是一个 “瀑布”(流程):获取数据(服务器)→ 渲染成 HTML(服务器)→ 加载代码(客户端)→ hydration(客户端)。任何一个阶段都不能在前一个阶段结束之前开始。 这就是为什么它的效率很低。

React 18 的解决方案是将工作分开,这样就可以为屏幕的一部分而不是整个应用程序做这些阶段的工作。

让我们看看如何在 React 18 中使用 <Suspense> 来解决这些问题。

流式 HTML 和选择性 hydration

React 18 中,有两个主要的 SSR 功能是由 Suspense 解锁的。

  • 在服务器上流式传输 HTML。要使用这个功能,需要从 renderToString 切换到新的 pipeToNodeWritable 方法。
  • 在客户端进行选择性的 hydration。要使用这个功能,你需要在客户端 切换到createRoot,然后开始用 <Suspense> 包装你的应用程序的一部分。

流式 HTML

例如,服务端渲染组件中有一个评论模块,我们可以用 <Suspense> 包裹评论块并告诉 React,在它准备好之前,React 应该显示 <Spinner /> 组件。

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

通过将 <Comments> 包装成 <Suspense>,我们告诉 React,它不需要等待<Comments />就可以开始为页面的其他部分传输 HTML。相反,React 将发送占位符(一个旋转器)而不是评论。

当服务器上的评论数据准备好后,React 会将额外的 HTML 发送到同一个流中,以及一个最小的内联 <script> 标签,将 HTML 放在 “正确的地方”。在客户端中通过 script 标签动态补齐这部分 HTML。

选择性 hydration

通过流式 HTML,我们可以提前发送最初的 HTML 了,但我们仍然有一个问题。在加载评论小组件的 JavaScript 代码之前,我们不能在客户端开始对我们的应用程序进行 hydration。

但在 React 18 中,<Suspense> 可以让我们在评论小组件加载之前就 hydrate 应用程序。

通过将 Comments 包裹在 <Suspense>中,告诉 React,他们不应该阻止页面的其他部分进行流式传输 ——— 而且,事实证明,也不应该阻止 hydration。这意味着第二个问题已经解决了:我们不再需要等待所有的代码加载完成,才能开始 hydration。React 可以在加载部分时同时进行 hydration。

所有组件完成 hydration 之前与页面互动

当我们将评论包裹在 <Suspense> 中时,还有一项改进发生在幕后。现在 hydration 不再阻碍浏览器做其他工作。

例如,假设用户在评论组件正在 hydration 时点击了侧边栏:

hydrate.png 在 React 18 中,浏览器可以在给 Suspense 里的内容进行 hydration 的过程中出现的微小空隙中进行事件处理。得益于此,点击被立即处理,在低端设备上长时间的 hydration 过程中,浏览器不会出现卡顿。例如,这可以让用户从他们不再感兴趣的页面上导航离开。

总结

React 18 为 SSR 提供了两个主要功能:

  • 流式 HTML 让你尽早开始发送 HTML,流式 HTML 的额外内容与 <script> 标签一起放在正确的地方。
  • 选择性 hydration 让你在 HTML 和 JavaScript 代码完全下载之前,尽早开始为你的应用程序进行 hydration。它还优先为用户正在互动的部分进行 hydration,创造一种即时 hydration 的错觉。

这些功能解决了 React 中 SSR 的三个长期存在的问题:

  • 你不再需要等待所有的数据在服务器上加载后再发送 HTML。相反,一旦你有足够的数据来显示应用程序的外壳,你就开始发送 HTML,其余的 HTML 在准备好后再进行流式传输。
  • 你不再需要等待所有的 JavaScript 加载来开始 hydration。相反,你可以使用代码拆分和服务器渲染。服务器 HTML 将被保留,React 将在相关代码加载时对其进行 hydration。
  • 你不再需要等待所有的组件被 hydrated 后才开始与页面互动了。相反,你可以依靠选择性 hydration,来优先考虑用户正在与之互动的组件,并尽早对它们进行 hydration。

useId

useId是一个新的hook,用于在客户端和服务器上生成唯一 ID,同时避免hydration mismatches。

当我们在使用 React 进行服务端渲染(SSR)时就会遇到一个问题:如果当前组件已经在服务端渲染过了,但是在客户端我们并没有什么手段知道这个事情,于是客户端还会重新再渲染一次(双端对比),这样就造成了冗余的渲染。

因此,react18提出了 useId 这个hook来解决这个问题,它使用组件的树状结构(在客户端和服务端都绝对稳定)来生成id。

useId.png

参考文献