外观
先说说我们遇到的问题。我们有个单页应用项目,因为架构问题等历史原因,大大小小已经有400多个页面了。如果将代码的圈复杂度简化定义为代码中if、for、while、case、catch、&&(和)、||(或)、?:(三元运算符)的数量的话,圈复杂度达到了 40000+,相对大部分公司的项目而言,这已经算大型项目。这些页面最终会被放到不同的目标环境下,比如有部分页面会用于放到APP本地资源包里,有部分页面会用于微信端wap站,也有同时被用于多个目标环境的情况。最早的时候,不同端里放的页面都是差不多的,差异性很小,而且当时页面数量少,webpack编译速度还是可以的,所以当时做的方案是只编译一次,然后对编译粗产物进行处理,根据页面级精度的目标环境配置,定向拷贝到各个目标产物目录中去,最后删掉编译粗产物所在临时目录。这个方案在当时是合理的。
但是现在已经严重影响开发、测试、发布的效率了——编译一次要一个小时(在我自己mac电脑上要75分钟)。所以我终于有可以做非业务需求的排期了。最终是可以优化到一刻钟以内了。最早的时候我的思路有偏差,在想如何提高webpack编译的速度。
先说下,我们这个老项目用的还是webpack3。因为我们公司不是传统的互联网公司,底子里更偏向金融(除了薪资,摊手),至少我们部门而言,技术方案偏保守,技术选型上我们不会选最新的大版本,一般是当时的次新版本(比如最近新写的项目,搭框架的时候用的就是webpack4——因为现在最新的是5),这个技术倾向也没啥问题,毕竟只是差一个大版本号,也不会有多大的技术负债的。我也理解有些同事会想什么都上最新的版本,三年前我刚来公司那会也是这种想法,不过当你负责的项目里有400多个页面时,但凡有问题出来就要你负责的话,你就不会这么想了。所以我现在也比较倾向于用迭代过很多次的最新大版本,或者次新大版本,那种刚出来还没迭代过几个小版本号的大版本是不会考虑的,除非是内部用用的项目。
言归正传,最早的时候我的思路有偏差,在想如何提高webpack编译的速度。网上那些搜"webpack 编译提速"、"webpack 编译优化"关键词能搜到的小点,比如预编译DLL包、抽公共组件啥的,当时来公司的时候都已经做过了。这次一开始的思路大致如下。
先来一个整体的感受:
查看babel-loader(版本为7.1.4)源码。发现我们用的版本里在指定了cacheDirectory选项后,babel-loader会在读取文件内容生成对应json文件后,对这个json文件进行压缩处理,然后在下次编译要用缓存数据时,对这些文件进行解压处理再读取其中的内容。这个json文件的内容可以参考下图,还是很好看懂的,里面会用一个字段(map.sourcesContent)记录输入文件源代码,再用一个字段(code)记录babel编译后的输出代码,还有个字段(map.sources)会记录源码文件的路径,metadata.modules里还记录了各种该源码文件引用的资源。这里提这个主要是方便自己以后看,如果哪天要通过类似的东西来实现什么功能的话,就可以借鉴(抄)了。
这里由于在公司流水线平台上编译每次都是新的工作区(之前的工作区文件会被删掉),所以babel-loader的缓存只要本地开发时用用就可以了,或者和相关部门一起弄下这块的缓存处理。到时候要弄的话,可以把这个版本的babel-loader备份一份修改一下,把压缩和解压的逻辑去掉,实测了下是可以少一些时间的,不过这并非此次的主要矛盾所在,到此打住。
类似的,通过查看源码,发现我们用的webpack-uglify-parallel(版本0.1.4),需要显示的传一个sourceMap: false(在其主页上没介绍这个选项),否则也会做多余的逻辑处理。
此外,看了下微前端方案(乾坤之类的),因为如果能拆成多个子项目,每个项目的编译时间都不长,而且还可以并行编译。因为需要跨域支持,但是我们主要场景是APP内本地H5资源包,file协议下主应用请求子应用页面访问的还是本地资源,并不会发请求到服务端,要做跨域支持要么是访问本地页面对应的远程页面并让服务端做好跨域支持(但是多一个请求会让本地子应用的打开速度变慢),或者是和APP原生开发团队一起处理本地文件读取权限问题(但是这容易引发安全问题)。这种方案不仅成本略高,而且更重要的是要改动业务代码组织结构并且极易增大H5全量包大小。
发现也没有其他什么好做优化的了,上面这些可优化的点对解决本次主要矛盾是没啥意义的。仅做记录。
我认为的正确的方式,是根据不同目标环境,只按需编译需要编译的页面。原本我是打算老老实实通过process.env对象来去编译命令中传给node的表示编译目标的参数,并根据现有的配置文件,来创建对应的配置文件,再去实际编译(工作量主要在对各种文件进行正则处理上)。这样自然是没问题的。但是我突然想到uniapp不是有条件编译这种功能吗(虽然我并没有用过),然后发现在webpack里通过loader的方式来实现这个需求也是可以的,本着尝新的心态,去写了一个,不过由于这周业务需求还蛮紧的,针对页面级别配置文件里正则写到一半觉得时间有点紧就注释掉了,改成按大模块级别的精度来做取舍判断了。如果你比较喜欢通过注释的方式来实现(其实也是写正则)条件编译,可以github上搜js-conditional-compile-loader的源码。
正则那块太有业务属性了,只贴非业务核心代码(说明:参考了js-conditional-compile-loader的代码,区别是他是通过注释来进行条件编译的,这样需要对我们的业务代码有较多的改动,并且是持续性的要维护,为了照顾同事们现有的开发习惯,我改成了刚才说的正则处理,这样对同事来说几乎是无感的):
const conditionalCompiler = {
// loader: require('./js-conditional-compile-loader/index.jsx'), // 这种写法是不可以的
loader: 'js-conditional-compile-loader',
options: {},
}
// ...其他代码
// webpack里loader处的配置
{
test: /\.jsx?$/,
use: [
'happypack/loader?id=babel',
conditionalCompiler, // 这个loader要尽早被使用到,在这个地方位置越靠后的loader会越先被用到
],
include: [resolve('src')],
},然后在package.json的开发依赖配置里配置js-conditional-compile-loader依赖指向本地源码,以方便开发(注意:每次修改完loader插件的代码后要重新安装一下依赖才会生效):
"js-conditional-compile-loader": "file:./build/js-conditional-compile-loader",然后就是这个loader的源码了(简化处理,仅做示意):
// const loaderUtils = require('loader-utils')
const CONFIG_USAGE = process.env.USAGE;
module.exports = function (source) {
const self = this;
// const options = loaderUtils.getOptions(self)
const resourcePath = self.resourcePath;
if (
!/projectName\/subProjectName\/src\/routes\/.+\/config\.lint\.jsx/.test(
resourcePath,
)
) {
return source;
}
try {
if (
CONFIG_USAGE === "ALL" ||
source.indexOf(`USAGES.${CONFIG_USAGE}`) !== -1
) {
return source;
}
const moduleNameMatches1 = source.match(
/const moduleName = '([a-zA-Z0-9_-]+)'/,
);
const moduleNameMatches2 = source.match(/moduleName: '([a-zA-Z0-9_-]+)',/);
const moduleName =
(moduleNameMatches1 && moduleNameMatches1[1]) ||
(moduleNameMatches2 && moduleNameMatches2[1]);
if (!moduleName) {
throw new Error(`[未成功获取到moduleName](${resourcePath})`);
}
return `
module.exports = {
routes: [],
moduleName: '${moduleName}',
}
`;
} catch (err) {
console.log(err); // eslint-disable-line
process.exit(1);
}
};这篇文章介绍的内容,可能对大部分项目而言没有太大的参考价值,因为正常的一个项目,你是不应该放这么多页面的。现在微前端架构已经出来挺久了,虽然乾坤上的issue还是挺多的,但是这个东西看着介绍就觉得应该不会太难处理的。我们这个项目其实也可以用微前端的思路,只是不能直接用,这种框架需要支持跨域请求资源,在APP资源包里的页面处于file协议,互相访问并不会请求到服务端(去请求服务端的一个备份镜像文件也不合适),开通app下file协议文件互相访问的权限的话又担心会引来安全问题,但是如果想做应该都是可以做的,只是要定制下,还需要相关部门协助支持。