源码级剖析 React 18 新特性,以及源码中未完成的新功能
React两个比较重要的版本更新
- 16.0 引入Fiber架构
- 16.8 引入React Hooks
Concurrent模式:并发模式
React已经着手开发Concurrent几年了,但是一直只存在于实验版本。到了React18,Concurrent终于正式投入使用了。虽然Concurrent不是API之类的新特性,但它是React18大部分新特性的实现基础,包括Suspense、transitions、流式服务端渲染等。
Concurrent模式有三个特点:
渲染是可中断的,React18之前的update是同步渲染,在这种情况下,一旦update开启,在任务完成前,都不可中断。可中断是非常重要的,因为任务有轻重缓急,在高优先级的任务(跟用户交互任务)来的时候,如果不先中断低优先级的任务,就会造成高优先级任务等待的局面。这个可中断 React其实是通过Fiber来实现的,Fiber本身是链表结构,想指向别的地方其实加一个属性值就可以了。
注意:这里说的同步,和setState所谓的同步异步不是一码事,而且setState所谓的异步本质上是个批量处理。
中间的update也可能会被遗弃。举个场景的例子:当用户从第一个页面快速切换到第三个页面的时候,虽然第二个页面没有被渲染完成,但是用户其实并不关心第二个页面,只需要着重渲染第三个页面就好了,所以第二个页面的update可以被遗弃掉
Concurrent模式下,还支持状态的复用。某些情况下,比如用户走了,又回来,那么上一次的页面状态应当被保存下来,而不是完全从头再来。实际情况下不能缓存所有的页面,非常消耗内存,所以还得做成可选的。目前,React正在用Offscreen组件来实现这个功能。另外,使用OffScreen,除了可以复用原先的状态,也可以使用它来当做新UI的缓存准备,新UI虽然没有被立即显示,但是可以在后台候着嘛。
新特性一:react-dom/client中的createRoot
React18之前用的是ReactDOM.render有三个参数:
- element:要渲染的 React 元素,通常是 JSX
- container:一个 DOM 元素,React 元素将被渲染到这个容器中
- callback (可选):渲染完成后调用的回调函数
React18用createRoot,参数是
container
,createRoot支持并发渲染,createRoot还有一个好处就是根节点的复用jsxconst root = createRoot(document.getElementById("root")); root.render(<SteStatePage />);
以前ReactDOM.render更新完成执行的callback,在React18中建议放到
useEffect
中SSR 中的 ReactDOM.hydrate 也换成了新的 hydrateRoot
以上两个API目前依然支持,只是已经移入legacy(/ˈleɡəsi/)模式,开发环境下会warning
新特性二:自动批量处理 Automatic Batching
如果你是React技术栈,那么你一定遇到过无数次这样的面试题:
setState是同步还是异步,可以实现同步吗,怎么实现,异步的原理是什么?
- React18之前:可同步可异步,同步的话把setState放在promises、setTimeout等计时器或者原生事件中等。所谓异步就是个批量处理。
- 以前的React的批量更新是依赖于合成事件的(即,在合成事件中,多个setState会被批量处理),到了React18之后,state的批量更新不再与合成事件(绑定在ReactElement上的事件,例如onClick)有直接关系,而是自动批量处理
// 以前: 这里的两次setState并没有批量处理,React会render两次
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 1000);
// React18: 自动批量处理,这里只会render一次
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 1000);
- 虽然建议setState批量处理,但如果想要同步setState,可以使用
flushSync
import { flushSync } from "react-dom";
changeCount = () => {
const { count } = this.state;
flushSync(() => {
this.setState({
count: count + 1,
});
});
console.log("改变count", this.state.count); //hx-log
};
// <button onClick={this.changeCount}>change count 合成事件</button>
新特性三:Suspense
- Suspense有点像catch,只不过Suspense捕获的不是异常,而是组件的suspending状态,即挂载中。
- 可以自己编写 ErrorBoundary(getDerivedStateFromError) 类组件包裹
Suspense
来捕获网络异常
- 可以自己编写 ErrorBoundary(getDerivedStateFromError) 类组件包裹
新特性四:transition
React把update分成两种:
- Urgent updates:紧急更新,指直接交互,通常指的用户交互。如点击、输入等。这种更新一旦不及时,用户就会觉得哪里不对。
- Transition updates:过渡更新,如UI从一个视图向另一个视图的更新。通常这种更新用户并不着急看到。
startTransition
startTransition
可以用在任何你想更新的时候。但是从实际来说,以下是两种典型适用场景:
- 渲染慢:如果你有很多没那么着急的内容要渲染更新
- 网络慢:如果你的更新需要花较多时间从服务端获取。这个时候也可以再结合
Suspense
import { useState, Suspense, startTransition } from 'react'
import { fetchData } from '../utils'
import ErrorBoundaryPage from './ErrorBoundaryPage'
const initialResource = fetchData()
export default function TransitionPage() {
const [resource, setResource] = useState(initialResource)
return (
<div>
<h3>TransitionPage</h3>
<ErrorBoundaryPage fallback="加载失败...">
<Suspense fallback="Loading...">
<User resource={resource} />
</Suspense>
</ErrorBoundaryPage>
// 这里的 Suspense 的 Loading 并不会显示
// startTransition 会将页面的更新标记为非紧急更新,使页面的交互看起来不会断
<button onClick={() => startTransition(() => setResource(fetchData()))}>
refresh
</button>
</div>
)
}
- 如果需要在transition期间做更多的处理,知道transition的实时情况,就需要使用
useTransition
,可以结合Suspense
去做显示
import { useState, Suspense, useTransition } from 'react'
import { fetchData } from '../utils'
import ErrorBoundaryPage from './ErrorBoundaryPage'
const initialResource = fetchData()
export default function TransitionPage() {
const [resource, setResource] = useState(initialResource)
const [isPending, startTransition] = useTransition()
return (
<div>
<h3>TransitionPage</h3>
<ErrorBoundaryPage fallback="加载失败...">
<Suspense fallback="Loading...">
<User resource={resource} />
</Suspense>
</ErrorBoundaryPage>
// 这里的 Suspense 的 Loading 并不会显示
// startTransition 会将页面数据的更新标记为非紧急更新,使UI的交互具有连续性
<button
disabled={isPending}
onClick={() => startTransition(() => setResource(fetchData()))}
>
refresh
</button>
{isPending ? 'pending' : ''}
</div>
)
}
与setTimeout的区别
在 startTransition
出现之前,我们可以使用 setTimeout
来实现优化。但是现在在处理上面的优化的时候,有了 startTransition
基本上可以抛弃 setTimeout
了,原因主要有以三点:
- 与
setTimeout
不同,startTransition
不会延迟调度,而是立即执行 startTransition
接收的函数是同步执行的,并且给这个更新加上了“transitions”标记,React 会在内部处理更新时参考这个标记- 使用
startTransition
处理更新比setTimeout
更早,在较快的设备上,用户几乎无法感知到这种过渡
useDeferredValue
使得我们可以延迟更新某个不那么重要的部分。
相当于参数版的transitions。
例如:假设我们有一个实时搜索输入框,用户在输入时会触发过滤一个庞大的列表。这个过滤操作比较耗时,因此在用户输入时会感觉到明显的卡顿,useDeferredValue
允许 React 将用户输入和过滤操作的更新解耦。输入框的值 (query) 会立即更新,而 deferredQuery 则会稍后更新。这样可以让输入的响应更加即时,而过滤操作则在后台优先级较低的情况下执行,从而减少卡顿感。如果没有 useDeferredValue
的情况下我们一般使用 防抖/节流。
import React, { useState, useDeferredValue } from 'react';
function SearchComponent({ largeList }) {
const [query, setQuery] = useState("");
// const deferredQuery = query;
const deferredQuery = useDeferredValue(query);
const filteredList = largeList.filter(item =>
item.includes(deferredQuery)
);
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{filteredList.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}
实验版新特性:SuspenseList
SuspenseList
在 DebugReact 中可用,也就是 DEV 环境下可以用。目前还未完成,预计18.X会正式支持,以下例子当做参考,以后也许会改变。
用于控制 Suspense
组件的显示顺序。
revealOrder
这个选项用于设置 Suspense 的加载顺序
together
所有Suspense 一起显示,也就是最后一个加载完了才一起显示全部forwards
按照顺序显示 Suspensebackwards
反序显示 Suspense
tail
是否显示fallback,只在 revealOrder
为 forwards
或者 backwards
时候有效
hidden
不显示collapsed
轮到自己再显示
import { useState, Suspense, SuspenseList } from "react";
import User from "../components/User";
import Num from "../components/Num";
import { fetchData } from "../utils";
import ErrorBoundaryPage from "./ErrorBoundaryPage";
const initialResource = fetchData();
export default function SuspenseListPage(props) {
const [resource, setResource] = useState(initialResource);
return (
<div>
<h3>SuspenseListPage</h3>
<SuspenseList tail="collapsed">
<ErrorBoundaryPage fallback={<h1>网络出错了</h1>}>
<Suspense fallback={<h1>loading - user</h1>}>
<User resource={resource} />
</Suspense>
</ErrorBoundaryPage>
<Suspense fallback={<h1>loading-num</h1>}>
<Num resource={resource} />
</Suspense>
</SuspenseList>
<button onClick={() => setResource(fetchData())}>refresh</button>
</div>
);
}
新的 Hooks
useId
用于产生一个在服务端与Web端都稳定且唯一的ID,也支持加前缀,这个特性多用于支持ssr的环境下:
import { useId } from 'react'
export default function NewHookApi() {
const id = useId()
return (
<div>
<h3 id={id}>NewHookApi</h3>
</div>
)
}
可以看到生成的 id 是下面这种效果:
注意:useId产生的ID不支持css选择器,如querySelectorAll。
useSyncExternalStore
这个虽然是 Library Hooks,但是这个 API 非常重要,跟状态管理有关系。
const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);
此Hook用于外部数据的读取与订阅,可应用Concurrent。
基本用法如下:
import { useStore } from "../store";
import { useId, useSyncExternalStore } from "../whichReact";
export default function NewHookApi(props) {
const store = useStore();
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
return (
<div>
<h3>NewHookApi</h3>
<button onClick={() => store.dispatch({ type: "ADD" })}>{state}</button>
</div>
);
}
useStore是我另外定义的,
export function useStore() {
const storeRef = useRef();
if (!storeRef.current) {
storeRef.current = createStore(countReducer);
}
return storeRef.current;
}
function countReducer(action, state = 0) {
switch (action.type) {
case "ADD":
return state + 1;
case "MINUS":
return state - 1;
default:
return state;
}
}
这里的createStore用的redux思路:
export function createStore(reducer) {
let currentState;
let listeners = [];
function getSnapshot() {
return currentState;
}
function dispatch(action) {
currentState = reducer(action, currentState);
listeners.map((listener) => listener());
}
function subscribe(listener) {
listeners.push(listener);
return () => {
// console.log("unmount", listeners);
};
}
dispatch({ type: "TIANNA" });
return {
getSnapshot,
dispatch,
subscribe,
};
}
useInsertionEffect
useInsertionEffect(didUpdate);
函数签名同useEffect,但是它是在所有DOM变更前同步触发。主要用于css-in-js库,往DOM中动态注入<style>
或者 SVG <defs>
。因为执行时机,因此不可读取refs。
在react组件中的执行顺序为:
- useInsertionEffect:DOM变更前同步触发
- useLayoutEffect:在浏览器重新绘制屏幕之前触发
- useEffect:组件挂载到页面时触发
function useCSS(rule) {
useInsertionEffect(() => {
if (!isInserted.has(rule)) {
isInserted.add(rule);
document.head.appendChild(getStyleForRule(rule));
}
});
return rule;
}
function Component() {
let className = useCSS(rule);
return <div className={className} />;
}
热爱编程,开源社区活跃参与者