前言
开发中经常使用到Modal/Dialog(模态对话框,弹窗),作用是中断用户的操作,使用户必须先响应对话框中的内容。
模态/非模态:模态对话框在弹出时不允许主窗口操作;非模态对话框弹出时不影响主窗口操作。
场景
对弹窗内嵌表单进行一些赋初值的操作,需要获取到表单的ref
(也就是组件实例),再调用其实例上的setFieldsValue
方法。至于怎么获取ref
则大有说法,具体方法可以参考我这篇文章⬇️
什么是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,其中回调函数会执行两次:
- 初始化时传入null
- 元素真正挂载时传入组件实例/真实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
,在visible
为true
时(弹窗渲染时)拿到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链掐断,但后来发现可以在setTimeout
和Promise.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>
)
}
createPortal
和render
的区别?
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
就返回了
经过对比实验,发现猜想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
扩展了以下功能:
- 何时渲染 -
mergedRender
+useDom
- 子元素
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
看似并没有做很多的改动,实际上只是改变了一下针对render优化的一个判断条件mergedRender
。
这里对于render的优化导致了ref无法在useEffect中同步拿到。
在pr前,每一次渲染的细节如下:
- 第一次渲染时弹窗不展示,
visible
为false
,所以传入Portal中mergedRender
被初始化为false
, - 点击按钮触发
setVisible(true)
,父组件状态变化引发第二次渲染,此时通过props
传入的open
为true
,但并不会更新mergedRender
的值(因为useState(initalValue)
中传入的initalValue
只在组件第一次初始化时被用到,useEffect
中open
改变引发setMergedRender
的执行要等到下一次渲染才起作用)。这一次渲染时在render优化处的条件判断会直接返回null
,在下一次渲染时mergedRender
的值才会为true
。此时父组件中useEffect
中注册的ref
相关的语句已经被执行,所以获取到的是undefined
mergedRender
变为true
在pr后,每一次渲染的细节如下:
- 第一次渲染时弹窗不展示,
visible
为false
,所以传入Portal中shouldRender
被初始化为false
, - 点击按钮触发
setVisible(true)
,父组件状态变化引发第二次渲染,此时通过props
传入的open
为true
,但shouldRender
仍为false
,通过同步的或计算得到mergedRender
为true
。这一次渲染时没有走到render优化的条件判断分支中,所以会正常执行createPortal
,ref
在commit阶段被正常更新。此时父组件中useEffect
中注册的ref
相关的语句被执行,所以获取到的是真实的DOM元素 shouldRender
变为true
总结
Portal组件通过对render优化条件恰当的控制来解决首次渲染后获取不到子元素ref的问题。底层基建通过无数细节保证了AntD中Modal组件的健壮性,值得学习。