问题现场
基于 umi 的模板通过 yarn dev
起应用时,应用运行正常;而在执行构建后,访问构建产物时页面会 crash ,查看报错如下:
umi.80e67342.js:227 TypeError: Cannot read property 'getType' of undefined
at Object._.deepClone (feedback.js:formatted:444)
at Object.sd.custom (feedback.js:formatted:1972)
at Object.sd.<computed> [as custom] (feedback.js:formatted:3172)
at Object.event_report (feedback.js:formatted:3487)
at Kn (umi.80e67342.js:238)
at umi.80e67342.js:139
at Array.forEach (<anonymous>)
at X.value (umi.80e67342.js:139)
at Ht (umi.80e67342.js:240)
at Object.E.x.listen (umi.80e67342.js:143)
查看报错文件,是第三方封装的埋点sdk中运行错误。于是第一时间怀疑是其文件中有运行时错误,但是查看源码没有发现异常,且在 dev 模式下运行正常,似乎又不应该是代码错误。
再查看问题现场,从错误处打开页面中加载的埋点文件,查看报错时的代码,如下:
function factoryFN() {
try {
let BatchSend = function() {
this.sendingData = 0
};
var sd = {}
, _ = sd._ = {};
// 省略中间代码...
_.getType = function(s) {
var i = Object.prototype.toString
, n = {
"[object Boolean]": "boolean",
"[object Number]": "number",
"[object String]": "string",
"[object Function]": "function",
"[object Array]": "array",
"[object Date]": "date",
"[object RegExp]": "regExp",
"[object Undefined]": "undefined",
"[object Null]": "null",
"[object Object]": "object"
};
return s instanceof Element ? "element" : n[i.call(s)]
}
,
_.deepClone = function(s) {
var i = _.getType(s), n; // 报错行
if (i === "array")
n = [];
else if (i === "object")
n = {};
else
return s;
if (i === "array")
for (var l = 0, f = s.length; l < f; l++)
n.push(_.deepClone(s[l]));
else if (i === "object")
for (var _ in s)
n[_] = _.deepClone(s[_]);
return n
}
第一眼看上去没有发现什么错误, _
已经在函数顶部被定义了, _.getType
也在上面已经定义了,为什么会报 _
的引用错误呢?于是想到是不是跟代码执行时机有关系,打断点查看,发现代码确实是在先执行完 _
的声明后执行到报错行的,且在执行到 _.deepClone
时变量 _
还是有效的,为何到了调用时抛错了?
在走了几个弯路(查看其他代码对于sdk的初始化和引用时机)后,没有找到头绪,于是回到错误代码处,仔细查看发现了端倪:
_.deepClone = function(s) {
var i = _.getType(s), n; // 报错行
// ... 省略
else if (i === "object")
for (var _ in s)
n[_] = _.deepClone(s[_]);
return n
}
查看此处的 for
语句,内部使用了 var _
的声明。我们在编写代码时,一般会使用 for (let k
这样的语法,以限制 k
在块级作用域;但这里skd出于兼容性考虑,写了 var
声明,而 var
声明的变量提升就导致在当前函数的执行上下文中遮蔽了外部的 _
变量访问,所以 _
就变成了 undefined
,于是访问 _.getType
时就抛错了。
找到了问题的症结,再去看sdk中的源码:
_.deepClone = function (data) {
var type = _.getType(data);
// ...省略
} else {
if (type === 'object') {
for (var key in data) {
obj[key] = _.deepClone(data[key]);
}
}
}
return obj;
};
源码中使用的是 key
,所有本不应该出现前述问题。到此问题的触发原因可以定位出来了:
源码中的 sdk 文件被混淆压缩器修改为了前述错误代码。这正是 terser 这样的工具的作用之一。terser 提供了 compress
和 mangle
两个功能,后者会对代码中的变量以及对象的属性命名进行统一替换,这里就将 key
替换为了 _
。替换字符默认为 _
,选择它的原因是应用中一般不会使用它当作变量命名使用。
而由于我们在源码中现在都会编写 es6+ 代码,且代码中不允许使用 var
进行变量声明(eslint.cn/docs/rules/…);且 terser 的处理原理是基于ast解析的变量名替换,所以对源码进行这样的命名替换是安全的。而这里的 sdk 文件是用来直接在浏览器中加载使用的,不应该再进行混淆操作。
关闭该 sdk 的混淆
定位到该问题后,解决方法自然是在进行混淆时过滤掉该文件。查看该文件的打包代码,是使用 copy-webpack-plugin
进行处理的:
config.plugin('CopyWebpackPlugin').use(CopyWebpackPlugin, [
{
patterns: [
{
from: `${api.paths.absTmpPath}/plugin-track/feedback.js`,
to: 'static/umd/js/feedback.js',
},
],
},
]);
这也是我们在构建中经常使用的一个插件。查看其文档,发现有一个参数可以关闭某个 pattern 下的压缩:
// webpack.config.js
module.exports = {
plugins: [
new CopyPlugin({
patterns: [
"relative/path/to/file.ext",
{
from: "**/*",
// Terser skip this file for minimization
info: { minimized: true },
},
],
}),
],
};
通过提供 info: { minimized: true }
信息,即可让通知混淆器忽略掉该文件。
于是进行设置,然而设置后发现并没有生效,该文件还是被混淆压缩了。经过调试发现该配置信息也确实提供给了对应的文件,那么为何没有生效呢,按说官网的文档不应该出错。于是继续查看应用的 webpack 构建配置,查看 optimization
的配置时发现:
optimization: {
noEmitOnErrors: [33mtrue[39m,
minimizer: [
ESBuildMinifyPlugin {
options: { minify: [33mtrue[39m, target: [32m'es2015'[39m }
}
],
minimize: [33mtrue[39m
},
显然,在混淆时使用了 esbuild ,而不是 terser。看来对应的插件中还没有处理 minimized
字段,所以该配置失效了。
找到原因后,最简单的方式是使用 terser 替换掉 esbuild 来做混淆,在 umi
的配置中只需要去掉 esbuild: {}
声明即可。然而在开发中还是可能会修改混淆器的配置,那么修改后这个问题就会再次引入,且大概率开发是一脸懵逼。。。
于是选择了一种更安全的修改方式,使用 fs.copy
替换掉 copy-webpack-plugin
。 umi
提供了多个插件hook,我们利用其中的 onBuildComplete
即可完美地匹配在 build 时执行拷贝:
api.onBuildComplete(({ err }) => {
if (!err) {
fse.copySync(
path.join(__dirname, 'feedback.js'),
resolveApp(`${api.paths.absOutputPath}/static/umd/js/feedback.js`),
);
}
});
修改完后执行build,打开构建产物页,一切正常,大功告成~