
大型管理后台重构复盘从 jQuery 到 React 的渐进式迁移全记录一、五年代码债的账单当模板拼接再也覆盖不了新需求接手一个 5 年前用 jQuery Bootstrap 搭建的管理后台时第一眼看到的不是代码而是app.js文件末尾的// TODO: refactor this mess注释。这个后台有 87 个业务页面核心模块包括客户管理、订单流转、风控审核、数据报表。每个页面都是独立的 HTML 模板通过后端的模板引擎Twig渲染jQuery 承担所有交互逻辑。重构的推动力不是技术洁癖而是三个业务受阻的事实新增权限控制粒度需要做到按钮级别的动态权限jQuery 的 DOM 操作方式导致权限判断散落在各处if (hasPermission) { $btn.show() }中修改一处可能遗漏 20 处。跨页面状态共享订单审核流程跨 3 个页面全局变量的状态传递在多次页面跳转后经常丢失。组件复用率为零同样的日期范围选择器在 14 个页面各写了一遍每个版本的参数约定都不一样。停止写新功能的重构红线很明确不能停工重写。87 个页面必须保持可用新需求照常交付重构和业务并行推进。二、双引擎并存如何让 Vue/Micro-Frontend 和老页面在同一个 SPA 下共存最开始选型时在 React 和 Vue 之间做了对比。考虑到团队技术栈和社区生态最终选择 React。共存的关键不是技术选型而是路由层如何同时服务两套渲染引擎。方案是在 Nginx 层不做区分全部请求落到同一个 SPA 入口。React Router 接管新路由未匹配的路由回退到后端渲染的 jQuery 页面// 路由分发器React 路由优先未匹配进入 jQuery 降级 import { BrowserRouter, Routes, Route } from react-router-dom; import { loadLegacyPage } from ./legacy-loader; function AppRouter() { return ( BrowserRouter Routes {/* React 重构后的路由 */} Route path/orders/* element{OrderModule /} / Route path/customers/* element{CustomerModule /} / Route path/risk/* element{RiskModule /} / {/* 兜底路由加载 jQuery 老页面 */} Route path* element{LegacyPageFallback /} / /Routes /BrowserRouter ); } function LegacyPageFallback() { const location useLocation(); useEffect(() { // 通过动态 script 注入老页面的 jQuery 逻辑 loadLegacyPage(location.pathname); return () { // 清理全局事件和定时器 cleanupLegacyPage(); }; }, [location.pathname]); // 渲染老页面的 HTML 壳 return div idlegacy-container /; }loadLegacyPage的核心逻辑是通过 fetch 拉取后端渲染的 HTML 片段注入到#legacy-container中然后按需加载对应的 jQuery 脚本。关键是每个老页面的脚本和样式必须做命名空间隔离const legacyScripts: Recordstring, string { /orders/list: /legacy/js/orders-list.js, /customers/detail: /legacy/js/customers-detail.js, }; async function loadLegacyPage(pathname: string): Promisevoid { const container document.getElementById(legacy-container); if (!container) return; // 拉取后端渲染的 HTML 片段 const htmlResponse await fetch(/legacy-render${pathname}); container.innerHTML await htmlResponse.text(); // 按需加载 jQuery 脚本注入前做沙箱包装 const scriptPath legacyScripts[pathname]; if (scriptPath) { const script document.createElement(script); script.src scriptPath; // 使用 IIFE 沙箱隔离变量 script.textContent await fetch(scriptPath).then(r r.text()); const wrapped (function($) { ${script.textContent} })(window.jQuery);; const blob new Blob([wrapped], { type: application/javascript }); script.src URL.createObjectURL(blob); document.body.appendChild(script); } } function cleanupLegacyPage(): void { const container document.getElementById(legacy-container); if (container) container.innerHTML ; // 卸载老页面注册的全局事件 $(document).off(.legacy); $(window).off(.legacy); }三、组件迁移的痛点全局状态从散沙到集中管理的重构路线从 jQuery 到 React 最核心的转变不是语法而是状态管理思维。jQuery 时代的状态散落在 DOM 属性、全局变量和$.data()中。迁移的第一步不是写 React 组件而是从现有页面中提取隐性状态// 状态提取脚本扫描 jQuery 页面中的关键数据 function extractLegacyState(): Recordstring, unknown { const state: Recordstring, unknown {}; // 从>// 迁移优先级评分模型 interface PageMigrationScore { path: string; changeFrequency: number; // 近 6 个月 commit 次数 dependencyCount: number; // 依赖的其他 jQuery 插件数 sharedStateCount: number; // 跨页面共享状态项数 priority: number; // 综合得分越高越优先 } function calculateMigratePriority( changeFreq: number, depCount: number, sharedStateCount: number ): number { // 高变更 少依赖 少共享状态 最优先低风险高回报 return changeFreq * 100 / (depCount * 10 sharedStateCount * 5 1); }对跨页面共享数据采用 Zustand 做轻量状态管理import { create } from zustand; import { persist } from zustand/middleware; interface OrderStore { currentOrderId: string | null; orderCache: Mapstring, OrderDetail; setCurrentOrder: (id: string) void; preloadOrder: (id: string) Promisevoid; clearCache: () void; } const useOrderStore createOrderStore()( persist( (set, get) ({ currentOrderId: null, orderCache: new Map(), setCurrentOrder: (id: string) set({ currentOrderId: id }), preloadOrder: async (id: string) { if (get().orderCache.has(id)) return; const detail await api.getOrderDetail(id); set(state { const newCache new Map(state.orderCache); newCache.set(id, detail); return { orderCache: newCache }; }); }, clearCache: () set({ orderCache: new Map(), currentOrderId: null }), }), { name: order-store } ) );四、交错的代价两套代码并存的隐性成本与应对双引擎并存的代价不能只看开发效率实际影响体现在三个层面1. 打包体积膨胀React 核心库 React Router Zustand Ant Design 的总 gzip 后体积约 120KB。对于新页面这是合理的。但老页面的 jQuery Bootstrap 也在同一个 HTML 中加载导致首次访问任何页面都加载了两套框架。解决方式是路由级别的代码分割// 按路由懒加载 React 模块老页面不加载 React const OrderModule React.lazy(() import(./modules/Order)); const CustomerModule React.lazy(() import(./modules/Customer)); // 只有当路由匹配到 React 页面时才加载对应 chunk在LegacyPageFallback组件中确保不 import 任何 React 组件库的内容。2. 样式冲突Bootstrap 的全局样式如.btn、.table与 Ant Design 的组件样式存在大量重叠。使用 CSS Module 无法解决全局样式的冲突。最务实的做法是为老页面套一层 Shadow DOM 或者 iframe 隔离function LegacyPageShell({ pathname }: { pathname: string }) { const iframeRef useRefHTMLIFrameElement(null); useEffect(() { const iframe iframeRef.current; if (!iframe) return; const doc iframe.contentDocument; if (!doc) return; // 在老页面的 iframe 中仅加载 Bootstrap隔离与 React 的样式冲突 doc.open(); doc.write( html head link relstylesheet href/legacy/css/bootstrap.min.css /head body div idapp${legacyHTML}/div script src/legacy/js/${pathname}.js/script /body /html ); doc.close(); }, [pathname]); return iframe ref{iframeRef} style{{ width: 100%, height: 100vh, border: none }} /; }3. 团队心智负担新人需要同时理解两套代码规范。这部分的成本无法用技术消除只能通过文档和 code review 缓解。每个老页面迁移完成后立即在文档中标注状态并删除对应的 jQuery 源文件避免遗留代码继续误导新成员。五、总结从 jQuery 到 React 的渐进式迁移核心策略可以归纳为三点路由层统一分发在 SPA 入口做路由分发React 路由优先匹配未匹配路由降级到老页面加载器。保证用户无感知切换。ifame 沙箱隔离老页面通过 iframe 或 Shadow DOM 做样式和脚本隔离避免与 React 组件库产生全局冲突。按变更频率排序迁移不是一视同仁地搬页面而是优先迁移高频变更 低依赖的低风险页面每迁移一个模块就下线对应的 jQuery 源文件。渐进式迁移不是技术的选择而是业务连续性的强制约束。在不能停工的前提下双引擎并存带来的额外复杂度是可接受的过渡成本。关键在于每一个迁移的页面都必须双向验证新老页面的功能回归并且在迁移完毕后立即做断舍离不给后续维护留坑。