Antd Modal组件溯源

989 阅读9分钟

前言

开发中经常使用到Modal/Dialog(模态对话框,弹窗),作用是中断用户的操作,使用户必须先响应对话框中的内容。

模态/非模态:模态对话框在弹出时不允许主窗口操作;非模态对话框弹出时不影响主窗口操作。

场景

对弹窗内嵌表单进行一些赋初值的操作,需要获取到表单的ref(也就是组件实例),再调用其实例上的setFieldsValue方法。至于怎么获取ref则大有说法,具体方法可以参考我这篇文章⬇️

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

什么是ref?

React提供的一种在标准数据流外操作子组件或者DOM元素的方法,通过ref可以获取到组件实例或者DOM节点,从而调用实例/DOM节点上的方法或者属性。

创建ref的方式

String ref

ref属性绑定字符串,现在已经不推荐使用。

class Com extends React.Component {
    const { divRef } = this.refs
    render() {
        return <div ref='divRef'>123</div>
    }
}

Object ref

React.createRef(类组件)

生成一个ref对象用于后续保存,绑定ref属性时使用这个divRef对象,多用于类组件。

class Com extends React.Component {
    divRef = createRef()
    render() {
        return div ref={divRef}>123</div>
    }
}
React.useRef(Hooks)

效果类似createRef,多用于函数式组件,在函数组件反复执行时“记住某个值”,可以理解成函数组件作用域外的一个全局变量。

createRef和useRef的区别?
  • createRef每次渲染都会返回一个新的引用;useRef每次都会返回相同的引用

createRef在类组件中表现正常的原因是因为类组件分离了生命周期,也就是说createRef只会被初始化渲染一次;在函数组件中的值则会随着函数组件重复执行而不断被初始化,这也是createRef不能在函数组件中使用的原因。

Callback ref

通过传入回调函数的方式绑定ref,其中回调函数会执行两次:

  1. 初始化时传入null
  2. 元素真正挂载时传入组件实例/真实DOM节点

ref的局限性

  • ref不能绑定在函数式组件上,因为函数式组件没有实例(每次渲染都会执行一遍),需要使用React.forwardRef对函数组件进行包装:
const _FuncCom = (props, ref) => { ... }
export const FuncCom = forwardRef(_FuncCom)
  • ref.current是可变的(Mutable)但状态不可变(Immutable),当ref作为useEffect的依赖项时,ref变化不会引起副作用的执行
useEffect(() => { console.log(ref) }, [ref.current])

分析

究其本质,通过对比场景发现:

Modal在弹出时,通过useEffect监听visible无法监听到Modal子元素中绑定的ref。

import React, {useEffect, useRef, useState} from 'react';  
import './App.css';  
import {Button, Modal} from "antd";  
  
function App() {  
    const ref = useRef<any>()  
    const [visible, setVisible] = useState<boolean>(false)  

    const handleShowModal = () => {  
        setVisible(true)   
    }  
    
    useEffect(() => {
        if (visible) console.log(ref) // 最优雅,最符合React设计思想的方式,但可惜是{current: undefined}
    }, [visible])

    return (  
        <div>  
            <Button onClick={handleShowModal}>show modal</Button>  
            <Modal title='form in modal' open={visible}>  
                 <div ref={ref}></div> 
             </Modal>  
        </div>  
    );  
}  
  
export default App;

毫无疑问,最优雅的方式应该是在useEffect中监听visible,在visibletrue时(弹窗渲染时)拿到ref并做相应的操作。这里有个可执行的codesandbox,可以玩一玩。

codesandbox地址:Modal children's ref-AntD 5.3.2

打开控制台并点击按钮,你会发现打印出的竟然不是undefined,而是真实的DOM节点,但其实背后是AntD团队的努力。早期AntD存在这个问题,有人在AntD的github提过issue: Modal render children is not sync which break getting ref in useEffect #26545 ,但随着AntD的基建 react-components升级,首次渲染无法获取子元素ref的问题被解决了。这就是为什么在上面的链接中岁月安好的原因。

原因

AntD中的Modal组件底层是Dialog(rc-dialog),再底层是Portal(rc-portal),通过调用ReactDOM.createPortal原生方法来在指定节点中插入DOM进行渲染(默认插入位置是document.body)。

问题就出在这个createPortal这里。

先说结论:createPortal的渲染是异步执行的,从而导致Modal组件中的子元素被异步渲染,在组件渲染完毕,useEffect中注册的副作用函数执行时,Modal组件子元素的真实DOM还没有挂载上去,从而获取不到ref

猜想1 - createPortal导致的ref链传递断裂

一开始猜想是不是createPortal是不是会把ref链掐断,但后来发现可以在setTimeoutPromise.resolve().then中拿到,因此猜测createPortal是类似于微任务一样的存在。如果有对createPortal很了解的同学请指教

