前端框架升级:不敢动的组件怎么办

三月接手一个后台系统,前端框架还停在 3.x,代码里四百多个组件文件,其中十九个是四年前自研的,负责表格、选择器、库存面板这几块。团队里没人愿意碰它们,改一行要回归半天,出了问题也说不清是谁的责任。框架要升到 5.x,官方迁移工具能改写语法,但这十九个它改不了,因为里面混着旧的生命周期写法、直接操作 DOM 的代码,还有一个自己封的事件总线。

我当时的判断是:这十九个组件不在这次升级里重写,先让它们活着,用一层适配包住,跑在新框架上。真正要改的是那些逻辑简单的展示组件,一天能改二十个。三个月后,十九个里换掉了十四个,剩下五个一直没动,也不影响业务。

这篇写这套做法:怎么分级、怎么并存、怎么用一个开关控制切换、什么时候允许把旧代码删掉。结论先说:不敢动的组件不用动,但你得给它划一块清楚的边界,让它的影响范围可见、可退、可度量。

一、先弄清"不敢动"到底怕什么

1.1 怕的不是报错,是没报错

升级前我做过一次试跑,把框架换到 5.x,编译能过,页面能开。问题出在上线之后:一个自研表格组件在某些数据下不渲染行,控制台没有任何报错,因为它内部用了旧的比对逻辑,把数据变更吞掉了。这类问题比崩溃难查,崩溃至少有堆栈。

所以评估老组件的风险,不看代码行数,看它有没有静默失败的可能:自己缓存了状态、自己判断了要不要更新、自己接管了事件。这三样占一样,就得当成高风险对待。

1.2 三种耦合要先找出来

十九个组件我逐个看了一遍,问题归成三类。样式耦合:组件内部写死了全局类名,改了会影响别的页面。状态耦合:直接读写挂在全局对象上的东西,升级后那个对象换了位置。生命周期耦合:卸载时做了清理,但清理依赖旧的销毁顺序。

这三类都不需要重写才能解决。样式改成局部作用域,全局对象换成上下文注入,清理逻辑挪到新的钩子里,一个组件半天到一天。真正需要重写的,是那些把业务规则写进渲染逻辑里的组件,我们只有两个。

1.3 按风险分三档

分档依据两条:被多少页面引用,出问题能不能马上发现。引用超过三十处、又只在特定操作下才出错的,划到高风险,这次不动。引用少于五处、出错立刻可见的,划到低风险,直接改。中间那批用适配层包起来,边跑边换。

十九个组件里,高风险四个,中间十一个,低风险四个。这个数字决定了排期,也决定了能不能跟老板交代清楚------一次升级只碰四个高风险组件中的零个,听起来像没干活,但这就是可控。

二、让两套东西并存的三种办法

2.1 先升框架,别动组件

顺序很重要。很多人是先把组件改好再升框架,改完发现新写法在旧框架里跑不起来,白改一遍。正确顺序是先把框架升上去,用官方兼容包让旧写法继续能跑,再把组件一个个换过来。

兼容包不是长期方案,它只是争取时间。我给兼容包设了六个月的期限:到期前要么改完,要么把没改的组件连同兼容包一起冻结,不给它继续扩散的机会。期限要写在升级单上,不能只挂在嘴上。

2.2 适配层只做三件事

适配层很薄,只干三件事:把新的上下文传进去,把旧组件的事件转出来,把样式隔离住。它不处理业务逻辑,也不做数据转换。一旦开始做数据转换,适配层就会长成第二个框架。

我们那个适配层一百二十行,一个文件,所有旧组件共用。写法上是一个高阶组件,接收旧组件和它需要的上下文,返回一个能在新框架里挂载的东西。这个文件后来成了整个升级里改动次数居首的文件,但它的边界一直没破。

2.3 一个组件一个开关

开关的粒度必须到组件,不能到页面。按页面切,一个页面里新旧混用,出问题分不清是哪边;按组件切,出问题时把开关关掉,页面其他地方不受影响。

开关的值不写死在代码里,从配置中心拉。这样切流量不用发版,出问题两分钟内能退回去。配置中心读不到时要有默认值,默认值取旧组件------宁可新功能不生效,不能让页面白屏。

