用 webpack 实现持久化缓存

什么是持久化缓存?

缓存(cache)一直是前端性能优化的重头戏,利用好静态资源的缓存机制,可以使我们的 web 应用更加快速和稳定。仅仅简单的资源缓存是不够的,我们还要为不断更新的资源做持久化缓存(Long term cache)。以前我们能利用服务端模板和构建打包,来给资源增加版本标记,如 app.js?v=1.0.0,但在大流量的网站中,这种更新部署方式会引起下面的问题:

大公司里怎样开发和部署前端代码? - 回答作者: 张云龙

上述回答中针对前端代码部署的最终方案是:

  • 细粒度文件名替换(文件内容摘要)
  • 细粒度资源依赖追踪和摘要
  • 非覆盖式更新

但想从头实现一整套完整的前端部署方案,对于小公司来说还是非常难的。不仅如此,从目前 Web 发展趋势来看,如今前端早已不是传统 Web 应用架构能够 Hold 住的了,前后端分离,前端应用化、工程化的需求在迅速增加:模块化开发、模块依赖解析、代码压缩、图片压缩、请求数最小化、雪碧图、字体压缩、CSS 预处理、ES2015/6/7 编译、模板引擎等,都是在构建过程中要实现的功能。

自从 Node.js 和 npm 问世后,最理解前端优化需求的前端架构师/工程师也可以用自己最熟悉的 JavaScript 来实现自己想要的工程化工具了,社区也先后创造出了 Grunt、gulp、fis、webpack、rollup 等工程化工具,它们作用及架构各不相同,如 gulp 专注流程化任务,rollup 专注模块打包……

对于今天提出的问题:持久化缓存,它涉及了模块化,模块依赖,静态资源优化,模块打包,文件摘要处理等问题,如今(2016+)能把这些问题解决并做的最好的社区驱动工具有且只有 webpack

同类模块打包工具横向对比表 -> Comparison - Why webpack?

目前 webpack 2.2.0 已正式发布,是时候用最新的工具来创建更完善的前端构建了。

通过配置,逐步实现持久化缓存

一、文件 Hash 摘要

  1. webpack 配置
  2. 不稳定的 chunkhash
  3. webpack-md5-hash 的问题
  4. 如何生成稳定的模块 ID?

二、如何避免频繁的 chunk 内容变动

  1. 合理划分公共模块
  2. 代码分割
  3. import()
  4. 提取公共模块
  5. 提取 CSS
  6. 对 chunks 做最后的优化

一、文件 Hash 摘要

Hash 文件名(vendor.f02bc2.js)是实现持久化缓存的第一步,目前 webpack 有两种计算 hash 的方式:

  1. 计算所有 chunks 的 hash —— [hash]
  2. 为每个 chunk 计算 hash —— [chunkhash]

第一种是每次编译生成一个唯一 hash,适合 chunk 拆分不多的小项目,但所有资源全打上同一个 hash,无法完成持久化缓存的需求。

第二种是 webpack 为每个 chunk 资源都生成与其内容相关的 hash 摘要,为不同的资源打上不同的 hash。

相关官方文档:

webpack 配置

JS 资源的 [chunkhash] 由 webpack 计算,Images/Fonts 的 [hash] 由 webpack/file-loader 计算,提取的 CSS 的 [contenthash] 由 webpack/extract-text-webpack-plugin 计算。避免冗杂,这里只写出了部分 webpack 2 配置:

// production
output: {  
  filename: '[name].[chunkhash:8].bundle.js',
  chunkFilename: '[name].[chunkhash:8].js'
},
module: {  
  rules: [{
    test: /\.(jpe?g|png|gif|svg)$/i,
    loader: 'url-loader',
    options: {
      limit: 1000,
      name: 'assets/imgs/[name].[hash:8].[ext]'
    }
  }, {
    test: /\.(woff2?|eot|ttf|otf)$/i,
    loader: 'url-loader',
    options: {
      limit: 10000,
      name: 'assets/fonts/[name].[hash:8].[ext]'
    }
  }]
},
plugins: [  
  new ExtractTextPlugin('[name].[contenthash:8].css')
]