猜想2 - createPortal是异步执行的

猜想createPortal的渲染是异步执行的,从而导致Modal组件中的子元素被异步渲染,在组件渲染完毕,useEffect中注册的副作用函数执行时,Modal组件子元素的真实DOM还没有挂载上去,从而获取不到ref

通过一个简单的demo可以得知,createPortal是在commit阶段之前完成的,ref会被正常绑定

const Modal = (props: any) => {  
    const {children} = props  
    return (  
        <>  
        {createPortal(children, document.body)}  
        </>  
    )  
}  
  
const Test = () => {  
  
    const [visible, setVisible] = useState(false)  
    const ref = useRef<any>()  

    useEffect(() => {  
        setTimeout(() => {  
            setVisible(true)  
        }, 1000)  
    }, [])  

    useEffect(() => {  
        if(visible) console.log(ref.current)  
    }, [visible])  

    return (  
        <div style={{height: '100vh', width: '100vw'}}>  
            {visible && <Modal><div ref={ref}>123</div></Modal>}  
        </div>  
    )  
}

createPortalrender的区别?

render(element, container, callback)

作用:

在提供的container里渲染一个React元素,并返回对该组件的引用。 如果React元素已经渲染过,则会对其执行更新操作,只会在必要时改变DOM来映射最新的React元素。

JSX -> 虚拟DOM -> render方法挂载到真实DOM

可以用unmountComponentAtNode(container)来卸载render对应的节点。

createPortal(children, container)

作用:

Portal(入口点)提供了一种将子节点渲染到父组件以外的DOM节点的优秀方案,使用场景有Modal,Dialog,Popup

这里有一个codesandbox的例子:create-portal-vs-render

区别

  • 通过createPortal渲染的元素可以出现在DOM结构中的任何地方,但通过Portal仍然可以完成事件冒泡(点击...)/context传递的特性。可以理解成Portal仅仅是让被渲染的元素在渲染时脱离了固定的结构,但本质上它仍然是React Tree中固定位置的普通节点
  • 通过render方法渲染的元素已经脱离了原本的React Tree,自然就无法通过事件冒泡机制触发父元素的事件以及接受父元素的context环境

PS: createPortal返回的对象比平常的ReactElement(VDom)对象多了一个containerInfo属性,这个属性指向当前挂载的节点。

猜想3 - 代码逻辑没有走到createPortal就返回了

image.png

经过对比实验,发现猜想3正确。这里对于render的优化导致了ref无法在useEffect中同步拿到。

Antd团队给出的解决方案

我们来看一下业界优秀组件库-AntD的解决方案是什么样的。

Portal - AntD团队开发的基建库

Portal.tsx

import * as React from 'react';
import { createPortal } from 'react-dom';
import canUseDom from 'rc-util/lib/Dom/canUseDom';
import warning from 'rc-util/lib/warning';
import { supportRef, useComposeRef } from 'rc-util/lib/ref';
import OrderContext from './Context';
import useDom from './useDom';
import useScrollLocker from './useScrollLocker';
import { inlineMock } from './mock';

export type ContainerType = Element | DocumentFragment;

export type GetContainer =
  | string
  | ContainerType
  | (() => ContainerType)
  | false;

export interface PortalProps {
  /** Customize container element. Default will create a div in document.body when `open` */
  getContainer?: GetContainer;
  children?: React.ReactNode;
  /** Show the portal children */
  open?: boolean;
  /** Remove `children` when `open` is `false`. Set `false` will not handle remove process */
  autoDestroy?: boolean;
  /** Lock screen scroll when open */
  autoLock?: boolean;

  /** @private debug name. Do not use in prod */
  debug?: string;
}

const getPortalContainer = (getContainer: GetContainer) => {
  if (getContainer === false) {
    return false;
  }

  if (!canUseDom() || !getContainer) {
    return null;
  }

  if (typeof getContainer === 'string') {
    return document.querySelector(getContainer);
  }
  if (typeof getContainer === 'function') {
    return getContainer();
  }
  return getContainer;
};

