Portal、forwardRef 和 useImperativeHandle 分别解决什么问题?
createPortal 把子组件渲染到另一个 DOM 节点里,常用于弹窗和浮层,但它在 React 树里的位置不变,context 照常读取,事件按 React 树冒泡。forwardRef 让函数组件把 ref 转给内部的 DOM;React 19 起 ref 可以直接作为 prop 传,forwardRef 不再需要,将在未来版本弃用。useImperativeHandle 让组件只向父组件暴露自定义的几个方法,而不是整个 DOM 节点。
简答
一个弹窗组件写在卡片里面,卡片有
overflow: hidden,弹窗就被裁掉了。createPortal能把弹窗的 DOM 放到document.body下面,绕开父元素的样式限制。它只改 DOM 的物理位置,在 React 树里弹窗还是卡片的子组件,能读到上层的 context,事件也按 React 树往上冒泡。
forwardRef解决的是另一件事:父组件想拿到子组件内部的<input>来调用focus()。以前函数组件收不到ref,要用forwardRef包一层。React 19 起ref可以像普通 prop 一样直接接收,react.dev 写明forwardRef已经不再需要,将在未来的版本里弃用。
useImperativeHandle用来控制父组件拿到的是什么。不想把整个 DOM 节点交出去,就只暴露focus、scrollIntoView这样几个方法,父组件拿不到底层节点,也就没法随便改它。要注意不要滥用 ref。官方的建议是,能用 props 表达的就别用 ref,比如弹窗的开关用
isOpen这个 prop,而不是暴露open()、close()方法。ref 只留给滚动、聚焦、触发动画、选中文字这类命令式操作。
Portal 把 DOM 放到哪里
下面这段代码要看两个 <p> 最后各自出现在哪:
import { createPortal } from 'react-dom';
function MyComponent() {
return (
<div style={{ border: '2px solid black' }}>
<p>这一段在父元素 div 里。</p>
{createPortal(<p>这一段在 document.body 里。</p>, document.body)}
</div>
);
}
两段文字写在同一个 div 里,可是浏览器里第二段出现在带边框的 div 外面,成了 body 的子节点。所以父元素的 overflow: hidden、z-index 层叠上下文这类样式就管不到它了,弹窗、提示框、下拉菜单常这样做。为什么父元素会裁掉浮层,见《absolute、fixed 和 sticky 的定位参照是什么?》。
那它和普通子组件还有区别吗?react.dev 说得很清楚:Portal 只改变 DOM 节点的物理位置,其他方面都和普通子组件一样。它能读到父组件提供的 context,事件也按 React 树而不是 DOM 树冒泡。所以 Portal 里点击一个按钮,外层 div 上的 onClick 也会触发,尽管在 DOM 里它们根本不是父子关系。不想要这种冒泡,可以在 Portal 内部阻止事件传播,或者把 Portal 移到 React 树更上层。
createPortal 的第二个参数是目标 DOM 节点。更新时换成另一个节点,Portal 里的内容会被重新创建。它还能把 React 组件渲染进一段不归 React 管的 DOM,比如地图库或旧页面留出来的容器。
React 19 之前为什么需要 forwardRef
父组件写 <MyInput ref={inputRef} />,想拿到里面的 <input>。在 React 19 之前,函数组件的 props 里收不到 ref,所以要这样写:
import { forwardRef } from 'react';
const MyInput = forwardRef(function MyInput(props, ref) {
return <input {...props} ref={ref} />;
});
forwardRef 把 ref 作为第二个参数交给组件,组件再把它放到真正的 <input> 上。React 19 起不用这一层了,ref 直接出现在 props 里:
function MyInput({ ref, ...props }) {
return <input {...props} ref={ref} />;
}
react.dev 的 forwardRef 页面现在标为 Deprecated,原文是:在 React 19 中 forwardRef 不再需要,改为把 ref 作为 prop 传递;forwardRef 将在未来的版本中弃用。所以老代码还能跑,新代码直接用 prop 写法。升级时怎样批量改,见《React 18 项目升级到 React 19,怎样分步迁移并验证行为?》。
useImperativeHandle 暴露什么
上面那种写法会把整个 <input> 节点交给父组件,父组件想怎么改就怎么改。如果只想让它调用两个方法,可以用 useImperativeHandle 自己定义 ref 拿到的对象。下面要看父组件最后拿到了什么:
import { useRef, useImperativeHandle } from 'react';
function MyInput({ ref, ...props }) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus() {
inputRef.current.focus();
},
scrollIntoView() {
inputRef.current.scrollIntoView();
},
}), []);
return <input {...props} ref={inputRef} />;
}
父组件的 ref.current 现在是 { focus, scrollIntoView } 这个对象,而不是 <input>。所以它能调用 ref.current.focus(),却拿不到 ref.current.style。暴露的方法也不一定要和 DOM 方法一一对应,官方例子里 Post 组件暴露的是 scrollAndFocusAddComment,一个方法里先滚动列表再聚焦输入框。
第三个参数是依赖数组,写法和 useEffect 一样,依赖变了才重新创建这个对象。
常见追问
Portal 里的事件为什么会触发外层组件的 onClick?
因为 React 的事件按 React 组件树冒泡,而 Portal 在组件树里仍然是原来父组件的子节点。DOM 位置变了,组件关系没变。如果外层不该响应,就在 Portal 里调用
e.stopPropagation(),或者把 Portal 挪到更上层的组件里。
为什么说不要滥用 ref?
ref 绕开了 React 的数据流,父组件直接命令子组件做事,状态就不容易追踪。react.dev 建议只把 ref 用于没法用 props 表达的命令式操作,比如聚焦、滚动、触发动画、选中文字。弹窗开关这种能用
isOpen表达的,就用 prop。
React 19 项目里还能用 forwardRef 吗?
能用,现在还没有删除。但官方已经说明它不再需要,并会在未来版本弃用,新写的组件直接把
ref当 prop 接收更好。