掀开React 18的盖头,从入门到熟练!

3,704 阅读12分钟

本文从React 18的核心概念、新功能、更新、新api和hooks4个方面展开和讲解,从而全面揭开React 18的神秘面纱,帮助你快速上手和使用。

如何升级到React 18

  1. 通过npmyarn或者pnpm安装 React 18和 React Dom
// 三种方式任取一种
// 使用npm
npm install react react-dom
// 使用yarn
yarn add react react-dom
// 使用pnpm 
pnpm install react react-dom
  1. 使用createRoot替代之前的renderindex.tsx或者index.js文件单重,用ReactDom.createRoot创建root节点渲染的方式来替换之前ReactDom.render的形式。
  • react 17版及以前
import ReactDOM from 'react-dom';
import App from 'App';

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

ReactDOM.render(<App />, container);
  • react 18版及以后
import ReactDOM from 'react-dom';
import App from 'App';

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

// 创建root
const root = ReactDOM.createRoot(container);

//通过root渲染App
root.render(<App />);

核心概念:Concurrency(并发)

React的并发到底做了什么,使性能得到了提升,下面提供了一个新旧版本的示例的对比

顶部是个slider,拖放后会对整个chart区域缩放

并发模式进行以下操作:

stack-reconciler.gif

火焰图调用信息如下

image.png

并发模式进行同样的操作:

fiber-reconciler.gif

火焰图调用信息如下 image.png

通过对比,可以很明显的感受到该场景下并发模式下的流畅性。

React 18之前,渲染是一个单一的、不间断的、同步的事务,一旦渲染开始,就不能被打断。这是因为早期采用的是“stack reconciler"调度(类似串行调度),stack reconciler采用递归的方式创建虚拟DOM并提交Dom Mutation,整个过程同步并且无法中断工作或进行拆分。如果组件树的层级很深,递归会占用线程很多时间,递归更新时间超过了16ms,用户交互就会卡顿。

React 18是并发渲染,并发是React渲染机制的一个基础性更新,React可以进行任务挂起(暂停)、恢复、中止、插入高优任务。这使得React可以快速响应用户的交互,即使它正处于一个繁重的渲染任务中。

并发是React渲染机制的一个基础性更新,suspense、流式服务器渲染和transitions等新功能都是由并发渲染提供的。

更新: Strict mode(严格模式)

React 18中的Strict mode将模拟mounting(挂载)、unmounting(卸载)和用以前的状态re-mounting(重新挂载)组件。这为未来的状态复用奠定了基础,在这种情况下,react可以通过使用卸载前的相同组件状态,来实现快速还原之前状态树并反馈到UI上。严格模式将确保组件在被多次挂载和卸载时具有很好的弹性效果。 启用方式也比较简单,将代码包裹在StrictMode组件中即可,在项目升级中可以逐个模块或者组件进行替换升级

const Root = () => {
 ...
 return (
   <!-- // 显示调用 -->
   <StrictMode>
     <App .../>
   </StrictMode>
 )
}

需要注意的是,严格模式仅影响开发环境,对生产环境无影响。

新功能

Automatic batching

