三月接手一个后台系统,前端框架还停在 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 层级,这些在新组件上一定失败。改法是把断言从结构转向行为:按钮点了有没有触发回调、列表渲染了几行、错误态有没有展示。行为断言在两边都能跑,才能用来对比。