React 18探秘(上)

by Teobler on 07 / 07 / 2021

views

React 17 那篇没有任何新特性的博客还历历在目,半年多后,终于等来了 17 铺路许久的 18 发布计划,本来想赶紧看看都有些啥,无奈事情略多,一直拖到现在,最近有点点时间,看看 18 给我们带来了什么。

17 发布消息出来的那会我一直好奇这个没有新特性的发布目的是啥,一通搜索之后得到了一些答案:17 在给未来的 Concurrent Mode 铺路,为大家做好未来渐进式升级的准备。 React 的 Concurrent Mode 在下一盘大棋,一盘包括了 RN / Web / SSR / Server Component 的大棋。而这次 18 的发布计划虽然还是没能发布 Concurrent Mode,但也透露了一些未来 Concurrent Mode 的样子。

根据发布计划来看,这次 18 的主要功能可以分成三类:

  1. 一些开箱即用的改进,比如自动批量更新
  2. 一些新的 API,比如 startTransition
  3. 以及新的流式服务端渲染

值得注意的是,这次虽然是一个大版本更新,但是 Concurrent Mode 是一个可选项,博客中提到大部分项目可以做到只消耗一个下午的时间就能完成升级。

自动批量更新

自动批量更新(Automatic batching)是里面最容易理解和使用的新功能。在聊这个功能之前,我们得先理解什么是批量更新(batching)。

我们假设有一段下面的代码:

const 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>
  );
}

如果你在更新 state 的时候是在同一个事件回调里的,那么 React 不会依次更新这些 state,因为这样的更新意味着有多少 state 就会有多少次 re-render。从性能角度考虑,由于这些 state 都是在同一个事件回调中更新的,所以可以认为他们可以一起更新,于是 React 就让这些 state 一次性一起更新了。

但是如果此时的更新发生在 fetch data 或者是 setTimeout 的回调里,那么 React 就不会做这样的优化了,即使那个更新依然在事件回调里:

const App = () => {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);
 
  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
    });
  }
 
  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

这是因为 React 18 之前仅仅只会在浏览器事件发生的过程中进行批量更新,而不会在事件结束后(比如 fetch data 的回调里面)批量更新。而如果你升级到 18 之后并且使用了 createRoot,那么这些更新都将自动批量更新,这无疑在框架层面提升了性能。

但如果你说我就是有特殊的情况需要依次更新 state,这咋办?

React 团队也考虑了这种特殊情况,提供了一个 flushSync API 来应对这种情况:

import { flushSync } from 'react-dom'; // Note: react-dom, not react
 
function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // React has updated the DOM by now
  flushSync(() => {
    setFlag(f => !f);
  });
  // React has updated the DOM by now
}

startTransition

这个 API 的目的也是为了提升性能问题,一句话来描述的话,startTransition 能够让你的应用持续相应用户的交互,哪怕这个时候页面正在进行重量级的更新。

这句话有些抽象,我们来举个实际例子。

假设网页上有个实时搜索框,用户可以在里面输入任意字符,然后前端应用用这些关键字发送请求到后端实时渲染从后端拿到的结果。

来需求了

一个很通用的需求,做过这个需求的同学都知道这个需求如果不做任何处理会有性能问题。浏览器需要同时处理用户的输入和页面的渲染,如果渲染量比较大,用户的输入能够感受到明显的卡顿。

映射到代码里,我们的(伪)代码可能长这样:

const App = () => {
  const [inputValue, setInputValue] = useState("");
  const [data, setData] = useState(null);
  
  const onChange = (event) => {
    // Urgent: Show what was typed
    setInputValue(event.target.value);
  }
  
  useEffect(() => {
    // Not urgent: Show the results
    setData(fetchData(inputValue))
  }, [inputValue]);
 
  return (
    <div>
      <input onChange={onChange} value={inputValue} />
      {data}
    </div>
  );
}

在这个例子里显示渲染结果的优先级并没有显示用户输入高。在 Web 应用中,相应用户交互的优先级几乎是最高的,因为这决定了你的应用是否是实时可用的,卡顿将带来不好的用户体验。

咋办呢