batching(批处理)是 React将多个状态更新分组到单个re-render中以获得更好的性能的操作。 例如,如果你在同一个点击事件中有两个状态更新,React 总是将它们分批处理到一个重新渲染中。如果你运行下面的代码,你会看到每次点击时,React 只执行一次渲染,尽管你设置了两次状态:

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

  function handleClick() {
    setCount(c => c + 1); // 不会触发重新渲染
    setFlag(f => !f); // 不会触发重新渲染
    // React这里只会触发一次渲染 (这就是batching!)
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

这对性能非常有用,因为它避免了不必要的重新渲染。它还可以防止您的组件呈现仅更新一个状态变量的“半完成”状态,这可能会导致错误。这可能会让您想起餐厅服务员在您选择第一道菜时不会跑到厨房,而是等待您完成订单。

但在React 18 之前,只有在React事件处理程序期间才会触发批量更新。默认情况下,React 不会对promisesetTimeout原生事件处理(native event handlers) 或其它React默认不进行批处理的事件进行批处理操作。

从 React 18的createRoot开始,所有更新都将Aumatic Batching(自动批处理),无论它们来自何处。

这意味着promisesetTimeout原生事件处理(native event handlers)`或任何其他事件内的更新将以与 React 事件内的更新相同的方式进行批处理。

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

  function handleClick() {
    fetchSomething().then(() => {
      // React 17 中,setCount和setFlag都会触发一次重新渲染
      // React 18 中,只会触发一次渲染,因为进行自动batching的操作
      setCount(c => c + 1);
      setFlag(f => !f);
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

但某些代码可能依赖于在状态更改后立即从 DOM 中读取某些内容。对于这些用例,您可以使用ReactDOM.flushSync()选择退出批处理:

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
}

React 不建议 频繁使用此场景。

Transitions

React中定义了两种状态更新(记住,后面返回会提及这个概念

  • Urgent updates(紧急更新):反馈用户的直接行为,比如:输入、点击、按键等等
  • Transition updates(过度性更新):用户看到的界面变化,从一个界面变化为另一个界面

Transitions是用来标记不需要紧急资源来更新的用户界面更新。例如:当在一个输入框字段中输入时,有两件事情正在发生:

  1. 一个闪烁的光标显示你正在输入的内容的视觉反馈
  2. 一个在后台搜索被输入的数据的搜索功能。

向用户显示视觉反馈是重要的,因此也是紧迫的。搜索则不那么紧急,因此可以被标记为非紧急。这些非紧急的更新被称为transitions。通过将非紧急的UI更新标记为 "transitions",React将知道哪些更新需要优先处理,使其更容易优化渲染并摆脱陈旧的渲染。

更新可以通过使用startTransition来标记为非紧急状态。针对上面的说明,下面是一个实际的示例:

import { startTransition } from 'react';

// 紧急: 展示输入了什么
setInputValue(input);

// 将不紧急的是状态更新标记为transition
startTransition(() => {
  // Transition: 展示搜索结果
  setSearchQuery(input);
});

这个看起来跟debounce或者是延迟(setTimeout之类的)很相似,两者有什么区别呢?

  • 执行时机: startTransition与setTimeout不同,会立即执行。setTimeout有一个保证的延迟,而startTransition的延迟取决于设备的速度,以及其他紧急渲染的情况。
  • 可控制: startTransition的更新可以被打断,不像setTimeout那样,不会冻结页面。当用startTransition标记时,React可以跟踪并暴露出pending状态来使用户感知。

新apis

createRoot

React中,Root是顶层的数据结构,它是一个tree,用来追踪React渲染。在以前的API当中,Root对用户并不是透明的,React直接把它绑定到Dom Element上,可以通过Dom节点访问到Root,并没有通过API的形式暴露出来

import * as ReactDOM from 'react-dom';
import App from 'App';

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

// 首次渲染
ReactDOM.render(<App tab="home" />, 通过rootElement);

// 更新:需要再次传递container
ReactDOM.render(<App tab="profile" />, 通过rootElement);

通过rootElement._reactRootContainer查看

在新API中,我们可以直接通过root来进行渲染

import * as ReactDOMClient from 'react-dom/client';
import App from 'App';

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

// 创建一个root
const root = ReactDOMClient.createRoot(rootElement);

// 首次渲染: 通过root渲染一个元素.
root.render(<App tab="home" />);

// 更新:不需要再次传递container
root.render(<App tab="profile" />);

两者有什么区别?

官方给出了两个说法:

  • 修复了一些之前更新过程中不合符ergonomics(工程学)的问题。并且避免了频繁传入container的问题(哪怕没有任何修改)。
  • 移除了hydrate并使用可以传入参数的root方法替换。并且移除了render callback函数。

render callback如何处理

我们都知道在以前的API中,我们可以传入一个回调函数,在组件render或者更新后会触发。

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

ReactDOM.render(container, <App tab="home" />, function() {
  // 首次渲染或者任何更新时触发.
  console.log('rendered').
});

新API中移除了callback,原因是在部分hydration和渐进式SSR渲染的过程中,回调的触发时机跟用户期望的方式不一致,现在官方推荐使用以下两种形式。

  • 用异步回调:通过requestIdleCallback, setTimeout
  • 显示传入callback,在组件中直接调用
    • 通过ref:当div添加到DOM中(一般是DOM Mutation完成的时),会同步触发
    • 通用useEffect:延时触发,在commit阶段完成后(页面渲染完成时)

用法需要根据具体的业务场景来进行选择。贴一下ref的代码示例:

function App({ callback }) {
  // Callback will be called when the div is first created.
  return (
    <div ref={callback}>
      <h1>Hello World</h1>
    </div>
  );
}
root.render(<App callback={() => console.log("renderered")} />);

hydrateRoot

早期的hydrate升级为了hydrateRoot。 以前:

import * as ReactDOM from 'react-dom';
import App from 'App';

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

// 通过hydration 渲染一个root节点
ReactDOM.hydrate(<App tab="home" />, container);

现在:

import * as ReactDOMClient from 'react-dom/client';
import App from 'App';

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

// 通过hydration**创建** 和 **渲染**一个root节点
const root = ReactDOMClient.hydrateRoot(container, <App tab="home" />);
// 不像createRoot,这里不需要再次单独调用root.render 

// 如果在hydration后想要再次更新root节点,可以直接调用render方法
root.render(<App tab="profile" />);

需要注意一点,和createRoot不同,hydrateRoot接入初始化的jsx作为第二个参数,这是 因为初次的服务端渲染需要匹配对应渲染tree。

新hooks

useId

useId是一个生成全局唯一id的hooks,它可以用在client和service端,从而可以避免水化过程中的不匹配,下面是一个简单的示例:

const CheckBox = () => {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>Do you like React?</label>
      <input type="checkbox" name="react" id={id} />
    </>
  )
}

它的实现也不复杂,源码中的核心实现,如下:

// Used for ids that are generated completely client-side (i.e. not during
// hydration). This counter is global, so client ids are not stable across
// render attempts.
let globalClientIdCounter: number = 0;

function mountId(): string {
  const hook = mountWorkInProgressHook();

  let id;
  if (getIsHydrating()) {
    const treeId = getTreeId();

    // Use a captial R prefix for server-generated ids.
    id = 'R:' + treeId;

    // Unless this is the first id at this level, append a number at the end
    // that represents the position of this useId hook among all the useId
    // hooks for this fiber.
    const localId = localIdCounter++;
    if (localId > 0) {
      id += ':' + localId.toString(32);
    }
  } else {
    // Use a lowercase r prefix for client-generated ids.
    const globalClientId = globalClientIdCounter++;
    id = 'r:' + globalClientId.toString(32);
  }

  hook.memoizedState = id;
  return id;
}
  • 客户端:一个全局计数器globalClientIdCounter,每次调用加+1后拼接r再转化成32进制输出返回。
  • 服务端:稍微复杂一些,会基于treeId + localIdCounter + 1,然后再拼接转化32进制输出,这是因为React 18升级后流式渲染是无序的,所以早期单纯计数的方案可能会有问题。

useTransition

搭配startTransition来使用,如果用户需要在UI上感知到transition,react提供了一个hooksuseTransition来获取transition的状态。

import { useTransition } from 'react';

const [isPending, startTransition] = useTransition();
// 如果pending了,返回一个指示器
 if (isPending) {
   return <Spinner />
 }

useDeferredValue

deferring(延迟)一个值,跟我们经常提到的debounce和throttle有点类似。在React 18中,当传递给useDeferredValue的值发生变化时,React会根据当前渲染的优先级来返回之前的值或者是最新的值。

我们可以将useDeferredValue看成两次渲染调度:

  1. 之前值的Urgent render(紧急渲染)
  2. 下一个值的Non-urgent render(非紧急渲染),跟startTransition类似。

useDeferredValuestartTransition从广义上来说有着相似的行为,他们主要的区别是使用场景:

  • startTransition:当一个事件处理器中需要触发更新(比如:setState)时使用
  • useDeferredValue: 当从父组件或者其它hook当中获取一个新的值。

useDeferredValue 仅延迟您传递给它的值。如果您想防止子组件在紧急更新期间重新渲染,您还必须使用 memo 或 useMemo 存储该组件,如下代码所示

function Typeahead() {
  const query = useSearchQuery('');
  const deferredQuery = useDeferredValue(query);

  // Memoizing 告诉 React 只在 deferredQuery 改变时重新渲染——而不是当查询改变时.
  const suggestions = useMemo(() =>
    <SearchSuggestions query={deferredQuery} />,
    [deferredQuery]
  );

  return (
    <>
      <SearchInput query={query} />
      <Suspense fallback="Loading results...">
        {suggestions}
      </Suspense>
    </>
  );
}

这种方式不是 useDeferredValue独有的,它与使用类似hooks(如 useThrottleValue 或 useDebouncedValue)的模式相同。

useSyncExternalStore

推荐用于从外部数据源读取和订阅的场景,其方式与水化和时间切片等并发渲染功能兼容。

该方法返回存储的值,并接受三个参数。

  • subscribe:注册一个回调的函数,每当store发生变化时就会调用。
  • getSnapshot:函数,返回store的当前值。
  • getServerSnapshot:返回服务器渲染时使用的快照的函数。

最基本的例子只是简单地订阅了整个store。

const state = useSyncExternalStore(store.subscribe, store.getSnapshot);

useInsertionEffect

useEffect的签名,但是它在所有的DOM mutation 之前 触发。使用这个方法可以在useLayoutEffect中读取布局之前将样式注入到DOM中。由于使用场景优先,这个hook中不能使用ref也不能触发更新。

useInsertionEffect 只建议一些css-in-js的代码库作者使用。推荐使用useEffect或者useLayoutEffect来代替。

废弃/不推荐

ReactDOM.render

这个是现在最常用的渲染React节点的方法,前面讲过了,这里不再展开了,后面也逐渐会废弃。

renderToString

将一个 React 元素渲染成其初始的 HTML。此 API 对 Suspense 支持有限,并且不支持流。后面也逐渐会废弃。 在服务端,建议使用 renderToPipeableStream (Node.js) 或者 renderToReadableStream (for Web Streams) 代替。

有收获的小伙伴麻烦来个三连暴击!

参考文章