前言
PS:本文编写时存在一些认知局限,请先阅读Antd Modal组件溯源
平台业务中很常见的一个场景:用户点击表格中某一行数据进行编辑,需要弹出弹窗,提供表单并赋初值,供用户进行操作。
对于Vue3来说,代码的书写是同步的,和逻辑顺序相符
<template>
<!-- Form -->
<el-button text @click="handleOpenDialogForm">
open a Form nested Dialog
</el-button>
<el-dialog v-model="dialogFormVisible" title="Shipping address">
<el-form :model="form">
<el-form-item label="Promotion name" :label-width="formLabelWidth">
<el-input v-model="form.name" autocomplete="off" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogFormVisible = false">Cancel</el-button>
<el-button type="primary" @click="dialogFormVisible = false">
Confirm
</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
const dialogFormVisible = ref(false)
const formLabelWidth = '140px'
const form = reactive({
name: '',
})
const handleOpenDialogForm = () => {
dialogFormVisible.value = true
form.name = '123'
}
</script>
对于React来说,通常会使用一个state比如const [visible, setVisible] = useState<boolean>(false)
来控制弹窗的开与关,但代码的书写如果使用同步写法,则会出现一些问题:
import React, {useEffect, useRef, useState} from 'react';
import './App.css';
import {Button, Form, Input, Modal} from "antd";
function App() {
const formRef = useRef<any>()
const [visible, setVisible] = useState<boolean>(false)
const handleShowModal = () => {
setVisible(true)
console.log(formRef) // undefined
formRef?.current?.setFieldValue('name', 'hello') // 此处拿不到formRef(未绑定真实DOM)
}
const handleOk = () => {
setVisible(true)
}
const handleCancel = () => {
setVisible(false)
}
return (
<div>
<Button onClick={handleShowModal}>show modal</Button>
{
visible && (
<Modal title='form in modal' open={true} onOk={handleOk} onCancel={handleCancel}>
<Form ref={formRef}>
<Form.Item name='name' label='name'>
<Input></Input>
</Form.Item>
</Form>
</Modal>
)
}
</div>
);
}
export default App;
对于setVisible(true)
后的部分,以同步的方式并不能立即拿到formRef
,原因在于React对于setState
的处理机制,这里我们拓展一下setState
的相关知识.
setState的类异步更新
对于React中的setState
,需要分成18以前和18以后来讨论(React在18进行了特性更新)
React 18以前
自动批量更新
先说结论,对于setState
:
- 在React合成事件(如
onClick
绑定的自定义函数)中是异步的 - 在Hooks(如
componentDidMount
,useEffect
)中是异步的 - 在原生事件和
setTimeout
,Promise.resolve().then
等宏微任务中是同步的
class App extends React.Component {
state = {
count: 0
}
componentDidMount() {
this.setState({count: this.state.count + 1})
console.log(this.state.count); // 0
this.setState({count: this.state.count + 1})
console.log(this.state.count); // 0
setTimeout(() => {
this.setState({count: this.state.count + 1})
console.log(this.state.count); // 2
this.setState({count: this.state.count + 1})
console.log(this.state.count); // 3
});
}
render() {
return <h1>Count: {this.state.count}</h1>
}
}
其中前两个setState被合并成了一个,后两个同步执行,组件一共渲染4次。
假想一下,如果每次setState
都是同步执行,那么如果setState
如果被调用n次,那么组件就要重新渲染n次,会浪费很多性能,所以React将setState
处理成了批量异步更新。本质上是对情况1和情况2都进行了一层包装,在开头和结尾对isBatchUpdate
标记进行了更新。
触发更新操作时,React会从 this
(类组件)或 hooks
返回的 setter
函数中找到对应的 Fiber
节点,然后根据传入 setState
的参数创建更新对象,并将更新对象保存在 Fiber
节点的 updateQueue
中。 这样我们在同一个事件循环中对组件的多次修改操作就可以记录下来,在下一个事件循环中统一进行处理。处理时就会遍历 updateQueue
中的修改,依次合并获取最终的 state
进行渲染。
如何实现?
数据库中的常用机制-事务,github.com/lizuncong/m…
手动批量更新
如果想在情况3下也实现异步批量更新,react-dom
提供了一个手动更新的API:unstable_batchedUpdates
,实现更细粒度的更新控制。
React 18以后
自动批量更新
React18实现自动异步批量更新的地方在于引入了ReactDom.createRoot
这个API,挂载<App>
时有了不同的写法。
React18以前:ReactDom.render(<App/>, document.getElementById('#root'))
React18以后:ReactDom.createRoot(document.getElementById('#root')).render(<App/>)
React的渲染流程
初始渲染流程
- 根组件的
JSX
定义会被babel
转换为React.createElement
的调用,其返回值为VNode树
。 React.render
调用,实例化FiberRootNode
,并创建根Fiber
节点HostRoot
赋值给FiberRoot
的current
属性- 创建更新对象,其更新内容为
React.render
接受到的第一个参数VNode树
,将更新对象添加到HostRoot
节点的updateQueue
中 - 处理更新队列,从
HostRoot
节点开始遍历,在其alternate
属性中构建WIP
树,在构建Fiber
树的过程中会根据VNode
的类型进行组件实例化、生命周期调用等工作,对需要操作视图的动作将其保存到Fiber
节点的effectTag
上面,将需要更新在DOM上的属性保存至updateQueue
中,并将其与父节点的lastEffect
连接。 - 当整棵树遍历完成后,进入
commit
阶段,此阶段就是将effectList
收集的DOM
操作应用到屏幕上。 commit
完成将current
替换为WIP
树。
WIP:WorkInProgress
更新渲染流程
- 组件调用
setState
触发更新,React
通过this
找到组件对应的Fiber
对象,使用setState
的参数创建更新对象,并将其添加进Fiber
的更新队列中,然后开启调度流程。 - 从根
Fiber
节点开始构建WIP
树,此时会重点处理新旧节点的差异点,并尽可能复用旧的Fiber
节点。 - 处理
Fiber
节点,检查Fiber
节点的更新队列是否有值,context
是否有变化,如果没有则跳过。 - 处理更新队列,拿到最新的
state
,调用shouldComponentUpdate
判断是否需要更新。 - 调用
render
方法获取VNode
,进行diff
算法,标记effectTag
,收集到effectList
中。
- 对于新元素,标记插入
Placement
- 旧
DOM
元素,判断属性是否发生变化,标记Update
- 对于删除的元素,标记删除
Deletion
- 遍历处理
effectList
,调用生命周期并更新DOM
。
如何解决?
拿不到formRef
的本质原因在于执行完setVisible(true)
后,组件并没有走到render阶段,便执行到了formRef
的方法语句。
思路1. setTimeout(不符合React设计思想,取巧,逻辑不严谨)
const handleShowModal = () => {
setVisible(true)
setTimeout(() => {
formRef?.current?.setFieldsValue(...)
}, 200)
}
通过定时器延后调用formRef
的时机,使其晚于render阶段渲染真实DOM的时机。
这种方法非常取巧,逻辑上并不严谨,两条任务线的耗时未知,实际上很难协调。
思路2. 使用情况3(取巧,逻辑严谨)
const handleShowModal = () => {
Promise.resolve.then(() => {
setVisible(true)
formRef?.current?.setFieldsValue(...)
})
}
通过放在宏/微任务中避免被异步执行,但存在渲染次数过多带来的性能浪费问题。
思路3. useEffect(符合React设计思想,但不可行)
const handleShowModal = () => {
setVisible(true)
}
...
useEffect(() => {
formRef?.current?.setFieldsValue(...)
}, [visible])
通过useEffect
监听visible
来在回调中执行formRef
的方法。思路是正确的,但问题出在useEffect
的执行时机和ref对象的绑定时机上。
useEffect中回调函数的执行时机:
- 首次渲染完毕(组件挂载完毕)后
- 依赖项更新后
**思路4. 回调ref(符合React设计思想,逻辑严谨,可行)
zh-hans.legacy.reactjs.org/docs/refs-a…
官方推荐的方法,话不多说,直接上代码。
const formRefCallback = useCallback((ref) => {
if (ref) setForm(ref)
}, [])
const [form, setForm] = useState<any>()
useEffect(() => {
form?.setFieldsValue(...)
}, [form])
return (
...
<Form ref={formRefCallback}>
)
如果将内联函数作为参数传入ref属性时,会在不同时机执行两次该回调函数:
- 参数为null初始执行一次
- commit阶段创建真实DOM时执行一次
使用useCallback
可以防止反复创建函数实例,解决上述隐患。
相比于直接使用useRef
绑定,回调ref能够确保一定在真实DOM创建后拿到,而useRef
+useEffect
无法保证真实DOM创建。