「b站视频讲解」
今天9点多,和往常一样,我和白骨精、菠萝、大鱼、大侦探、99群里小伙伴发完早安后继续睡。11点多,我还没到公司,后厂村堵车,群里小伙伴和我说React18正式发版了已经,我。。。
也是巧了,我上周才把mini react的手写hook视频教程录制完毕,打算这周轻松点,做点React源码的文章和视频,昨天才重新配置了下React18 RC的DebugReact,今天React18的正式版就出了,虽然大部分我在B站早就讲过了,地址:www.bilibili.com/video/BV1rK… 本文相关的内容,明天我会在B站更新视频教程,如果明天拔牙不太影响我说话~
不废话了,接下来上React18。
Concurrent
背景
React18最重要的改变必须是Concurrent,就像哪吒降生一样,打磨了很长时间了,终于正式见人了。
Concurrent Or Concurrency,中文我们通常翻译为并发,也有少部分翻译成并行。React已经着手开发Concurrent几年了,但是一直只存在于实验版本。到了React18,Concurrent终于正式投入使用了。
Concurrent并不是API之类的特性,而是一种能让你的React项目同时具有多个版本UI的幕后机制,相当爱迪生背后的特斯拉。
Concurrent很重要,虽然它不是API之类的新特性,但是如果你想解锁React18的大部分新特性,诸如transition、Suspense等,背后就要依赖Concurrent这位大佬。
React虽然一直在强调开发者并不真的需要了解Concurrent是什么,但是忽然来了一句:
So while it’s not super important to know how concurrency works, it may be worth knowing what it is at a high level.
「虽然掌握 concurrency 工作的细节并非至关重要,但是从大体上了解它一下还是有必要的。」
什么是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:
const root = createRoot(document.getElementById("root"));
root.render(jsx);
这里的root对象上会有render和unmount函数,但是这里的unmount并不接受callback。
以前ReactDOM.render第三个参数是在更新完成执行的callback,而在React18中,这样的callback会建议放在useEffect中:
const root = createRoot(document.getElementById("root"));
function AppWithCallbackAfterRender() {
useEffect(() => {
console.log('rendered');
});
return jsx
}
root.render(<AppWithCallbackAfterRender/>);
ssr中的ReactDOM.hydrate也换成了新的hydrateRoot。
以上两个API目前依然支持,只是已经移入legacy模式,开发环境下会报warning。
自动批量处理 Automatic Batching
如果你是React技术栈,那么你一定遇到过无数次这样的面试题:
setState是同步还是异步,可以实现同步吗,怎么实现,异步的原理是什么?
恭喜你,接下来React18之后,这个面试题中的前半部分可以被划入史册了,但是后半部分依然是你我React技术党逃不开的宿命。不过也不是什么大事,谁让你认识我呢~
先回答上面那个问题,可同步可异步,同步的话把setState放在promises、setTimeout或者原生事件中等。所谓异步就是个批量处理,为什么要批量处理呢。举个例子,老人以打渔为生,难道要每打到一条沙丁鱼就下船去集市上卖掉吗,那跑来跑去的成本太高了,卖鱼的钱都不够路费的。所以老人都是打到鱼之后先放到船舱,一段时间之后再跑一次集市,批量卖掉那些鱼。对于React来说,也是这样,state攒够了再一起更新嘛。
但是以前的React的批量更新是依赖于合成事件的,到了React18之后,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之前,记得改一下~
虽然建议setState批量处理,但是如果你有一些其它理由或者需要应急,想要同步setState,这个时候可以使用flushSync,下面的例子中,log的count将会和button上的count同步:
// import { flushSync } from "react-dom";
changeCount = () => {
const { count } = this.state;
flushSync(() => {
this.setState({
count: count + 1,
});
});
console.log("改变count", this.state.count); //sy-log
};
// <button onClick={this.changeCount}>change count 合成事件</button>
Suspense
可以“等待”目标UI加载,并且可以直接指定一个加载的界面(像是个 spinner),让它在用户等待的时候显示。
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
其实Suspense也早就出现在React中了,只不过之前功能有限。在React18中,背靠Concurrent模式,Suspense终于爆发了自己的光彩。
在概念上,Suspense有点像catch,只不过Suspense捕获的不是异常,而是组件的suspending状态,即挂载中。
基本使用:避免等待太久
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>SuspensePage</h3>
<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>
<button onClick={() => setResource(fetchData())}>refresh</button>
</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;
}
}
SuspenseList
SuspenseList在DebugReact中可用,也就是DEV环境下可以用。目前还未完成,预计18.X会正式支持,以下例子当做参考,以后也许会改变。
用于控制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>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>
);
}
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>TransitionPage</h3>
<Suspense fallback={<h1>loading - user</h1>}>
<User resource={resource} />
</Suspense>
<Suspense fallback={<h1>loading-num</h1>}>
<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>Button</h3>
<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。
Suspense与transitions结合
所谓提高用户体验,一个重要的准则就是保证UI的连续性,如下面的例子,如果此时我想把tab从‘photos’切换到‘comments’,但是Comments又没法立马渲染出来,这个时候不可避免地,就会Photos页面消失,显现Spinner的loading页面,等一会儿,Comments页面才姗姗来迟。
function handleClick() {
setTab('comments');
}
<Suspense fallback={<Spinner />}>
{tab === 'photos' ? <Photos /> : <Comments />}
</Suspense>
从UI连续性上来说,这个中间出现的Spinner就已经破坏了连续性。而实际上,正常人的反应其实是没有那么快,短暂的延迟我们是感觉不到的。所以考虑到UI的连续性,上面的例子,交互可不可以修改一下,把上面页面的切换当做transitions,这样即使tab切换,但是依然短暂停留在Photos,之后再改变到Comments:
function handleClick() {
startTransition(() => {
setTab('comments');
});
}
上面这个例子我们使用的是startTransition,如果需要知道pending状态,可以使用useTransition:
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(() => {
setTab('comments');
});
}
<Suspense fallback={<Spinner />}>
<div style={{ opacity: isPending ? 0.8 : 1 }}>
{tab === 'photos' ? <Photos /> : <Comments />}
</div>
</Suspense>
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>UseDeferredValuePage</h3>
{/* 保持将当前文本传递给 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>
);
});
新的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}>NewHookApi</h3>
</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>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,
};
}
对于还在用自定义store来做低代码项目的我有点开心,可以用于升级我的项目了,原先定义的forceUpdate、unsubscribe之类的,可以去掉了~
useInsertionEffect
useInsertionEffect(didUpdate);
函数签名同useEffect,但是它是在所有DOM变更前同步触发。主要用于css-in-js库,往DOM中动态注入<style>
或者 SVG <defs>
。因为执行时机,因此不可读取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} />;
}
React18源码学习
说了这么多React18,大家有没有好奇React18源码到底是怎么写的呢,接下来我们就来学习下吧。React源码本身很庞大,如果说一点点去看的话,怕是要看到天荒地老了,接下来我先给大家一个项目,就是React源码调试项目,也许你尝试过sourcemap的调试方法,但是那样你只能打断点,没法留下自己的注释,也很难下次接着上次的看。
所以不如来用下我的DebugReact项目吧,在这个项目里,src里有一个react的无压缩包是我从github的facebook/react直接下载下来的,稍作修改之后,我再引用的React API就是来自这个未压缩的react了,也就是说你可以随意打log或者debug来调试源码。
这个DebugReact我会经常更新,项目地址是:github.com/bubucuo/Deb… ,欢迎star支持。当然github上的这个项目里没有react包,因为太大了,传不上去,所以你需要自己下载react再做些修改,修改方法我放在md里了,但是这个文件之后可能会发生改变。建议使用我放在公众号上的压缩包,关注我的公众号“bubucuo”,回复“debug”即可获取包链接,这个包我会不定期更新最新的~ 关于DebugReact,已完成如下:
1. 为什么学习源码
-
如何调试React18源码
-
解析Concurrent模式
-
React18新特性
5. 类组件初次渲染与更新流程
-
类组件的setState原理
-
React VDOM DIFF流程剖析
-
函数组件初次渲染与更新流程
-
函数组件的setState
-
Hooks原理与源码解析
-
React中的合成事件
-
任务调度
-
任务调度算法实现
04 mini react
最后再给大家介绍一个我目前在做的react项目:mini react,手写一套react,我会用简单易懂又符合源码的逻辑来实现react,帮助大家的react源码学习、项目深入与面试。仓库地址是:github.com/bubucuo/min…
目前已经完成功能列表如下:
-
mini react指南
-
VDOM与fiber
-
fiber的构建与任务执行
-
初次渲染
-
实现原生节点初次渲染
-
实现函数组件、类组件、文本、Fragment的初次渲染
-
React中的任务调度与最小堆
-
实现任务调度
10. 实现useReducer
11. 实现useState
-
节点的删除与更新
-
修改目录
14. 删除多个老节点
-
实现完整的React VDOM DIFF
-
对比React 与Vue的 VDOM DIFF
-
实现useEffect与useLayoutEffect
最后
如果你也想对React源码、React周边框架源码如router、redux、mobx等、从算法中学习前端、低代码项目感兴趣,欢迎加入~
关注公众号“bubucuo”,回复“菠萝”加入技术群,回复“大鱼”加入读书群,也可直接回复“1”联系我~