大前端的时代,前端的重要性已经已经无可置疑。在某些情况下,前端甚至决定了产品的成败。体验优良,性能卓越的前端产品留得住用户,体验糟糕的前端即使后端能力再优秀,最后也难逃被用户卸载的命运。
为了追求前端的性能,前端需要对构建出来的js负责,确保发送给用户的js没有一行多余。这样才能确保最快的加载时间。但是,这个目标通常是比较难做到的。其中一个原因是程序员对webpack构建出来的js都包含什么不是十分清晰;另外一个原因是:大部分项目用了很多轮子(npm包),以确保项目可以按时上线。这些轮子虽然可以使用,但是代码不一定是精简的,我们又对这些代码没有控制能力。即使有些冗余,很多人也都只是忍了。
这里以我们的项目为例介绍我们是如何解决这两个问题的。
发现系统问题
首先,我们需要分析一下我们的项目都用了哪些库,打包出来都多大。
我们用了一个工具webpack-bundle-analyzer。
npm install
过后,把这个插件告诉webpack。这样webpack打包的时候webpack-bundle-analyzer
就能对打包的文件进行统计。
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
{
// ...
plugins: [
new BundleAnalyzerPlugin()
]
}
这是我们项目的文件打包情况。
左边的commons.js
都是来自于npm里面的文件,可以发现左边一个巨大的all.js占用了commons.js
将近一半的体积。这个文件足足有18w行。优化这个文件的体积定能收到不错的效果。遗憾的是这个文件我们手工修改不了,是protobuf工具生成的。
优化protobuf生成的js
protobuf是一个流行的google的序列化库。我们的后台接口定义在.proto文件中,然后通过protobufjs这个工具转换.proto文件为.js文件,就是这个all.js
文件。因为后台每次开发新业务需求会新增加协议文件,所以这个文件从上线以来就呈一直增长的趋势。
这是我们的构建命令pbjs -t static-module -r trade --sparse -w es6 -p ./proto/proto3.0 --no-create --no-comments --no-delimited --no-beautify --no-verify file1.proto file2.proto file3.proto file4.proto
。最后面几个是输入文件,每次都往后面增加。
现在要优化这个文件就要把这个文件打散,按业务逻辑生成多个js,需要加载某个业务的时候再加载相应的js文件。
好在我们的protobuf已经是按业务层级划分的。
biz1
file1.proto
file2.proto
biz2
file3.proto
file4.proto
...
理论上,只需要按业务划分,多次调用pbjs命令生成多个js就行。
像这样:
pbjs biz1/file1.proto biz1/file2.proto > biz1.js
pbjs biz2/file3.proto biz2/file4.proto > biz2.js
事实上这还不够。看下生成的biz1.js
文件。(biz2.js
文件结构类似)
import * as $protobuf from "protobufjs/minimal";
const $Reader = $protobuf.Reader, $Writer = $protobuf.Writer, $util = $protobuf.util;
const $root = $protobuf.roots.trade || ($protobuf.roots.trade = {});
export const gf = $root.gf = (() => {
const gf = {};
gf.client = (function() {
const client = {};
client.biz1 = (function() {
const biz1 = {};
/// ...
return biz1;
});
return client;
});
gf.common = (function() {
/// ...
});
return gf;
})();
export { $root as default };
这个文件带了部分ES6语法,但是命名空间都是通过js的自执行函数生成的。这种叫做js的module pattern,在IE6的年代,这种写法曾大行其道。
注意到中间的gf.common
了么?由于biz1/file1.proto
文件引用了项目的公共common.proto
文件,这块逻辑会在每个文件中都存在。我们最好把它都删除掉,一个文件有就好了。这是第一个我们要做的。
还有一个极其严重的问题:这个module pattern没有考虑到命名空间分散在多个文件的情况。biz1.js
文件声明了gf.client.biz1
,加载了biz2.js
文件后,直接被gf.client.biz2
替换,而不是把biz2模块附加上去。
我们把嵌套去掉,这样方便看出问题。这是三个嵌套的命名空间。
gf.client.biz = (function() {
const biz1 = {};
/// ...
return biz1;
})();
gf.client = (function() {
const client = {};
/// ...
return client;
})();
gf = (function() {
const gf = {};
/// ...
return gf;
})();
看,每层嵌套都是一个新的object!
假如每个object声明的时候可以先看下是否存在,就可以做到模块融合的效果。把上面三处嵌套改为如下的结构的代码。
gf.client.biz1 = (function() {
const biz1 = $root.gf.client.biz1 || ($root.gf.client.biz1 = {});
return biz1;
})();
gf.client = (function() {
const client = $root.gf.client || ($root.gf.client = {});
return client;
})()
gf = (function() {
const gf = $root.gf || ($root.gf = {});
return gf;
})()
要做到对js的这两处改动可不容易。因为js是高度结构化的。来个批量替换,万一替换到字符串里面的东西了不是砸着自己的脚了么?
使用babel修改protobuf产出文件
这里我们请出修改js文件的神器babel。咦?Babel不是个ES6转换工具么?对!但是它还是有很多轮子可以拆下来做点事情的。
我们需要用到它的四个轮子,把我们的转换工具跑起来。
- @babel/parser: 这个库吃掉我们的js文件,生成结构化的js的语法树。
- @babel/traverse: 这个库是用来遍历上一步生成的语法树的。
- @babel/types:定义了语法树中出现的各种类型,字符串,表达式,标识符等等。
- @babel/generator:这个库吃掉js语法树,生成js文件,大功告成。
四个轮子使用的大致流程如下:
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generator = require('@babel/generator').default;
// 1. parser转换js文件为语法树
let ast = parser.parse(code, {
sourceType: 'module'
});
let traverser = {
// 2. 更改js语法树的逻辑放这里
};
// 3. 遍历语法树
traverse(ast, traverser);
// 4. 转换语法树为js
return generator(ast).code;
删除部分js代码
我们先来完成删除common块的任务。对!就是这块:
gf.common = (function() {});
这是一个赋值的表达式,所以我们用ExpressionStatement
捕获这块。但是表达式是很多的,还要要进行筛选。
左边得是一个MemberExpression
,还得是gf.common
。
const traverser = {
ExpressionStatement(path) {
let left = path.node.expression.left;
if (left && left.type == 'MemberExpression'
&& left.object.type == 'Identifier' && left.object.name == 'gf'
&& left.property.type == 'Identifier' && left.property.name == 'common') {
// 这就是gf.common = ...没错了
path.remove();
}
},
};
替换部分js代码
第二个修改点,我们要追踪嵌套的js模块,还要手工生成一段js代码,替换老的代码。
这是我们要修改的代码:
const gf = {};
通过如下代码找到修改的目标语句。这句是一个VariableDeclaration
,长度为1,因为这句只有一个变量的赋值;
而且一定要node.kind == 'const'
且右边是个ObjectExpression
,且没有任何属性。
let pathes = ['$root'];
const traverser = {
VariableDeclaration(path) {
if (path.node.declarations.length != 1) {
return;
}
let decl = path.node.declarations[0];
if (path.node.kind == 'const' && decl.init.type == 'ObjectExpression' && decl.init.properties.length == 0) {
pathes.push(decl.id.name);
// 这里识别出目标语句
}
}
};
请注意这里我们用了pathes
变量识别了深度递归的时候的模块名字,这样我们可以得到['$root', 'gf', 'client', 'biz1']
这样一个数组。
有了这个数组,我们通过递归调用如下genMemberExp
函数,获得$root.gf.client.biz1
这段js语法树,这是一段嵌套的MemberExpression
。
function genMemberExp(pathes) {
if (pathes.length == 1) {
return t.Identifier(pathes[0]);
}
return t.MemberExpression(
genMemberExp(
pathes.slice(0, pathes.length - 1),
),
t.Identifier(pathes[pathes.length - 1])
);
}
好了!万事俱备只欠东风。我们把新的语法树替换老的语法树。
// 用新生成的js替换老的
path.replaceWith(t.VariableDeclaration(
'const',
[t.variableDeclarator(
t.Identifier(decl.id.name),
t.logicalExpression(
'||',
memberExp,
t.AssignmentExpression(
'=',
genMemberExp(pathes),
t.ObjectExpression([]) // 空object {}
)
)
)]
));
最终,通过generator
把我们修改过的的语法树转换为js。从而砍掉了将近一半的commons.js
。
总结
js文件大小对前端体验有决定性的作用。在做优化的时候,除了常规的优化手段,首屏,懒加载等,用babel做一些构建期的js优化是一个新的思路。有些时候甚至能达到立竿见影的效果。