React18,有意思的并发模式 -- 包含新特性讲解,可以各取所需
React18,有意思的并发模式 -- 包含新特性讲解,可以各取所需
开始前
• 本文讲的全部是 客户端 发生的改动,至于 node 端的改动涉及到了SSR,以后有机会会新开一篇来讲解,流式渲染这个东西也是挺有意思的
• 本文所有的讲解都会贴上代码,感兴趣的可以自己粘出来自己试试
• 通篇都是大白话,不会有什么思维负担,如果只是对新特性感兴趣可以只看前边带序号的标题内容即可
什么是并发模式
新版本的用户体验方面可以说是更上了一层楼,难产多年的并发模式也是终于提到了台面上,官方更是为了这次更新推出了好几个新的hook
来支持并发模式这一特性
所以本次更新的主要核心也是围绕着并发模式而展开的,那么并发模式是个什么东西?
对于 JS 来说并不存在传统的并发,因为JS是单线程,它做不到其他语言那样,可以通过多进程多线程来达到一坨子任务一起执行的效果,对于JS来说,所谓的并发就是同时间创建多个异步任务执行
即便是并行创建的异步任务也是有可能会出现卡顿的,因为JS的队列 Event Loop
单线程的特性,再怎么延时执行,如果前一个不执行完,下一个任务无论怎样都必须得等待着
所以并发模式要解决的两件事就出来了
1. 如何让我们的视图也能像是在并发一样,多个UI更改可以一起执行
2. 如何解决因任务堵车的情况所导致的,卡顿,延时等情况
下面来看下新出的 API 都干了什么事,然后在回过头来解答这两个问题
0. 搭建环境
这里用的是 vite + ts + react18
的环境,webpack
可以用 create-react-app
来创建
创建项目工程
https://www.vitejs.net/guide/#scaffolding-your-first-vite-project
pnpm create vite react18 -- --template react-ts
升级下版本
因为截止目前(2022.4.8)官方的模板默认还是 17
pnpm add react@latest react-dom@latest
改变挂载方式
18版本挂载方式和 17 不同
1. 现在需要用
createRoot
来代替以前的ReactDom.render
方法,这时将会开启并发模式2.
createRoot
会创建一个容器然后使用render
方法渲染组件
//以前
import ReactDOM from "react-dom"
ReactDOM.render(
<App/>,
document.querySelector("#root")
)
//现在
import { createRoot } from "react-dom/client";
import App from "./App";
const app = createRoot(document.querySelector("#root")!)
app.render(<App/>)
适配 TS
如果引入新版本的 API 提示报错的话就配置下
需要在 tsconfig.json
中显示声明下类型,在types
中新增以下内容
"react/next", "react-dom/next"
"types": ["react/next", "react-dom/next"]
如果还是报错就重新装下声明文件
pnpm add @types/react@latest @types/react-dom@latest
1. 不一样的批量更新 -- 并发模式
为了性能考虑,我们的更新都是异步进行的,这样框架可以在更新时把这多次更新合并为一次,来达到性能优化的目的
因为内部调度的机制,在以前对于 react 来说,如果我们将更新放在了异步任务中,则会变成类似于 vue 的 nextTick 一样的效果,组件会在本次更新视图完成后,立即进行下一次的更新
function App() {
const [n, setN] = useState(0)
const handleChange = () => {
setN(1)
setTimeout(() => {
debugger
setN(n => v + 1)
}, 0)
}
return null
}
而在并发模式下,即使是异步任务,或者微任务都将可以享受到批处理(更新合并)
好处是,减少了多次渲染的开销
坏处是,以前这种更新后立即更新的做法会受到影响
2. 同步更新 -- 跳出批量更新
因为并发模式对于批处理(更新合并)的强大处理,让上边举得例子的做法变得充满了不确定性
于是官方出了个专门的方法 -- flushSync
,用来在我们想要的时候告诉 react 哪些更新是不需要批处理的
flushSync
名字也很直接,作用就和名字一样,同步更新
以下 demo 可以粘贴试试,不同的更新下,可以看到在debugger中视图展示的内容是完全不一样的, flushSync
会立即更新
import { useLayoutEffect, useState } from 'react'
import { flushSync } from 'react-dom'
export default function FlushSync() {
const [num1, setNum1] = useState(0)
const [num2, setNum2] = useState(0)
const asyncChange = () => {
setNum1(num1 + 1)
setNum2(num2 + 1)
debugger
}
const syncChange = () => {
flushSync(() => {
setNum1(num1 + 1)
setNum2(num2 + 1)
})
debugger
}
useLayoutEffect(() => {
console.log(num1, num2)
}, [num1, num2])
return <div>
<p>num1: {num1}</p>
<p>num2: {num2}</p>
<p>
<button onClick={asyncChange}>异步++</button>
<button onClick={syncChange}>同步++</button>
</p>
</div>
}
3. 新增 hook -- 5个
useId
作用:用于返回一个唯一的 id
有时候我们会想要给元素或者某些地方,创建个唯一的标识,并且标识还带有一些随机性
比如通过这种方式 Math.random()
或者 new Date().getTime()
如果只是一个端这确实是可以的,但是如果涉及到服务端渲染就不好使了
因为服务端渲染,需要先在服务端跑一遍组件生成流或字符串,然后在到达浏览器时进行注水( 可以想成又跑了次组件 ),如果使用无厘头随机的方式创建唯一 id,这两端的内容就会出现不同一而出问题
使用 demo
import { useId } from 'react'
export default function UId() {
const id = useId()
return (
<div>
useId -- {id} <Child />
</div>
)
}
function Child() {
const id = useId()
return <h1>child id -- {id} </h1>
}
使用场景
1. 服务端渲染
2. 懒得写随机数时,用它会方便些
useDeferredValue
作用:延迟更新后的值反应在页面中
每当我们进行输入后(比如 input 组件),会进行大量的逻辑计算等一些非常耗时的操作,因为 JS 单线程的缘故,假如这些耗时的逻辑计算不完成我们,页面就会出现假死,卡顿,键盘不灵的情况,这样用户体验就会很不好
基于 react 的调度系统,我们可以让 输入框的部分实时渲染,而需要基于经过大量计算逻辑才能输出的视图部分延时展示,这就会产生一种明明计算能力不够,但是页面依然流畅的感觉
useDeferredValue
的作用就有了,当每次监听的值发生改变后,如果当前有足够的时间和资源给我们,就会立即返回最新值,反之则会延时返回给我们最新值,于是我们就可以基于这个延时的值来判断说,是否应该进行耗时任务
使用 demo,这个例子中每次输入都伴随着大量的耗时计算,使用了useDeferredValue
的值的列表在资源不足时跳过了一部分,因为新值速度很快所以用户无法察觉中间的丢失,而未使用的列表则会实时刷新,造成卡顿
import { ChangeEvent, useDeferredValue, useEffect, useState } from 'react'
export default function DeferredValue() {
const [input, setInput] = useState('')
//接受一个基本值,
const deferInput = useDeferredValue(input)
const handleInput = (e: ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value)
}
return (
<div>
<p>
<input type="text" onChange={handleInput} />
</p>
<List type="延迟列表" input={deferInput} />
<List type="普通列表" input={input} />
</div>
)
}
function List({ type = '', input = '' }: Record<string, any>) {
const [count, setCount] = useState(-1)
const [list, setList] = useState(() => makeList(input))
useEffect(() => {
setCount(count + 1)
setList(makeList(input))
}, [input])
return (
<div style={{ display: 'inline-block' }}>
<hr />
<h2>{type}</h2>
<h3>触发次数 {count}</h3>
<ul>
{list.map((item, index) => (
<li key={index}>
{item.input}--{item.value}
</li>
))}
</ul>
<hr />
</div>
)
}
// 模拟很消耗 CPU 的任务
function makeList(input: string) {
return [...new Array(1000).keys()].map(() => ({
input,
value: Math.random() * 100000
}))
}
useDeferredValue
做的事很像是我们通常所做的 节流防抖,作用其实是差不多的,不同的是,作为使用者的我们只能粗略的给一个延时时间,而useDeferredValue
是基于 react 内部的调度来动态选择延时,这样的用户体验会更好
使用场景:类比可用于视图的 节流防抖函数的使用场景
useTransition
作用:告诉 react 可以懒执行哪些更新任务
和 useDeferredValue 类似,只不过useDeferredValue 延时的是值,而useTransition
延时的是更新任务
没有参数
返回值
1. 内部的过度状态
2. 一个回调函数,函数内部执行的
setState
方法将会延时执行,延时期间将内部过度装状态变成 true
使用 demo,每次更新都会有 loading 的状态
import {
ChangeEvent,
useDeferredValue,
useEffect,
useState,
useTransition
} from 'react'
export default function Transition() {
const [input, setInput] = useState('')
const handleInput = (e: ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value)
}
return (
<div>
<p>
<input type="text" onChange={handleInput} />
</p>
<List input={input} />
</div>
)
}
function List({ input = '' }: Record<string, any>) {
const [list, setList] = useState<any[]>([])
const [isPending, startTransition] = useTransition()
useEffect(() => {
startTransition(() => setList(makeList(input)))
}, [input])
return isPending ? (
<h1>pending...</h1>
) : (
<div style={{ display: 'inline-block' }}>
<ul>
{list.map((item, index) => (
<li key={index}>
{item.input}--{item.value}
</li>
))}
</ul>
</div>
)
}
// 模拟很消耗 CPU 的任务
function makeList(input: string) {
return [...new Array(10000).keys()].map(() => ({
input,
value: Math.random() * 100000
}))
}
使用场景:建议有类似的耗时任务都可以大量使用
useInsertionEffect
这个 API 主要是提供给 css in js
的库作者的,它最大的特点是执行的时机,它将会在每次视图更新后第一时间执行
和常规的副租用 hook 相比,执行顺序如下
1. useInsertionEffect 2. useLayoutEffect 3.useEffect
使用 demo
import { useEffect, useInsertionEffect, useLayoutEffect } from "react"
export default function InsertionEffect() {
useInsertionEffect(() => {
console.log( "1. useInsertionEffect" )
})
useLayoutEffect(() => {
console.log( "2. useLayoutEffect" )
})
useEffect(() => {
console.log( "3. useEffect" )
})
return <div>useInsertionEffect</div>
}
使用场景:不建议用,因为要想要优先执行放在useLayoutEffect
是完完全全够了的,如果只是为了尽可能追求快而使用,那么这个 API 将变得毫无意义
useSyncExternalStore
这是提供给库用的,它的作用是连接 react 内部与外部 状态管理 的一个桥梁
之所以为了外部的状态专门出了个 API 是因为,在并发模式下可能会出现竟态所引起的状态错误的情况
在以前,每次更新都意味着,触发更新 ->计算状态 -> 同步到视图 这种一比一的关系。而在并发模式下呢,在计算状态时,可能会出现更新到一半,发现预留的更新时间超时了或者有优先级更高的任务进来了,那么此时更新状态会被保存,等到前边的大哥忙完了在继续自己的更新
这就会导致一个问题,假如外边有一个对象const store = { open: false }
, 我在上半程把 open 改成了 true,那么如果中间插腿进来的大哥任务把 open 改回了 false,那么当更新下半程时状态就不对了,因为我上半场白忙活了
这就是竟态的隐患,为了解决这种隐患,useSyncExternalStore
会在每次任务执行时使用用户提供的判断函数的结果来判断,如果我们返回 true 则 react 知道出现竟态了,为了保证状态的正确会立即进行一轮更新来保证状态是对的
参数有 3 个
1. 回调函数会接收到一个订阅函数,我们可以将其保存,并在需要的时候调用来从外部触发更新
2. 返回一个布尔值的快照函数,作用是告诉 react 状态有没有过期,过期了会立马重新更新
3. 和参数 2 一样,只不过是用于 服务端
使用 demo,对于我们开发者来说自己使用的概率很低
import { useSyncExternalStore } from 'react'
const store = {
data: {
msg: 'from store'
},
listeners: [] as any[],
getSnapshot() {
return this.data.msg
},
subscribe(onStoreChange: () => void) {
this.listeners.push(onStoreChange)
return () => {
this.listeners.splice(this.listeners.indexOf(onStoreChange), 1)
}
},
dispatch() {
this.listeners.forEach(f => f())
}
}
export default function SyncExternalStore() {
const res = useSyncExternalStore(
store.subscribe.bind(store),
store.getSnapshot.bind(store)
)
console.log( res )
return <div>useSyncExternalStore</div>
}
4. 更加强大的 Suspense
Suspense
的作用是,如果内部存在挂起(比如懒加载组件时)将会进行状态回退(使用 fallback 的UI 占位)
但在以前如果审查元素,观察 DOM 树就会发现一个很搞笑的情况,除了尚未加载的组件其实还是会加载,只是看不见
而现在则是真正做到了全部回退
使用 demo,可以放在不同版本的 react 试试,然后观察下 dom 树结构的内容变化
import { FC, lazy, Suspense, useEffect } from "react"
function AsyncComponent() {
useEffect(() => {
console.log( "AsyncComponent" )
}, [])
return <h1>AsyncComponent</h1>
}
const LazyAsyncComponent = lazy(() => {
return new Promise<{default: FC}>(resolve => {
setTimeout(() => {
resolve({ default: AsyncComponent })
}, 5000)
})
})
function SyncComponent() {
useEffect(() => {
console.log( "SyncComponent" )
}, [])
return <h1>SyncComponent</h1>
}
export function TestSuspense() {
return <Suspense fallback={<h1>loading component...</h1>}>
<SyncComponent />
<LazyAsyncComponent />
</Suspense>
}
未来的看法
现在回来开头提出的两个问题,这两个问题可以一起回答
react 和 vue 不同,因为 jsx 的高度灵活性导致很那做到 vue3 那样,可以用静态分析来高度优化组件性能的目的。所以在大方向选择了调度,它依然没有解决因大量计算导致的资源占用的总耗时,但是却可以做到区分优先级,给用户一种貌似很快的假象
并发模式,fiber,这次新出的 API,都是在想尽办法,如何让宝贵的资源和算力花在刀刃上
在以后 Suspense
或将成为焦点,围绕调度做的事已经相当多了,增了异步支持的批处理,能延时值的useDeferredValue
,能延时计算的useTransition
,但是耗时的任务再怎么延时都要花那么长的时间,所以Suspense
的特性或许可以深度挖掘一下
毕竟Suspense
的主要作用宏观的概括就是,如果内部状态在尚未处理好前,可以用另外的方式在占位或者填充,给用户更好的用户体验