Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AntD Modal组件溯源 #1

Open
jiayu1011 opened this issue Apr 27, 2023 · 0 comments
Open

AntD Modal组件溯源 #1

jiayu1011 opened this issue Apr 27, 2023 · 0 comments

Comments

@jiayu1011
Copy link
Owner

前言

开发中经常使用到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是异步执行的

React中的setState本身就是异步批量更新,createPortal也是异步的话,相当于异步中的异步。

结果证明猜想2正确。

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属性,这个属性指向当前挂载的节点。

Antd团队给出的解决方案

充分了解createPortal之后,我们来看一下业界优秀组件库-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

看似并没有做很多的改动,实际上只是改变了一下useDom第一个参数的变化时机,其中useDom是一个同步插入DOM的方法。

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

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

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

  1. 第一次渲染时弹窗不展示,visiblefalse,所以传入Portal中shouldRender被初始化为false
  2. 点击按钮触发setVisible(true),父组件状态变化引发第二次渲染,此时通过props传入的opentrue,但shouldRender仍为false,通过或计算得到mergedRender为true。这一次渲染时useDom第一个参数会被传入true,所以真实DOM在第二次渲染时就被改变了。此时父组件中useEffect中注册的ref相关的语句被执行,所以获取到的是真实的DOM元素
  3. shouldRender变为true

总结

Portal组件通过提前同步插入DOM来解决首次渲染后获取不到子元素ref的问题。底层基建通过无数细节保证了AntD中Modal组件的健壮性,值得学习。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant