记Esbuild压缩代码引起的问题 排查+梳理+复现+解决

1,008 阅读4分钟

一、事情起因

线上系统m3u8视频无法播放 videojs提示媒体信息类型不支持

  • 1.排除链接问题: 使用vlc播放发现是可用的
  • 2.排除格式问题: 查看媒体信息是h264, videojs是支持的
  • 3.打断点后发现: 播放组件输出的typevideo/mp4而不是application/x-mpegURL
  getType() {
    ...
    if (/\.m3u8$/.test(this.url)) {
      return "application/x-mpegURL"
    }
    return "video/mp4"
  }
  • 4.问题找到, m3u8链接search部分带有鉴权信息(xxx.m3u8?token=xxx), 正则不匹配
/\.m3u8(\?.*)?$/
  • 5.修正后本地调试环境测试ok, 打包部署测试后依旧无法播放, 但是报错信息变了

image.png image.png

二、查找问题

  • 1.报错位于blob worker内, 反查源码找到生成位置, 定位到出错源码位于videojs内, 查看上下文代码发现是取function内字符串代码后生成worker
// node_modules\video.js\dist\video.cjs.js
var transform = function transform(code) {
  return "var browserWorkerPolyFill = " + browserWorkerPolyFill.toString() + ";\n" + 'browserWorkerPolyFill(self);\n' + code;
};

var getWorkerString = function getWorkerString(fn) {
  return fn.toString().replace(/^function.+?{/, '').slice(0, -1);
};
...
var workerCode = transform(getWorkerString(function () {
  function createCommonjsModule(fn, basedir, module) {
    return module = {
      path: basedir,
      exports: {},
      require: function require(path, base) {
        return commonjsRequire(path, base === undefined || base === null ? module.path : base);
      }
    }, fn(module, module.exports), module.exports;
  }
  
  function commonjsRequire() {
    throw new Error('Dynamic requires are not currently supported by @rollup/plugin-commonjs');
  }
...
  • 2.排查源码本身并没有包含运行错误部分, 对比打包后对应部分输出代码, 多出部分:

image.png

  • 3.查找a方法声明, 在代码顶部找到定义. 问题初步确认, worker来自于function.toString(), 并不包含顶部的a方法声明, 又因在独立的context内运行, 自然报错 a is not defined.
var Vg = Object.defineProperty;
var a = function (Fe, Pe) {
  return Vg(Fe, "name", { value: Pe, configurable: !0 });
};
  • 3.原始代码内并没有a以及相关存在, 那么它又是哪里来的呢? 检查项目构建器webpack配置发现, videojs位于node_modules内, 且代码为es5格式, 并没有经过任何loader处理, 只剩下esbuildminify插件在代码输出后进行压缩(通过github issue发现类似问题, 都是babel或其他loader处理代码造成的, 与本次情况相似又不相同)
  • 4.观察a的功能可知他在为function o设置name属性, 不由的让人联想起项目里minify用的属性keepNames. 查看 esbuild文档 关于keepNames的描述只有配置作用,没有实现原理说明.
new ESBuildMinifyPlugin({
  target: 'ie10',
  include: /static[\\/]js/,
  keepNames: true,
})
  • 5.由于文档没有说明, 我们定位到esbuild源码

github.com/evanw/esbui…

func (p *parser) keepExprSymbolName(value js_ast.Expr, name string) js_ast.Expr {
  value = p.callRuntime(value.Loc, "__name", []js_ast.Expr{value,
    {Loc: value.Loc, Data: &js_ast.EString{Value: helpers.StringToUTF16(name)}},
  })
  value.Data.(*js_ast.ECall).CanBeUnwrappedIfUnused = true
  return value
}

func (p *parser) keepClassOrFnSymbolName(loc logger.Loc, expr js_ast.Expr, name string) js_ast.Stmt {
  return js_ast.Stmt{Loc: loc, Data: &js_ast.SExpr{
    Value: p.callRuntime(loc, "__name", []js_ast.Expr{
      expr,
      {Loc: loc, Data: &js_ast.EString{Value: helpers.StringToUTF16(name)}},
    }),
    IsFromClassOrFnThatCanBeRemovedIfUnused: true,
  }}
}
...
if p.options.keepNames {
  p.symbols[s2.Fn.Name.Ref.InnerIndex].Flags |= ast.DidKeepName
  fn := js_ast.Expr{Loc: s2.Fn.Name.Loc, Data: &js_ast.EIdentifier{Ref: s2.Fn.Name.Ref}}
  stmts = append(stmts, p.keepClassOrFnSymbolName(s2.Fn.Name.Loc, fn, name))
}
...
//除了fn语句 还有fn表达式 箭头函数等等都是通过 keepClassOrFnSymbolName 和 keepExprSymbolName保留名称的

查看源码后发现最终通过添加或者包含调用runtime方法__name实现keepNames, 继续查找runtime__name的详细, 与压缩抽取后的生产代码行为一致.

github.com/evanw/esbui…

text += `
 ...
 // Update the "name" property on the function or class for "--keep-names"
 export var __name = (target, value) => __defProp(target, 'name', { value, configurable: true })
 ...
`
  • 6.minify在压缩function时, keepNames配置导入runtime helper内的__name方法给函数保留名称, 在压缩过程中抽取提升至代码顶部. 由于worker代码来源于function.toString()内部, 且运行时处于独立的环境, 所以运行时自然找不到__name方法. minify在压缩变量名时按照a-zA-Z字母顺序组合, 所以位于顶部的__name方法自然被压缩为a方法, __name未找到时自然就报错 Uncaught Syntax error: a is not defined

三、解决问题

  • 方式一: 关闭keepNames, 腿疼砍腿, 显然不是正确的解决方案
  • 方式二: 按需压缩, 本项目采用的是webpack输出assets后交由esbuild压缩, 所以在esbuild无法按照module进行精细的压缩控制,但是可以利用webpacksplitChunksvideojs单独打包为一个文件关闭keepNames.
  optimization: {
    minimizer: [
      new ESBuildMinifyPlugin({
        target: 'ie10',
        include: /static[\\/]js/,
        exclude: /static[\\/]js[\\/]videojs\..*\.js/,
        keepNames: true,
      }),
      new ESBuildMinifyPlugin({
        target: 'ie10',
        include: /static[\\/]js[\\/]videojs\..*\.js/
      })
    ],
    splitChunks: {
      cacheGroups: {
        videojs: {
          test: /[\\/]node_modules[\\/]video\.js[\\/]/,
          minChunks: 1,
          name: 'videojs'
        }
      }
    }
 },

四、事后总结

  • 为什么之前videojs没有报出问题?

    生产环境已经一年多未重新部署, 之前上线版本用的压缩工具是terser

  • 为什么选择esbuild压缩?

    esbuildterser相比拥有极大的性能优势, 特别是在大数量级代码遍历的情况下, 但是要注意esbuild为了性能牺牲了部分信息.

In particular, esbuild is not designed to preserve the value of calling .toString() on a function. The reason for this is because if all code inside all functions had to be preserved verbatim, minification would hardly do anything at all and would be virtually useless. However, this means that JavaScript code relying on the return value of .toString() will likely break when minified

官方文档就有提到压缩后对于函数进行toString是不可靠的, 而引起本次bug的videojs就是通过toString取函数内容用于worker.

查看了最新版本的video.js代码, 依旧采用了function.toString()生成worker代码, 其实可以采取更好的方案, 比如编译dist时提前提取成字符串, 比如单独分离worker文件, 而不是使用内联形式.