likes
comments
collection
share

悄悄告诉你:React18文档里写错的地方

作者站长头像
站长
· 阅读数 17

大家好,我卡颂

React18正式版已经发布一段时间了,如果你升级到v18,且仍使用ReactDOM.render创建应用,会收到如下报警:

悄悄告诉你:React18文档里写错的地方

大意是说:v18使用createRoot而不是render创建应用,如果你仍使用render创建应用,那么应用的行为将同v17一样。

React团队之所以有底气让大家都升级到v18,使用createRoot,是因为他们作出了承诺:

悄悄告诉你:React18文档里写错的地方

大意是说:如果你升级到v18,只要不使用并发特性(比如useTransition),React会和之前版本表现一致(更新会同步、不可中断)

今天这篇文章想说的是:某些情况下,上述说法是错误的。

欢迎加入人类高质量前端框架群,带飞

不说废话,上示例

示例中有ab两个状态,首次渲染完2秒后会触发ab更新。

其中触发b更新的方式比较特殊:模拟点击,间接触发b更新:

function App() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  const BtnRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    setTimeout(() => {
      setA(9000);
      BtnRef.current?.click();
    }, 2000);
  }, []);

  return (
    <div>
      <button 
        ref={BtnRef} 
        onClick={() => setB(1)}>
        b: {b}
      </button>
      {Array(a).fill(0).map((_, i) => {
        return <div key={i}>{a}</div>;
      })}
    </div>
  );
}
完整示例地址

现在我们有两种挂载<App/>的方式。

v18之前的方式:

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

// v18之前创建应用的方式
ReactDOM.render(<App/>, rootElement);

v18提供的方式:

const root = ReactDOM.createRoot(rootElement);

// v18创建应用的方式
root.render(
  <App />
);

为了看清这两者的区别,有两种方式:

  1. 调大setA(9000)中的值,使页面渲染更多项。页面渲染时卡顿越明显,渲染顺序的差异越明显
setTimeout(() => {
  setA(9000);
  BtnRef.current?.click();
}, 2000);
  1. react-dom.development.jscommitRootImpl方法中打断点

悄悄告诉你:React18文档里写错的地方

这个方法是React渲染时调用的方法,在这里打断点可以看出页面渲染的顺序。

对于ReactDOM.render创建的应用,触发更新后渲染顺序如下:

首先:

悄悄告诉你:React18文档里写错的地方

其次:

悄悄告诉你:React18文档里写错的地方

对于ReactDOM.createRoot创建的应用,触发更新后渲染顺序如下:

首先:

悄悄告诉你:React18文档里写错的地方

其次:

悄悄告诉你:React18文档里写错的地方

渲染顺序显然是变了,这和React文档里的说法是相悖的。

背后的原因是什么呢?

更新的优先级,无处不在

先解释下示例中的b为什么采用触发onClick事件的方式间接触发更新:

BtnRef.current?.click();

这是因为:不同方式触发的更新有不同优先级onClick回调中触发的更新是最高优的,即同步优先级

那么问题来了,v18不使用并发特性,所有更新不都该是同步、不可中断么?

这话是没错,更新本身是同步、不可中断的。但是更新是需要调度的。

在示例中,如果采用ReactDOM.createRoot创建应用,那么触发更新时的优先级如下:

setTimeout(() => {
  // 触发更新,优先级为“默认优先级”
  setA(9000);
  // 触发更新,优先级为“同步优先级”
  BtnRef.current?.click();
}, 2000);

接下来React的执行流程如下:

  1. a触发更新,优先级为“默认优先级”
  2. 调度a的更新,优先级为“默认优先级”
  3. b触发更新,优先级为“同步优先级”
  4. 调度b的更新,优先级为“同步优先级”
  5. 此时发现已经有个更新在调度(a的更新),且优先级更低(默认优先级 < 同步优先级)
  6. 取消a的更新的调度,转而开始调度b的更新
  7. 调度流程结束,开始同步、不可中断的执行b的更新
  8. b对应更新渲染到页面中
  9. 此时发现还有一个更新(a的更新),调度他
  10. 调度流程结束,开始同步、不可中断的执行a的更新
  11. a对应更新渲染到页面中

可见,只要采用ReactDOM.createRoot创建应用,那么优先级的影响就会一直存在,与使用了并发特性的区别是:

  • 只有默认优先级同步优先级
  • 优先级只会影响调度,不会中断更新的执行

老版React的历史包袱

那么采用ReactDOM.render创建的应用执行顺序又是怎么一回事呢?

记不记得一道经典(且毫无意义)的React面试题:React的更新是同步还是异步的?

下面两种情况,a打印的结果是1么?

// 情况1
onClick() {
  this.setState({a: 1});
  console.log(a);
}
// 情况2
onClick() {
  setTimeout(() => {
    this.setState({a: 1});
    console.log(a);
  })
}

其中,情况2中a打印结果是1

之所以会有这种情况,是React早期实现批处理时的瑕疵造成的,并不是什么有意为之的特性。

React使用Fiber架构重构后,完全可以规避这个瑕疵。但为了与老版本行为保持一致,刻意实现成这样。

所以,在我们的示例中,这两个更新不会受到优先级的影响,但会受到为了兼容老版本造成的影响:

setTimeout(() => {
  setA(9000);
  BtnRef.current?.click();
}, 2000);

React的执行流程如下:

  1. a触发更新,因为是在setTimeout中触发的,所以会同步执行后续更新流程
  2. a对应更新渲染到页面中
  3. b触发更新,因为是在setTimeout中触发的,所以会同步执行后续更新流程
  4. b对应更新渲染到页面中

总结

React作为一款维护了快10年的框架,在经历重大版本更新后要保持框架行为前后一致,实属不易。

更新顺序的变化对一般应用影响不大。

但是,如果你的应用依赖更新后页面中当前的值作出后续判断,那么需要注意升级到v18后的这些细微变化。