webpack 使用外部的库_Webpack编译速度优化实战

当你的应用的规模还很小时,你可能不会在乎Webpack的编译速度,无论使用3.X还是4.X版本,它都足够快,或者说至少没让你等得不耐烦。但随着业务的增多,嗖嗖嗖一下项目就有上百个组件了,也是件很简单的事情。这时候当你再独立编前端模块的生产包时,或者CI工具中编整个项目的包时,如果Webpackp配置没经过优化,那编译速度都会慢得一塌糊涂。编译耗时10多秒钟的和编译耗时一两分钟的体验是迥然不同的。出于开发时的心情的考虑,加上不能让我们前端的代码编译拖累整个CI的速度这两个出发点,迫使我们必须去加快编译速度。本文主要是探讨下可做编译速度优化的地方,对一些API使用上不会做太多讲解,需要的同学可以直接翻看文档中的介绍。笔者的Webpack版本为4.29.6,后文中内容都基于这个版本。

一、已存在的针对编译速度的优化

笔者这套Webpack架子源自CRA的eject,基于Webpack4.x,在Loader和Plugin的选择和设计上已是最佳实践方案,基本上无需改动什么。其原有的对编译的优化配置在于这三处:

1. 通过terser-webpack-plugin的parallel和cache配置来并行处理并缓存之前的编译结果。terser-webpack-plugin是之前UglifyPlugin的一个替代品,因为UglifyPlugin已经没有继续维护了,从Webpack4.x起,已经推荐使用terser-webpack-plugin来进行代码压缩、混淆,以及Dead Code Elimination以实现Tree Shaking。对于parallel从整个设置的名称大家就会知道它有什么用,没错,就是并行,而cache也就是缓存该插件的处理结果,在下一次的编译中对于内容未改变的文件可以直接复用上一次编译的结果。

2. 通过babel-loader的cache配置来缓存babel的编译结果。

3. 通过IgnorePlugin设置对moment的整个locale本地化文件夹导入的正则匹配,来防止将所有的本地化文件进行打包。如果你确实需要某国语言,仅手动导入那国的语言包即可。

在项目逐渐变大的过程中,生产包的编译时间也从十几秒增长到了一分多钟,这是让人受不了的,这就迫使着笔者必须进行额外的优化以加快编译速度,为编包节省时间。下面的段落就讲解下笔者做的几个额外优化。

二、多线程(进程)支持

从上个段落的terser-webpack-plugin的parallel设置中,我们可以得到这个启发:启用多进程来模拟多线程,并行处理资源的编译。于是笔者引入了HappyPack,笔者之前的那套老架子也用了它,但之前没写东西来介绍那套架子,这里就一并说了。关于HappyPack,经常玩Webpack的同学应该不会陌生,网上也有一些关于其原理的介绍文章,也写得很不错。HappyPack的工作原理大致就是在Webpack和Loader之间多加了一层,改成了Webpack并不是直接去和某个Loader进行工作,而是Webpack test到了需要编译的某个类型的资源模块后,将该资源的处理任务交给了HappyPack,再由HappyPack再起内部进行线程调度,分配一个线程调用处理该类型资源的Loader来处理这个资源,完成后上报处理结果,最后HappyPack把处理结果返回给Webpack,最后由Webpack输出到目的路径。将都在一个线程内的工作,分配到了不同的线程中并行处理。

使用方法如下:

首先引入HappyPack并创建线程池:

const HappyPack = require(‘happypack‘);

const happyThreadPool= HappyPack.ThreadPool({size: require(‘os‘).cpus().length - 1});

替换之前的Loader为HappyPack的插件:

{

test:/\.(js|mjs|jsx|ts|tsx)$/,

include: paths.appSrc,

use: [‘happypack/loader?id=babel-application-js‘],

},

将原Loader中的配置,移动到对应插件中:

newHappyPack({

id:‘babel-application-js‘,

threadPool: happyThreadPool,

verbose:true,

loaders: [

{

loader: require.resolve(‘babel-loader‘),

options: {

...省略

},

},

],

}),