那么在 React 18 之前我们如何解决这个问题呢?没错,通用解是 debounce 和 throt。在这个场景下虽然 throt 优于 debounce,但是他们依然有一个绕不开的问题:假如渲染时间片的确很大,虽然降低了渲染次数,但是在渲染期间如果用户再次输入,这次输入依然会被渲染阻塞,卡顿依然会出现。

那么 React 18 可以怎么做?

import { startTransition } from "react";
 
useEffect(() => {
  startTransition(() => {
    setData(fetchData(inputValue));
  });
}, [inputValue])

startTransition 能将某一个更新标记为“不紧急”,在该更新进行中如果有更加紧急的更新发生,那么这个“不紧急”的更新将被打断,去更新优先级更高的任务。

这里有一个官方实例从浏览器的角度详细解析了这个 API 带来的性能优化有多少。

什么是 transion

所以,在 React 上下文中, transition 是个啥?

实际上,React 将 state 的更新分成了两类:

  • **紧急更新 (Urgent updates)**将直接作用于用户交互,比如输入、点击等等
  • **过渡更新 (Transition updates)**将 UI 从一个视图过渡到另一个视图

页面交互的反馈需要与物理反馈一一对应,比如用户在键盘上输入了一串字符,那么理论上页面上也应该立马出现一串对应的字符,否则用户就会认为你的网页有问题,不好用 -- 毕竟他的键盘是好好的。

而搜索结果的实时反馈相对而言没有这么重要,不管是用户输入第一个字符时的搜索结果,还是第三个字符时的搜索结果都不重要,因为用户想要输入五个字符,只要五个字符一输入完毕,页面就显示正确的结果即可。这些都只是 UI 的过渡

但同时你又不能阻塞我的删除操作,毕竟我输完五个字符后,可能发现第三个字符输错了。即 UI 的过渡不能阻塞用户的交互

怎么做到的

在代码运行时,如果一个函数被包裹在 startTransion 中,这个函数的执行并不是被延迟了,这也是它与 setTimeout 最大的不同。相反,这个函数会被立即执行:

console.log('1')
startTransition(() => {
  console.log('2')
  setSearchQuery('hello')
})
console.log('3')
// output:
// 1
// 2
// 3

只不过在执行前 React 会标记一个 transion 开始了。然后在这个 transion 期间的 state 更新也会被标记,这些标记决定了在渲染阶段 React 如何处理这些更新:

let isInTransition = false
 
function startTransition(fn) {
  isInTransition = true
  fn()
  isInTransition = false
}
 
function setState(value) {
  stateQueue.push({
    nextState: value,
    isTransition: isInTransition
  })
}

而它的源码实现也同样整洁。

亿点小细节

需要注意的是,如果有多个 transion 他们会被自动批量更新,而不是独立更新:

startTransition(() => {
  navigateToNewTab();
});
 
startTransition(() => {
  dismissModal();
});

这是因为在进行设计的时候 transion 是可以相互嵌套的,那么就会出现这样的情况:

// Outer transition, added by some wrapper  function
startTransition(() => {
  // Nested transitions, called by some inner function that is wrapped by
  // an abstraction
  startTransition(() => {
    navigateToNewTab();
  });
  
  startTransition(() => {
    dismissModal();
  });
});

外层的 transion 希望里面的函数批量自动更新,但是里面的两个 transion 却希望各自独立执行,为了避免冲突,所有的 transion 将自动批量更新。

面向未来的 startTransion

这个 API 的目的不止于此,就现在来说它还能配合 Suspense 支持 data fetching 的渲染的优化,稍后 React 团队将放出更多例子和文章。

在未来,React 想要将计划中的动画效果也包含在这个 API 里,也就是在未来只要使用了这个 API,React 可以自动帮你解决页面渲染,动画淡入淡出等问题,但是这个计划要想实现应该是在很久以后了,看看那个时候在看文章的你还有没有在写代码吧 :)

参考

  1. The Plan for React 18
  2. React 的 Concurrent Mode 是否有过度设计的成分?
  3. New feature: automatic batching
  4. New feature: startTransition
  5. Question: startTransition behavior