一文让你搞懂React18新特性及其实现

6,474 阅读11分钟

该文章已被字节架构前端录用

一、 Render API

众所周知,react17提供了三种入口模式

  • legacy 模式: ReactDOM.render(, rootNode)。没有开启新功能,这是react17采用的默认模式。
  • blocking 模式: ReactDOM.createBlockingRoot(rootNode).render()。作为迁移到concurrent 模式的过渡模式。
  • concurrent 模式: ReactDOM.createRoot(rootNode).render()。这个模式开启了所有的新功能。
    react18正式迁移到了concurrent 模式,同时,用户也可以继续使用react17下的旧API(但是会有警告提示)。
// React 17
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

const roo**t = document.getElementById('root')!;
ReactDOM.render(<App />, root);

// React 18
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = document.getElementById('root')!;
ReactDOM.createRoot(root).render(<App />);

二、 Automatic Batching

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

  //示例一:react17会render三次,react18只需要render两次,setTimeout内部批量更新
  const handleClick = async () => {
    setTimeout(() => {
        setC1((c) => c + 1);
        setC1((c) => c + 1);
    }, 0);
    setC2((c) => c + 1);
  };
  
   //示例二:react18中也需要render两次
  const handleClick = async () => { 
      await setC1((c) => c + 1); //提升到同步优先级,类似flushSync 
      setC2((c) => c + 1); 
  };

这里做了两个demo,大家可以进去试试看看,根据打印次数判断渲染次数。
react17-demo:react17-demo - CodeSandbox
react18-demo:react18-demo - CodeSandbox
那么,如果我不想要批处理呢?

flushSync

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

  function handleClick() {
    flushSync(() => {
      setC1((c) => c + 1);
    });     
    setC2((c) => c + 1);
  }

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

实现

自动批处理的实现在React18中是基于优先级的,用lane来进行优先级的控制。先简单介绍一下lane。 lane 是一个表示 priority 的一个东西,它通过二进制位来表示。优先级最高的SyncLane为 1,其次为 2、4、8 等等,所有 lane 的定义可参考源码。react通过 lanes 表达批量更新。lanes 是一个整数,该整数所有二进制位为 1 对应的优先级任务都将被执行。例如 lanes 为 17 (10001)时,表示将异步并行更新SyncLane(值为 1)和DefaultLane(值为 16)的任务。

个人理解:lane用来弥补expirationTime 的缺陷,它首先说明这个任务是个什么任务(确定优先级,确定lane值) ,其次说明哪些任务应该被 batching 到一起做(lane相同即batching 到一起做) 。然后通过lanes确定哪些并行更新

下面结合源码看一下批量更新的实现,该方法是18中每一次更新调度的必经之路,批处理的实现的核心在于当相同优先级的更新发生时,并不会生成新的任务,而是复用上一次的任务,从而实现合并。
👉为了便于理解,对源码做了一定程度的简化,下同

function ensureRootIsScheduled(root, currentTime) {
  ......
  // Determine the next lanes to work on, and their priority.
  var nextLanes = getNextLanes(root, root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes); 
  
  // This returns the priority level computed during the `getNextLanes` call.
  var newCallbackPriority = returnNextLanesPriority();
  
  // Check if there's an existing task. We may be able to reuse it.
  if (existingCallbackNode !== null) {
    var existingCallbackPriority = root.callbackPriority;
    // 👇以下判断是关键逻辑
    if (existingCallbackPriority === newCallbackPriority) {  
    // The priority hasn't changed. We can reuse the existing task. Exit.   
        return ; 
    } 
    // The priority changed. Cancel the existing callback. We'll schedule a new
    // one below.
    cancelCallback(existingCallbackNode);
  } 
  
  // Schedule a new callback.
  var newCallbackNode;
  ......
  root.callbackPriority = newCallbackPriority;
  root.callbackNode = newCallbackNode;
} // This is the entry point for every concurrent task, i.e. anything that
// goes through Scheduler.

再看看FlushSync的实现:

export function flushSync(fn) {
  try {
    // DiscreteEventPriority === SyncLane
    setCurrentUpdatePriority(DiscreteEventPriority);
    fn && fn();
  } finally {
    setCurrentUpdatePriority(previousPriority);
  }
}

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

三、Transitions

过渡是 React 18中的一个新概念,用于区分紧急和非紧急更新。

紧急更新反映了直接交互,例如键入、单击、按下等。

非紧急(过渡)更新将 UI 从一个视图转换到另一个视图。

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

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

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

// Urgent: Show what was typed
setSliderValue(input);

// Not urgent: Show the results
setGraphValue(input);

startTransition

包装在 startTransition 中的更新被视为非紧急更新,如果出现更紧急的更新(如点击或按键),则会中断。

import { startTransition } from 'react';
// Urgent
setSliderValue(input);
// Mark any state updates inside as transitions
startTransition( () => {
 // Transition: Show the results
setGraphValue(input);
}); 