三、开关怎么判、怎么退

3.1 判定要稳定,不能随机

早期我用随机数按比例放量,问题是同一个用户刷新一次就换一套组件,样式闪、状态丢,报错也复现不了。改成对设备号做散列,同一个设备每次落在同一个桶里,调整比例时只有跨过边界的设备会切换。

散列还有个好处:出问题能定位。用户报错时带上设备号,我能算出他落在哪个桶,判断他走的是新组件还是旧组件。这个判断省掉了大量"你清一下缓存再试试"的来回。

3.2 完整实现

js 复制代码
// upgrade-gate.js ------ 组件级升级开关:按环境、设备散列、出错次数决定用新组件还是旧组件
const FLAGS = {
  'order-table':  { stage: 'canary', percent: 10, fallback: 'order-table-legacy' },
  'goods-picker': { stage: 'beta',   percent: 50, fallback: 'goods-picker-legacy' },
  'stock-panel':  { stage: 'off',    percent: 0,  fallback: 'stock-panel-legacy' },
};

function hash(str) {              // 稳定散列:同一设备每次落点一致,刷新不跳变
  let h = 2166136261;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return (h >>> 0) % 100;
}

function pick(name, ctx) {
  const flag = FLAGS[name];
  if (!flag) return name;
  if (flag.stage === 'off') return flag.fallback;
  if (flag.stage === 'full') return name;
  return hash(ctx.deviceId + ':' + name) < flag.percent ? name : flag.fallback;
}

const errorCount = {};

function render(name, ctx, props) {
  const target = pick(name, ctx);
  if (target === FLAGS[name].fallback) return registry[target](props);
  try {
    return registry[target](props);
  } catch (err) {
    errorCount[name] = (errorCount[name] || 0) + 1;
    if (errorCount[name] >= 3) FLAGS[name].stage = 'off';   // 连续三次失败,本机退回旧组件
    report(name, target, ctx.deviceId, err);                // 上报带组件名与目标版本
    return registry[FLAGS[name].fallback](props);
  }
}

3.3 两处细节值得说

一处是出错自动降级:新组件连续抛错三次,本机当天不再尝试,直接走旧组件。这不是遮丑,是保证用户能继续用。一个组件的问题不该让整个页面不可用,尤其是收银、开单这种不能停的页面。

另一处是上报里带组件名和目标版本。没有这个字段,你只会看到错误率上升,不知道是哪个组件升上去导致的。有了它,错误能按组件聚合,谁的问题一眼能看出来。

3.4 回退不等于删代码

开关关掉之后,旧组件还在,新组件也在,两份代码都要维护。这是并存的代价,必须接受,但要有期限。我们的规则是一个组件并存不超过两个月:要么改完删旧的,要么放弃这次改动删新的。

没有这条规则,两年后代码里会有三十个开关,没人敢删,也没人记得当初为什么加。开关本身也要登记,谁加的、为什么加、什么时候该删,写在一张表里,升级单关掉时逐条过。

四、怎么确认真的换过去了

4.1 埋点对比,不看感觉

验证不能靠"我点了一遍没问题"。我们在开关两侧埋同样的点:渲染耗时、交互次数、报错次数。切流量一周后对比两组数据,看新组件的报错率是不是高于旧组件。

头一轮对比就发现一个组件的输入框在中文输入法下会丢字符,旧组件没这个问题。这个毛病在人工测试里漏了,因为测试的人用的是英文输入。埋点不只看数量,还要看输入法、浏览器版本这些维度,否则差异被平均掉了。

4.2 样式回归用截图,不用眼睛

样式问题人工看不出来。我们用截图对比:同一批页面在开关开、关两种状态下各截一张,做像素差,差异超过阈值的人工确认。

阈值不能设太严,字体渲染在不同机器上有细微差别。我们的做法是圈定区域,只比表格、表单、弹层这三块,页头页脚不比。区域定下来之后,误报从每周几十条降到个位数。

4.3 删旧代码要满足三个条件

