2022-从用法到实现,一文带你拥抱React 18

原文地址

从用法到实现,一文带你拥抱React 18

前言

React团队在打造快速响应的用户界面上其实一直在探索,最终给出的答案就是整合了人机交互研究成果(后文会具体介绍)的Concurrent Rendering

然而因为这背后的复杂性、稳定性、兼容性等问题,从16版本的Async Mode开始就在不断打磨,耗时多年,经历了17版本过渡,最终伴随着18版本正式上线,这一版本中开发者可以完全平滑的开启并发特性。

今天希望通过这篇文章带大家一起深入了解一下React 18中的新特性、新能力。

Concurrent Rendering

ConcurrencyReact 18的关键词,可以理解成是一种背后的机制,保证React能够同时准备多套UI。具体到表现上区别于以往的最大的特点就是渲染是可中断的。这意味着当你的应用正在进行复杂更新的时候,仍然可以与页面进行交互,保证一个流畅的用户体验。 既然是一种背后的机制,实际上开发者并非需要先学习并发渲染才能使用React18,但是能够掌握并发渲染对于新特性的理解有非常大的作用。

核心实现是通过组件作为一个基本的工作单元将一个大的更新任务进行拆分,然后以时间切片的方式,分布在不同的时间片来执行,每个时间片执行完成后都会主动释放控制权,使得浏览器能够处理其它用户事件。而具体时间片上执行哪个任务是由任务上的相关优先级决定的,当高优先级的更新到来时,会中断旧的更新,优先执行高优先级更新,待完成后继续执行低优先级更新,因此在一个时间段内,我们看React在并发的执行多个渲染任务。

Auto Batching

首先批处理是指React将多次状态更新合并成一次重渲染来提升性能,早在16的版本中其实就已经包含了批处理能力,如下面的例子:

