React18正式版源码级剖析
本文适合对React18.0.0源码感兴趣的小伙伴阅读。
欢迎关注前端早茶,与广东靓仔携手共同进阶~
一、前言
本文是广东靓仔的好友bubucuo-高老师写的,高老师最近在准备React18的视频,有兴趣的小伙伴可以去学习学习。
React18最重要的改变必须是Concurrent,就像哪吒降生一样,打磨了很长时间了,终于正式见人了。
Concurrent Or Concurrency,中文我们通常翻译为并发,也有少部分翻译成并行。React已经着手开发Concurrent几年了,但是一直只存在于实验版本。到了React18,Concurrent终于正式投入使用了。
Concurrent并不是API之类的特性,而是一种能让你的React项目同时具有多个版本UI的幕后机制,相当爱迪生背后的特斯拉。
Concurrent很重要,虽然它不是API之类的新特性,但是如果你想解锁React18的大部分新特性,诸如transition、Suspense等,背后就要依赖Concurrent这位大佬。
是的,如果你不想追求high level,就别学了。
二、Concurrent
什么是Concurrent
Concurrent最主要的特点就是渲染是可中断的。没错,以前是不可中断的,也就是说,以前React中的update是同步渲染,在这种情况下,一旦update开启,在任务完成前,都不可中断。
注意:这里说的同步,和setState所谓的同步异步不是一码事,而且setState所谓的异步本质上是个批量处理。
Concurrent模式特点
在Concurrent模式下,update开始了也可以中断,晚点再继续嘛,当然中间也可能被遗弃掉。
关于可中断
先说可中断这件事情的重要性。对于React来说,任务可能很多,如果不区分优先级,那就是先来后到的顺序。虽然听起来很合理,但是现实是普通车辆就应该给救护车让路,因为事有轻重缓急嘛。那么在React中呢,如果高优先级任务来了,但是低优先级任务还没有处理完毕,就会造成高优先级任务等待的局面。比如说,某个低优先级任务还在缓慢中,input框忽然被用户触发,但是由于主线程被占着,没有人搭理用户,结果是用户哐哐输入,但是input没有任何反应。用户一怒之下就走了,那你那个低优先级的任务还更新个什么呢,用户都没了。
由此可见,对于复杂项目来说,任务可中断这件事情很重要。那么问题来了,React是如何做到的呢,其实基础还是fiber,fiber本身链表结构,就是指针嘛,想指向别的地方加个属性值就行了。
关于被遗弃
在Concurrent模式下,有些update可能会被遗弃掉。先举个🌰:比如说,我看电视的时候,切换遥控器,从1频道切换到2频道,再切换到3频道,最后在4频道停下来。假如这些频道都是UI,那么2、3频道的渲染其实我并不关心,我只关心4频道的结果,如果你非要花时间把2和3频道的UI也渲染出来,最终导致4频道很久之后才渲染出来,那我肯定不开心。正确的做法应该是尽快渲染4频道就行了,至于2和3频道,不管渲染了多少了,遗弃了就行了,反正也不需要了。
最后回到项目的实际场景,比如我想在淘宝搜索“老人与海”,那么我在输入框输入“老人与海”的过程中,“老人”会有对应的模糊查询结果,但是不一定是我想要的结果,所以这个时候的模糊查询框的update就是低优先级,“老人”对应UI的update相对input的update,优先级就会低一些。在现在React18中,这个模糊查询相关的UI可以被当做transition。关于transition,等下我会有细讲。
关于状态复用
Concurrent模式下,还支持状态的复用。某些情况下,比如用户走了,又回来,那么上一次的页面状态应当被保存下来,而不是完全从头再来。当然实际情况下不能缓存所有的页面,不然内存不得爆炸,所以还得做成可选的。目前,React正在用Offscreen组件来实现这个功能。嗯,也就是这关于这个状态复用,其实还没完成呢。不过源码中已经在做了:
另外,使用OffScreen,除了可以复用原先的状态,我们也可以使用它来当做新UI的缓存准备,就是虽然新UI还没登场,但是可以先在后台准备着嘛,这样一旦轮到它,就可以立马快速地渲染出来。
Concurrent总结
总结一下,Concurrent并不是API之类的新特性,但是呢,它很重要,因为它是React18大部分新特性的实现基础,包括Suspense、transitions、流式服务端渲染等。三、React的新特性
前文说了那么多Concurrent并不是新特性,而是React18新特性的实现基础。那么新特性都有哪些呢,下面来看吧:
react-dom/client中的createRoot
创建一个初次渲染或者更新,以前我们用的是ReactDOM.render,现在改用react-dom/client中的createRoot,这个函数的返回值是卸载函数。
ssr中的ReactDOM.hydrate也换成了新的hydrateRoot。
以上两个API目前依然支持,只是已经移入legacy模式,开发环境下会报warning。
自动批量处理 Automatic Batching
如果你是React技术栈,那么你一定遇到过无数次这样的面试题:
先回答上面那个问题,可同步可异步,同步的话把setState放在promises、setTimeout或者原生事件中等。所谓异步就是个批量处理,为什么要批量处理呢。举个例子,老人以打渔为生,难道要每打到一条沙丁鱼就下船去集市上卖掉吗,那跑来跑去的成本太高了,卖鱼的钱都不够路费的。所以老人都是打到鱼之后先放到船舱,一段时间之后再跑一次集市,批量卖掉那些鱼。对于React来说,也是这样,state攒够了再一起更新嘛。
// 以前: 这里的两次setState并没有批量处理,React会render两次
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 1000);
// React18: 自动批量处理,这里只会render一次
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 1000);
所以如果你项目中还在用setTimeout之列的“黑科技”实现setState的同步的话,升级React18之前,记得改一下~ // import { flushSync } from "react-dom";
changeCount = () => {
const { count } = this.state;
flushSync(() => {
this.setState({
count: count + 1,
});
});
console.log("改变count", this.state.count); //sy-log
};
//
transition
React把update分成两种:
Urgent updates 紧急更新,指直接交互,通常指的用户交互。如点击、输入等。这种更新一旦不及时,用户就会觉得哪里不对。
Transition updates 过渡更新,如UI从一个视图向另一个视图的更新。通常这种更新用户并不着急看到。
startTransition
startTransition
可以用在任何你想更新的时候。但是从实际来说,以下是两种典型适用场景:
渲染慢:如果你有很多没那么着急的内容要渲染更新。
网络慢:如果你的更新需要花较多时间从服务端获取。这个时候也可以再结合
Suspense
。
import {useEffect, useState, Suspense} from "react";
import Button from "../components/Button";
import User from "../components/User";
import Num from "../components/Num";
import {fetchData} from "../utils";
const initialResource = fetchData();
export default function TransitionPage(props) {
const [resource, setResource] = useState(initialResource);
// useEffect(() => {
// console.log("resource", resource); //sy-log
// }, [resource]);
return (
<div>
<h3>TransitionPageh3>
<Suspense fallback={<h1>loading - userh1>}>
<User resource={resource} />
Suspense>
<Suspense fallback={<h1>loading-numh1>}>
<Num resource={resource} />
Suspense>
<Button
refresh={() => {
setResource(fetchData());
}}
/>
div>
);
}
Button
import {
//startTransition,
useTransition,
} from "react";
export default function Button({refresh}) {
const [isPending, startTransition] = useTransition();
return (
<div className="border">
<h3>Buttonh3>
<button
onClick={() => {
startTransition(() => {
refresh();
});
}}
disabled={isPending}>
点击刷新数据
button>
{isPending ? <div>loading...div> : null}
div>
);
}
与setTimeout异同
在startTransition
出现之前,我们可以使用setTimeout
来实现优化。但是现在在处理上面的优化的时候,有了startTransition
基本上可以抛弃setTimeout
了,原因主要有以三点:首先,与setTimeout
不同的是,startTransition
并不会延迟调度,而是会立即执行,startTransition
接收的函数是同步执行的,只是这个update被加了一个“transitions"的标记。而这个标记,React内部处理更新的时候是会作为参考信息的。这就意味着,相比于setTimeout
, 把一个update交给startTransition
能够更早地被处理。而在于较快的设备上,这个过度是用户感知不到的。useTransition
在使用startTransition更新状态的时候,用户可能想要知道transition的实时情况,这个时候可以使用React提供的hook api useTransition
。
import { useTransition } from 'react';
const [isPending, startTransition] = useTransition();
如果transition未完成,isPending值为true,否则为false。
useDeferredValue
使得我们可以延迟更新某个不那么重要的部分。
相当于参数版的transitions。
举例:如下图,当用户在输入框输入“书”的时候,用户应该立马看到输入框的反应,而相比之下,下面的模糊查询框如果延迟出现一会儿其实是完全可以接受的,因为用户可能会继续修改输入框内容,这个过程中模糊查询结果还是会变化,但是这个变化对用户来说相对没那么重要,用户最关心的是看到最后的匹配结果。
用法如下:
import {useDeferredValue, useState} from "react";
import MySlowList from "../components/MySlowList";
export default function UseDeferredValuePage(props) {
const [text, setText] = useState("hello");
const deferredText = useDeferredValue(text);
const handleChange = (e) => {
setText(e.target.value);
};
return (
<div>
<h3>UseDeferredValuePageh3>
{/* 保持将当前文本传递给 input */}
<input value={text} onChange={handleChange} />
{/* 但在必要时可以将列表“延后” */}
<p>{deferredText}p>
<MySlowList text={deferredText} />
div>
);
}
MySlowList
import React, {memo} from "react";
function ListItem({children}) {
let now = performance.now();
while (performance.now() - now < 3) {}
return <div className="ListItem">{children}div>;
}
export default memo(function MySlowList({text}) {
let items = [];
for (let i = 0; i < 80; i++) {
items.push(
<ListItem key={i}>
Result #{i} for "{text}"
ListItem>
);
}
return (
<div className="border">
<p>
<b>Results for "{text}":b>
p>
<ul className="List">{items}ul>
div>
);
});
Suspense
可以“等待”目标UI加载,并且可以直接指定一个加载的界面(像是个 spinner),让它在用户等待的时候显示。 }>
<Comments />
</Suspense>
其实Suspense也早就出现在React中了,只不过之前功能有限。在React18中,背靠Concurrent模式,Suspense终于爆发了自己的光彩。基本使用:避免等待太久
import {useState, Suspense} 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 SuspensePage(props) {
const [resource, setResource] = useState(initialResource);
return (
<div>
<h3>SuspensePageh3>
<ErrorBoundaryPage fallback={<h1>网络出错了h1>}>
<Suspense fallback={<h1>loading - userh1>}>
<User resource={resource} />
Suspense>
ErrorBoundaryPage>
<Suspense fallback={<h1>loading-numh1>}>
<Num resource={resource} />
Suspense>
<button onClick={() => setResource(fetchData())}>refreshbutton>
div>
);
}
错误处理
每当使用 Promises,大概率我们会用catch()
来做错误处理。但当我们用 Suspense 时,我们不等待 Promises 就直接开始渲染,这时 catch()
就不适用了。这种情况下,错误处理该怎么进行呢?在 Suspense 中,获取数据时抛出的错误和组件渲染时的报错处理方式一样——你可以在需要的层级渲染一个错误边界组件来“捕捉”层级下面的所有的报错信息。export default class ErrorBoundaryPage extends React.Component {
state = {hasError: false, error: null};
static getDerivedStateFromError(error) {
return {
hasError: true,
error,
};
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
结合transitions
所谓提高用户体验,一个重要的准则就是保证UI的连续性,如下面的例子,如果此时我想把tab从‘photos’切换到‘comments’,但是Comments又没法立马渲染出来,这个时候不可避免地,就会Photos页面消失,显现Spinner的loading页面,等一会儿,Comments页面才姗姗来迟。function handleClick() {
setTab('comments');
}
}>
{tab === 'photos' ? <Photos /> : <Comments />}
</Suspense>
从UI连续性上来说,这个中间出现的Spinner就已经破坏了连续性。而实际上,正常人的反应其实是没有那么快,短暂的延迟我们是感觉不到的。所以考虑到UI的连续性,上面的例子,交互可不可以修改一下,把上面页面的切换当做transitions,这样即使tab切换,但是依然短暂停留在Photos,之后再改变到Comments:
function handleClick() {
startTransition(() => {
setTab('comments');
});
}
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(() => {
setTab('comments');
});
}
}>
<div style={{ opacity: isPending ? 0.8 : 1 }}>
{tab === 'photos' ? <Photos /> : <Comments />}
div>
</Suspense>
SuspenseList
用于控制Suspense组件的显示顺序。
revealOrder
Suspense加载顺序
together
所有Suspense一起显示,也就是最后一个加载完了才一起显示全部
forwards
按照顺序显示Suspense
backwards
反序显示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>SuspenseListPageh3>
<SuspenseList tail="collapsed">
<ErrorBoundaryPage fallback={<h1>网络出错了h1>}>
<Suspense fallback={<h1>loading - userh1>}>
<User resource={resource} />
Suspense>
ErrorBoundaryPage>
<Suspense fallback={<h1>loading-numh1>}>
<Num resource={resource} />
Suspense>
SuspenseList>
<button onClick={() => setResource(fetchData())}>refreshbutton>
div>
);
}
四、新的Hooks
关于useTransition与useDeferredValue上面已经介绍过了,接下来说下React18其它的新Hooks,其中useSyncExternalStore与useInsertionEffect属于Library Hooks。也就是普通应用开发者一般用不到,这俩主要用于那些需要深度融合React模型的库开发,比如Recoil等。
useId
用于产生一个在服务端与Web端都稳定且唯一的ID,也支持加前缀,这个特性多用于支持ssr的环境下:
export default function NewHookApi(props) {
const id = useId();
return (
<div>
<h3 id={id}>NewHookApih3>
div>
);
}
注意:useId产生的ID不支持css选择器,如querySelectorAll。
useSyncExternalStore
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>NewHookApih3>
<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,
};
}
对于还在用自定义store来做低代码项目的我有点开心,可以用于升级我的项目了,原先定义的forceUpdate、unsubscribe之类的,可以去掉了~
useInsertionEffect
useInsertionEffect(didUpdate);
函数签名同useEffect,但是它是在所有DOM变更前同步触发。主要用于css-in-js库,往DOM中动态注入 或者 SVG
。因为执行时机,因此不可读取refs。
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} />;
}
具体内容可以前往:
https://github.com/reactwg/react-18/discussions/110
文章转载于:高老师:https://juejin.cn/post/7080854114141208612
五、最后
在我们阅读完官方文档后,我们一定会进行更深层次的学习,比如看下框架底层是如何运行的,以及源码的阅读。 这里广东靓仔给下一些小建议:- 在看源码前,我们先去官方文档复习下框架设计理念、源码分层设计
- 阅读下框架官方开发人员写的相关文章
- 借助框架的调用栈来进行源码的阅读,通过这个执行流程,我们就完整的对源码进行了一个初步的了解
- 接下来再对源码执行过程中涉及的所有函数逻辑梳理一遍
关注我,一起携手进阶
欢迎关注前端早茶,与广东靓仔携手共同进阶~