一次完整的React状态管理迁移实践与踩坑总结

Top丶银银 前端 阅读 2,087
赞 24 收藏
二维码
手机扫码查看
反馈

这破迁移方案整得我头大

上周上线前最后一天,本来以为万事大吉,结果测试组甩过来一个bug:线上老版本还能用的功能,新架构里直接挂了——某个嵌套很深的表单页面,动态字段不渲染了。当场血压拉满,因为这个功能开发阶段根本没人提要改,也没人告诉我旧系统是靠一堆魔幻的jQuery + inline script撑起来的。

一次完整的React状态管理迁移实践与踩坑总结

我们这项目是从一套十多年的老前端往现代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),也不利于长期维护。但它简单、可控、可灰度发布,适合我们这种资源有限的小团队。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论