webpack 构建优化
从去年 6 月份到现在开发的两个项目,都有围绕 webpack 进行构建打包。在使用 webpack 上也有一定的心得。在过去一周,将 webpack 版本升级为 2,同时又针对项目进行了一定的优化。接下来就跟大家做一个简单的分享(本文的 webpack 配置代码是基于 webpack 2.2版本)。
为何使用 webpack ?
在使用一个项目构建工具之前,我们首先要想一想项目构建的目标是什么?不妨从以下几点思考一下:
-
是否满足构建需求
自动化构建主要解决的问题就是自动化,比如:代码检查,文件合并,资源压缩,脚本编译等等。显然 webpack 有丰富的 loader 和 plugin,并且主要定位是一个模块化的构建方案,基本可以满足大部分的构建需求。 -
构建成本和扩展性
webpack 本身的目标还是解决模块化构建的问题,提供一个方便快捷的配置型模块化构建方案。如果你的项目是多页面,并且有精细化资源处理策略。使用 webpack 显然需要有较为复杂的配置文件。并且可能随着项目资源入口增加或者打包处理策略的多样化,webpack 可能存在一定的瓶颈。比如:一个多页面的项目,我们根据页面内容或者分类,需要进行多样的针对性的脚本编译和文件合并方案。显然 webpack 不是一个很好的解决方案。 -
工作流
任何工具或者技术脱离业务场景和项目现状来谈,往往缺乏说服力。构建工具除了解决基本的自动化问题,当面临团队多人协作开发和前端工程化的问题,选择一个合适团队的构建方案,提供一个高效,可维护的工作流才是重中之重。基于 webpack,gulp,grunt 等工具进行二次封装,来集成环境依赖,资源管理,发布流程等任务的工作流可以大大提高我们的工作效率。
当然以上三点仅是一个参考,并不是说 webpack 已经解决了这几个问题,亦或者说 webpack 是最好的方案,还是需要符合个人或者团队的现状。本文也不会完全解决这些问题。只是一个实践的分享。
webpack 构建
在进行 webpack 构建的过程,也是一个渐进实现的我们构建目标的过程,不要期望一次就能完美的配置好。我们需要合理的分阶段的完成我们的目标。首先是可以保证我们的项目通过 webpack 进行构建是可运行,可用的。这一部分作为基础就不在这篇文章中介绍了,我们着重针对优化来进行讲解。
优化目标
优化目标(优化什么?)是我们进行优化首先要确定的。目前根据我们团队的问题和大家对 webpack 构建的吐槽。我们注重围绕两个方面进行优化:
- 合理构建项目资源:包括如果缩小资源打包大小,异步加载资源等等。
- 缩短项目构建时间:包括热更新(hot reload)和生产环境打包(build)两个方面。
接下来我们就针对不同目标,来进行针对性的优化。
合理构建项目资源
合理构建项目资源就是围绕着,减少资源打包体积,优化客户端资源加载等展开
###合理拆包
在项目中,我们常用的 js 资源主要包括:基础类库,第三方工具库,公共UI组件库,业务UI组件,业务组件等等中的几种组合。合理拆包可以优化打包速度,同时也可以提高资源加载速度。但是拆包要依据一定规则。
- 公共库单独打包 针对频率变化较高的业务脚本资源,如果和低频的基础类库,UI 库统一打包,会导致几个问题:
- 打包合并后的脚本过大,不利于资源加载。
- 脚本打包后,往往会增加 hash 来避免客户端缓存带来的更新问题,对于低频更新的类库,其本身脚本内容就不小,如果一刀切一起更新,更影响用户体验和用户流量。
- 如果存在多页面清空,公共库存在多重使用,如果不单独拆出来,会存在资源重复打包。
- 拆包可以使更新的范围缩小也能带来 rebuild 速度的提升。
- 低频第三方工具库细粒度引入 如 _loadash, rxjs 等,如果我们不是重度全方位的使用,尽量限定引入工具类和方法,减少依赖的体积。
- 适度使用polyfill 如果进行 ES6 进行开发,可以使用 babel transform-runtime 在构建时来运行编译。但是在多页面使用的情况下也会出现一定出入。另外建议大家还是需要对 polyfill 有一定了解,如果足够了解的话,也可以自己配置 polyfill 。
以上的建议都是注重把握资源拆包粒度,目的就是保证资源打包的灵活性和最小化。
###异步资源加载
在进行单页面开发时,如果一次加载所有模块的代码,将会给首屏渲染带来很大的负担。尤其在移动端开发,网络环境比较复杂,异步延迟加载低频非主页内容是一个很好的解决方案。使用方式如下:
// 异步加载方式
require.ensure([], (require) => {
require('../pageA.js').default
})
上边仅仅是使用的方式,最简单直接就是和 react-router 结合,使用 getComponet 的来加载子页面。如下:
const routes = {
// ...
{
path: 'pageA',
getComponent(nextState, cb) {
require.ensure([], (require) => {
cb(null, require('./views/pageA').default)
// ...
})
}
},
// ....
}
当然具体还要和项目本身诉求和子页面加载频率和缓存策略等相关,并不能一律使用懒加载,如果经常加载几 kb 的 js 资源,反而适得其反。
###webpack Tree-shaking
说到 Tree-shaking (摇树)就不得不提到 rollup.js,在 js 中较早引入这个概念并且得到比较好的推广的就是 rollup.js(如果了解 angular2 的应该对这个不陌生)。 webpack2 现在也支持了这个优化技术。两者都是基于 ES6 modules,通过静态分析来进行 tree shaking 的。那我们怎么使用 three shaking 呢?不需要像 Axel 这篇文章Tree-shaking with webpack 2 and Babel6配置,可以直接将babel-preset-es2015的 modules 配置一下即可:
{
"presets": [
"es2015",
{
"modules": false
}
]
}
另外需要注意的是如果你想直接对 npm 安装的第三方包也能使用 tree-shaking ,就要注意首先第三方库本身是否是使用 ES6 module ,其次要注意
package.json
中mian
的路径指向的是不是经过打包处理后的库资源。但是很多库会按照 rollup 约定使用 jsnext:main 字段。 但是 webpack2 不默认支持这种引入方式,所以需要手动指定。
###构建分析
完成项目构建之后,我们也想了解项目打包后的依赖和打包后的资源内容包含了什么?这时候就可以借助一个工具 webpack-bundle-analyzer。
缩短项目构建时间
如何缩短项目构建的时间,除了需要进行打包构建的资源本身的大小和处理方式以外,根据不同的环境,需要进行一定的优化和取舍。
###增量更新
增量更新是建立在拆包的基础上的,如果打包资源没有进行拆分,每次局部的修改 webpack 都会重新 rebuild 整个资源。所以基础就是合理拆包,减小更新影响的范围。在此基础上,还要考虑 webpack 是否如我们所期望的,能够提供最优的更新机制。显然现实并没有那么美好。我们还是需要去解决一些问题。 例如:需要使用 dll 或者 commonChunksPlugin 中 names 字段增加 manifest。来避免局部更新影响公共(vendor.js) 文件的重新编译。
###开启缓存
在对资源进行编译合并打包,不论是使用什么 loader 和 plugin,根据需要开启相应的缓存可以大大减少 rebuild 所需时间。比如:使用 babel-loader 通过配置 query 的 cacheDirectory 字段为 true 来开启缓存;happyPack plugin 设置 cache,等等有很多 loader 和 plugin 都提供了缓存支持。
###happypack
happypack 是 webpack 的一个插件,目的是通过多进程模型,来加速代码构建,一般情况可以对 ES6,sass 等编译开启,效果比较明显。但是在 webpack2 上支持不是很友好,但是经过简单的配置,依然可以开启多进程,配置如下:
const HappyPack = require('happypack');
const happyThreadPool = HappyPack.ThreadPool({ size: 4 }); // 创建4个线程的自定义线程池,用于多个插件来共享
module.exports = {
//...
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
loaders: ['happypack/loader?id=js'] // id=js
},
{
test: /\.(css|scss|sass)$/,
// 类似 ExtractTextPlugin,也是通过 loader 中声明来调用 HappyPack 的 plugin
loader: ExtractTextPlugin.extract({
fallbackLoader: 'style-loader',
loader: ['happypack/loader?id=sass']
})
}
]
},
plugins: [
// babe-loader 进行多线程构建
new HappyPack({
cache: true, // 开启缓存,默认在项目根目录生成.happypack 缓存文件,可以自行配置缓存文件
cacheContext: {
env: process.env.NODE_ENV
},
id: 'js', // 通过 id名称进行关联
threadPool: happyThreadPool, //使用第三方线程池
loaders: [{
loader: 'babel-loader',
query: {
cacheDirectory: true
}
}]
}),
// sass-loader 进行多线程构建
new HappyPack({
cache: true,
cacheContext: {
env: process.env.NODE_ENV
},
id: 'sass',
threadPool: happyThreadPool,
loaders: [
'style-loader',
'css-loader?sourceMap',
'sass-loader?sourceMap'
]
})
// ...
]
}
经过开启多线程,一般构建时间可以减少 10-30% ,效果还是比较明显的。更多地详细配置可以戳 happypack
###hot reload 构建配置
针对开发环境 hot reload 时,我们一些线上的打包策略和任务很多都可以考虑去除,如: uglify 代码压缩,合理选择 devtool的模式,如果有对图片和字体使用 url-loader 改为 file-loader,不用进行limit限制(不过没有多大的优化),等等。
其它
- webpack optimize 包含的 plugin 如: AggressiveSplittingPlugin 拆分为一定大小范围的包,LimitChunkCountPlugin 限制 chunk 的数量等等,可以看看官方的 example
- 多环境配置,使用 DefinePlugin 来配置环境,同时根据生产环境,开发环境,测试环境来模块化配置,使用 webpack-merge 来进行组合。
- 基于后端模板的多页面配置,可以参考我配置的 一个栗子
问题
在项目构建的尝试中,不断地深入过程中也发现了几个问题,在这里和大家分享一下:
###多页面异步 ExtractTextPlugin 问题
在进行多页面打包,并且采用了异步加载的方案后,当我们在多个页面中引用一些公共业务组件的时候,就会出现 bug,具体依赖关系如下图(红色为异步加载模块):
当进行打包构建后,真实的结果是,引用的 10.js
的页面没有加载 detail-component.css 。原因是因为:extractPlugin 默认不去处理异步 chunk 它会爬取在entry中的各个chunk(非异步的),将包含 css 的chunk中内容移除(此时这个 chunk 内容已被清空),单独打包为 css 。而 10.js
就 import 空的 chunk ,所以就导致样式没有加载成功。
暂时的解决办法就是:1. 业务 UI 组件也剥离出来; 2. 在页面的同步 chunk 直接加载。
###内联 css image 路径报错
当我们在开发环境进行 hot reload 调试,css 文件都被编译放在内存中,以 blob 格式存储。浏览器加载样式文件的协议就是 blob 协议。此时如果 image 没有被打包存储为 blob 格式。我们 css 文件中 url('www.a.com/assets/image.png')
根据 blob 协议在内存中查找,此时无法找到对应的 image 文件。
解决办法:1. 完整路径(包括协议);2.开发环境统一样式,图片和资源的打包方式。
其实还有各种各样的问题和不完美,就不一一提出了。
由于涉及到公司的项目,具体的优化细节如:代码和相关图片,这里就不放出来了。
总结
如果仅仅是配置完成一个简单的 SPA ,使用 webpack 确实经过几句配置就可以搞定。但是整体而言它还有几点不足:
- 灵活性 可能因为前几年使用 grunt,gulp 与 browserify 较多,也大大小小配置构建了一些项目和工具库的打包,我个人比较认可它们的灵活性。尤其比较喜欢 gulp,基于 stream,处理速度快,根据自定义 task 可以很灵活的针对不同资源进行更细粒度的处理。
- 速度 不论你怎么优化,webpack 本身作为模块化管理和打包工具,统一将 js,css,图片,字体,json 等等都视为模块,通过依赖关系分析然后再通过 loader 和 plugin 进行编译和处理,让你简单的配置就可以实现这样的能力,代价就是要付出性能上的牺牲。
- 插件过程不透明 在项目构建中经常会使用到很多插件(包括一些第三方插件),webpack 本身的处理流程和思想我们都比较熟悉,但是很多插件就不是了。因为 webpack 插件都是通过监听 webpack 定义的各个事件,然后在诸如 compilation 等事件中进行相应的操作。而很多插件自身也注册了很多自定义事件,为了方便其它插件进行扩展。但是这些对于使用者都是黑盒的。插件调用的先后顺序往往会和我们的预期不一致,这样就限制了通过简单组合插件来完成功能的能力。
以上都仅仅是我个人的一些见解,可能存在了解不够深入产生的误解或问题。也希望大家能够理解。(是时候开评论了,要不看官们无法吐槽呀)