外部库移动DOM节点导致React组件卸载报错,无额外DIV如何修复
React组件卸载报错解决方案(外部库操作DOM场景)
问题背景
我们正在基于React封装一套组件库,部分依赖的底层库会直接操作DOM树,导致React尝试卸载对应React组件时触发崩溃。
复现代码
function Sample () { let [shouldRender, setShouldRender] = React.useState(true); return ( <React.Fragment> <button onClick={() => setShouldRender(!shouldRender)}>show/hide</button> { shouldRender && <Component /> } </React.Fragment> ); } function Component () { let ref = React.useRef(); React.useEffect(() => { let divElement = ref.current; someExternalLibrary.setup(divElement); return () => someExternalLibrary.cleanup(divElement); }); return <div ref={ref} id="div1">Hello world</div>; } ReactDOM.render( <Sample />, document.getElementById('container') ); let someExternalLibrary = { setup: function(divElement) { let beacon = document.createElement('div'); beacon.id = `beacon${divElement.id}`; divElement.parentElement.replaceChild(beacon, divElement); document.body.append(divElement); }, cleanup: function(divElement) { let beacon = document.getElementById(`beacon${divElement.id}`); beacon.parentElement.replaceChild(divElement, beacon); } }
报错信息
Error: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node. at removeChildFromContainer (react-dom.development.js:10337:17) at unmountHostComponents (react-dom.development.js:21324:11) at commitDeletion (react-dom.development.js:21377:7) at commitMutationEffects (react-dom.development.js:23437:13) at HTMLUnknownElement.callCallback (react-dom.development.js:3942:16) at Object.invokeGuardedCallbackDev (react-dom.development.js:3991:18) at invokeGuardedCallback (react-dom.development.js:4053:33) at commitRootImpl (react-dom.development.js:23151:11) at unstable_runWithPriority (react.development.js:2764:14) at runWithPriority$1 (react-dom.development.js:11306:12)
问题原因
上述示例中,外部库会将React组件内部的元素移动到其他位置。即便外部库在清理阶段通过占位节点(beacon)将元素放回原位置,点击show/hide按钮触发组件卸载时仍然报错,核心原因是React的执行顺序问题:
React卸载组件的默认执行顺序如下:
- 进入commit mutation阶段,提前缓存要删除的组件根节点的父节点引用(此时根节点还被外部库移动到body下,缓存的父节点为body)
- 执行DOM删除操作,尝试从缓存的父节点(body)上删除根节点
- 异步执行useEffect的清理函数,此时才把根节点移回原位置
所以等到清理函数执行时,React已经完成了删除尝试,报错已经触发。
你提到的用React Fragment关联的方案不可行:Fragment是虚拟节点,不会生成任何真实DOM,无法和真实HTMLElement绑定,也不能干预React的DOM操作逻辑。
解决方案
目前已知的包额外div的方案受架构限制无法落地,优先推荐以下方案:
方案1:将清理逻辑移到useLayoutEffect中(最优方案,无侵入)
useLayoutEffect的清理函数执行时机早于React的DOM删除操作,我们可以把外部库的还原逻辑放在这里,确保React执行删除时,根节点已经回到原父节点下,父节点引用与实际一致。
修改后的Component代码如下:
function Component () { let ref = React.useRef(); // 替换useEffect为useLayoutEffect React.useLayoutEffect(() => { let divElement = ref.current; someExternalLibrary.setup(divElement); return () => someExternalLibrary.cleanup(divElement); }, []); // 建议加空依赖,避免不必要的重复执行 return <div ref={ref} id="div1">Hello world</div>; }
该方案不需要修改任何DOM结构,代码改动量极小,完全符合现有架构要求。
方案2:手动接管DOM生命周期(兜底方案)
如果方案1因特殊场景不适用,可以采用手动管理DOM挂载卸载的方案,完全绕过React的默认删除逻辑:
function Component () { // 内存中创建组件根节点,React不会直接管理它的删除 const [rootEl] = React.useState(() => document.createElement('div')); const placeholderRef = React.useRef(); React.useLayoutEffect(() => { // 把根节点插入到占位节点的位置 placeholderRef.current.before(rootEl); // 初始化外部库 someExternalLibrary.setup(rootEl); return () => { // 先清理外部库 someExternalLibrary.cleanup(rootEl); // 手动删除节点,不需要React处理 rootEl.remove(); }; }, []); // 渲染一个隐藏的占位节点,供React挂载,不会影响业务逻辑 return <span ref={placeholderRef} style={{display: 'none'}} />; }
如果不能接受任何额外DOM节点,可以进一步用React.createPortal实现,原理一致。
内容的提问来源于stack exchange,提问作者Swimburger
相关产品推荐
相关产品推荐

