一次完整的React状态管理迁移实践与踩坑总结
这破迁移方案整得我头大
上周上线前最后一天,本来以为万事大吉,结果测试组甩过来一个bug:线上老版本还能用的功能,新架构里直接挂了——某个嵌套很深的表单页面,动态字段不渲染了。当场血压拉满,因为这个功能开发阶段根本没人提要改,也没人告诉我旧系统是靠一堆魔幻的jQuery + inline script撑起来的。
我们这项目是从一套十多年的老前端往现代React架构迁。理想很丰满:渐进式迁移,新页面用React,老页面保留,慢慢切。实际操作?全是坑。最恶心的是那些“看似静态”的页面,其实背后塞满了通过document.write、eval甚至直接在HTML里写function的脏活。
先上最终解法,不然你看着也烦
问题核心是:老页面里的JS脚本依赖全局作用域和DOM就绪时机,而我们在React里用iframe或动态script注入时,执行环境变了,document.readyState还是loading,但我们的mount时机已经过了,导致那些基于DOMContentLoaded的逻辑压根不触发。
后来试了下发现,最稳的不是搞什么沙箱、polyfill,而是简单粗暴地把整个老页面内容捞出来,在React组件里模拟一次“重放”:
function LegacyPageReplayer({ url }) {
const containerRef = useRef(null);
useEffect(() => {
const controller = new AbortController();
const loadAndReplay = async () => {
try {
const response = await fetch(url, { signal: controller.signal });
const htmlText = await response.text();
// 提取body内容
const parser = new DOMParser();
const doc = parser.parseFromString(htmlText, 'text/html');
const body = doc.querySelector('body');
if (!body || !containerRef.current) return;
// 清空容器
containerRef.current.innerHTML = '';
// 深拷贝所有子节点
const children = Array.from(body.childNodes);
children.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
containerRef.current.appendChild(node.cloneNode(true));
} else if (node.nodeType === Node.TEXT_NODE) {
containerRef.current.appendChild(document.createTextNode(node.textContent));
}
});
// 手动执行所有内联script(type=text/javascript 或无type)
const scripts = Array.from(containerRef.current.querySelectorAll('script'))
.filter(s => !s.src && s.type !== 'module' && !s.getAttribute('data-executed'));
scripts.forEach(script => {
try {
// 标记已执行,避免重复
script.setAttribute('data-executed', 'true');
new Function(script.textContent)();
} catch (e) {
console.warn('Script执行失败:', e);
}
});
// 加载外部脚本(注意顺序)
const externalScripts = Array.from(doc.querySelectorAll('script[src]'))
.filter(s => !s.type || s.type === 'text/javascript');
for (const script of externalScripts) {
const src = script.src;
if (!src) continue;
await new Promise((resolve, reject) => {
const el = document.createElement('script');
el.src = src;
el.async = false; // 保证顺序
el.onload = resolve;
el.onerror = reject;
document.head.appendChild(el);
}).catch(e => {
console.error('外链脚本加载失败:', src, e);
});
}
} catch (err) {
if (err.name !== 'AbortError') {
console.error('加载老页面失败:', err);
}
}
};
loadAndReplay();
return () => controller.abort();
}, [url]);
return <div ref={containerRef} style={{ minHeight: '600px' }} />;
}
就这么个组件,往路由里一塞,老URL来的请求全走它,基本能还原90%以上的行为。剩下那10%是些奇葩场景,比如某些脚本里写了window.parent.xxx,这种只能单独打补丁。
折腾过程简直离谱
一开始真傻,想着“现代化”,想把老代码拆成模块,手动重构。花了一天半,发现光是那个表单的依赖关系图就画了三张A4纸——从一个onclick出发,牵出五个全局函数,调两个外域API,还顺带改了localStorage里的结构。果断放弃。
然后尝试iframe方案。看起来干净,隔离性好。问题是:
- 跨域?不行,老系统域名和新系统不一样,cookie策略锁死了
- 同域?可以,但样式冲突,老系统的CSS像野狗一样到处乱咬
- 通信麻烦,postMessage来回传数据,一个表单提交要搞七八个监听
- 最关键:SEO没了。搜索引擎可不会等你iframe加载完再抓内容
这里我踩了个坑:以为设置sandbox="allow-scripts allow-same-origin"就能跑通一切,结果某些老脚本用了document.domain = 'xxx'做降域,iframe里直接抛错。查MDN才发现sandbox会禁掉这个能力。白折腾三小时。
后来试了下动态create script标签插入head,load完后触发render。听起来合理吧?问题在于顺序——老系统里十几个标签,有的是库(jQuery),有的是配置,有的是业务逻辑,还有的是立即执行函数。只要顺序错一个,后面全崩。
我一度想用AST解析HTML,构建依赖树……直到同事路过看了眼说:“你这不是在做浏览器该干的事吗?”
顿悟了。干脆别模拟了,直接让这些脚本在当前环境下“重新活一遍”。只是要把它们从原始HTML里扒出来,一个个喂给执行环境。
几个细节差点又要加班
第一个坑:cloneNode(true)不会执行script内容,这是标准行为。所以必须手动提取并new Function()去跑。但要注意,这样丢失了上下文。比如原script里用了this,默认指向window还好,但如果是在某个with块里(没错,真有这种代码),就会出问题。不过我们这项目没这种情况,侥幸过关。
第二个坑:外链脚本异步加载顺序。一开始用了Promise.all,并发加载。结果jQuery还没定义,业务脚本就开始调$(),直接报错。改成for…of循环+await才解决。虽然慢一点,但稳。
第三个坑:内存泄漏。每次切换页面都重新加载,但老脚本里绑的eventListener没清理。目前做法是在组件卸载时清空container,但有些全局绑定的事件(比如window.onbeforeunload)还是残留。暂时用WeakMap记录绑定源,卸载时手动remove,不够完美,但线上没出事。
为啥不搞微前端?
不是没考虑过qiankun之类的。但团队规模小,运维成本扛不住。而且老系统压根不符合任何微前端规范——没有bootstrap、mount、unmount生命周期,全是side effect。改造它等于重写,还不如直接干。
另外,我们迁移目标是一年内完全下线老系统,没必要引入复杂架构。这个replayer组件算是过渡期的“创可贴”,贴得住就行。
还有点小毛病
改完后仍有一两个小问题:
- 某些页面有定时器setInterval,切换走之后还在后台跑,需要加个清理机制
- 打印功能受影响,因为动态生成的内容可能分页异常
- 个别老脚本检测navigator.userAgent来判断环境,现在都是Chrome,反而触发了错误分支
但都不影响主流程。产品点头,测试放行,上线了再说。
总结一下
这次迁移让我明白:有时候最土的办法最有效。不要一上来就想着架构、设计模式、工程化,先把事情做成再说。尤其面对遗产代码,尊重它的运行时环境比强行“净化”更重要。
这个方案肯定不是最优的,有安全风险(比如XSS),也不利于长期维护。但它简单、可控、可灰度发布,适合我们这种资源有限的小团队。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

暂无评论