最近看到了美团的前端团队的一篇文章,文中提到前端发布仅需10秒,默默的看了一下我们自己的发布时间。。。

先定一个小目标,争取把 Webpack 的打包时间优化到10秒以内吧。

先看一下现在打包一次需要的时间,73013ms,下面开始一步一步见证奇迹:

73013
73013

0. 模块分析

有很多工具提供了可视化的分析,如Webpack-bundle-analyzerwebpack-chartwebpack-analyse
以Webpack-bundle-analyzer为例,它提供了一个下图所示的图表,展示了引入的所有模块的大小、路径等信息,可以针对性的做出优化。

Webpack-bundle-analyzer
Webpack-bundle-analyzer

使用上也很简单:

1
2
// 安装:
npm install webpack-bundle-analyzer --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// webpack.config.js 配置
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

plugins: [
new BundleAnalyzerPlugin({
   analyzerMode: 'server',
   analyzerHost: '127.0.0.1',
   analyzerPort: 8888,
   reportFilename: 'report.html',
   defaultSizes: 'parsed',
   openAnalyzer: true,
   generateStatsFile: false,
   statsFilename: 'stats.json',
   logLevel: 'info'
})
]

运行webpack命令,会自动在浏览器中打开http://127.0.0.1:8888/页面,展示可视化图表。

1. 升级到 Weback4.x

Webpack4 带来了极大的性能提升,按照开发者博客中的说法,构建速度最多甚至有高达98%的提升。

升级过程中遇到了一些网上的“Webpack4升级指南”等文章中没有列出的问题,在此分享一下:

1.1 升级 Vue-loader

Vue-loader 目前最新版本为 v15.2.6,使用方式有了很大不同。
现在,我们需要引入一个新的插件 VueLoaderPlugin ,具体使用方式如下:

1
2
3
4
5
6
7
8
// webpack.config.js
const VueLoaderPlugin = require('vue-loader/lib/plugin')

module.exports = {
plugins: [
new VueLoaderPlugin()
]
}

同时,在 v15版本的 Vue-loader 中,不再需要单独为 .vue 组件中的模板、CSS等内容单独配置 loader,可以共用普通文件的配置,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// webpack.config.js

module: {
rules: [{
    test: /\.vue$/,
    loader: 'vue-loader'
}, {
    // 它会应用到普通的 `.js` 文件
    // 以及 `.vue` 文件中的 `<script>` 块
    test: /\.js$/,
    loader: 'babel-loader'
}, {
    // 它会应用到普通的 `.css` 文件
    // 以及 `.vue` 文件中的 `<style>` 块
    test: /\.css$/,
    use: [
     'vue-style-loader',
     'css-loader'
    ]
}]
}

1.2 升级 Vue-router

在Vue-router v13.0.0版本中对模块导入做了更新,需要加入 default 配置,如下所示:

1
2
3
const Foo = () => import('./Foo.vue')
// 需要改为
const Foo = () => import('./Foo.vue').then(m => m.default)

同理:

1
2
3
const Foo = require('./Foo.vue')
// 需要改为
const Foo = require('./Foo.vue').default

详情可以参考https://github.com/vuejs/vue-loader/releases/tag/v13.0.0

1.3 Chunk 的命名
如果使用 webpackchunkname 魔法注释来命名,需要注意 .babelrc 中 comment 必须为true

1.4 提取 CSS 文件

在 Webpack4 环境下 extract-text-webpack-plugin 需要安装 @next 版本,我们这里直接使用了 mini-css-extract-plugin 来代替。

同时,在 mode 为 development 时,NamedChunksPluginNamedModulesPlugin 会默认开启,不需要再显式指定。

1.5 本地 mock 处理

2.x版本的 Vue-CLI 启动了一个 express 服务来处理本地数据的 mock,我们尝试做了一些简化,在 webpack-dev-server 的 before 方法中,使用 webpack-api-mocker 插件拦截了请求,读取本地的 mock 数据(JSON文件)返回。其中,mock/index.js 是通过服务启动过程中遍历本地数据文件生成的。