不要在开发环境使用 [chunkhash]/[hash]/[contenthash],因为不需要在开发环境做持久缓存,而且这样会增加编译时间,开发环境用 [name] 就可以了。

不稳定的 chunkhash

不过,只是计算 chunk MD5 摘要并修改 chunk 资源文件名是不够的。Chunk 的生成还涉及到依赖解析和模块 ID 分配,这是无法稳定实质上没有变化的 chunk 文件的 chunkhash 变动问题的本源,附一个未关闭的相关 issue:

Vendor chunkhash changes when app code changes #1315

正如问题 [#1315] 描述的那样:虽然只修改了 app.js 的代码,但在最终的构建结果中,vendor.js 的 chunkhash 也被修改了,尽管 vendor.js 的内容没有实质变化。

其实这个场景比较简单,只生成了 entry 和 vendor 两个 chunk,造成上述问题的原因有两个:

  1. webpack runtime 中包含 chunks ID 及其对应 chunkhash 的对象,但 runtime 被集成到 vendor.js 中;
  2. entry 内容修改后,由于 webpack 的依赖收集规则导致构建产生的 entry chunk 对应的 ID 发生变化,webpack runtime 也因此被改变。

webpack runtime(webpackBootstrap代码不多,主要包含几个功能:

  • 全局 webpackJsonp 方法:模块读取函数,用来区分模块是否加载,并调用 __webpack_require__ 函数;
  • 私有 __webpack_require__ 方法:模块初始化执行函数,并给执行过的模块做标记;
  • 异步 chunk 加载函数(用 script 标签异步加载),加载的 chunk 内容均被 webpackJsonp 包裹的,script 加载成功会直接执行。这个函数还包含了所有生成的 chunks 的路径。在 webpack 2 中这个函数用到了 Promise,因此可能需要提供 Promise Polyfill;
  • 对 ES6 Modules 的默认导出(export default)做处理。

对于复杂项目的构建,由于模块间互相依赖,这种问题影响更为巨大:可能只改动了一个小模块,但在构建后,会发现所有与之直接或间接相关的 chunk 及其 chunkhash 都被更新了……这与我们期望的持久化缓存的需求不符。

解决这个问题的核心在于生成稳定的模块 ID,避免频繁的 chunk 内容变动。

如果你看过 #1315 的回复,可能会了解到 webpack-md5-hash 插件可以解决这个问题,甚至 webpack 2 的文档中也提示用这个插件解决。但我可以负责任的告诉你,这个插件有缺陷……不要使用它,除非你想背黑锅。

webpack-md5-hash 的问题

erm0l0v/webpack-md5-hash相关源码) 通过模块路径来排序 chunk 的所有依赖模块(仅这个 chunk 中的模块,不含被 CommonsChunkPlugin 剔除的模块),并将这些排序后的模块源代码拼接,最后用 MD5 拼接后内容的 chunkhash。插件这么做的好处是,使 chunkhash 与该 chunk 内代码做直接关联,让 chunk 与其依赖的模块 ID 无关化,无论模块 ID 如何变化,都不会影响父 chunk 的实质内容及 chunkhash。

这个方法比较有效,但在一些情景下,会使 webpack-md5-hash 失效,使构建变得不可信:

Hash does not change when only imported modules IDs change #7

比如一个简单场景:有两个入口 vendor 和 app。
当 app.js 被修改后,其 chunk ID 随之改变,vendor.js 中 app 对应的 chunk ID 也会改变,即 vendor 内容有变动,其 chunkhash 也理应改变。但 webpack-md5-hash 是根据 chunk 内实际包含模块而生成的 chunkhash,和仅有 ID 引用的 chunk 内容无关,vendor 只包含 app chunk ID 的引用,并不包含其代码,所以此次构建中 vendor 的 chunkhash 并不会改变。这样造成的结果便是:浏览器依然会下载旧的 vendor,直接导致发版失误!

因此 webpack-md5-hash 并没有解决之前的问题:

  1. 如何生成稳定的模块ID?
  2. 如何避免频繁的 chunk 内容变动?

我们先来解决第一个问题,第二个下一节解决。

如何生成稳定的模块 ID?

默认,模块的 ID 是 webpack 根据依赖的收集顺序递增的正整数,这种 ID 分配方式不太稳定,因为修改一个被依赖较多的模块,依赖这个模块的 chunks 内容均会跟着模块的新 ID 一起改变,但实际上我们只想让用户下载有真正改动的 chunk,而不是所有依赖这个新模块的 chunk 都重新更新。

因此 webpack (1) 默认的模块 ID 分配不是很合适,我们需要其他工具来帮我们稳定 ID:

  1. OccurrenceOrderPlugin
    这个插件可以改变默认的 ID 决定方式,让 webpack 以依赖模块出现的次数决定 ID 的值,次数越多 ID 越小。在依赖项变动不大情况下,还是一个比较好的方法,但当依赖出现次数有变化时,输出的模块 ID 则可能会有大幅变动(级联)。(目前 webpack 2 已经将此插件默认启用 🎉)

  2. recordsPath 配置
    它会输出每次构建的「模块路径(loaders + module path)」与 ID 键值对 JSON,在下次构建时直接使用 JSON 中的 ID。但当修改模块路径或 loader 时,ID 会更新。
    同时,需要注意的是 webpack.optimize.DedupePlugin() 插件不可与 recordsPath 共存,它会改变存下来的模块 ID。

  3. NamedModulesPlugin
    这个模块可以将依赖模块的正整数 ID 替换为相对路径(如:将 4 替换为 ./node_modules/es6-promise/dist/es6-promise.js)。

    • 开发模式,它可以让 webpack-dev-server 和 HMR 进行热更新时在控制台输出模块名字而不是纯数字;
    • 生产构建环境,它可以避免因修改内容导致的 ID 变化,从而实现持久化缓存。

    但是有两个缺点:

    • 递增 ID 替换为模块相对路径,构建出来的 chunk 会充满各种路径,使文件增大;
    • 模块(npm 和自己的模块)路径会泄露,可能导致安全问题。
  4. HashedModuleIdsPlugin
    这是 NamedModulesPlugin 的进阶模块,它在其基础上对模块路径进行 MD5 摘要,不仅可以实现持久化缓存,同时还避免了它引起的两个问题(文件增大,路径泄露)。用 HashedModuleIdsPlugin 可以轻松地实现 chunkhash 的稳定化!

    不过这个插件只被添加到了 webpack 2 中,可能是因为 webpack 2 正式版还没有发布,HashedModuleIdsPlugin 一直没有文档,所以这里有必要指明如何使用

    new webpack.HashedModuleIdsPlugin()
    

    如果使用了 HashedModuleIdsPlugin,NamedModulesPlugin 就不要再添加了。

    幸运的是,我们可以通过直接添加 HashedModuleIdsPlugin.js 为模块到 webpack 1 的配置中,也能达到同样稳定 chunkhash 的功能。

    const HashedModuleIdsPlugin = require('./HashedModuleIdsPlugin')
    // ...
    new HashedModuleIdsPlugin()
    

至此 chunkhash 已经稳定,是时候解决另一个问题了……

二、如何避免频繁的 chunk 内容变动?

一般场景下,我们可能不需要做太多的优化,也不用追求持久化缓存,常规配置即可:

为了节省篇幅,所有配置代码我会尽量缩减,文章最后会提供 DEMO,包含完整配置。

{
  entry: { entry },
  plugins: [
    new HtmlWebpackPlugin({
      chunks: ['vendor', 'entry']
    }),
    new webpack.optimize.CommonsChunkPlugin({
      names: 'vendor',
      minChunks: Infinity
    })
  ]
}

但随着业务需求变化,最初的单页模式可能无法满足需求,而且把公共模块全部提取到 vendor 中,也无法做到较好的持久化缓存,我们需要更合理地划分并提取公共模块。

合理划分公共模块

稍大型的应用通常会包含这几个部分:

类型 公用率 使用频率 更新频率
库和工具 vue/react/redux/whatwg-fetch 等
定制 UI 库和工具 UI 组件/私有工具/语法 Polyfill/页面初始化脚本等
低频库/工具/代码 富文本编辑器/图表库/微信 JSSDK/省市 JSON 等
业务模块 包含业务逻辑的模块/View

根据公用/使用/更新率来做公共模块的划分是比较科学:

  • 库和工具 - libs
  • 定制 UI 库和工具 - vendor
  • 业务模块 - entries
  • 低频库/工具/代码 - 分割为 chunk

我们可通过指定模块的入口 chunk,来直接分离模块。以 Vue 搭建的多入口单页应用为例:

{
  entry: {
    libs: [
      'es6-promise/auto',
      'whatwg-fetch',
      'vue',
      'vue-router'
    ],
    vendor: [
      /*
       * vendor 中均是非 npm 模块,
       * 用 resolve.alias 修改路径,
       * 避免冗长的相对路径。
       */
      'assets/libs/fastclick',
      'components/request',
      'components/ui',
      'components/bootstrap' // 初始化脚本
    ],
    page1: 'src/pages/page1',
    page2: 'src/pages/page2'
  },
  plugins: [
    new HtmlWebpackPlugin({
      // 省略部分配置
      template: 'src/pages/page1/index.html',
      chunks: ['libs', 'vendor', 'page1']
    }),
    new HtmlWebpackPlugin({
      template: 'src/pages/page2/index.html',
      chunks: ['libs', 'vendor', 'page2']
    })
  ]
}

多页入口最好用脚本来扫描目录并生成,手动添加维护性较差,可参考 multi-vue

代码分割

除了入口代码的分离,我们还缺少对「低频库/工具/代码」的处理,对于这类代码最好的办法是做代码分割(Code Splitting),做到按需加载,进一步加速应用。

webpack 提供了几种添加分割点的方法:

  • CommonJs: require.ensure
  • AMD: require
  • ES6 Modules (webpack 1 不支持)

添加分割点可以主动将指定的模块分离成另一个 chunk,而不是随当前 chunk 一起打包。对于这几种情况处理非常好:

  • 比较大,且不常用的库/工具,如 D3.js、Draft.js、微信 JSSDK、querystring 等;
  • 单页应用中不常用的 router view,即某些不常访问的介面。

CommonJs 和 AMD 添加分割点的方法就不再赘述了,详情请查看文档:

注意

如果你使用了 babili (babel-minify) 来压缩你的 ES6+ 代码,请不要使用 require.ensure/require,因为 babili 会把 require 关键字压缩,导致 webpack 无法识别,造成构建问题。

import()

webpack 2 在 1.x 的基础上增加了对 ES6 模块(ES6 Modules)的支持,这意味着在 webpack 2 环境下,import 导入模块语法不再需要编译为 require。还优化了 ES6 模块依赖(Tree-shaking,后面会谈到),并实现了 JS Loader Standard 规范定义中的 import(path) 方法

注意

在 webpack v2.1.0-beta.28 中,System.import 方法已被废弃,因为 System.import 不在提案中了,import() 代替

由于 import() 仅仅是个语法,不涉及转换,因此我们需要使用 babel 插件 syntax-dynamic-import 来让 babel 可以识别这个语法。另外 import() 也依赖编译环境,要想让运行环境通过 import() 进行按需加载,需要额外的插件:

const { search } = window.location  
import('./components/querystring.js')  
  .then(querystring => {
    const searchquery = querystring.parse(search)
    // ...
  })
  .catch(err => {
    Toast.error(err)
    console.error(err)
  })

配合 react-router:

import { Router, Route, hashHistory } from 'react-router'  
import App from './App'

const lazyLoad = moduleName => _ =>  
  import(`./components/${moduleName}`)
    .then(module => module.default)
    .catch(err => console.error(err))

export default function Root () {  
  return (
    <Router history={hashHistory}>
      <Route path='/' component={App}>
        <Route path='/home' getComponent={lazyLoad('Home')} />
        <Route path='/posts' getComponent={lazyLoad('Posts')}>
          <Route path=':id' getComponent={lazyLoad('Article')} />
        </Route>
        <Route path='/about' getComponent={lazyLoad('About')} />
      </Route>
    </Router>
  )
}

用模板字符串来动态加载模块时,webpack 在编译阶段会把可能加载的模块打包,并用正则匹配加载,懒加载示例代码可见 blade254353074/react-router-lazy-import

提取公共模块

在上述例子中,我们划分了公共模块,并进行了代码分割,下面我们要做的是:提取频繁共用的模块,将 webpack runtime 构建为内联 script。

提取频繁共用的模块

提取公共模块要使用 Commons-chunk-plugin,对于持久化缓存来说,我们只需要将共用的模块打包到 libs/vendor 中即可。

模块有两种共用情况:

  • libs/vendor 与其他 chunk 共用的模块,如:vue/react/moment/whatwg-fetch
  • 多个 chunks 间共用的模块,如 page1 和 page2 共用 Header 组件

对于想把所有共用的模块全部提取的需求,我们可以做如下配置:

new webpack.optimize.CommonsChunkPlugin({  
  names: ['libs', 'vendor'].reverse()
})

用上述配置构建时,webpack 会将 webpack runtime 打包到 libs 中(names 数组末尾的 chunk),而 chunks 间共用的模块会打包到 vendor中。

如果你不想让仅有两个 chunks 共用的模块被提取到 vendor 中,而想让 n 个 chunks 共用的模块被提取出来时,可以借助 minChunks 实现。
minChunks 是指限定模块被 chunks 依赖的最少次数,低于设定值(2 ≤ n ≤ chunks 总数)将不会被提取到公共 chunk 中。如果 chunks 太多,又不想让所有公共模块被分离到 vendor 中,可以将 minChunks 设为 Infinity,则公共 chunk 仅仅包含在 entry 中指定的模块,而不会把其他共用的模块提取进去。

new webpack.optimize.CommonsChunkPlugin({  
  names: ['libs', 'vendor'].reverse(),
  // minChunks: 3
  minChunks: Infinity
})

CommonsChunkPlugin 似乎还是有些 Bug,当我用 vue-style-loader 时,其中的 addStyle.js 会被添加到依赖中,但在以下配置中,addStyle.js 在打包后会被 CommonsChunkPlugin 漏掉,导致无法正常运行:

new webpack.optimize.CommonsChunkPlugin({
  names: ['libs', 'vendor'].reverse()
})
manifest (清单)

尽管我们已经划分好了 chunks,也提取了公共的模块,但仅改动一个模块的代码还是会造成 Initial chunk (libs) 的变化。原因是这个初始块包含着 webpack runtime,而 runtime 还包含 chunks ID 及其对应 chunkhash 的对象。因此当任何 chunks 内容发生变化,webpack runtime 均会随之改变。

manifestwebpack runtime 中的 chunks 清单

正如文档 # Manifest File - Code Splitting - Libraries中描述的那样,我们可以通过增加一个指定的公共 chunk 来提取 runtime,从而进一步实现持久化缓存:

new webpack.optimize.CommonsChunkPlugin({  
  // 将 `manifest` 优先于 libs 进行提取,
  // 则可以将 webpack runtime 分离到这个块中。
  names: ['manifest', 'libs', 'vendor'].reverse()
  // manifest 只是个有意义的名字,也可以改成其他名字。
})

manifest 只是个特定的名字(可能是包含了 chunks 清单,所以起名 manifest),如果仅仅是为了分离 webpack runtime,可以将 manifest 替换成任意你想要的名字。

这样在我们构建之后,就会多打包一个特别小(不足 2kb)的 manifest.js,解决了 libs 经常「被」更新的问题。不过,你可能发现了一个问题 —— manifest.js 实在是太小了,以至于不值得再为一个小 js 增加资源请求数量。

这时候我们可以引入另一个插件:inline-manifest-webpack-plugin
它可以将 manifest 转为内联在 html 内的 inline script,因为 manifest 经常随着构建而变化,写入到 html 中便不需要每次构建再下载新的 manifest 了,从而减少了一个小文件请求。此插件依赖 html-webpack-plugin 和 manifest 公共块,因此我们要配置 HtmlWebpackPlugin 且保持 manifest 的命名:

{
  module: {
    rules: [{
      test: /\.ejs$/,
      loader: 'ejs-loader'
    }]
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      names: ['manifest', 'libs', 'vendor'].reverse()
    }),
    new HtmlWebpackPlugin({
      template: 'src/pages/page1/index.ejs',
      chunks: ['manifest', 'libs', 'vendor', 'page1']
    }),
    new InlineManifestWebpackPlugin()
  ]
}