大致使用方式如上所示,HappyPack的配置讲解文章有很多,不会配的同学可以自己搜索,本文这里只是顺带说说而已。

HappyPack老早也没有维护了,它对url-loader的处理是有问题的,会导致经过url-loader处理的图片都无效,笔者之前也去提过一个Issue,有别的开发者也发现过这个问题。总之,用的时候一定要测试一下。

对于多线程的优势,我们举个例子:

比如我们有四个任务,命名为A、B、C、D。

任务A:耗时5秒

任务B:耗时7秒

任务C:耗时4秒

任务D:耗时6秒

单线程串行处理的总耗时大约在22秒。

改成多线程并行处理后,总耗时大约在7秒,也就是那个最耗时的任务B的执行时长,仅仅通过配置多线程处理我们就能得到大幅的编译速度提升。

写到这里,大家是不是觉得编译速度优化就可以到此结束了?哈哈,当然不是,上面这个例子在实际的项目中根本不具有广泛的代表性,笔者实际项目的情况是这样的:

我们有四个任务,命名为A、B、C、D。

任务A:耗时5秒

任务B:耗时60秒

任务C:耗时4秒

任务D:耗时6秒

单线程串行处理的总耗时大约在75秒。

改成多线程并行处理后,总耗时大约在60秒,从75秒优化到60秒,确实有速度上的提升,但是因为任务B的耗时太长了,导致整个项目的编译速度并没有发生本质上的变化。事实上笔者之前那套Webpack3.X的架子就是因为这个问题导致编译速度慢,所以,只靠引入多线程就想解决大项目编译速度慢的问题是不现实的。

那我们还有什么办法吗?当然有,我们还是可以从TerserPlugin得到灵感,那就是依靠缓存:在下一次的编译中能够复用上一次的结果而不执行编译永远是最快的。

至少存在有这三种方式,可以让我们在执行构建时不进行某些文件的编译,从最本质上提升前端项目整体的构建速度:

1. 类似于terser-webpack-plugin的cache那种方式,这个插件的cache默认生成在node_modules/.cache/terser-plugin文件下,通过SHA或者base64编码之前的文件处理结果,并保存文件映射关系,方便下一次处理文件时可以查看之前同文件(同内容)是否有可用缓存。其他Webpack平台的工具也有类似功能,但缓存方式不一定相同。

2. 通过externals配置在编译的时候直接忽略掉外部库的依赖,不对它们进行编译,而是在运行的时候,通过

3. 将某些可以库文件编译以后保存起来,每次编译的时候直接跳过它们,但在最终编译后的代码中能够引用到它们,这就是Webpack DLLPlugin所做的工作,DLL借鉴至Windows动态链接库的概念。

后面的段落将针对这几种方式做讲解。

三、Loader的Cache

除了段落一中提到的terser-webpack-plugin和babel-loader支持cache外,Webpack还直接另外提供了一种可以用来缓存前序Loader处理结果的Loader,它就是cache-loader。通常我们可以将耗时的Loader都通过cache-laoder来缓存编译结果。比如我们打生产环境的包,对于Less文件的缓存你可以这样使用它:

{

test:/\.less$/,

use: [

{

loader: MiniCssExtractPlugin.loader,

options: {

...省略

},

},

{

loader:‘cache-loader‘,

options: {

cacheDirectory: paths.appPackCacheCSS,

}

},

{

loader: require.resolve(‘css-loader‘),

options: {

...省略

},

},

{

loader: require.resolve(‘postcss-loader‘),

options: {

...省略

}

}

]

}

Loader的执行顺序是从下至上,因此通过上述配置,我们可以通过cache-laoder缓存postcss-loader和css-loader的编译结果。

但我们不能用cache-loader去缓存mini-css-extract-plugin的结果,因为它的作用是要从前序Loader编译成的含有样式字符串的JS文件中把样式字符串单独抽出来打成独立的CSS文件,而缓存这些独立CSS文件并不是cache-loader的工作。