使用后效果:

222_.gif 应该可以明显的感受到,虽然图表的更新还是会有些延迟,但是整体的用户体验相对之前是非常好的。

useTransition

一般情况下,我们可能需要通知用户后台正在工作。为此提供了一个带有 isPending 转换标志的 useTransitionReact 将在状态转换期间提供视觉反馈,并在转换发生时保持浏览器响应。

import { useTransition } from 'react';

const [isPending, startTransition] = useTransition();

return isPending && <Spin />

useDeferredValue

返回一个延迟响应的值,可以让一个state 延迟生效,只有当前没有紧急更新时,该值才会变为最新值。useDeferredValueuseTransition 一样,都是标记了一次非紧急更新。

import { useDeferredValue } from 'react';

const deferredValue = useDeferredValue(value);

useDeferredValueuseTransition 其实挺相似的:

  • 相同:useDeferredValue 本质上和内部实现与 useTransition 一样都是标记成了非紧急更新任务。
  • 不同:useTransition 是把更新任务变成了延迟更新任务,而 useDeferredValue 是产生一个新的值,这个值作为延时状态。

debounce 的区别:

debouncesetTimeout 总是会有一个固定的延迟,而 useDeferredValue 的值只会在渲染耗费的时间下滞后,在性能好的机器上,延迟会变少,反之则变长。

实现

// startTransition
function startTransition(setPending, callback, options) {
  ......
  setPending(true);
  var prevTransition = ReactCurrentBatchConfig$2.transition;
  
  // start transition
  ReactCurrentBatchConfig$ 2. transition = {};
  var currentTransition = ReactCurrentBatchConfig$2.transition;

  try {
    setPending(false);
    callback();
  } finally {
  
    // Recovery
    setCurrentUpdatePriority(previousPriority);
    ReactCurrentBatchConfig$2.transition = prevTransition;
    }
  }
}

export function requestUpdateLane(fiber: Fiber) {
  // ...
  
  // requestCurrentTransition => ReactCurrentBatchConfig.transition
  const isTransition = requestCurrentTransition() !== null ;
  
  if (isTransition) {
 return claimNextTransitionLane();
}
  
  // ...
}

startTransition内部时会使用一个全局变量ReactCurrentBatchConfig$2.transition作为是否开启transition的开关,后边的setPending(false)callback()在触发dispatchAction()的时候会调用requestUpdateLanerequestUpdateLane返回的是isTransition任务。优先级下降,因此执行时间要比普通更新晚,同时即使更新发生时,也可以被高优先级的更新打断,从而不阻塞用户渲染。

// useTransition
function mountTransition() {
  var _mountState = mountState(false),
      isPending = _mountState[0],
      setPending = _mountState[1]; // The `start` method never changes.

  var start = startTransition.bind(null, setPending);
  var hook = mountWorkInProgressHook();
  hook.memoizedState = start;
  return [isPending, start];
}

useTransition的核心其实就是通过useState维护了一个pending,然后将setPending作为参数传递给startTransition

// useDeferredValue
function updateDeferredValue(value) {
  var hook = updateWorkInProgressHook();
  var resolvedCurrentHook = currentHook;
  var prevValue = resolvedCurrentHook.memoizedState;
  return updateDeferredValueImpl(hook, prevValue, value);
}

function updateDeferredValueImpl(hook, prevValue, value) {
  var shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes);

  if (shouldDeferValue) {
    // This is an urgent update. If the value has changed, keep using the
    // previous value and spawn a deferred render to update it later.
    if (!objectIs(value, prevValue)) {
      var deferredLane = claimNextTransitionLane();
      currentlyRenderingFiber$1.lanes = mergeLanes(currentlyRenderingFiber$1.lanes, deferredLane);
      markSkippedUpdateLanes(deferredLane);
      hook.baseState = true;
    } 
    // Reuse the previous value
 return prevValue;
    
  } else {
    // This is not an urgent update, so we can use the latest value 
    if (hook.baseState) {
      // Flip this back to false.
      hook.baseState = false;
      markWorkInProgressReceivedUpdate();
    }

    hook.memoizedState = value;
    return value;
  }
}

useDeferredValue的实现首先是判断当前更新的优先级,如果是一个紧急更新则直接返回prevValue,并且在当前fiber中标记一个transition更新。当非紧急更新发生时,直接返回最新的值。

四、useId

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

我们首先介绍一下 SSR 的流程:

在服务端,我们会将 React 组件渲染成为一个字符串,这个过程叫做脱水「 dehydrate 」。字符串以 html 的形式传送给客户端,作为首屏直出的内容。到了客户端之后,React 还需要对该组件重新激活,用于参与新的渲染更新等过程中,这个过程叫做「 hydrate 」。

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

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

实现

