You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
import*asReactfrom'react';import{createPortal}from'react-dom';importcanUseDomfrom'rc-util/lib/Dom/canUseDom';importwarningfrom'rc-util/lib/warning';import{supportRef,useComposeRef}from'rc-util/lib/ref';importOrderContextfrom'./Context';importuseDomfrom'./useDom';importuseScrollLockerfrom'./useScrollLocker';import{inlineMock}from'./mock';exporttypeContainerType=Element|DocumentFragment;exporttypeGetContainer=|string|ContainerType|(()=>ContainerType)|false;exportinterfacePortalProps{/** 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;}constgetPortalContainer=(getContainer: GetContainer)=>{if(getContainer===false){returnfalse;}if(!canUseDom()||!getContainer){returnnull;}if(typeofgetContainer==='string'){returndocument.querySelector(getContainer);}if(typeofgetContainer==='function'){returngetContainer();}returngetContainer;};constPortal=React.forwardRef<any,PortalProps>((props,ref)=>{const{
open,
autoLock,
getContainer,
debug,
autoDestroy =true,
children,}=props;const[shouldRender,setShouldRender]=React.useState(open);constmergedRender=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(()=>{constcustomizeContainer=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,);constmergedContainer=innerContainer??defaultContainer;// ========================= Locker ==========================useScrollLocker(autoLock&&open&&canUseDom()&&(mergedContainer===defaultContainer||mergedContainer===document.body),);// =========================== Ref ===========================letchildRef: React.Ref<any>=null;if(children&&supportRef(children)&&ref){({ref: childRef}=childrenasany);}constmergedRef=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 renderif(!mergedRender||!canUseDom()||innerContainer===undefined){returnnull;}// Render inlineconstrenderInline=mergedContainer===false||inlineMock();letreffedChildren=children;if(ref){reffedChildren=React.cloneElement(childrenasany,{ref: mergedRef,});}return(<OrderContext.Providervalue={queueCreate}>{renderInline
? reffedChildren
: createPortal(reffedChildren,mergedContainer)}</OrderContext.Provider>);});if(process.env.NODE_ENV!=='production'){Portal.displayName='Portal';}exportdefaultPortal;
前言
开发中经常使用到Modal/Dialog(模态对话框,弹窗),作用是中断用户的操作,使用户必须先响应对话框中的内容。
模态/非模态:模态对话框在弹出时不允许主窗口操作;非模态对话框弹出时不影响主窗口操作。
场景
对弹窗内嵌表单进行一些赋初值的操作,需要获取到表单的
ref
(也就是组件实例),再调用其实例上的setFieldsValue
方法。至于怎么获取ref
则大有说法,具体方法可以参考我这篇文章⬇️React中关于Modal内嵌Form引发的一些思考
什么是ref?
React提供的一种在标准数据流外操作子组件或者DOM元素的方法,通过
ref
可以获取到组件实例或者DOM节点,从而调用实例/DOM节点上的方法或者属性。创建ref的方式
String ref
给
ref
属性绑定字符串,现在已经不推荐使用。Object ref
React.createRef
(类组件)生成一个
ref
对象用于后续保存,绑定ref
属性时使用这个divRef
对象,多用于类组件。React.useRef
(Hooks)效果类似
createRef
,多用于函数式组件,在函数组件反复执行时“记住某个值”,可以理解成函数组件作用域外的一个全局变量。createRef和useRef的区别?
createRef
每次渲染都会返回一个新的引用;useRef
每次都会返回相同的引用createRef
在类组件中表现正常的原因是因为类组件分离了生命周期,也就是说createRef
只会被初始化渲染一次;在函数组件中的值则会随着函数组件重复执行而不断被初始化,这也是createRef
不能在函数组件中使用的原因。Callback ref
通过传入回调函数的方式绑定ref,其中回调函数会执行两次:
ref的局限性
ref
不能绑定在函数式组件上,因为函数式组件没有实例(每次渲染都会执行一遍),需要使用React.forwardRef
对函数组件进行包装:ref.current
是可变的(Mutable)但状态不可变(Immutable),当ref
作为useEffect
的依赖项时,ref变化不会引起副作用的执行分析
究其本质,通过对比场景发现:
Modal在弹出时,通过useEffect监听visible无法监听到Modal子元素中绑定的ref。
毫无疑问,最优雅的方式应该是在
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
inuseEffect
#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
是异步执行的React中的setState本身就是异步批量更新,createPortal也是异步的话,相当于异步中的异步。
结果证明猜想2正确。
createPortal
和render
的区别?render(element, container, callback)
JSX -> 虚拟DOM ->
render
方法挂载到真实DOM可以用
unmountComponentAtNode(container)
来卸载render
对应的节点。createPortal(children, container)
这里有一个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
Portal组件对createPortal进行了封装,经过分析,我判断基于createPortal扩展了以下功能:
mergedRender
+useDom
ref
合并传递 -mergedRef
在这一次pr中,作者解决了首次渲染拿不到子元素ref的问题:fix: Portal render logic #8
对应的测试单元:
对应的源码改动如下:
Portal.tsx
看似并没有做很多的改动,实际上只是改变了一下
useDom
第一个参数的变化时机,其中useDom
是一个同步插入DOM的方法。在pr前,每一次渲染的细节如下:
visible
为false
,所以传入Portal中mergedRender
被初始化为false
,setVisible(true)
,父组件状态变化引发第二次渲染,此时通过props
传入的open
为true
,但并不会更新mergedRender
的值(因为useState(initalValue)
中传入的initalValue
只在组件第一次初始化时被用到,useEffect
中open
改变引发setMergedRender
的执行要等到下一次渲染才起作用)。这一次渲染时useDom
第一个参数会被传入false
,所以真实DOM并没有被改变,在下一次渲染时mergedRender
的值才会为true
。此时父组件中useEffect
中注册的ref
相关的语句已经被执行,所以获取到的是undefined
mergedRender
变为true
在pr后,每一次渲染的细节如下:
visible
为false
,所以传入Portal中shouldRender
被初始化为false
,setVisible(true)
,父组件状态变化引发第二次渲染,此时通过props
传入的open
为true
,但shouldRender
仍为false
,通过或计算得到mergedRender为true。这一次渲染时useDom
第一个参数会被传入true
,所以真实DOM在第二次渲染时就被改变了。此时父组件中useEffect
中注册的ref
相关的语句被执行,所以获取到的是真实的DOM元素shouldRender
变为true
总结
Portal组件通过提前同步插入DOM来解决首次渲染后获取不到子元素ref的问题。底层基建通过无数细节保证了AntD中Modal组件的健壮性,值得学习。
The text was updated successfully, but these errors were encountered: