It's all about
connecting the dots

webpack条件编译

一、背景介绍

先说说我们遇到的问题。我们有个单页应用项目,因为架构问题等历史原因,大大小小已经有400多个页面了。这些页面最终会被放到不同的目标环境下,比如有部分页面会用于放到APP本地资源包里,有部分页面会用于微信端wap站,也有同时被用于多个目标环境的情况。最早的时候,不同端里放的页面都是差不多的,差异性很小,而且当时页面数量少,webpack编译速度还是可以的,所以当时做的方案是只编译一次,然后对编译粗产物进行处理,根据页面级精度的目标环境配置,定向拷贝到各个目标产物目录中去,最后删掉编译粗产物所在临时目录。这个方案在当时是合理的。

但是现在已经严重影响开发、测试、发布的效率了——编译一次要一个小时(在我自己mac电脑上要75分钟)。所以我终于有可以做非业务需求的排期了。最终是可以优化到一刻钟以内了。最早的时候我的思路有偏差,在想如何提高webpack编译的速度。

先说下,我们这个老项目用的还是webpack3。因为我们公司不是传统的互联网公司,底子里更偏向金融(除了薪资,摊手),至少我们部门而言,技术方案偏保守,技术选型上我们不会选最新的大版本,一般是当时的次新版本(比如最近新写的项目,搭框架的时候用的就是webpack4——因为现在最新的是5),这个技术倾向也没啥问题,毕竟只是差一个大版本号,也不会有多大的技术负债的。我也理解有些同事会想什么都上最新的版本,三年前我刚来公司那会也是这种想法,不过当你负责的项目里有400多个页面时,但凡有问题出来就要你负责的话,你就不会这么想了。所以我现在也比较倾向于用迭代过很多次的最新大版本,或者次新大版本,那种刚出来还没迭代过几个小版本号的大版本是不会考虑的,除非是内部用用的项目。

二、错误方向

言归正传,最早的时候我的思路有偏差,在想如何提高webpack编译的速度。网上那些搜”webpack 编译提速”、”webpack 编译优化”关键词能搜到的小点,比如预编译DLL包、抽公共组件啥的,当时来公司的时候都已经做过了。这次一开始的思路大致如下。

先来一个整体的感受:

  • 将页面路由分成4份路由组,注释掉其中3份单独编译1份。按这样的方式对4份路由组都单独编译。发现每一份的时间差不多都是几分钟。这个时间和全路由编译要一个多小时的时间差异是巨大的。但是这也必要不充分地说明,业务代码层面应该是没有什么大问题的(指对编译速度的影响)。
  • 只有一份路由组时,注释掉代码压缩步骤后,是有一定提速的,但是路由数量变多后,这个提速效果显著降低,基本上全路由编译的时候,是否压缩代码已经对总时长没有影响了。大概率猜测页面上数量少时计算任务少编译时间的瓶颈在IO上,是IO密集型任务;当页面数量很多时,计算任务多编译时间瓶颈在CPU上,是计算密集型任务。
  • 在我自己的电脑上,编译1组路由时用时大概是2分钟,同时编译2组路由时用时大概5分钟,同时编译3组路由时用时大概30分钟,同时编译4组路由时用时大概75分钟。可以发现编译时长和代码量总体上并不是一个线性增长的关系。猜测是因为计算需要从入口文件开始去爬引用到的文件,然后一层层去爬,不同入口爬到的文件有交集文件,也有各自独立的文件,毛估估很像是数据结构里的图(遍历时间复杂度为O(N^2)),而且我大致去算了下也能找到一个合适的模拟函数曲线,不过鉴于样本量有限,每个页面大小差别也很大,也没啥科学性。我在IDE里跳转到webpack源码后发现不是很好确认这点,便作罢。反正肯定不会需要我去优化webpack在这个计算过程用到的算法的——我也没那个能力,这块应该算是webpack的核心技术难点(就是webpack进度0.92对应的任务——这个0.92在webpack源码里写死的数字,但是实际这一步到进度1在时间尺度上的距离可能是很长的),专业的事让专业的人做。
  • 用那个计算webpack编译流程里各个loader和plugin用时的插件(名字不记得了,但是你在npm仓库上搜“webapck measure”应该就能搜出来,也不一定要用这个,类似功能的都可以),对于总时长75分钟而言,这些loader和plugin的用时都是可以忽略的。但是对于文件数量不多的情况下,babel-loader的用时尚有优化的意义。不考虑esbuild替换uglify啥啥啥的,因为实测项目里只能用0.4版本,太低了,觉得有风险。

查看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协议文件互相访问的权限的话又担心会引来安全问题,但是如果想做应该都是可以做的,只是要定制下,还需要相关部门协助支持。

赞(8) 打赏
转载需注明来源并给出来源页链接:峰间的云 » webpack条件编译

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