为什么需要虚拟DOM

先说结论:

  1. 操作真实 DOM 的代价是巨大的
  2. 虚拟 DOM 为框架自动优化 DOM 操作提供了可能

什么是虚拟 DOM

以 JS 的形式描述真实的 DOM 结构。

操作真实 DOM 存在什么问题

从浏览器内核结构来说,在内存中,DOM 模块独占一块内存,这和 JS 引擎所管理的内存并无直接关系,也就是说 JS 引擎不能直接操作真实 DOM 树
为了给 JS 提供操作 DOM 树的能力,浏览器在全局对象上为 JS 封装了一个document对象,该对象上封装了大量操作 DOM 的接口,这些接口都是 c++实现的。

1
window.document.getElementById();

当我们调用这个函数时,JS 引擎没有直接和 DOM 操作交互,而是由浏览器来操作 DOM,再由浏览器把操作结果返回给 JS 引擎。所以我们说操作真实 DOM 的代价是比较大的(还涉及到 c++和 javascript 数据结构转换的问题)。

早期模板引擎(EJS)存在的问题

模板引擎的思想,让我们只需要关注数据层的变化。但是还存在以下问题:

  • 每次数据变化都是重新渲染整个列表视图
  • 当数据量巨大时,性能不尽人意

虚拟 DOM 解决了什么问题

为了解决模板引擎的问题,虚拟 DOM 出生了。
既然模板解决方案每次数据变化都是重新渲染整个列表视图,那只操作变化部分的 DOM 不就好了。既然操作真实 DOM 性能损耗巨大,那操作假的不就好了。
对比下:

  • 模板引擎的渲染:数据 + 模板 –> 直接渲染为真实 DOM –> 挂载至页面
  • 虚拟 DOM 的渲染:数据 + 模板 –> 虚拟 DOM –> 真实 DOM –> 挂载至页面

虚拟 DOM 带来的好处

  • 页面性能的提升:只处理变化的 DOM,减少操作真实 DOM 的次数
  • 开发效率的提升:react 中的 jsx、vue 模板语法
  • 跨平台得以实现:一份代码,多端使用

react-this.setState是同步还是异步的

  1. 如果需要依赖更新后的值,如果是classComponent,我们可以在componentDidUpdate中执行,或者this.setState的第二个参数。如果是functionComponent,我们可以在useEffect中执行
  2. 不同模式下的 react,这个答案是不一样的

本文讨论第二点

react 的三种模式:

  • legacy,react 当前使用的模式,ReactDOM.render
  • blocking,开启部分concurrent模式特性的中间模式
  • concurrent,react v18 启用。任务中断/任务优先级都是针对concurrent模式

lagecy 模式

lagecy 模式下,this.setState触发的更新是异步的。因为它会命中batchedUpdates函数。

batchedUpdates

定义:
batchedUpdates 表示批处理,react 会将多次**this.setState**合并为一次更新,并异步执行。这样只会触发一次render函数,以此提高性能。

实现:
fn:包含this.setState的函数,比如点击事件、生命周期等。
react 内部有个batchedUpdates 函数,在执行这个fn之前,会给全局变量executionContext附加上BatchedContex这个 flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export function batchedUpdates(fn, a) {
var prevExecutionContext = executionContext;
executionContext |= BatchedContext;

try {
return fn(a);
} finally {
executionContext = prevExecutionContext;

if (executionContext === NoContext) {
resetRenderTimer();
flushSyncCallbackQueue();
}
}
}

然后它会执行这个 fn,执行完之后,会把BatchedContexexecutionContext去除。

如果全局变量executionContext包含了BatchedContext,他就会认为这是一次批处理。批处理中的setState会被合并为一次更新。
classComponent 为例
例子一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { PureComponent } from 'react';

export default class MyClassCmp extends PureComponent {
constructor() {
super();
this.state = {
count: 0,
};

this.onAdd = this.onAdd.bind(this);
}

onAdd() {
this.setState({ count: this.state.count + 1 });
this.setState({ count: this.state.count + 1 });
console.log('after', this.state.count); // 输入 0
}

componentDidUpdate() {
console.log('componentDidMount', this.state.count); // 输出 1
}

render() {
const { count } = this.state;
return (
<div>
count: {count}
<button onClick={this.onAdd}>add</button>
</div>
);
}
}