但如果是要缓存开发环境的Less编译结果,cache-loader可以缓存style-loader的结果,因为style-loader并没有从JS文件中单独抽出样式代码,只是在编译后的代码中添加了一些额外代码,让编译后的代码在运行时,能够创建包含样式的

在对样式文件配置cache-loader的时候,一定要记住上述这两点,要不然会出现样式无法正确编译的问题。

除了对样式文件的编译结果进行缓存外,对其他类型的文件(除了会打包成独立的文件外)的编译结果进行缓存也是可以的。比如url-laoder,只要大小没有达到limitation的图片都会被打成base64,大于limitation的文件会打成单独的图片类文件,就不能被cache-loader缓存了,如果遇到了这种情况,资源请求会404,这是在使用cache-loader时需要注意的。

当然,通过使用缓存能得到显著编译速度提升的,依旧是那些耗时的Loader,如果对某些类型的文件编译并不耗时,或者说文件本身数量太少,都可以先不必做缓存,因为即便做了缓存,编译速度的提升也不明显。

最后笔者将所有Loader和Plugin的cache默认目录从node_modules/.cache/移到了项目根目录的build_pack_cache/目录(生产环境)和dev_pack_cache目录(开发环境),通过NODE_ENV自动区分。这么做是因为笔者的CI工程每次会删除之前的node_modules文件夹,并从node_modules.tar.gz解压一个新的node_modules文件夹,所以将缓存放在node_modules/.cache/目录里面会无效,笔者也不想去动CI的代码。通过这个改动,对cache文件的管理更直观一些,也能避免node_modules的体积一直增大。如果想清除缓存,直接删掉对应目录即可。当然了,这两个个目录是不需要被Git跟踪的,所以需要在.gitignore中添加上。CI环境中如果没有对应的缓存目录,相关Loader会自动创建。而且,因为开发环境和生产环境编译出的资源是不同的,在开发环境下对资源的编译往往都没有做压缩和混淆处理等,为了有效地缓存不同环境下的编译结果,需要区分开缓存目录。

四、外部扩展externals

按照Webpack官方的说法:我们的项目如果想用一个库,但我们又不想Webpack对它进行编译(因为它的源码很可能已是经过编译和优化的生产包,可以直接使用)。并且我们可能通过window全局方式来访问它,或者通过各种模块化的方式来访问它,那么我们就可以把它配置进extenals里。

比如我要使用jquery可以这样配置:

externals: {

jquery:‘jQuery‘}

我就可以这样使用了,就像我们直接引入一个在node_modules中的包一样:

import $ from ‘jquery‘;

$(‘.div‘).hide();

这样做能有效的前提就是我们在HTML文件中在上述代码执行以前就已经通过了

externals还支持其他灵活的配置语法,比如我只想访问库中的某些方法,我们甚至可以把这些方法附加到window对象上:

externals : {

subtract : {

root: ["math", "subtract"]

}

}

我就可以通过 window.math.subtract 来访问subtract方法了。

对于其他配置方式如果有兴趣的话可以自行查看文档。

但是,笔者的项目并没有这么做,因为在它最终交付给客户后,应该是处于一个内网环境(或者一个被防火墙严重限制的环境)中,极大可能无法访问任何互联网资源,因此通过

五、DllPlugin

在上个段落中的结尾处,提到了笔者的项目在交付用户后会面临的网络困境,所以笔者必须选择另外一个方式来实现类似于externals配置能够提供的功能。那就是Webpack DLLPlugin以及它的好搭档DLLReferencePlugin。笔者有关DLLPlugin的使用都是在构建生产包的时候使用。

要使用DLLPlugiin,我们需要单独开一个webpack配置,暂且将其命名为webpack.dll.config.js,以便和主Webpack的配置文件webpack.config.js进行区分。内容如下:

‘use strict‘;

process.env.NODE_ENV= ‘production‘;const webpack= require(‘webpack‘);

const path= require(‘path‘);

const {dll}= require(‘./dll‘);

const DllPlugin= require(‘webpack/lib/DllPlugin‘);

