一、事情起因
线上系统m3u8视频无法播放 videojs提示媒体信息类型不支持
- 1.排除链接问题: 使用
vlc
播放发现是可用的 - 2.排除格式问题: 查看媒体信息是
h264
, videojs是支持的 - 3.打断点后发现: 播放组件输出的
type
是video/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, 打包部署测试后依旧无法播放, 但是报错信息变了
二、查找问题
- 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.排查源码本身并没有包含运行错误部分, 对比打包后对应部分输出代码, 多出部分:
- 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
处理, 只剩下esbuild
的minify
插件在代码输出后进行压缩(通过github issue
发现类似问题, 都是babel
或其他loader
处理代码造成的, 与本次情况相似又不相同) - 4.观察
a
的功能可知他在为function o
设置name
属性, 不由的让人联想起项目里minify
用的属性keepNames
. 查看 esbuild文档 关于keepNames的描述只有配置作用,没有实现原理说明.
new ESBuildMinifyPlugin({
target: 'ie10',
include: /static[\\/]js/,
keepNames: true,
})
- 5.由于文档没有说明, 我们定位到
esbuild
源码
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
的详细, 与压缩抽取后的生产代码行为一致.
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
进行精细的压缩控制,但是可以利用webpack
的splitChunks
将videojs
单独打包为一个文件关闭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压缩?
esbuild
与terser
相比拥有极大的性能优势, 特别是在大数量级代码遍历的情况下, 但是要注意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
文件, 而不是使用内联形式.