在Visjs timeline的item模板中使用JSX导致内存泄漏问题求解
内存泄漏成因
- 非法的API组合使用:
ReactDOM.render的返回值为类组件实例/函数组件的null,本身不属于合法的React元素,将其作为参数传入createPortal属于不符合React规范的用法,会导致React内部维护的fiber节点引用和DOM节点引用无法被正常追踪和释放。 - 缺失组件卸载逻辑:vis.js timeline在更新、移除item节点时,只会直接操作DOM删除对应元素,不会主动触发React组件的卸载流程。每次调用
ReactDOM.render往目标元素挂载组件时,旧的组件实例、关联的事件监听、状态引用都还被React内部持有,导致被删除的DOM节点无法被GC回收,最终累积为Detached HTMLDivElements。 - 双重渲染冗余:
ReactDOM.render本身已经会把JSX渲染到目标元素中,再套一层createPortal相当于重复创建了DOM关联,进一步放大了引用泄漏的问题。
规避方案
方案1:手动管控组件卸载(兼容React17及更早版本)
不要混用createPortal和ReactDOM.render,每次渲染新组件前先卸载目标节点上已有的React组件,同时在timeline销毁时做全局清理,直接返回DOM节点可避免移除portal后出现的报错问题:
// 卸载指定节点上的React组件 const unmountReactComponent = (domNode) => { if (domNode) ReactDOM.unmountComponentAtNode(domNode); }; const options = { template: (item, element) => { // 先清理旧组件,避免残留引用 unmountReactComponent(element); // 直接渲染JSX到目标节点 ReactDOM.render(<Tag key={item.id}>{item.id}</Tag>, element); // 直接返回DOM节点给vis timeline,避免触发innerHTML报错 return element; }, }; // 监听timeline的销毁事件做全局清理 timeline.on('destroy', () => { // 遍历所有已渲染的item节点执行卸载逻辑 timeline.itemsData.get().forEach(item => { const element = timeline.itemSet.items[item.id]?.dom; unmountReactComponent(element); }); });
方案2:用React18 createRoot + 实例缓存
如果使用React18,用官方推荐的createRootAPI代替旧的render,通过WeakMap缓存每个节点对应的root实例,避免重复创建:
import { createRoot } from 'react-dom/client'; // 用WeakMap存root实例,节点被回收时自动释放对应的缓存 const rootCache = new WeakMap(); const options = { template: (item, element) => { let root = rootCache.get(element); if (!root) { root = createRoot(element); rootCache.set(element, root); } root.render(<Tag key={item.id}>{item.id}</Tag>); return element; }, }; // timeline销毁时清理所有缓存的root timeline.on('destroy', () => { timeline.itemsData.get().forEach(item => { const element = timeline.itemSet.items[item.id]?.dom; rootCache.get(element)?.unmount(); rootCache.delete(element); }); });
方案3:Portal统一托管(无手动卸载更稳定)
如果要使用Portal,不要和render混用,将所有Portal放在你的React组件树内统一渲染,由React自动管控生命周期:
const TimelineWrapper = () => { const timelineRef = useRef(null); const [portalList, setPortalList] = useState([]); useEffect(() => { const options = { template: (item, element) => { // 仅新增不存在的portal,避免重复渲染 setPortalList(prev => { if (prev.some(p => p.itemId === item.id)) return prev; return [ ...prev, { itemId: item.id, element, portal: ReactDOM.createPortal(<Tag>{item.id}</Tag>, element) } ]; }); // 监听item移除事件,自动删除对应的portal timelineRef.current.on('remove', (event) => { if (event.items.includes(item.id)) { setPortalList(prev => prev.filter(p => p.itemId !== item.id)); } }); return element; } }; // 初始化timeline逻辑 timelineRef.current = new vis.Timeline(timelineRef.current, items, groups, options); return () => { timelineRef.current.destroy(); }; }, []); return ( <div> <div ref={timelineRef} /> {/* 所有portal在组件树内渲染,React自动处理卸载 */} {portalList.map(p => p.portal)} </div> ); };
该方案完全符合React生命周期规范,不需要手动处理卸载逻辑,内存泄漏风险最低。
内容的提问来源于stack exchange,提问作者user16706683
相关产品推荐
相关产品推荐