const Portal = React.forwardRef<any, PortalProps>((props, ref) => {
  const {
    open,
    autoLock,
    getContainer,
    debug,
    autoDestroy = true,
    children,
  } = props;

  const [shouldRender, setShouldRender] = React.useState(open);

  const mergedRender = shouldRender || open;

  // ========================= Warning =========================
  if (process.env.NODE_ENV !== 'production') {
    warning(
      canUseDom() || !open,
      `Portal only work in client side. Please call 'useEffect' to show Portal instead default render in SSR.`,
    );
  }

  // ====================== Should Render ======================
  React.useEffect(() => {
    if (autoDestroy || open) {
      setShouldRender(open);
    }
  }, [open, autoDestroy]);

  // ======================== Container ========================
  const [innerContainer, setInnerContainer] = React.useState<
    ContainerType | false
  >(() => getPortalContainer(getContainer));

  React.useEffect(() => {
    const customizeContainer = getPortalContainer(getContainer);

    // Tell component that we check this in effect which is safe to be `null`
    setInnerContainer(customizeContainer ?? null);
  });

  const [defaultContainer, queueCreate] = useDom(
    mergedRender && !innerContainer,
    debug,
  );
  const mergedContainer = innerContainer ?? defaultContainer;

  // ========================= Locker ==========================
  useScrollLocker(
    autoLock &&
      open &&
      canUseDom() &&
      (mergedContainer === defaultContainer ||
        mergedContainer === document.body),
  );

  // =========================== Ref ===========================
  let childRef: React.Ref<any> = null;

  if (children && supportRef(children) && ref) {
    ({ ref: childRef } = children as any);
  }

  const mergedRef = useComposeRef(childRef, ref);

  // ========================= Render ==========================
  // Do not render when nothing need render
  // When innerContainer is `undefined`, it may not ready since user use ref in the same render
  if (!mergedRender || !canUseDom() || innerContainer === undefined) {
    return null;
  }

  // Render inline
  const renderInline = mergedContainer === false || inlineMock();

  let reffedChildren = children;
  if (ref) {
    reffedChildren = React.cloneElement(children as any, {
      ref: mergedRef,
    });
  }

  return (
    <OrderContext.Provider value={queueCreate}>
      {renderInline
        ? reffedChildren
        : createPortal(reffedChildren, mergedContainer)}
    </OrderContext.Provider>
  );
});

if (process.env.NODE_ENV !== 'production') {
  Portal.displayName = 'Portal';
}

export default Portal;

Portal组件对createPortal进行了封装,经过分析,我判断基于createPortal扩展了以下功能:

  1. 何时渲染 - mergedRender+useDom
  2. 子元素ref合并传递 - mergedRef

在这一次pr中,作者解决了首次渲染拿不到子元素ref的问题:fix: Portal render logic #8

对应的测试单元:

describe('Portal', () => {
    
    ...
    
    describe('ref-able', () => {
        it('first render should ref accessible', () => {  
        let checked = false;  

        const Demo = ({ open }: { open?: boolean }) => {  
            const pRef = React.useRef();  

            React.useEffect(() => {  
            if (open) {  
            checked = true;  
            expect(pRef.current).toBeTruthy();  
            }  
        }, [open]);  

        return (  
            <Portal open={open}>  
                <div>  
                    <p ref={pRef} />  
                </div>  
            </Portal>  
        );  
    };  

        const { rerender } = render(<Demo />);  
        expect(checked).toBeFalsy();  

        rerender(<Demo open />);  
        expect(checked).toBeTruthy();  
    });
    })

})

对应的源码改动如下:

Portal.tsx image.png

看似并没有做很多的改动,实际上只是改变了一下针对render优化的一个判断条件mergedRender

image.png

这里对于render的优化导致了ref无法在useEffect中同步拿到。

在pr前,每一次渲染的细节如下:

  1. 第一次渲染时弹窗不展示,visiblefalse,所以传入Portal中mergedRender被初始化为false
  2. 点击按钮触发setVisible(true),父组件状态变化引发第二次渲染,此时通过props传入的opentrue,但并不会更新mergedRender的值(因为useState(initalValue)中传入的initalValue只在组件第一次初始化时被用到,useEffectopen改变引发setMergedRender的执行要等到下一次渲染才起作用)。这一次渲染时在render优化处的条件判断会直接返回null,在下一次渲染时mergedRender的值才会为true。此时父组件中useEffect中注册的ref相关的语句已经被执行,所以获取到的是undefined
  3. mergedRender变为true

在pr后,每一次渲染的细节如下:

  1. 第一次渲染时弹窗不展示,visiblefalse,所以传入Portal中shouldRender被初始化为false
  2. 点击按钮触发setVisible(true),父组件状态变化引发第二次渲染,此时通过props传入的opentrue,但shouldRender仍为false,通过同步的或计算得到mergedRendertrue。这一次渲染时没有走到render优化的条件判断分支中,所以会正常执行createPortalref在commit阶段被正常更新。此时父组件中useEffect中注册的ref相关的语句被执行,所以获取到的是真实的DOM元素
  3. shouldRender变为true

总结

Portal组件通过对render优化条件恰当的控制来解决首次渲染后获取不到子元素ref的问题。底层基建通过无数细节保证了AntD中Modal组件的健壮性,值得学习。