const TerserPlugin= require(‘terser-webpack-plugin‘);

const getClientEnvironment= require(‘./env‘);

const paths= require(‘./paths‘);

const shouldUseSourceMap= process.env.GENERATE_SOURCEMAP !== ‘false‘;

module.exports= function (webpackEnv = ‘production‘) {

const isEnvDevelopment= webpackEnv === ‘development‘;

const isEnvProduction= webpackEnv === ‘production‘;

const publicPath= isEnvProduction ? paths.servedPath : isEnvDevelopment && ‘/‘;

const publicUrl= isEnvProduction ? publicPath.slice(0, -1) : isEnvDevelopment && ‘‘;

const env=getClientEnvironment(publicUrl);return{

mode: isEnvProduction?

‘production‘:

isEnvDevelopment&& ‘development‘,

devtool: isEnvProduction?

‘source-map‘:

isEnvDevelopment&& ‘cheap-module-source-map‘,

entry: dll,

output: {

path: isEnvProduction?paths.appBuildDll : undefined,

filename:‘[name].dll.js‘,

library:‘[name]_dll_[hash]‘},

optimization: {

minimize: isEnvProduction,

minimizer: [

...省略

]

},

plugins: [newwebpack.DefinePlugin(env.stringified),newDllPlugin({

context: path.resolve(__dirname),

path: path.resolve(paths.appBuildDll,‘[name].manifest.json‘),

name:‘[name]_dll_[hash]‘,

}),

],

};

};

为了方便DLL的管理,我们还单独开了个dll.js文件来管理webpack.dll.config.js的入口entry,我们把所有需要DLLPlugin处理的库都记录在这个文件中:

const dll ={

core: [‘react‘,‘@hot-loader/react-dom‘,‘react-router-dom‘,‘prop-types‘,‘antd/lib/badge‘,‘antd/lib/button‘,‘antd/lib/checkbox‘,‘antd/lib/col‘,

...省略

],

tool: [‘js-cookie‘,‘crypto-js/md5‘,‘ramda/src/curry‘,‘ramda/src/equals‘,

],

shim: [‘whatwg-fetch‘,‘ric-shim‘],

widget: [‘cecharts‘,

],

};

module.exports={

dll,

dllNames: Object.keys(dll),

};

对于要把哪些库放入DLL中,请根据自己项目的情况来定,对于一些特别大的库,又没法做模块分割和不支持Tree Shaking的,比如Echarts,建议先去官网按项目所需功能定制一套,不要直接使用整个Echarts库,否则会白白消耗许多的下载时间,JS预处理的时间也会增长,减弱首屏性能。

然后我们在webpack.config.js的plugins配置中加入DLLReferencePlguin来对DLLPlugin处理的库进行映射,好让编译后的代码能够从window对象中找到它们所依赖的库:

{

...省略

plugins: [

...省略//这里的...用于延展开数组,因为我们的DLL有多个,每个单独的DLL输出都需要有一个DLLReferencePlguin与之对应,去获取DLLPlugin输出的manifest.json库映射文件。

// dev环境下暂不采用DLLPlugin优化。

...(isEnvProduction ?dllNames.map(dllName=> newDllReferencePlugin({

context: path.resolve(__dirname),

manifest: path.resolve(__dirname,‘..‘, `build/static/dll/${dllName}.manifest.json`)

})) :

[]

),

...省略

]

...

}

我们还需要在承载我们应用的index.html模板中加入

CRA这套架子已经使用了DefinePlugin来在编译时创建全局变量,最常用的就是创建process环境变量,让我们的代码可以分辨是开发还是生产环境,既然已有这样的设计,何不继续使用,让DLLPlugn编译的独立JS文件名暴露在某个全局变量下,并在index.html模板中循环这个变量数组,循环创建

然后我们改造一下index.html:

<% if(process.env.NODE_ENV=== "production") {%>

<%process.env.DLL_NAMES.forEach(function(dllName){%>

.dll.js">

Please allow your browser to run JavaScript scripts.
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值