onAdd中调用this.setState,会被认为是批处理,并不能马上获取到最新的count。可以在componentDidUpdate钩子中或者this.setState的第二个参数中获取到最新值。

如何跳出batchedUpdates

如果fn中触发的this.setState是异步执行的话,等this.setState执行的时候,全局的executionContext就已经不存在BatchedContext,他就会跳出批处理。
跳出批处理后,每次调度更新都会执行scheduleUpdateOnFiber函数,函数内部有段逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// react V < 18
function scheduleUpdateOnFiber(fiber, lane, eventTime) {
if (lane === SyncLane) {
// 省略其他代码...

if (executionContext === noContext) {
flushSyncCallbackQueue();
}
}
}

// 这里不用关心,只是单纯了解下18版本源码
// react V18
// flushSyncCallbacksOnlyInLegacyMode 命名更语义化了
function scheduleUpdateOnFiber(fiber, lane, eventTime) {
if (
lane === SyncLane &&
executionContext === NoContext &&
(fiber.mode & ConcurrentMode) === NoMode && // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode.
!ReactCurrentActQueue$1.isBatchingLegacy
) {
// Flush the synchronous work now, unless we're already working or inside
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
// scheduleCallbackForFiber to preserve the ability to schedule a callback
// without immediately flushing it. We only do this for user-initiated
// updates, to preserve historical behavior of legacy mode.
resetRenderTimer();
flushSyncCallbacksOnlyInLegacyMode();
}
}

也就是说,如果executionContext什么都没有的话,会执行fulshSyncCallbackQueue函数,同步的执行这次更新。

例子二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
onAdd () {
// this.setState({ count: this.state.count+1 })
// this.setState({ count: this.state.count+1 })
// console.log('after', this.state.count); // 输入 0

setTimeout(() => {
this.setState({ count: this.state.count+1 })
this.setState({ count: this.state.count+1 })
console.log('after', this.state.count); // 输入 2
})
}

componentDidUpdate() {
console.log('componentDidMount', this.state.count); // 输出两次: 1 2
}

concurrent 模式

ReactDOM.render(<App />, rootNode)变为为ReactDOM.createRoot(rootNode).render(<App />),这样就切换到了concurrent 模式下。
concurrent模式下,例子一的表现结果与laecy模式一致。
例子二,after 的输出 从2变为0componentDidMount里的输出两次1和2 变为输出一次1,这是为什么呢?

这是因为,执行scheduleUpdateOnFiber函数的前提是本次更新的优先级是同步的优先级lane === SyncLane。那什么是同步的优先级呢,也就是ReactDOM.render创建的应用的更新都是同步的优先级,而concurrent模式创建的应用有不同的优先级,所以不会命中fulshSyncCallbackQueue,即同步的更新。

总结

  • lagecy 模式命中 batchedUpdates时异步
  • lagecy 模式未命中batchedUpdates时同步
  • concurrent 模式都是异步

react更新流程

我们了解了 react 的Scheduler-Reconciler-Renderer架构体系

  • Scheduler负责任务的优先级调度
  • Reconciler工作的工作阶段被称为render阶段。因为在该阶段会调用render方法
  • Renderer工作的阶段被称为commit阶段。commit阶段会把render阶段提交的信息渲染到页面上。

前面已经介绍了render阶段commit阶段render阶段完成后会进入commit阶段,而render阶段之前就是触发状态更新阶段

render 阶段前阶段

状态更新的整个调用路径的关键节点:

触发状态更新

在 react 中,有以下方法可以触发状态更新:

  • this.setState
  • this.focusUpdate
  • ReactDOM.render
  • useState
  • useReducer

每次触发状态更新都会走一遍render阶段前阶段->render阶段->commit阶段这个流程。

创建 Update 对象

react中,有多种触发状态更新的方法,他们是如何保持同一套状态更新机制呢?
每次状态更新都会创建一个保存更新状态相关内容的对象,我们叫他Update。在render阶段会根据Update计算新的state

从 fiber 到 root,得到 rootFiber

这个阶段,触发状态更新的fiber上已经包含了Update对象。
我们知道,render阶段是从rootFiber开始向下遍历。那么如何从触发状态更新的fiber得到rootFiber呢?
答案是:调用markUpdateLaneFromFiberToRoot
这个方法的工作:从触发状态更新的fiber一直向上遍历到rootFiber,得到rootFiber,并返回rootFiber

