React18新特性介绍&&升级指南

作者: 竹叶寨少主 | 来源:发表于2022-06-03 15:39 被阅读0次

    react历次版本迭代主要想解决的是两类导致网页卡顿的问题,分别是cpu密集型任务和io密集型任务导致的卡顿问题,react18提出的并发特性(Concurrent Rendering)就是为了解决上述问题。

    Concurrent Rendering

    什么是concurrent

    简单体验一下

    concurrent不算是个新鲜概念,react很早之前就开始为其铺路,早在v16/v17就引入了fiber架构和实验性的concurrent Mode,开启后整个应用会开启并发更新模式,但这将带来较大的breaking changes因此react18提出了Concurrent Rendering的概念,即没有并发模式,只有并发特性,也就是说并发特性只是个可选项。默认情况下整个应用仍使用同步更新(legacy模式),在使用了并发特性后相关的更新再开启并发更新,不用的话就没有breaking changes。

    concurrent带来的变动可以概括为以下两点:

    时间分片

    该模式下当更新任务的render过程无法在浏览器的一帧内完成时,会被分为多个task进行可中断的更新,以此来保证浏览器每一帧都有空余时间进行绘制,可以说时间分片是concurrent的实现基础。

    更新优先级

    该模式下更新任务会带有优先级,低优先级任务的执行将让位于高优先级任务。

    这句话有两层含义,后面会有具体示例说明

    • 同一上下文中的高优先级任务将优先执行

    • 不同上下文中的高优先级任务将打断正在执行的低优先级任务

    什么是优先级

    legacy模式下没有优先级的概念,因此所有任务都是同步执行。而开启并发特性后一切行为的基础就是任务的优先级。

    在react应用中我们可能在不同上下文中触发setState,如点击/输入事件,异步接口回调,react18中也可能在concurrentAPI中触发等等。在react18中不同上下文中触发的setState的优先级是不一样的。react使用lane模型来描述优先级,该模型使用31位二进制来表示优先级, 位数越小(值越小)则优先级越高。

    以下是项目中最常见的几类任务的优先级

    // 离散事件优先级,例如:点击事件,input输入,focus等触发的更新任务,优先级最高
    export const DiscreteEventPriority: EventPriority = SyncLane;
    // 连续事件优先级,例如:滚动事件,拖动事件等
    export const ContinuousEventPriority: EventPriority = InputContinuousLane;
    // 默认事件优先级,例如:异步接口回调中触发的更新任务
    export const DefaultEventPriority: EventPriority = DefaultLane;
    

    渲染模式对比

    结合performance对比下legacy与conurrent这两种渲染模式

    Legacy Mode

    所谓legacy模式,即传统的react渲染模式,我们使用reactDOM.render创建的react应用都是使用这种模式,下面以一个demo为例分析。

    渲染模式对比示例demo 在demo中用一个定时器延迟1000ms模拟接口请求,另一个定时器延迟1040ms模拟触发一次点击事件,可以明显看到列表渲染和点击事件的更新结果先后展示在视图中,分析:

    image.png

    可以看到列表请求触发的更新和点击事件触发的更新先后进行render,而列表更新的整个过程处于一个宏任务中且耗时200多ms,浏览器每16ms刷新一次,因此导致render的过程中浏览器的每一帧都没有时间绘制,反应到真实场景中将使得用户感到点击操作卡顿。

    总结:legacy模式下的所有更新都是同步调度,没有优先级之分。

    legacy下的更新调度流程
    image.png

    Concurrent Rendering

    将上述demo开启并发特性观察效果,可以看到虽然点击事件在时序上是后触发的,但其更新结果却优先提交到了视图。分析:

    image.png

    注意到点击事件触发时已经处于接口数据的render过程中,随后点击事件触发的更新打断了正在进行的render而优先执行,提交到视图后继续进行接口数据的render。

    总结:

    • 开启并发特性后更新任务将带有优先级,click事件的更新优先级高于接口请求的更新优先级,因而前者会打断后者的render过程优先执行。

    • 当更新的render流程过于耗时而超过浏览器一帧的时间时,更新任务将被分割为多个task进行可中断的更新,每个task的执行时间不超过16ms(time slice)。这使得浏览器的每一帧中有空余时间进行绘制,点击事件的更新可以优先呈现到视图中。

    • 渲染阶段(commit)是不可被打断的。

    并发模式带来的优势是显然易见的,他使得浏览器在任何情况下都有空余时间绘制,使得在不同性能的设备上紧急任务都能优先render并提交到视图。

    concurrent下的更新调度流程图
    image.png

    如何开启Concurrent Rendering

    react18提供了新的根结点创建方式:ReactDOM.createRoot()。使用此API创建的react应用将启用react18全部新特性。

    import React from 'react';
    // 注意这里ReactDOM是从client引入
    import ReactDOM from 'react-dom/client';
    import App from './contest';
    ReactDOM.createRoot(document.getElementById('root')).render(<App />);
    

    出于兼容性考虑,传统的ReactDOM.renderAPI也会继续保留,使用ReactDOM.render创建的react18应用的表现与react17完全一致。

    image.png

    Concurrent Render API

    下面是react18新引入的用于开启并发特性的API,只有用到这些API时才会开启并发更新。

    startTransition

    这是react18新引入的一个API,它允许我们以一个过渡优先级TransitionLane)来调度一次更新。可以称这类更新为过渡任务。过渡任务拥有较低的优先级,它带来的影响可以从以下两方面分析:

    1.过渡任务的执行过程将开启时间分片

    开启时间分片后,当任务耗时过长时可以保证每一帧都能空出时间交给浏览器绘制,使得试图不卡顿。

    Time Slice示例demo

    2.过渡任务的由于优先级较低,因此将让位于其他高优先级任务。

    高优先级任务优先执行示例demo

    以下称setA(20000)为任务A,setB(1)为任务B

    对于任务A,当我们不用startTransition调度时,可以明显看出a b以及列表是同时展示出来的,这是由于effect中的两次更新由于优先级一致因此被合并更新,同步执行。当使用startTransition调度时明显看到b优先变为1,这是由于任务A优先级低于任务B,因此优先执行任务B。即同一上下文中高优先级任务将优先执行

    与setTimeout的区别

    上述demo看起来似乎用setTimeout也能达到类似效果,事实上此API与setTimeout最重要的区别是处于transtions状态的任务是可以中断渲染的,是可以被高优先级任务打断的。对于渲染并发的场景下,setTimeout 仍然会使页面卡顿,因为超时后,setTimeout 的任务还是会执行且不可被打断,仍然会阻塞页面交互。

    将demo改造下,startTransition改为setTimeout,并在200ms后模拟触发一次点击事件(任务C)。由于点击事件处于setTimeout中因此在任务队列中它会排在任务A之后,而列表的渲染时间明显多于200ms,因此当任务C执行时一定还处于任务A的render过程中。而我们可以看到任务C的更新结果最后展示的,这也印证了setTimeout中的更新任务一旦开始就是不可被打断的。

    最后将任务A还原为startTransition调度,可以看到任务B,任务C先后提交,任务A最后提交。我们知道点击事件的优先级高于过渡优先级,因此任务C可以打断任务A的render过程优先执行,这其实就是典型的高优先级任务打断低优先级任务的执行

    与节流防抖的区别

    节流防抖解决的是也是频繁触发渲染的问题,但是还是会存在一些问题。比如100ms防抖,当列表渲染非常快时,远远小于100ms,但是却需要等待到100ms后才会开始执行更新。而节流则无法解决更新耗时过长的问题。比如列表渲染需要耗时1s,那么在这1s内用户依旧无法进行交互,其实与setTimeout的问题是类似的,而trasntions任务在过渡期间理论上是可以多次被高优先级任务打断的。

    useTransition

    startTransition可以调度一个过渡任务,过渡任务有一个过渡期,可以认为过渡任务的更新在被提交到视图之前都属于过渡期,而用户无法感知当前是否处于过渡期。为了解决这个问题,React 提供了一个带有 isPending 状态的 hook:useTransition 。useTransition 执行后返回一个数组,数组有两个状态值:

    • 当处于过渡状态的标志—isPending。

    • startTransition,可以把里面的更新任务变成过渡任务,等同于与上述的startTransitionAPI。

    import { useTransition } from 'react' 
    const  [ isPending , startTransition ] = useTransition ()
    

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

    { isPending && <Spinner/> }
    

    useTransition示例demo

    useDefferedValue

    useDeferredValue 的实现效果也类似于startTransition。

    const [a, setA] = useState(0);
    const deferredA = useDeferredValue(a);
    

    useDeferredValue实质上是基于原始state生产一个新的state(DeferredValue),当对原始state进行setState时,DeferredValue的值会通过过渡任务得到,因此视图中使用DeferredValue就会得到和startTransitionAPI一样的效果,事实上这两个API相当于从两个角度实现过渡任务,本质上是一样的。

    useDeferredValue示例demo

    其他变更

    Automatic Batching

    legacy模式下,除合成事件&生命周期外,在其他的事件回调中(异步方法,原生事件等)的多次setState不会批量处理,即每次setState都会render一次。

    legacy模式下的batchUpdate示例demo

    每次setState后我们可以通过ref拿到最新的dom属性,在legacy模式下可以使用ReactDom.unstable_batchedUpdates强制批量更新,而在react18应用中任何事件回调中的多次setState都会合并处理。

    Automatic Batching示例demo

    这是由于新版batchedUpdate的实现基于更新优先级,只要更新的优先级一致那么更新将合并。

    flushSync

    特殊情况需要立即获取更新结果时可以使用react18新提供的flushSync。

    transitions与Suspense配合解决io瓶颈(不稳定)

    Suspense 是 React 提供的一种异步处理机制,在v16/v17中,Suspense主要是配合React.lazy进行组件层面的code spliting,而未来react希望逐步将Suspense用于所有的异步操作场景,目前已有相关API/库进行支持。

    Suspense处理异步操作示例demo

    demo中几乎看不到异步代码,完全用同步的思维获取接口数据且不会用带async/await这种语法糖。我们认为数据是已经存在的,我们做的只是读数据而非拉数据。

    上述demo的执行流程如下:

    1. 首次调用userResource.read,会创建一个promise(即fetchUser的返回值)。

    2. 由于是同步调用因此取不到数据,此时userResource中会将这个promise throw出去

    3. React内部会catch这个promise(handleEfrror),离User组件最近的祖先Suspense组件渲染fallback

    renderRootConcurrent

    // renderRootConcurrent
    do {
        try {
          workLoopConcurrent();
          break;
        } catch (thrownValue) {
          handleError(root, thrownValue);
        }
      } while (true);
    
    1. promsie resolve或reject时重新触发一次调度,此时再调用userResource.read会返回resolve/reject的结果(即fetchUser请求的数据),使用该数据继续render

    这里关键是userResource的实现,目前react有一个专门提供createReouceAPI的库react-cache,但目前还处于实验阶段无法用于生产环境,下面简单分析下实现原理。

    const cache = {};
    export function createResource(fetch) {
      const resource = {
        read: params => {
          // 这里临时用id做个缓存 react-cache内部有一套单独的缓存清理算法
          if (!cache[params]) {
            const promise = fetch(params);
            let suspender = promise.then(
              r => {
                cache[params].status = 'resolved';
                cache[params].result = r;
              },
              e => {
                cache[params].status = 'rejected';
                cache[params].result = e;
              }
            );
            cache[params] = {
              promise: suspender,
              status: 'pending',
              result: null,
            };
            throw suspender;
          } else {
            if (cache[params].status === 'resolved') {
              return cache[params].result;
            }
            throw cache[params].promise;
          }
        },
      };
      return resource;
    }
    

    react18新增的concurrentAPI可以配合suspense使用,当startTranstion调度的更新任务触发任意一个suspense组件挂起时,将导致当前组件进入pending状态,此时只要关联了suspense的变更就会被‘暂停’提交,直到在内存中构建完成后才会被会提交。此特性一般用于接口返回较快且有loading的页面,可以在避免闪烁问题的同时,使得在视图切换前仍然可以保持响应

    github交互示例

    将上述demo改造下

    transitions+Suspense处理异步示例demo

    目前suspense处理异步场景相关的库尚不稳定,猜测此特性后面可能由路由库集成并暴露相关api给用户。

    移除inUnmount警告

    image.png

    日常开发中经常碰到这个警告,它的本意是避免由于未及时清理effect hook中的订阅而导致的内存泄漏问题

    useEffect(() => {
      function handleChange() {
        setState(store.getState());
      }
      store.subscribe(handleChange);
      return () => store.unsubscribe(handleChange);
    }, []);
    

    但日常开发中更多的警告场景是在已卸载的组件中进行setState

    async function handleSubmit() {
      setLoading(true);
      // 在我们等待时组件可能会卸载
      await post('/some-api');
      setLoading(false);
    }
    

    实际上这里并没有实际的内存泄漏,promise在resolve之后就会被回收。对于这种警告我们一般的实践是手动判断isUnmount,但这实际上只是抑制了警告,并没有解决实质问题且会增加代码复杂度,因此是没有必要的。react此次更新旨在剔除业务代码中所有的isUnmount判断。

    组件卸载后setState会不会有其他副作用?

    react触发的任何更新最终都通过scheduleUpdateOnFiber进行调度,当触发更新所在组件已卸载时不继续进行调度流程,因此不会产生其他副作用。

     // scheduleUpdateOnFiber
    
     // 当内部获取不到根fiber节点时就不再继续调度更新
     const root = markUpdateLaneFromFiberToRoot(fiber, lane);
      if (root === null) {
        return null;
      }
    

    markUpdateLaneFromFiberToRoot

    function markUpdateLaneFromFiberToRoot(
    ){
     let node = sourceFiber;
     // fiber.return代表父节点,若当前fiber对应dom已卸载则fiber.return为null
     let parent = sourceFiber.return;
      while (parent !== null) {
      // 方法内部会向上遍历到根fiber节点
      }
      // 当遍历结果不是根fiber时会返回null
      if (node.tag === HostRoot) {
        const root: FiberRoot = node.stateNode;
        return root;
      } else {
        return null;
      }
    }
    

    允许组件返回undefined

    React 17 中如果组件在 render 中返回了 undefined,React 会在运行时抛错。

    用于三方库的API

    以下API一般用于三方库的开发,通常不会用于实际业务开发当中。

    useInsertionEffect

    这个Hook执行时机在 DOM 生成之后,useLayoutEffect执行之前,此时无法访问DOM节点的引用。一般用于解决 CSS-in-JS 库在渲染中动态注入样式的性能问题。

    useSyncExternalStore

    此API一般用于第三方状态管理库如redux/mobx,它们在控制状态时可能并非直接使用react的 state,而是自己在外部维护了一个store对象,脱离了react的管理,此时若使用concurrentAPI则可能出现兼容性问题,useExternalStore就是为了解决这种问题,基本实现原理是将render过程强制变为同步的不可中断的更新。

    SSR

    更多信息可见Upgrading to React 18 on the serverNew Suspense SSR Architecture in React 18

    升级指南

    官方升级指南

    收益点

    理论上任何由cpu密集型任务导致的卡顿问题都可以考虑是否可用并发特性优化。

    兼容性

    只要不用concurrentAPI那么表现将与旧版本一致。

    redux/mobx兼容性

    正常用没问题(同步更新),但不能使用concurrentAPI调度store的更新操作

    startTransition调度mobx更新示例demo

    更新开始后,有 10 个 ShowText 节点需要render, 每个节点render时需要耗时 20ms 以上,这就导致每个 ShowText render结束以后都需要中断让出主线程。在协调中断时,修改 store 状态,后续的 ShowText 节点在恢复 render 时,会使用修改以后的 store 状态导致最后出现状态不一致的情况,因此在实际业务中极端情况下可能会出问题。

    这是由于demo中脱离react state而在外部单独维护数据源,而concurrent是react内部状态的处理机制,因此外部数据是无法处理更新中断的问题(内容撕裂问题),redux中表现也是如此,不过最新的react-redux8.0.0中已经使用useSyncExternalStoreAPI解决了此问题。

    多次render带来的影响

    不安全生命周期

    在高优先级打断低优先级的case中,低优先级任务事实上render了两次,而

    componentWillReceivePropscomponentWillMountcomponentWillUpdate 这几个生命周期钩子都是在render时触发的,方法内部都可以修改state,当组件重复render时,不正当的操作会引来额外的副作用,因此react将这几个生命周期方法定义为 unsafe_xxxxx,在并发模式下可能有问题,目前项目中没有用到这几个钩子。

    饥饿问题

    低优先级任务的render过程多次被高优先级任务打断而得不到执行的现象称为饥饿问题。react通过过期时间来解决饥饿问题,不同优先级对应不同的过期时间。当低优先级任务一直未执行而超过时期时间时该任务会被视为过期任务,其优先级会被提升为同步优先级,会立即执行。

    饥饿问题示例demo

    重复render问题

    由于这种case只有在使用concurrentAPI时才有可能出现,因此在开启concurrent时需确认组件中是否有在effect之外处理的特殊逻辑,即组件每次render都会执行的逻辑。

    高优先级打断低优先级任务导致组件重复render示例demo

    关于batchUpdates

    需要排除项目中是否存在强行在两次setState中立即取值的case,react18中此场景需要结合flushSync使用

    不支持IE11

    Concurrent模式下的任务调度流程

    最后简单分析下concurrentMode下的任务更新调度流程。
    concurrentMode下的任务更新可以概括为异步可中断的更新,这种基于更新任务的优先级来统筹调度的模式的架构基础是fiber tree,它使得render过程中可以中断。

    function workLoopConcurrent() {
      // 当wip构造完成或时间切片用尽时停止工作
      while (workInProgress !== null && !shouldYield()) {
        performUnitOfWork(workInProgress);
      }
    }
    

    workLoopConcurrent 都会判断本次协调对应的优先级和上一次时间片到期中断的协调优先级是否一样。如果一样,说明没有更高优先级的更新产生,可以继续上次未完成的协调;如果不一样,说明有更高优先级的更新进来,此时要清空之前已开始的协调过程,从根节点开始重新协调。等高优先级更新处理完成以后,再次从根节点开始处理低优先级更新

    而调度的核心逻辑则主要来源于schduler模块,schduler是一个独立于react的专门用于任务调度的库。react应用每产生一个更新任务都会通过schduler模块暴露的API(scheduleCallback)来注册一个更新任务,此逻辑主要在ensureRootIsScheduled中完成:

    function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
      // 得到当前正在调度的fiber节点
      const existingCallbackNode = root.callbackNode;
      const nextLanes = getNextLanes(
        root,
        root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
      );
      // 获得当前根fiber节点下最高优先级的lane
      const newCallbackPriority = returnNextLanesPriority();
      if (nextLanes === NoLanes) {
        return;
      }
      if (existingCallbackNode !== null) {
        // 当本次调度的优先级与正在调度的优先级一致时则不继续调度 auto batch
        const existingCallbackPriority = root.callbackPriority;
        if (existingCallbackPriority === newCallbackPriority) {
          return;
        }
        // 若不一致,说明本次的调度优先级一定高于正在调度的优先级,取消当前的调度
        cancelCallback(existingCallbackNode);
      }
      // 注册调度任务
      let newCallbackNode;
      if (newCallbackPriority === SyncLanePriority) {
        // 同步优先级 进行同步调度 将在本轮事件循环同步执行
        newCallbackNode = scheduleSyncCallback(
          performSyncWorkOnRoot.bind(null, root),
        );
      } else if (newCallbackPriority === SyncBatchedLanePriority) {
        newCallbackNode = scheduleCallback(
          ImmediateSchedulerPriority,
          performSyncWorkOnRoot.bind(null, root),
        );
      } else {
        const schedulerPriorityLevel = lanePriorityToSchedulerPriority(
          newCallbackPriority,
        );
        // 非同步优先级,使用schduler进行异步调度
        newCallbackNode = scheduleCallback(
          schedulerPriorityLevel,
          performConcurrentWorkOnRoot.bind(null, root),
        );
      }
      // 更新标记
      root.callbackPriority = newCallbackPriority;
      root.callbackNode = newCallbackNode;
    }
    

    schduler内部维护一个队列(taskQueue)来管理所有被调度的任务,通过messageChannel来实现异步调度,此过程在unstable_scheduleCallback中完成。每次调度都会从taskQueue中取出最高优先级的任务执行,执行过程中可能因为切片用尽或任务队列已清空而中断,再次回到队列消费的逻辑。此过程在workLoop中完成。

    相关文章

      网友评论

        本文标题:React18新特性介绍&&升级指南

        本文链接:https://www.haomeiwen.com/subject/mzpmprtx.html