一个构建引入问题的分析

419 阅读1分钟

问题现场

基于 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: true,
    minimizer: [
      ESBuildMinifyPlugin {
        options: { minify: true, target: 'es2015' }
      }
    ],
    minimize: true
  },

显然,在混淆时使用了 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,打开构建产物页,一切正常,大功告成~