调度更新

现在我们拥有了一个rootFiber,该rootFiber对应的fiber树中的某个fiber节点包含一个Update。接下来通知Scheduler根据更新的优先级,决定以同步还是异步的方式调度本次更新。
调用的方法是ensureRootIsScheduled
这个方法会根据优先级调度回调函数执行,这里调度的回调函数为:

1
2
performSyncWorkOnRoot.bind(null, root); // 同步的回调函数
performConcurrentWorkOnRoot.bind(null, root); // 异步的回调函数

这个回调函数也是render阶段的入口函数。

render 阶段

同步或异步调度本次更新,根据 rootFiber 得到 fiber 树

commit 阶段

副作用对应的DOM操作在 commit 阶段执行。
执行 DOM 操作前:

  • 处理DOM节点渲染后的autoFocus/blur逻辑
  • 会调用getSnapshotBeforeUpdate,能在操作 DOM 前捕获 DOM 信息(如滚动位置)
  • 异步调度useEffect

执行 DOM 操作:

  • 通过一次插入 DOM 操作将整颗DOM树插入页面。
  • 执行useEffect、useLayoutEffect销毁函数
  • 会调用componentWillUnmount
  • 解绑 ref

执行 DOM 操作后:

  • 会调用useLayoutEffect回调函数
  • 调度useEffect,在Layout阶段完成后再异步执行useEffect回调函数
  • 调用this.setState的第二个参数回调函数

useLayoutEffect从上一次更新的销毁函数调用到本次更新的回调函数调用是同步执行的。
useEffect则需要先调用,在Layout阶段完成后再异步执行。

redux+ts

安装reduxreact-redux@reduxjs/toolkit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// store/index.ts

import { configureStore } from '@reduxjs/toolkit';

import userSliceReducer from './userSlice';

const store = configureStore({
reducer: {
user: userSliceReducer,
},
});

// 从 store 本身推断出 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<typeof store.getState>;
// 推断出类型: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;

export default store;

定义 useDispatch, useSelector hook 类型

1
2
3
4
5
6
7
8
// store/hooks.ts

import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';

// 在整个应用程序中使用,而不是简单的 `useDispatch` 和 `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

创建一个 reducer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { createSlice } from '@reduxjs/toolkit';
import { RootState, AppDispatch } from './index';

const userSlice = createSlice({
name: 'user',
initialState: {
value: 123,
},
reducers: {
incremented: (state) => {
// Redux Toolkit 允许在 reducers 中编写 "mutating" 逻辑。
// 它实际上并没有改变 state,因为使用的是 Immer 库,检测到“草稿 state”的变化并产生一个全新的
// 基于这些更改的不可变的 state。
state.value += 1;
},
decremented: (state) => {
state.value -= 1;
},
},
});

export const { incremented, decremented } = userSlice.actions;

export const selectCount = (state: RootState) => state.user.value;

export default userSlice.reducer;

组件使用

1
2
3
4
import { useAppSelector, useAppDispatch } from '@/store/hooks';

const count = useAppSelector((state) => state.user.value);
console.log('count: ', count);

react组件中的共享变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Feature.tsx
import React from react;

// 定义全局变量
let pageNumber = 1;

const Feature = () => {

const onLoadMore = () => {
setLoading(true);
setListData(
data.concat(
[...new Array(pageSize)].map(() => ({
createdAt: '',
uri: '',
loading: true,
})),
),
);
pageNumber++;

request.get(list, { pageNumber, pageSize }).then(res => {
// ...
})

return (
<div onClick={onLoadMore}>加载更多</div>
)
}
export default Feature;

当前文件中,我们在 Feature 函数前面定义了一个pageNumber变量,每次调用onLoadMorepageNumber++,这个逻辑在当前页面没有问题。
当我们离开这个组件,下次重新进入时,pageNumber的值不是预期的1,而是1+onLoadMore的调用次数。也就是说Feature函数前定义的变量,不会随Feature函数的销毁而销毁。

而且这个变量是被所有 Feature组件共享的。

参考: https://zh-hans.react.dev/learn/referencing-values-with-refs#fix-debouncing

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×