• 用 webpack 实现持久化缓存


    什么是持久化缓存?

    原文链接https://sebastianblade.com/using-webpack-to-achieve-long-term-cache/

    缓存(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 配置:

    JavaScript
    // 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,包含完整配置。

    JavaScript
    {
      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 搭建的多入口单页应用为例:

    JavaScript
    {
      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() 进行按需加载,需要额外的插件:

    JavaScript
    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:

    React JSX
    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 组件

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

    JavaScript
    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 中指定的模块,而不会把其他共用的模块提取进去。

    JavaScript
    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,从而进一步实现持久化缓存:

    JavaScript
    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 的命名:

    JavaScript
    {
      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:

    HTML
    <!-- 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

    JSON
    {
      "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 清除,还能去掉很多不必要的代码,如无用的条件代码、未使用的变量、不可达代码等。

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

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

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

    其他资源

    引用

    个人分类: Webpack打包工具
  • 相关阅读:
    溢出判断
    nm命令学习看不到static的函数符号表原因分析
    写作猫
    postgresql B树索引
    枚举转字符串
    17GDB使用符号表调试release程序
    WSL安装
    16GDB死锁问题定位
    Gin 通用的日志中间件
    go GCM加密解密 gin中间件加密解密,gin文件流处理
  • 原文地址:https://www.cnblogs.com/hngdlxy143/p/9154919.html
Copyright © 2020-2023  润新知