webpack 性能调优与 Gzip 原理
从本节开始,咱们进入网络层面的性能优化世界。前端
你们能够从第一节的示意图中看出,咱们从输入 URL 到显示页面这个过程当中,涉及到网络层面的,有三个主要过程:node
- DNS 解析
- TCP 链接
- HTTP 请求/响应
对于 DNS 解析和 TCP 链接两个步骤,咱们前端能够作的努力很是有限。相比之下,HTTP 链接这一层面的优化才是咱们网络优化的核心。所以咱们开门见山,抓主要矛盾,直接从 HTTP 开始讲起。react
HTTP 优化有两个大的方向:webpack
- 减小请求次数
- 减小单次请求所花费的时间
这两个优化点直直地指向了咱们平常开发中很是常见的操做——资源的压缩与合并。没错,这就是咱们天天用构建工具在作的事情。而时下最主流的构建工具无疑是 webpack,因此咱们这节的主要任务就是围绕业界霸主 webpack 来作文章。web
webpack 的性能瓶颈
相信每一个用过 webpack 的同窗都对“打包”和“压缩”这样的事情烂熟于心。这些老生常谈的特性,我更推荐你们去阅读文档。而关于 webpack 的详细操做,则推荐你们读读这本 关于 webpack 的掘金小册,这里咱们把注意力放在 webpack 的性能优化上。算法
webpack 的优化瓶颈,主要是两个方面:npm
- webpack 的构建过程太花时间
- webpack 打包的结果体积太大
webpack 优化方案
构建过程提速策略
不要让 loader 作太多事情——以 babel-loader 为例
babel-loader 无疑是强大的,但它也是慢的。json
最多见的优化方式是,用 include 或 exclude 来帮咱们避免没必要要的转译,好比 webpack 官方在介绍 babel-loader 时给出的示例:数组
module: { rules: [ { test: /\.js$/, exclude: /(node_modules|bower_components)/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } } } ] }
这段代码帮咱们规避了对庞大的 node_modules 文件夹或者 bower_components 文件夹的处理。但经过限定文件范围带来的性能提高是有限的。除此以外,若是咱们选择开启缓存将转译结果缓存至文件系统,则至少能够将 babel-loader 的工做效率提高两倍。要作到这点,咱们只须要为 loader 增长相应的参数设定:浏览器
loader: 'babel-loader?cacheDirectory=true'
以上都是在讨论针对 loader 的配置,但咱们的优化范围不止是 loader 们。
举个🌰,尽管咱们能够在 loader 配置时经过写入 exclude 去避免 babel-loader 对没必要要的文件的处理,可是考虑到这个规则仅做用于这个 loader,像一些相似 UglifyJsPlugin 的 webpack 插件在工做时依然会被这些庞大的第三方库拖累,webpack 构建速度依然会所以大打折扣。因此针对这些庞大的第三方库,咱们还须要作一些额外的努力。
不要放过第三方库
第三方库以 node_modules 为表明,它们庞大得可怕,却又不可或缺。
处理第三方库的姿式有不少,其中,Externals 不够聪明,一些状况下会引起重复打包的问题;而 CommonsChunkPlugin 每次构建时都会从新构建一次 vendor;出于对效率的考虑,咱们这里为你们推荐 DllPlugin。
DllPlugin 是基于 Windows 动态连接库(dll)的思想被创做出来的。这个插件会把第三方库单独打包到一个文件中,这个文件就是一个单纯的依赖库。这个依赖库不会跟着你的业务代码一块儿被从新打包,只有当依赖自身发生版本变化时才会从新打包。
用 DllPlugin 处理文件,要分两步走:
- 基于 dll 专属的配置文件,打包 dll 库
- 基于 webpack.config.js 文件,打包业务代码
以一个基于 React 的简单项目为例,咱们的 dll 的配置文件能够编写以下:
const path = require('path') const webpack = require('webpack') module.exports = { entry: { // 依赖的库数组 vendor: [ 'prop-types', 'babel-polyfill', 'react', 'react-dom', 'react-router-dom', ] }, output: { path: path.join(__dirname, 'dist'), filename: '[name].js', library: '[name]_[hash]', }, plugins: [ new webpack.DllPlugin({ // DllPlugin的name属性须要和libary保持一致 name: '[name]_[hash]', path: path.join(__dirname, 'dist', '[name]-manifest.json'), // context须要和webpack.config.js保持一致 context: __dirname, }), ], }
编写完成以后,运行这个配置文件,咱们的 dist 文件夹里会出现这样两个文件:
vendor-manifest.json vendor.js
vendor.js 没必要解释,是咱们第三方库打包的结果。这个多出来的 vendor-manifest.json,则用于描述每一个第三方库对应的具体路径,我这里截取一部分给你们看下:
{ "name": "vendor_397f9e25e49947b8675d", "content": { "./node_modules/core-js/modules/_export.js": { "id": 0, "buildMeta": { "providedExports": true } }, "./node_modules/prop-types/index.js": { "id": 1, "buildMeta": { "providedExports": true } }, ... } }
随后,咱们只需在 webpack.config.js 里针对 dll 稍做配置:
const path = require('path'); const webpack = require('webpack') module.exports = { mode: 'production', // 编译入口 entry: { main: './src/index.js' }, // 目标文件 output: { path: path.join(__dirname, 'dist/'), filename: '[name].js' }, // dll相关配置 plugins: [ new webpack.DllReferencePlugin({ context: __dirname, // manifest就是咱们第一步中打包出来的json文件 manifest: require('./dist/vendor-manifest.json'), }) ] }
一次基于 dll 的 webpack 构建过程优化,便大功告成了!
Happypack——将 loader 由单进程转为多进程
你们知道,webpack 是单线程的,就算此刻存在多个任务,你也只能排队一个接一个地等待处理。这是 webpack 的缺点,好在咱们的 CPU 是多核的,Happypack 会充分释放 CPU 在多核并发方面的优点,帮咱们把任务分解给多个子进程去并发执行,大大提高打包效率。
HappyPack 的使用方法也很是简单,只须要咱们把对 loader 的配置转移到 HappyPack 中去就好,咱们能够手动告诉 HappyPack 咱们须要多少个并发的进程:
const HappyPack = require('happypack') // 手动建立进程池 const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length }) module.exports = { module: { rules: [ ... { test: /\.js$/, // 问号后面的查询参数指定了处理这类文件的HappyPack实例的名字 loader: 'happypack/loader?id=happyBabel', ... }, ], }, plugins: [ ... new HappyPack({ // 这个HappyPack的“名字”就叫作happyBabel,和楼上的查询参数遥相呼应 id: 'happyBabel', // 指定进程池 threadPool: happyThreadPool, loaders: ['babel-loader?cacheDirectory'] }) ], }
构建结果体积压缩
文件结构可视化,找出致使体积过大的缘由
这里为你们介绍一个很是好用的包组成可视化工具——webpack-bundle-analyzer,配置方法和普通的 plugin 无异,它会以矩形树图的形式将包内各个模块的大小和依赖关系呈现出来,格局如官方所提供这张图所示:
在使用时,咱们只须要将其以插件的形式引入:
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; module.exports = { plugins: [ new BundleAnalyzerPlugin() ] }
拆分资源
这点仍然围绕 DllPlugin 展开,可参考上文。
删除冗余代码
一个比较典型的应用,就是 Tree-Shaking
。
从 webpack2 开始,webpack 原生支持了 ES6 的模块系统,并基于此推出了 Tree-Shaking。webpack 官方是这样介绍它的:
Tree shaking is a term commonly used in the JavaScript context for dead-code elimination, or more precisely, live-code import. It relies on ES2015 module import/export for the static structure of its module system.
意思是基于 import/export 语法,Tree-Shaking 能够在编译的过程当中获悉哪些模块并无真正被使用,这些没用的代码,在最后打包的时候会被去除。
举个🌰,假设个人主干文件(入口文件)是这么写的:
import { page1, page2 } from './pages' // show是事先定义好的函数,你们理解它的功能是展现页面便可 show(page1)
pages 文件里,我虽然导出了两个页面:
export const page1 = xxx export const page2 = xxx
但由于 page2 事实上并无被用到(这个没有被用到的状况在静态分析的过程当中是能够被感知出来的),因此打包的结果里会把这部分:
export const page2 = xxx;
直接删掉,这就是 Tree-Shaking 帮咱们作的事情。
相信你们不难看出,Tree-Shaking 的针对性很强,它更适合用来处理模块级别的冗余代码。至于粒度更细的冗余代码的去除,每每会被整合进 JS 或 CSS 的压缩或分离过程当中。
这里咱们以当下接受度较高的 UglifyJsPlugin 为例,看一下如何在压缩过程当中对碎片化的冗余代码(如 console 语句、注释等)进行自动化删除:
const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); module.exports = { plugins: [ new UglifyJsPlugin({ // 容许并发 parallel: true, // 开启缓存 cache: true, compress: { // 删除全部的console语句 drop_console: true, // 把使用屡次的静态值自动定义为变量 reduce_vars: true, }, output: { // 不保留注释 comment: false, // 使输出的代码尽量紧凑 beautify: false } }) ] }
有心的同窗会注意到,这段手动引入 UglifyJsPlugin 的代码实际上是 webpack3 的用法,webpack4 如今已经默认使用 uglifyjs-webpack-plugin 对代码作压缩了——在 webpack4 中,咱们是经过配置 optimization.minimize 与 optimization.minimizer 来自定义压缩相关的操做的。
这里也引出了咱们学习性能优化的一个核心的理念——用什么工具,怎么用,并非咱们这本小册的重点,由于全部的工具都存在用法迭代的问题。但如今你们知道了在打包的过程当中作一些如上文所述的“手脚”能够实现打包结果的最优化,那下次你们再去执行打包操做,会不会对这个操做更加留心,从而本身去寻找彼时操做的具体实现方案呢?我最但愿你们掌握的技能就是,先在脑海中留下“这个xx操做是对的,是有用的”,在往后的实践中,能够基于这个认知去寻找把正确的操做落地的具体方案。
按需加载
你们想象这样一个场景。我如今用 React 构建一个单页应用,用 React-Router 来控制路由,十个路由对应了十个页面,这十个页面都不简单。若是我把这整个项目打一个包,用户打开个人网站时,会发生什么?有很大机率会卡死,对不对?更好的作法确定是先给用户展现主页,其它页面等请求到了再加载。固然这个状况也比较极端,但却能很好地引出按需加载的思想:
-
一次不加载完全部的文件内容,只加载此刻须要用到的那部分(会提早作拆分)
-
当须要更多内容时,再对用到的内容进行即时加载
好,既然说到这十个 Router 了,咱们就拿其中一个开刀,假设我这个 Router 对应的组件叫作 BugComponent,来看看咱们如何利用 webpack 作到该组件的按需加载。
当咱们不须要按需加载的时候,咱们的代码是这样的:
import BugComponent from '../pages/BugComponent' ... <Route path="/bug" component={BugComponent}>
为了开启按需加载,咱们要稍做改动。
首先 webpack 的配置文件要走起来:
output: { path: path.join(__dirname, '/../dist'), filename: 'app.js', publicPath: defaultSettings.publicPath, // 指定 chunkFilename chunkFilename: '[name].[chunkhash:5].chunk.js', },
路由处的代码也要作一下配合:
const getComponent => (location, cb) { require.ensure([], (require) => { cb(null, require('../pages/BugComponent').default) }, 'bug') }, ... <Route path="/bug" getComponent={getComponent}>
对,核心就是这个方法:
require.ensure(dependencies, callback, chunkName)
这是一个异步的方法,webpack 在打包时,BugComponent 会被单独打成一个文件,只有在咱们跳转 bug 这个路由的时候,这个异步方法的回调才会生效,才会真正地去获取 BugComponent 的内容。这就是按需加载。
按需加载的粒度,还能够继续细化,细化到更小的组件、细化到某个功能点,都是 ok 的。
等等,这和说好的不同啊?不是说 Code-Splitting 才是 React-Router 的按需加载实践吗?
没错,在 React-Router4 中,咱们确实是用 Code-Splitting 替换掉了楼上这个操做。并且若是有使用过 React-Router4 实现过路由级别的按需加载的同窗,可能会对 React-Router4 里用到的一个叫“Bundle-Loader”的东西印象深入。我想不少同窗读到按需加载这里,内心的预期或许都是时下大热的 Code-Splitting,而非我呈现出来的这段看似“陈旧”的代码。
可是,若是你们稍微留个心眼,去看一下 Bundle Loader 并不长的源代码的话,你会发现它居然仍是使用 require.ensure 来实现的——这也是我要把 require.ensure 单独拎出来的重要缘由。所谓按需加载,根本上就是在正确的时机去触发相应的回调。理解了这个 require.ensure 的玩法,你们甚至能够结合业务本身去修改一个按需加载模块来用。
这也应了我以前跟你们强调那段话,工具永远在迭代,惟有掌握核心思想,才能够真正作到触类旁通——惟“心”不破!
彩蛋:Gzip 压缩原理
恭喜你们迎来了本小册的第一个彩蛋。彩蛋为选学内容,以原理性知识为主。意在拓宽你们的技术视野,加深你们对优化相关知识的理解。
前面说了很多 webpack 的故事,目的仍是帮你们更好地实现压缩和合并。说到压缩,可不仅是构建工具的专利。咱们平常开发中,其实还有一个便宜又好用的压缩操做:开启 Gzip。
具体的作法很是简单,只须要你在你的 request headers 中加上这么一句:
accept-encoding:gzip
相信不少同窗对 Gzip 也是了解到这里。之因此为你们开这个彩蛋性的小节,毫不是出于炫技要来给你们展现一下 Gzip 的压缩算法,而是想和你们聊一个和咱们前端关系更密切的话题:HTTP 压缩。
HTTP 压缩是一种内置到网页服务器和网页客户端中以改进传输速度和带宽利用率的方式。在使用 HTTP 压缩的状况下,HTTP 数据在从服务器发送前就已压缩:兼容的浏览器将在下载所需的格式前宣告支持何种方法给服务器;不支持压缩方法的浏览器将下载未经压缩的数据。最多见的压缩方案包括 Gzip 和 Deflate。
以上是摘自百科的解释,事实上,你们能够这么理解:
HTTP 压缩就是以缩小体积为目的,对 HTTP 内容进行从新编码的过程
Gzip 的内核就是 Deflate,目前咱们压缩文件用得最多的就是 Gzip。能够说,Gzip 就是 HTTP 压缩的经典例题。
该不应用 Gzip
若是你的项目不是极端迷你的超小型文件,我都建议你试试 Gzip。
有的同窗或许存在这样的疑问:压缩 Gzip,服务端要花时间;解压 Gzip,浏览器要花时间。中间节省出来的传输时间,真的那么可观吗?
答案是确定的。若是你手上的项目是 1k、2k 的小文件,那确实有点高射炮打蚊子的意思,不值当。但更多的时候,咱们处理的都是具有必定规模的项目文件。实践证实,这种状况下压缩和解压带来的时间开销相对于传输过程当中节省下的时间开销来讲,能够说是微不足道的。
Gzip 是万能的吗
首先要认可 Gzip 是高效的,压缩后一般能帮咱们减小响应 70% 左右的大小。
但它并不是万能。Gzip 并不保证针对每个文件的压缩都会使其变小。
Gzip 压缩背后的原理,是在一个文本文件中找出一些重复出现的字符串、临时替换它们,从而使整个文件变小。根据这个原理,文件中代码的重复率越高,那么压缩的效率就越高,使用 Gzip 的收益也就越大。反之亦然。
webpack 的 Gzip 和服务端的 Gzip
通常来讲,Gzip 压缩是服务器的活儿:服务器了解到咱们这边有一个 Gzip 压缩的需求,它会启动本身的 CPU 去为咱们完成这个任务。而压缩文件这个过程自己是须要耗费时间的,你们能够理解为咱们以服务器压缩的时间开销和 CPU 开销(以及浏览器解析压缩文件的开销)为代价,省下了一些传输过程当中的时间开销。
既然存在着这样的交换,那么就要求咱们学会权衡。服务器的 CPU 性能不是无限的,若是存在大量的压缩需求,服务器也扛不住的。服务器一旦所以慢下来了,用户仍是要等。Webpack 中 Gzip 压缩操做的存在,事实上就是为了在构建过程当中去作一部分服务器的工做,为服务器分压。
所以,这两个地方的 Gzip 压缩,谁也不能替代谁。它们必须和平共处,好好合做。做为开发者,咱们也应该结合业务压力的实际强度状况,去作好这其中的权衡。
小结
说了这么多,咱们都在讨论文件——准确地说,是文本文件及其构建过程的优化。
但一个完整的现代前端应用,除了要包含 HTML、CSS 和 JS,每每还须要借助图片来提升用户的视觉体验。而图片优化的思路、场景与措施,又是另一个说来话长的故事了。下面,咱们就一块儿进入图片的小天地,一窥究竟。