省流版:一个 switch-case 里的
break,源码运行正常,构建后却失效并穿透到了下一个 case。根因是@babel/plugin-transform-block-scoping在做块级作用域转换时,把break降级成了函数返回值信号,而内层新生成的 switch 把它吞掉了。升级该插件到 7.10.5+ 即可修复。
事件篇:源码没错,构建后崩了
我曾一直以为,switch 不过是 if-else 的方便写法罢了,直到有一天,我写了这样一段代码:
javascript
function serialize(items, ignore) {
const data = []
items.forEach(item => {
switch (item.templateStyle) {
case 'selected-brand-module': {
const maxRank = 5
if (!ignore && !item.title) return
for (let i = 0; i < item.groups.length; i++) {
item.groups[i].sort(
(a, b) =>
Math.min(a.rank, maxRank) - Math.min(b.rank, maxRank)
)
}
data.push({
templateStyle: item.templateStyle,
templateName: 'brand'
})
break
}
case 'theme-template': {
data.push({
templateStyle: item.templateStyle,
templateName: 'category'
})
break
}
}
})
return data
}
const items = [{
templateStyle: 'selected-brand-module',
title: '',
groups: [[{ rank: 2 }, { rank: 1 }]]
}]
console.log(serialize(items, true))
显而易见,传入的 items 里只有一个 selected-brand-module 元素,因此输出的应该是一个长度为 1 的数组:
css
[ {templateStyle: 'selected-brand-module', templateName: 'brand'}]
可实际上,最终输出的是:
css
[ {templateStyle: 'selected-brand-module', templateName: 'brand'}, {templateStyle: 'selected-brand-module', templateName: 'category'}]
你大概会把我前面的代码复制到 Node 或浏览器控制台里跑一遍,发现并没有我说的两个元素,而是正常的一个元素------我当时的疑惑比你还要大。我以为是自己 Vue 项目里出了诡异情况,而将代码原封不动复制到独立运行环境中,确实正确。
初步可以判断的是,selected-brand-module 分支中的 break 并没有打断逻辑,代码以一个诡异的方式继续运行下去,进入了 theme-template 分支!
我的 Vue 项目染上脏东西了!
解谜篇:凶手是 Babel
净手、沐浴、焚香之后,大喊了三声"我是无神论者!!!",我开始冷静分析这其中的差异,一个单词便浮现在我眼前:
babel
我把最小示例直接交给现代浏览器和 Node 执行,都只得到一个对象;但在当前 Vue CLI 项目里,浏览器运行的是经过构建链转换的代码。于是我对比了浏览器实际执行的脚本与源码,发现异常出现在 Babel 对块级作用域的转换中。为了排除其他构建步骤的影响,我只启用 @babel/plugin-transform-block-scoping 转译最小示例:
php
const { transformSync } = require('@babel/core')
const source = `
function serialize(items, ignore) {
const data = []
items.forEach(item => {
switch (item.templateStyle) {
case 'selected-brand-module': {
const maxRank = 5
if (!ignore && !item.title) return
for (let i = 0; i < item.groups.length; i++) {
item.groups[i].sort(
(a, b) =>
Math.min(a.rank, maxRank) - Math.min(b.rank, maxRank)
)
}
data.push({
templateStyle: item.templateStyle,
templateName: 'brand'
})
break
}
case 'theme-template': {
data.push({
templateStyle: item.templateStyle,
templateName: 'category'
})
break
}
}
})
return data
}
`
const input = () => [{
templateStyle: 'selected-brand-module',
title: '',
groups: [[{ rank: 2 }, { rank: 1 }]]
}]
const run = code => Function(`${code}; return serialize`)()(
input(),
true
).map(({ templateStyle, templateName }) => ({
templateStyle,
templateName
}))
const compiled = transformSync(source, {
plugins: ['@babel/plugin-transform-block-scoping'],
babelrc: false,
configFile: false
}).code
console.log('原生:', run(source))
console.log('Babel:', run(compiled))
最终的结果显而易见了(新机子哇一直摸你肚子)------凶手就是 babel :

解决篇:编译产物露了馅
发现了凶手之后,只要把 compiled 打印出来,事情就昭然若揭了:
javascript
function serialize(items, ignore) {
var data = [];
items.forEach(item => {
switch (item.templateStyle) {
case 'selected-brand-module':
{
var _ret = function () {
var maxRank = 5;
if (!ignore && !item.title) return {
v: void 0
};
for (var i = 0; i < item.groups.length; i++) {
item.groups[i].sort((a, b) => Math.min(a.rank, maxRank) - Math.min(b.rank, maxRank));
}
data.push({
templateStyle: item.templateStyle,
templateName: 'brand'
});
return "break";
}();
switch (_ret) {
case "break":
break;
default:
if (typeof _ret === "object") return _ret.v;
}
}
case 'theme-template':
{
data.push({
templateStyle: item.templateStyle,
templateName: 'category'
});
break;
}
}
});
return data;
}
重点就在于这一段:
csharp
switch (_ret) {
case "break":
break;
default:
if (typeof _ret === "object") return _ret.v;
}
也就是说,Babel 编译之后,又在里面加了一层 switch,而我们之前代码里的 break 被用来处理内层 switch 的跳出,最终没有成功作用到 selected-brand-module 分支的跳出,从而进入了 theme-template 分支。那么为什么 Babel 要这么编译呢?经过代码修改的反复实验,这个问题由以下几个原因共同造成:
- 因为
maxRank的闭包,整个分支的逻辑被封装成了一个函数_ret() - 因为函数中存在多个不同逻辑的退出机制(
!ignore && !item.title时的 return 和这段逻辑最后的 break),Babel 采用了 switch 来判断_ret()的返回值 - 而在新的 switch 中,获取到 break 逻辑时仅跳出了新 switch,没有办法让原来外层 switch 里的代码逻辑中断
所以,如果你手上也在维护一个老版本 Babel 的项目,建议顺手做三件事:
- 升级
@babel/plugin-transform-block-scoping到 7.10.5 及以上版本,这个坑官方已经修了(修复版本改用了 if-else,而没有继续套一层 switch) - 正确使用闭包:像上面那段示例代码,
maxRank完全可以写到 for 循环的作用域内、根本不需要闭包;如果逻辑的作用域比较复杂,尽量用 if-else 代替 switch - switch 分支后如果没有其他逻辑,直接在分支里用 return 退出整个函数来代替 break,彻底避免逻辑进入下一个分支