本文从React 18的核心概念、新功能、更新、新api和hooks4个方面展开和讲解,从而全面揭开React 18的神秘面纱,帮助你快速上手和使用。
如何升级到React 18
- 通过
npm、yarn或者pnpm安装 React 18和 React Dom
// 三种方式任取一种
// 使用npm
npm install react react-dom
// 使用yarn
yarn add react react-dom
// 使用pnpm
pnpm install react react-dom
- 使用
createRoot替代之前的render在index.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区域缩放:
非并发模式进行以下操作:
火焰图调用信息如下
并发模式进行同样的操作:
火焰图调用信息如下
通过对比,可以很明显的感受到该场景下并发模式下的流畅性。
在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
不会对promise、setTimeout、原生事件处理(native event handlers) 或其它React默认不进行批处理的事件进行批处理操作。
从 React 18的createRoot开始,所有更新都将Aumatic Batching(自动批处理),无论它们来自何处。
这意味着promise、setTimeout、原生事件处理(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>
);
}
- ✅ 演示:React 18
createRoot在事件处理之外的批处理!(注意控制台中的每次点击渲染一次!) - 🟡 演示:React 18 with legacy
render保留了旧的行为(注意控制台中每次点击两次渲染。)
但某些代码可能依赖于在状态更改后立即从 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是用来标记不需要紧急资源来更新的用户界面更新。例如:当在一个输入框字段中输入时,有两件事情正在发生:
- 一个闪烁的光标显示你正在输入的内容的视觉反馈
- 一个在后台搜索被输入的数据的搜索功能。
向用户显示视觉反馈是重要的,因此也是紧迫的。搜索则不那么紧急,因此可以被标记为非紧急。这些非紧急的更新被称为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);
在新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看成两次渲染调度:
- 之前值的Urgent render(紧急渲染)
- 下一个值的Non-urgent render(非紧急渲染),跟
startTransition类似。
useDeferredValue和startTransition从广义上来说有着相似的行为,他们主要的区别是使用场景:
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) 代替。