1
2
3
4
5
6
7
8
// webpack.dev.conf.js
const apiMocker = require('webpack-api-mocker');
require('./mock-generator.js')();
devServer: {
before(app) {
apiMocker(app, path.resolve('./mock/index.js'));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// mock/index.js
const fs = require('fs');
function fromJSONFile(filepath) {
return (req, res) => {
const data = fs.readFileSync('mock' + filepath).toString();
const json = JSON.parse(data);
return res.json(json);
};
};
const proxy = {
'GET /aaa/bbb': fromJSONFile('/aaa/bbb.json'),
'GET /aaa/ccc': fromJSONFile('/aaa/ccc.json')
};
module.exports = proxy;

升级完成之后,打包时间直接减少了半分钟,达到了44.534秒,离小目标还有很大距离,我们继续。

44534
44534

2. 路由处理(异步加载)

在前面的打包完成的图片中,我们可以看到生成了大量的文件,统计了一下,体积总计高达22.07M,文件近60个。
权衡了诸如加载时间等方面之后,我们决定采用按照一级路由来打包的方式。
具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Vue.use(Router);
import Vue from 'vue';
import Router from 'vue-router';
export default new Router({
routes: [{
path: '/a',
component: () =>
import ( /* webpackChunkName: 'a' */ '@/pages/a'),
name: 'a'
}, {
path: '/b',
component: () => import ( /* webpackChunkName: 'b' */ '@/pages/b'),
name: 'b',
children: [{
path: 'b/m',
component: require('@/pages/b/m').default,
name: 'm',
children: [{
path: 'b/m/p',
component: require('@/pages/b/m/p').default,
name: 'p'
}, {
path: 'b/m/q',
component: require('@/pages/b/m/q').default,
name: 'q'
},
]
}]
}]

经过上面的优化,我们将生成的js文件数量减少到了8个,大小减小到了5M.
来看一下打包时间:21.959秒!距离小目标越来越近了。

21959
21959

3. HappyPack/thread-loader

HappyPack 可以将原有的 webpack 对 loader 的执行过程,从单一进程的形式扩展为多进程的模式,从而加速代码构建。使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const HappyPack = require('happypack');
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });

module: {
loaders: [{
test: /\.less$/,
loader: ExtractTextPlugin.extract(
'style', path.resolve(__dirname, './node_modules', 'happypack/loader') + '?id=less'
)
}]
},
plugins: [
new HappyPack({
id: 'less',
loaders: ['css!less'],
threadPool: happyThreadPool,
cache: true,
verbose: true
})
]

经过测试,在我们的项目中,对 js 和 ts 文件使用 happypack 收益最大。

此处需要注意的是,vue-loader 不支持 happypack,可以使用 thread-loader 来进行加速,同样是新建一个进程来执行 loader 操作,使用方式也很简单:

1
2
3
4
5
6
7
8
9
 module: {
rules: [{
test: /\.vue$/,
use: [
'thread-loader',
'vue-loader'
]
}]
}

但是在我们的项目中,经过测试,thread-loader 对于打包速度几乎没有影响,是因为它本身的额外开销导致,建议只在极高性能消耗的场景下使用。

完成之后,测试一下打包时间:15.101秒。

14814
14814

4. 缓存loader的执行结果(cacheDirectory/cache-loader)

我们可以对loader做如下配置来开启缓存:

1
loader: 'babel-loader?cacheDirectory=true'

或者我们也可以使用 cache-loader :

1
2
3
4
5
6
7
rules: [{
test: /\.vue$/,
use: [
'cache-loader',
'vue-loader'
]
}]

加入缓存之后,再次测试打包时间:13.915秒。

13915
13915

5. 模块进一步细分(splitChunks)

在 Webpack4 中移除了我们此前常用的 CommonsChunkPlugin 插件,取而代之的是 splitChunks
splitChunks 的默认配置已经足够我们日常使用,没有特殊需求可以不必特意处理。
我们此处的配置如下(生产环境):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
optimization: {
splitChunks: {
cacheGroups: {
commons: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
},
// styles: {
// name: 'index',
// test: /.stylus|css$/,
// chunks: 'all',
// enforce: true
// }
}
}
}

其中,commons 部分的作用是分离出 node_modules 中引入的模块,styles 部分则是合并 CSS 文件。
经过测试,在我们的项目中,styles 部分使构建时间增加了大约2秒,因此我们放弃了这部分操作。

6. 使用DllPlugin拆分模块

开发过程中,我们经常需要引入大量第三方库,这些库并不需要随时修改或调试,我们可以使用DllPlugin和DllReferencePlugin单独构建它们。
具体使用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
entry: {
vendor: [
'axios',
'vue-i18n',
'vue-router',
'vuex'
]
},
output: {
path: path.resolve(__dirname, '../static/'),
filename: '[name].dll.js',
library: '[name]_library'
},
plugins: [
new webpack.DllPlugin({
path: path.join(__dirname, 'build', '[name]-manifest.json'),
name: '[name]_library'
})
]
}

执行webpack命令,build目录下即可生成 dll.js 文件和对应的 manifest 文件,使用 DLLReferencePlugin 引入:

1
2
3
4
5
6
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require('./build/vendor-manifest.json')
})
]

由于我们的项目中原本已经通过这种方式打包了大部分第三方库,所以这里对打包速度的提升不大,仅仅提升2秒,来到了11.509秒。

11509
11509

7. 精简不必要的模块

在我们的项目中,引入了一些如 moment、lodash 等重型库,然而他们提供的绝大部分功能都是我们不需要的,权衡之后,我们移除了他们,自己实现了部分功能或使用了更小体积的库代替。

移除了这些库之后,我们的打包时间来到了8.921秒!小目标达成了~

8921
8921

但是这距离10秒发布还不够,我们需要争取压缩出更多时间留给发布系统。还能不能继续提升呢?答案是肯定的。

8. 优化模块查找路径

Node.js的模块的载入及缓存机制如下:

载入内置模块
载入文件模块
载入文件目录模块
载入node_modules里的模块
自动缓存已载入模块
如果模块名不是路径,也不是内置模块,Node将试图去当前目录的node_modules文件夹里搜索。如果当前目录的node_modules里没有找到,Node会从父目录的node_modules里搜索,这样递归下去直到根目录。

我们可以对搜索过程进行一些优化,比如可以像下面这样指定路径:

1
2
3
4
5
6
7
8
exclude: /node_modules/, // 排除不处理的目录
include: path.resolve(__dirname, 'src') // 精确指定要处理的目录
resolve: {
modules: [path.resolve(__dirname, 'node_modules')], // 指定node_modules的位置
alias: {
'api': resolve('src/api') // 创建别名
}
}

我们再来看一下时间:7.66秒!

7660
7660

到这里,我们的这次优化基本完成了,其实还有很多可以优化的空间,比如升级一颗 i9 处理器~
这里也只是列举出了一些常见的收益较大的优化方式,希望能对大家有一点帮助,也欢迎有兴趣的同学一起交流。