删旧代码要满足三条:开关全量为新、新组件跑满一个月没有严重问题、旧组件的引用数为零。第三条要靠静态扫描,不能靠搜索,因为有些引用是动态拼出来的。

引用数清零之后先注释掉,跑一周,再删。这一步看着多余,但我们在这一步抓到过一次动态引用,是运营配置里存的一个组件名,扫描扫不到,只有运行时才会暴露。

五、边界:这次不该动的东西

5.1 顺手重构是升级失败的主要原因

升级过程中很容易顺手改点别的:这个组件命名不好,那个接口字段该合并。每改一处,回归范围就大一圈,出了问题也说不清是升级导致的还是重构导致的。

我给自己定的规矩是:升级期间只做等价替换,不改行为。想改的记在清单里,升级完再单独排。三个月下来清单记了四十六条,后来真正做了的不到十条,说明大部分"顺手"其实没必要。

5.2 什么时候该停

有一种情况要停:某个组件的替换反复出问题,改了三次还有新毛病。这时候不要继续投入,把它退回旧组件,标记为暂不升级,写清原因和当时的测试结论。

后来留下五个组件没升,都是这种。它们跑在兼容包上,兼容包到期后跟着一起冻结。这不是把技术债清零,是把债记在明面上,比稀里糊涂改坏了强。升级的目标是让系统能往前走,不是把所有代码都变成新的。

常见问题

问:兼容包到期了还没改完,会出什么问题?

答:兼容包一般是把旧 API 挂在新版本上做转接,它能跑但绕过了新版本的一些优化,比如编译期的静态分析、部分树的复用。到期后官方不再修它的问题,遇到新版本的破坏性变更也没人管。我们的处理是到期前一个月冻结未完成项,把兼容包锁死在一个固定版本,不再跟着主框架升。

问:按设备散列分桶,会不会有些设备一直抽不到新组件?

答:会,散列是确定性的,落在百分比之外的设备就一直是旧组件。所以放量到全量不能靠调比例到一百,那样等于绕过开关。正确做法是把开关的 stage 直接改成 full,让所有设备走新组件,同时保留一次性回退的能力。比例只用来做中间阶段的流量分配。

问:新旧组件并存期间,两边的状态怎么同步?

答:状态放在组件外面,由页面容器持有,新旧组件都从容器读写,不各自维护一份。如果旧组件内部有自己的缓存,就要在挂载时把容器状态灌进去,卸载时把变更回写。这一步不做,切换开关时用户会看到数据回到旧值,很难解释。

问:组件升级要不要连测试用例一起改?

答:要,而且测试先改。旧组件的测试大多依赖内部结构,比如查找某个类名、断言某个 DOM 层级,这些在新组件上一定失败。改法是把断言从结构转向行为:按钮点了有没有触发回调、列表渲染了几行、错误态有没有展示。行为断言在两边都能跑,才能用来对比。

相关推荐
Eloudy1 小时前
`ibv_get_device_list` 实现分析
网络·gpu·rdma
AIGS0011 小时前
AI问数能问什么问题?——跨ERP、MES的数据一句话查出来,每个数字带出处
人工智能·数字员工·流程优化·智能问数·企业ai落地·ai问数·本体语义
java_logo1 小时前
Docker 部署 Portainer CE 中文版:轻松搭建容器管理平台
运维·docker·容器·portainer·容器管理·轩辕镜像·portainer ce
u1301301 小时前
AI 日报(2026年10月23日)
人工智能
瓶瓶持续学习中1 小时前
物理AI与数字孪生如何协同:从三维建模到仿真训练
人工智能
AAA小肥杨1 小时前
大模型量化简介
人工智能·大模型
七牛开发者8 小时前
从 GLM 到 GPT,HarnessPro 如何用 RSI 降低 Agent 成本
人工智能·gpt·agent·harness·rsi
跨境卫士-小汪8 小时前
TikTokShop欧洲商品接口架构调整跨境技术团队需要做好哪些准备
大数据·人工智能·新媒体运营·跨境电商·跨境·营销策略
古法老登瞎折腾9 小时前
大数据智能运维实战(十一):运维 MCP 的工程化——权限、限流、审计与错误处理
java·大数据·运维·spring ai·mcp