React中关于Modal内嵌Form引发的一些思考

1,325 阅读6分钟

前言

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(如componentDidMountuseEffect)中是异步
  • 在原生事件和setTimeoutPromise.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的渲染流程

初始渲染流程

  1. 根组件的 JSX 定义会被 babel 转换为 React.createElement 的调用,其返回值为 VNode树
  2. React.render 调用,实例化 FiberRootNode,并创建 根Fiber 节点 HostRoot 赋值给 FiberRoot 的 current 属性
  3. 创建更新对象,其更新内容为 React.render 接受到的第一个参数 VNode树,将更新对象添加到 HostRoot 节点的 updateQueue 中
  4. 处理更新队列,从 HostRoot 节点开始遍历,在其 alternate 属性中构建 WIP 树,在构建 Fiber 树的过程中会根据 VNode 的类型进行组件实例化、生命周期调用等工作,对需要操作视图的动作将其保存到 Fiber 节点的 effectTag 上面,将需要更新在DOM上的属性保存至 updateQueue 中,并将其与父节点的 lastEffect 连接。
  5. 当整棵树遍历完成后,进入 commit 阶段,此阶段就是将 effectList 收集的 DOM 操作应用到屏幕上。
  6. commit 完成将 current 替换为 WIP 树。

WIP:WorkInProgress

更新渲染流程

  1. 组件调用 setState 触发更新,React 通过 this 找到组件对应的 Fiber 对象,使用 setState 的参数创建更新对象,并将其添加进 Fiber 的更新队列中,然后开启调度流程。
  2. 从根 Fiber 节点开始构建 WIP 树,此时会重点处理新旧节点的差异点,并尽可能复用旧的 Fiber 节点。
  3. 处理 Fiber 节点,检查 Fiber 节点的更新队列是否有值,context 是否有变化,如果没有则跳过。
  4. 处理更新队列,拿到最新的 state,调用 shouldComponentUpdate 判断是否需要更新。
  5. 调用 render 方法获取 VNode,进行 diff 算法,标记 effectTag,收集到 effectList 中。
  • 对于新元素,标记插入 Placement
  • 旧 DOM 元素,判断属性是否发生变化,标记 Update
  • 对于删除的元素,标记删除 Deletion
  1. 遍历处理 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属性时,会在不同时机执行两次该回调函数:

  1. 参数为null初始执行一次
  2. commit阶段创建真实DOM时执行一次

使用useCallback可以防止反复创建函数实例,解决上述隐患。

相比于直接使用useRef绑定,回调ref能够确保一定在真实DOM创建后拿到,而useRef+useEffect无法保证真实DOM创建。