function useId() {
  var task = currentlyRenderingTask;
  // 获取组件结构以生成ID
  var treeId = getTreeId(task.treeContext);
  var responseState = currentResponseState;

  if (responseState === null) {
    throw new Error('Invalid hook call. Hooks can only be called inside of the body of a function component.');
  }

  var localId = localIdCounter++;
  return makeId(responseState, treeId, localId);
}

五、Suspense

SSR

React 18 在服务器上添加了对 Suspense 的支持,并使用并发渲染特性扩展了它的功能。

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

想更加深入了解原理可参考大佬的文章:React 18 中新的 Suspense SSR 架构 - 掘金

transition

function handleClick() {
  setTab('comments');
}
<Suspense fallback={<Spinner />}>
  {tab === 'photos' ? <Photos /> : <Comments />}
</Suspense>;

在这个示例中,如果tab'photos'设置为 'comments',但Comments暂停,用户将看到一个Spinner 。因为用户不想再看到PhotosComments还没有准备好渲染任何东西,而 React 需要保持用户体验一致,所以它只能显示Spinner上面的内容。

但是,有时这种用户体验并不理想(此时用户无法进行交互)。有时在准备新 UI 时显示“旧” UI 会更好。你可以结合useTransitionReact 做到这一点:

const [isPending, startTransition] = useTransition();

function handleClick() {
  startTransition(() => {
    setTab('comments');
  });
}

<Suspense fallback={<Spinner />}>
  <div style={{ opacity: isPending ? 0.8 : 1 }}>
    {tab === 'photos' ? <Photos /> : <Comments />}
  </div>
</Suspense>

在这个示例中,我们可以使用isPending它来向用户反映正在发生的事情。UI 保持完全交互——例如,用户可以根据需要切换回'photos'选项卡。

可以在demo中试试看:codesandbox.io/s/react18-s…

六、useSyncExternalStore

useSyncExternalStore 是由 useMutableSource 改变而来,主要用来解决外部数据tearing(撕裂)问题。

useSyncExternalStore旨在供库使用,而不是应用程序代码。

tearing

Screen tearing is a visual artifact in video display where a display device shows information from multiple frames in a single screen draw - wiki

简单的说,就是在屏幕上看到了同一个物体的不同帧的影像,画面仿佛是“撕裂的”,对应的react中,指使用了过去版本的状态进行画面渲染引起的UI不一致或者崩溃。

引入并发渲染后,渲染是可能被更高优先级的任务中断,这也使得tearing成为可能。 但React本身对于state的更新做了很多的工作来避免这个问题,但是如果我们的依赖了外部的状态,比如 redux,它在控制状态时可能并非直接使用的 React 的 state,而是自己在外部维护了一个 store 对象,脱离了 React 的管理,也就无法依靠 React 自动解决撕裂问题。因此 React 对外提供了这样一个 API。

通过这个demo能更好的了解什么是撕裂:useSyncExternalStore demo - CodeSandbox

七、useInsertionEffect

useInsertionEffect应该仅限于 css-in-js 库作者。

useInsertionEffect是一个新的钩子,它允许 CSS-in-JS 库解决在渲染中注入样式的性能问题。 这个 Hooks 执行时机在 DOM 生成之后,useLayoutEffect 之前,它的工作原理大致和 useLayoutEffect 相同,只是此时无法访问 DOM 节点的引用,一般用于提前注入 <style> 脚本。

八、一点实践

Table一页展示2224条数据

未使用react18的transition特性

可以从执行堆栈图看到,由于同时渲染的组件过多,JS执行时间为5.72s(js代码阻塞时长)

111.jpg

使用react18的transition特性(useTransiton or useDeferredValue)

  // useTransiton
  const [isPending, startTransition] = useTransition();

  const getList = async () => {
    const res: IRes = await request.get({...});
    const list = res?.Response?.Data;
    startTransition( () => { 
       setList(list as IDetail[]); 
    });
  };
  
  //useDeferredValue
const deferredList = useDeferredValue(list);
// 以上两种方式二选一

飞书20220710-171041.jpg

image.png 可以看到原来的一个长任务,被拆分成了许多5ms左右的短任务(时间分片)和一个长任务,这样浏览器就有剩余时间执行样式布局样式绘制,减少掉帧的可能性,最终还是有一个2.27s的长任务。

时间分片:它的本质就是将长任务分割为一个个执行时间很短的任务,然后再一个个地执行。

这里有一个疑问:为什么最后还是有一个长任务?

这是为了防止某次更新由于优先级过低,一直无法执行,React有个「过期机制」:每个更新都有个过期时间,如果在过期时间内都没有执行,那么他就会过期。 过期后的更新会同步执行(也就是说他的优先级变得和SyncLane一样),表现为最后一个长任务。

总结

盘点了一下react18的新特性以及简单剖析了一下各自的源码实现,如有错误,欢迎大家指正😃

参考文档