EJS Template:

<!-- ejs template -->  
<!DOCTYPE html>  
<html lang="zh-CN">

<head>  
  <meta charset="UTF-8">
  <title>Template</title>
  <%= htmlWebpackPlugin.files.webpackManifest %>
</head>

<body>  
  <div id="app"></div>
</body>

</html>  

在 inline-manifest-webpack-plugin 的帮助下进行构建,最终我们的 html 便内联了 webpack runtime 脚本,提高了页面的加载速度: inline manifest内联 manifest 的 html

提取 CSS

这篇文章主要针对 JS 资源的持久化缓存优化,关于 CSS 提取请看 webpack/extract-text-webpack-plugin

对 chunks 做最后的优化

webpack 中对 chunks 做优化的还有这几个插件:

  • DedupePlugin (webpack 2 已废弃)
  • OccurrenceOrderPlugin (webpack 2 默认启用)
  • LimitChunkCountPlugin
  • MinChunkSizePlugin
  • UglifyJsPlugin
  • AggressiveMergingPlugin
  • DllPlugin
  • DllReferencePlugin

关于 Tree Shaking

尽管 webpack 2 还未大量使用,但现在我们有一个不得不用 webpack 2 的理由 —— Tree Shaking

注意

为了避免 import x from 'foo' 被 babel 转换为 require,我们需要在 .babelrc 的 presets 配置中标明 "modules": false

{
  "presets": [
    ["latest", {
      "es2015": { "modules": false }
    }]
  ],
  "plugins": ["transform-runtime", "syntax-dynamic-import"],
  "comments": false
}

webpack 在构建过程中只会标记出未使用的 exports,并不会直接将 dead code 去掉,因为为了使工具尽量通用,webpack 被设计为:只标注未使用的 imports/exports。真正的清除死代码工作,交给了 UglifyJS/babili 等工具。

webpack tree shakingDoes webpack include unused imports in the bundle or not?

UglifyJsPlugin 不仅可以将未使用的 exports 清除,还能去掉很多不必要的代码,如无用的条件代码、未使用的变量、不可达代码等。

new webpack.optimize.UglifyJsPlugin({  
  compress: { warnings: true }
})

warning如果打开了 UglifyJsPlugin 的 warning 功能,就可以在构建结果中看到代码清除警告。

因此必须在生产环境中配置 UglifyJsPlugin,并启用 -p (production) 环境,才能真正发挥 Tree Shaking 的作用。

其他资源

引用

正在加载 Disqus 评论组件...