React16在16.8版本中推出了Fiber架构设计,极大的解决了大型渲染任务的卡顿问题。但此时的Fiber架构依然不够完善,依然存在一定的问题。React18的到来,相比以前,又有了新的突破
React 18 的核心设计理念:并发渲染
在 React 18 之前,React 的渲染模式是 "同步" 和 "不可中断" 的。
请注意:同步和不可中断我都打了引号,表明这不是真正意义上的同步和不可中断,而仅仅是针对react渲染任务的同步和不可中断(后面会说明)
Fiber的引入
Fiber自16.8版本时便已经引入,引入Filber后的react,终结了传统 React 同步递归处理整个组件树,导致处理时间过长时,阻塞其他任务从而导致卡顿的问题。
如何解决的:
react将整颗渲染树,拆分成一个一个的节点,这个节点就是Fiber,它是一个对象,记录着每个react组件的相关信息。这样一样,react可以在每一帧的时间线内,一次只处理一个Fiber单元的工作(这些工作包括 调用渲染方法收集子元素、diff,标记副作用,内存中生成dom,收集副作用等等),处理完成后,会去查看当前线程是否空闲,如果空闲,才会继续执行下一个Fiber,否则就去做其他要做的事情。通过这种增加细粒度的方式,减少了每个渲染任务的执行时间,从而避免一个大的渲染任务阻塞其他任务(如用户输入等)导致明显卡顿的问题
诶,这就有个疑问了,这不是可中断的吗?为啥说是不可中断的呢,为啥还说它是同步的呢
传统Fiber的弊端
虽然Fiber已经可以将一个大任务切多个小任务了,但是它依然存在一定的弊端。
Fiber的每个任务一旦开始执行,将无法停止,这也意味着,Fiber的所有任务,依然是按照顺序一个一个执行的,前一个任务没有结束之前,下一个Fiber任务将不会开始执行。只是每个Fiber任务执行的间隙之间,可以穿插执行一些其他任务而已。
故,对于每个Fiber任务单元来说,它本质上还是一个同步的过程,并且每个Fiber任务一旦开始执行,将不可中断。
并发渲染
并发渲染的核心思想 :将渲染工作分解成小的单元,在浏览器的每一帧的空闲时期去执行这些工作单元,并且可以被更高优先级的任务(如用户输入)所中断,这就是所谓的抢占式调度
这里有个关键的点,就是Fiber任务添加了一个优先级的概念,高优先级的任务可以中断低优先级任务的执行。且被中断的低优先级任务,不会再缓存,在等待高优先级任务执行完成后,会重新开始执行这个任务,而不会接着之前的进度继续执行。
这里涉及到渲染树的丢弃与重建:
-
React 16.8:中断后,保留WorkInProgress Tree的状态,恢复时继续构建
-
React 18:中断后,可以完全丢弃当前的WorkInProgress Tree,基于最新状态重新开始构建
javascript
// React 18的抢占式中断
function prepareFreshStack(root, nextLanes) {
// 丢弃当前的workInProgress树!
root.workInProgress = null;
workInProgressRoot = null;
// 基于最新的props和state重新开始
workInProgress = createWorkInProgress(root.current, nextLanes);
}
有人又要问了WorkInProgress Tree是什么。
WorkInProgress Tree是react双缓存架构中,缓存在内存中的一颗渲染树
-
Current Tree: 当前屏幕上显示内容对应的 Fiber 树。
-
WorkInProgress Tree: 正在内存中构建的、下一次要渲染的 Fiber 树。
当开始一次更新时,React 会从 Current Tree 的根节点开始,为每个需要更新的 Fiber 节点创建一个 "替身" (在 alternate 上),这些替身共同构成了 WorkInProgress Tree。所有的工作(如调用 render、diff)都在 WorkInProgress Tree 上进行。完成后,通过简单的指针交换,WorkInProgress Tree 就变成了新的 Current Tree。
react18为了实现并发渲染这一根本特性,对Fiber架构做了一系列的深化
1. Fiber对象新增属性:lanes(车道概念,一个表示更新优先级的位掩码。这是实现调度和中断的基础。), 用来标识任务的优先级
javascript
// React内部的车道定义(简化)
export const SyncLane = 0b0000000000000000000000000000001; // 最高优先级
export const InputContinuousLane = 0b0000000000000000000000000000100; // 用户输入
export const DefaultLane = 0b0000000000000000000000000010000; // 普通更新
export const TransitionLane = 0b0000000000000000000000000100000; // 过渡更新
export const IdleLane = 0b0100000000000000000000000000000; // 最低优先级
2. 自动批处理 :
在 React 17 及以前,只有在 React 的事件处理函数(如 onClick)中的多次 setState 会被批处理,合并成一次重新渲染。而在 setTimeout, Promise, 原生事件监听器等中的 setState 不会批处理,导致多次渲染。
React18在任何地方(事件处理函数、setTimeout、Promise 等)的更新都会被自动批处理。这减少了不必要的渲染,提升了性能。
实现原理:React 18 有一个统一的"更新上下文"机制。在执行任何用户代码时,React 会先设置一个"批处理上下文",在这个上下文中触发的所有更新都会被收集起来,在上下文退出时统一处理。如果需要强制同步更新,可以使用 ReactDOM.flushSync() 退出批处理。
让我们来看一段代码:
javascript
// 这是一个React17的demo
import React, { useEffect, useState } from 'react';
import './App.css';
function App() {
console.log("React17 is Rerender=====")
const [count, setCount] = useState(0);
const [name, setName] = useState("");
const [inputValue, setInputValue] = useState("123");
useEffect(() => {
setTimeout(() => {
setCount(count + 1);
setName(`${name}+`);
setInputValue(`${inputValue}-`);
}, 0)
}, [])
return (
<div className="App">
demo
</div>
);
}
export default App;
此时,刷新页面,控制台输出为:
可以看到,除了首次的页面渲染,setTimout中的3次状态更新都触发了一次render
再看下react18上的表现
javascript
// 这是React 18的demo
import { useState } from 'react'
import './App.css'
import { useEffect } from 'react'
function App() {
console.log('React18 is rerender====')
const [count, setCount] = useState(0);
const [name, setName] = useState("");
const [inputValue, setInputValue] = useState("123");
useEffect(() => {
setTimeout(() => {
setCount(count + 1);
setName(`${name}+`);
setInputValue(`${inputValue}-`);
}, 0)
}, [])
return (
<>
<div>
demo
</div>
</>
)
}
export default App
此时,控制台输出:
可以看出,除了首次渲染外,setTimout内的3次状态更新仅触发了一次render
3. startTransition - 非紧急更新
react18新增一个api startTransition,它能将状态的更新标记为非紧急更新(优先级lanes会更低),而react18在执行Fiber任务时,会优先执行优先级高的任务,再执行优先级低的任务。
javascript
import { startTransition } from 'react'
import { useState } from 'react'
import './App.css'
import { useEffect } from 'react'
function App() {
const [count, setCount] = useState(0)
const [input, setInput] = useState('')
const handleClick = () => {
// startTransition(() => {
// setInput(`${input}+`)
// })
setInput(`${input}+`)
setCount(count + 1)
}
useEffect(() => {
console.log("input: 变化了")
}, [input])
useEffect(() => {
console.log("count: 改变了")
}, [count])
return (
<>
<div>
<button
onClick={handleClick}
>
更新数据
</button>
</div>
</>
)
}
export default App
这是未使用startTransition的代码,正常情况下,很明显,useEffect会依次输出
此时,我们使用startTransition去改变input
javascript
import { startTransition } from 'react'
import { useState } from 'react'
import './App.css'
import { useEffect } from 'react'
function App() {
const [count, setCount] = useState(0)
const [input, setInput] = useState('')
const handleClick = () => {
startTransition(() => {
setInput(`${input}+`)
})
// setInput(`${input}+`)
setCount(count + 1)
}
useEffect(() => {
console.log("input: 变化了")
}, [input])
useEffect(() => {
console.log("count: 改变了")
}, [count])
return (
<>
<div>
<button
onClick={handleClick}
>
更新数据
</button>
</div>
</>
)
}
export default App
此时控制台输出:
4. useDeferredValue - 延迟值
react还提供了useDeferredValue用于延迟一个值的更新,本质就是降低需要延迟的值的变化导致的渲染优先级。
我们先来看个例子,场景:输入框输入
javascript
// 场景:输入框输入
import { useDeferredValue, useState, useMemo } from 'react';
function SearchBox() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// 基于延迟的查询值进行计算
const results = useMemo(() => {
return searchAPI(deferredQuery);
}, [deferredQuery]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList results={results} />
</div>
);
}
原理:
这段代码中,当用户不断快速的输入值时,query会快速的不断变化,页面会快速不断的渲染,但是deferredQuery的值的更新,却是延迟的,因为它的更新优先级很低。
这可以避免什么问题呢
当searchAPI是一个昂贵的操作时,如果直接依赖query,那么每次query的改变,都将触发searchAPI这个昂贵操作的时候,可能导致卡顿的出现。而依赖deferredQuery时,deferredQuery的改变是延迟的,当query不断快速变化时,query的变化是高优先级的,会不断的中断deferredQuery这个低优先级的变化,导致deferredQuery会等到query不停的变化完成后(用户可能停止输入),deferredQuery才发生改变,从而使searchAPI只执行一次。
假设用户快速输入hello
当依赖query时:
clike
用户输入: h -> 搜索: h
用户输入: he -> 搜索: he
用户输入: hel -> 搜索: hel
用户输入: hell -> 搜索: hell
用户输入: hello -> 搜索: hello
当依赖deferredQuery时
clike
用户输入: h -> deferredQuery: "" (可能还是空,取决于用户是否快速输入)
用户输入: he -> deferredQuery: "" 或 "h"
用户输入: hel -> deferredQuery: "h" 或 "he"
用户输入: hell -> deferredQuery: "he" 或 "hel"
用户输入: hello -> deferredQuery: "hel" 或 "hell"
用户停止输入后 -> deferredQuery: "hello"
5. Concurrent Features :并发特性的开关
React 18 并没有默认开启完全的并发模式,而是提供了三个"模式"入口,让你可以渐进式地使用并发特性:
-
ReactDOM.createRoot: 这是开启并发特性的唯一方式。使用这个 API 创建的根节点,其内部更新才有可能以并发方式执行。
-
ReactDOM.render: 传统模式,所有更新仍然是同步的。
-
ReactDOM.createBlockingRoot: 遗留模式,介于两者之间。
关键点: 即使使用了 createRoot,也不是所有更新都是并发的。它只是为并发提供了可能。更新的具体行为取决于你使用的 API。比如上面说的startTransition 或者 useDeferredValue
总结:
React 16的Fiber架构提供了可中断的渲染的基础,但是并没有根据优先级来调度更新,而是按照顺序处理更新,因此一旦开始渲染,就会一直直到完成,中间不会因为更高优先级的更新而中断。
React 18则实现了完整的并发渲染,它可以根据优先级来中断和重新调度更新,从而让用户交互等紧急更新能够立即生效。
在React 18中,并发(Concurrency)并不是指同时执行多个任务(并行),而是指在单个JavaScript线程中,通过任务调度和中断机制,让多个更新任务可以"同时"进行(即交替执行),从而能够优先处理高优先级的更新,提升用户体验。
React 18的并发渲染核心在于:更新可以有优先级,并且渲染工作可以被中断和恢复,而且可以根据优先级进行调度。