function App() {
  const [count, setCount] = useState(0);

  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount((c) => c + 1); // Does not re-render yet

    setFlag((f) => !f); // Does not re-render yet

    // React will only re-render once at the end (that's batching!)
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>

      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

然而如果更新发生在timeouts, promises, native event handlers等非React events事件中,React 18之前的版本默认都不会进行合并:

function handleClick() {
  fetchSomething().then(() => {
    // React 17 and earlier does NOT batch these because

    // they run *after* the event in a callback, not *during* it

    setCount((c) => c + 1); // Causes a re-render

    setFlag((f) => !f); // Causes a re-render
  });
}

React 18中,不会更新发生在哪里,都会自动合并。一般来说,这种自动合并是完全安全且无感知的,但是仍然有一些bad case需要特殊处理以保持向前兼容,比如下面的case

handleClick = () => {
  setTimeout(() => {
    this.setState(({ count }) => ({ count: count + 1 }));

    // { count: 1, flag: false } before 18

    // { count: 0, flag: false } in 18

    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

18之前,由于更新会同步执行,因此我们能够获得中间状态。然而在18中,即使是setTimeout中的更新也会自动合并,并在next tick中合并执行,打印的状态为初始化状态,从而前后不一致。

针对这种情况,我们可以使用React 18中提供的ReactDOM.flushSync来保持向前兼容。

handleClick = () => {
  setTimeout(() => {
    ReactDOM.flushSync(() => {
      this.setState(({ count }) => ({ count: count + 1 }));
    });

    // { count: 1, flag: false }

    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

Deep dive

自动批处理实现的关键在于React18中更新是基于优先级的,我们结合下面源码看一下,该方法是18中每一次更新调度的必经之路,批处理的实现的核心在于当相同优先级的更新发生时,并不会生成新的任务,而是复用上一次的任务,从而实现合并。

function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
  // Determine the next lanes to work on, and their priority.

  const nextLanes = getNextLanes(root);

  // We use the highest priority lane to represent the priority of the callback.

  const newCallbackPriority = getHighestPriorityLane(nextLanes);

  // Check if there's an existing task. We may be able to reuse it.

  const existingCallbackPriority = root.callbackPriority;

  if (existingCallbackPriority === newCallbackPriority) {
    // The priority hasn't changed. We can reuse the existing task. Exit.

    return;
  }

  // Cancel the existing callback.

  cancelCallback(existingCallbackNode);

  // schedule a new one.

  if (newCallbackPriority === SyncLane) {
    scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
  } else {
    newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,

      performConcurrentWorkOnRoot.bind(null, root)
    );
  }
}

如果理解了这个,那个FlushSync的实现也就比较简单了,无非是将内部更新的优先级强制指定为SyncLane,即指定为同步优先级,具体效果就是每一次更新时都会同步的执行渲染。

export function flushSync(fn) {
  try {
    // DiscreteEventPriority === SyncLane

    setCurrentUpdatePriority(DiscreteEventPriority);

    fn && fn();
  } finally {
    setCurrentUpdatePriority(previousPriority);
  }
}

Transition

transition18版本中新提出的概念,也是最能体现并发渲染优势的上层应用。先看一个例子,当滑块滑动时,下方的图表会一起更新,然而图表更新是一个CPU密集型操作,比较耗时。由于阻塞了渲染导致页面失去响应,用户能够非常明显的感受到卡顿。

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

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

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

一个是比较紧急需要立刻反应在界面上,否则用户会觉得遇到了bug。而另一个对于用户而言,查询结果的显示,即使存在一些延迟也是可接受的。这种接受程度正是人机研究的成果:

we know from research that interactions like hover and text input need to be handled within a very short period of time, while clicks and page transitions can wait a little longer without feeling laggy. —— Putting Research into Production

React 18之前所有的更新都是一样的,缺乏一种机制,来声明这种不同紧急程度的更新,Transition就用来解决这种问题。React 18中提供了startTransition来让开发者显示的声明非紧急更新。transition取名的含义是,React中从开发者角度将状态更新分成了两类:

  • 一类是用户输入、点击等紧急更新,另一类是将UI从一个视图转变成另外一个
  • transition中对应的更新类型为第二种。

startTransition

通过starTransition可以标记非紧急的更新,具体的执行时机会根据当前空闲程度动态决定。

import { startTransition } from "react";

// Urgent
setSliderValue(input);

// Mark any state updates inside as transitions
startTransition(() => {
  // Transition: Show the results

  setGraphValue(input);
});

useTransition

可以通过useTransition中的pending来确定当前更新的执行状态,并渲染一些loading

import { useTransition } from "react";

const [isPending, startTransition] = useTransition();

return isPending && <Spinner />;

然而即使我们根据isPending设置了loading界面,这个loading也并非一定会出现,根据人机交互的研究成果,过多的显示一些中间视图会让用户感觉得更慢,因此在性能比较好时,transition中的更新很快执行,loading界面将不会展示,也就是不会出现“闪一下”的情况,来提升用户体验:

research shows that displaying too many intermediate loading states when transitioning between screens makes a transition feel slower. —— Putting Research into Production

我们再来感受一下使用了transition之后的效果:

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

与以往解决方案的的区别

虽然在18版本之前React本身并没有提供声明更新优先级的方式,但是我们仍然可以通过JS来优化这一问题,比如通过setTimeout / throttle / debounce

setSliderValue(input);

setTimeout(() => {
  setGraphValue(input);
}, 0);

与之相比,transition的区别主要表现在:

  • 执行时机setTimout/throttle/debounce 均为异步执行,而transition为同步执行,因此会比他们更早的触发更新调度,在性能较好时可能在同一帧完成更新,而这种情况在比如throttle中被强制拉大,比如100ms
  • 交互体验,不管是延迟还是减频,当真正触发更新,如果渲染时间比较久,依然会发生界面卡顿,而通过transition触发的更新并不会阻塞用户界面,能够一直保持响应
  • 精确控制,需要额外实现loading控制,而且往往不够精确,现在transition内部会为我们自动维护这个loading状态,并且足够精确

Deep dive

我们结合源码看一下transition是如何实现的,可以看到本身的实现并不复杂,在startTransition内部时会开启一个全局变量,其后执行的更新都会根据该开关默认开启transition优先级,而这个优先级要比一般的优先级更低一些,因此执行时间要比普通更新晚,同时即使更新发生时,也可以被高优先级的更新打断,从而不阻塞用户渲染。

function startTransition(setPending, callback, options) {
  // ...

  const prevTransition = ReactCurrentBatchConfig.transition;

  // Tag We are inside transition

  ReactCurrentBatchConfig.transition = {};

  callback();

  // Recovery

  ReactCurrentBatchConfig.transition = prevTransition;
}

// Request priority when fiber update

export function requestUpdateLane(fiber: Fiber) {
  // ...

  // requestCurrentTransition => ReactCurrentBatchConfig.transition

  const isTransition = requestCurrentTransition() !== null;

  if (isTransition) {
    return claimNextTransitionLane();
  }

  // ...
}

useTransition的实现本质上就是通过useState+startTransition,实现关键点在于两次setPending,虽然是同步执行,但两者的区别在于第一次的setPending为普通优先级,而第二次因为全局开关打开,会被授予transition优先级,与callback中的更新自动合并,因此pending会首先被置成true,在transition更新完成时,重置为false

function mountTransition() {
  const [isPending, setPending] = mountState(false);

  const start = (callback) => {
    setPending(true);

    const prevTransition = ReactCurrentBatchConfig.transition;

    // Tag We are inside transition

    ReactCurrentBatchConfig.transition = {};

    setPending(false);

    callback();

    // Recovery

    ReactCurrentBatchConfig.transition = prevTransition;
  };

  return [isPending, start];
}

可以思考一下,🤔 下面这两种方式有什么区别吗

startTransition(() => {
  setValue1(value);

  setValue2(value);
});

startTransition(() => {
  setValue1(value);
});

startTransition(() => {
  setValue2(value);
});

从执行的效果看,由于目前所有的transition中的更新会被标记成同样的优先级,同样的优先级意味着会在同一次调度中一起执行,所以这两种用法目前是完全一样的。

但是从合理性的角度,两个完全独立的transition应该是可以独立去执行的,也就是会被赋予更细粒度的优先级,这也是transition后续更新的方向。而如何认为完全独立,并不是仅仅使用了两个单独startTransitionReact会从内部自动进行检测更新的状态是否有依赖,因为如果两个更新有重叠的话,实际上进行拆分会有浪费,比如一个下拉框,我切换了很多次之后,中间状态再展示并没有意义了,只需要渲染最终状态就可以了。

useDeferredValue

现在我们知道更新发生时,我们可以通过startTransition来标记低优先的更新:

import { startTransition } from "react";

const Comp = () => {
  const handleChange = () => {
    setSliderValue(input);

    startTransition(() => {
      setGraphValue(input);
    });
  };

  // ...
};

那如果更新触发的实际我们并不知道,比如从parent compnent / other hooks /多个handler / URL改变,这种情况下,React提供了另一个hook updateDeferredValue来标记非紧急更新。

import { useDeferredValue } from "react";

const Comp = (input) => {
  const graphValue = useDeferredValue(input);

  // ...updating depends on graphValue
};

上方代码的具体效果是当input改变时,返回的graphValue并不会立即改变,会首先返回上一次的input值,不存在更紧急的更新时,才会变成最新的input,因此可以通过graphValue是否改变来进行一些低优先级的更新,所以效果是基本上是和transition是一致的。

而实现上我们可以看到,其实就是封装了useState + useEffect + startTransition,调度一个Effect发起一个startTransition来更新具体的value

function updateDeferredValue(value) {
  const [prevValue, setValue] = updateState(value);

  updateEffect(() => {
    const prevTransition = ReactCurrentBatchConfig.transition;

    ReactCurrentBatchConfig.transition = {};

    setValue(value);

    ReactCurrentBatchConfig.transition = prevTransition;
  }, [value]);

  return prevValue;
}

但是上面的实现有一个问题,比如当接受的value并非一个memorizedValue,即每次都是一个新的对象,startTransition更新发生的时候会再次触发这个方法,又会发起一次新的调度,从而造成死循环。因此前不久,对这个这个hook的实现做了一版更新PR,新的实现如下:

function updateDeferredValue(value) {
  const 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.

    const prevValue = currentHook.memoizedState;

    if (!is(value, prevValue)) {
      // Mark an Transition Update

      const deferredLane = claimNextTransitionLane();

      currentlyRenderingFiber.lanes = mergeLanes(
        currentlyRenderingFiber.lanes,

        deferredLane
      );
    }

    return prevValue;
  }

  hook.memoizedState = value;

  return value;
}

可以看到新的实现不会依赖effect,而是根据当前更新的优先级来,如果是一个紧急更新则直接返回prevValue,并且在当前fiber中标记一个transition更新。当非紧急更新发生时,直接返回最新的值。

Upgrade

Upgrade Root API

同时还需要升级一下root api才能使用最新的并发特性。

  • Legacy Root API
import ReactDOM from "react-dom";

import App from "App";

const container = document.getElementById("app");

// Initial render.

ReactDOM.render(<App />, container);
  • New Root API
import * as ReactDOMClient from "react-dom/client";

import App from "App";

const container = document.getElementById("app");

// Create a root.

const root = ReactDOMClient.createRoot(container);

// Initial render: Render an element to the root.

root.render(<App />);

为了代码的平滑升级,两种方式都将会保留,不然效果会是,升级完18的包发现运行直接崩溃= =,但是使用旧的render无法使用新特性且会获得warning,引导进行root升级

新旧RootAPI还有一点改动是,原先render会包含第三个参数,也就是一个回调,新的api移除了

import * as ReactDOM from 'react-dom';

import App from 'App';

const container = document.getElementById('app');

ReactDOM.render(container, <App tab="home" />, function() {

  // Called after inital render or any update.

  console.log('rendered').

});

但是可以通过其它方式来代替,比如:

  • 通过ref
import * as ReactDOMClient from "react-dom/client";

function App({ callback }) {
  // Callback will be called when the div is first created.

  return (
    <div ref={callback}>
      <h1>Hello World</h1>
    </div>
  );
}

const rootElement = document.getElementById("root");

const root = ReactDOMClient.createRoot(rootElement);

root.render(<App callback={() => console.log("renderered")} />);
  • 通过useEffect
import { useEffect } from "react";

import * as ReactDOMClient from "react-dom/client";

function App() {
  useEffect(() => {
    // Callback will be called when the div is first created.

    console.log("renderered");
  }, []);

  return (
    <div>
      <h1>Hello World</h1>
    </div>
  );
}

const rootElement = document.getElementById("root");

const root = ReactDOMClient.createRoot(rootElement);

root.render(<App />);

两者的区别在于

  • Ref会在应用添加到dom之后同步的执行
  • Effects由于本身的实现是异步的,会有一个小的延迟

使用哪种方式取决于你具体的业务场景

Fix Tearing & useSyncExternalStore

绝大多数类库不需要做任何改动就能够兼容React 18,然而由于并发渲染的原因使用部分类库会引发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不一致或者崩溃。由于JS是单线程的,其实web开发中不会遇到这个问题,但是引入并发渲染后,渲染是可能被更高优先级的任务中断,这也使得tearing成为可能。 但React本身对于state的更新做了很多的工作来避免这个问题,但是如果我们的依赖了外部的状态,React就无能为力了,因为它并不知道你什么时候更新了外部的状态,看下面的例子:

  • 同步渲染

同步渲染

  • 并发渲染

并发渲染

这里externalStore就是我们依赖的一些外部状态,比如常见的redux,我们来看一使用react-redux 7的例子,可以直观的感受到tearing的存在。因此18中专门针对这部分类库提供相关的API - useSyncExternalStore,比如react-redux 8.0.0实际上就已经集成进去了,大家可以把上面demo中的依赖改成这个版本再试一下。

具体用法的话,这边直接做了一个demo,为了体现具体的改动,实现了一个redux-like,重点关注两个useSelector,看一下使用两个不同selector的效果

相信大家应该可以感受到区别,不过我们看到虽然使用了useSyncExternalStore通过接入useSyncExternalStore可以达到 「✅ Make it right」的效果,但是也能感受到要比没有使用之前更卡了,并没有达到「🚀 Make it fast」的效果,其实原因跟这个方法本身的实现有关:

function updateSyncExternalStore<T>(
  subscribe: (() => void) => () => void,

  getSnapshot: () => T,

  getServerSnapshot?: () => T
): T {
  const nextSnapshot = getSnapshot();

  const prevSnapshot = hook.memoizedState;

  const snapshotChanged = !is(prevSnapshot, nextSnapshot);

  // store内值更新时,进行一致性检查

  updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
    subscribe,
  ]);

  // commit环境开始之前,进行一次一致性检查

  if (inst.getSnapshot !== getSnapshot || snapshotChanged) {
    pushEffect(
      HookHasEffect | HookPassive,

      updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),

      undefined,

      null
    );
  }

  // render环节结束时,进行一次一致性检查

  if (!includesBlockingLane(root, renderLanes)) {
    pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
  }
}

// ...

// 一致性检查方法内

if (checkIfSnapshotChanged(inst)) {
  // Force a sync re-render.

  scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
}

// ...

可以看到,实现的核心是加了三重保障来进行一致性检查,当出现不一致时就发起一个同步更新的调度,因此transition的效果就失灵了,所以会阻塞页面交互出现了卡顿。对于这个问题React团队还在研究和探索,仍然还有一段路要走。

另外对于类库开发者来说,在社区并发特性全面铺开前,不鼓励在类库中使用Concurrent Features,因为这会使得依赖我们类库的上层应用默认开启了并发模式,而这可能不是他们所期望的。