使用长期缓存

webpack 如何帮助进行资源缓存

优化应用大小之后,下一个提高应用加载速度的方法是缓存。使用缓存将应用的部分内容保留在客户端,避免每次都重新下载。

使用捆绑版本控制和缓存标头

常见的缓存方法是

  1. 告知浏览器将文件缓存很长时间(例如一年)

    # Server header
    Cache-Control: max-age=31536000
    

    如果您不熟悉 Cache-Control 的作用,请参阅 Jake Archibald 的精彩博文关于缓存最佳实践

  2. 并在文件更改时重命名文件以强制重新下载

    <!-- Before the change -->
    <script src="./index-v15.js"></script>
    
    <!-- After the change -->
    <script src="./index-v16.js"></script>
    

这种方法告诉浏览器下载 JS 文件,缓存它并使用缓存副本。浏览器仅在文件名更改时(或一年后)才会访问网络。

使用 webpack,您可以执行相同的操作,但不是使用版本号,而是指定文件哈希值。要将哈希值包含到文件名中,请使用 [chunkhash]

// webpack.config.js
module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.[chunkhash].js' // → bundle.8e0d62a03.js
  }
};

如果您需要文件名将其发送到客户端,请使用 HtmlWebpackPluginWebpackManifestPlugin

HtmlWebpackPlugin 是一种简单但不太灵活的方法。在编译期间,此插件会生成一个 HTML 文件,其中包含所有已编译的资源。如果您的服务器逻辑不复杂,那么这应该足够您使用了

<!-- index.html -->
<!DOCTYPE html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>

WebpackManifestPlugin 是一种更灵活的方法,如果您有复杂的服务器部分,则非常有用。在构建期间,它会生成一个 JSON 文件,其中包含不带哈希值的文件名和带哈希值的文件名之间的映射。在服务器上使用此 JSON 文件来查找要使用的文件

// manifest.json
{
  "bundle.js": "bundle.8e0d62a03.js"
}

延伸阅读

将依赖项和运行时提取到单独的文件中

依赖项

应用依赖项的更改频率通常低于实际应用代码。如果您将它们移动到单独的文件中,浏览器将能够单独缓存它们 – 并且不会在每次应用代码更改时都重新下载它们。

要将依赖项提取到单独的块中,请执行三个步骤

  1. 将输出文件名替换为 [name].[chunkname].js

    // webpack.config.js
    module.exports = {
      output: {
        // Before
        filename: 'bundle.[chunkhash].js',
        // After
        filename: '[name].[chunkhash].js'
      }
    };
    

    当 webpack 构建应用时,它会将 [name] 替换为块的名称。如果我们不添加 [name] 部分,我们将不得不通过哈希值来区分块 – 这非常困难!

  2. entry 字段转换为对象

    // webpack.config.js
    module.exports = {
      // Before
      entry: './index.js',
      // After
      entry: {
        main: './index.js'
      }
    };
    

    在此代码段中,“main”是块的名称。此名称将替换步骤 1 中的 [name]

    到目前为止,如果您构建应用,此块将包含整个应用代码 – 就像我们没有执行这些步骤一样。但这将在稍后发生变化。

  3. 在 webpack 4 中,将 optimization.splitChunks.chunks: 'all' 选项添加到您的 webpack 配置中

    // webpack.config.js (for webpack 4)
    module.exports = {
      optimization: {
        splitChunks: {
          chunks: 'all'
        }
      }
    };
    

    此选项启用智能代码拆分。启用此选项后,如果供应商代码大于 30 kB(在缩小和 gzip 之前),webpack 将提取供应商代码。它还会提取公共代码 – 如果您的构建生成多个捆绑包(例如,如果您将应用拆分为路由),这将非常有用。

    在 webpack 3 中,添加 CommonsChunkPlugin

    // webpack.config.js (for webpack 3)
    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
        // A name of the chunk that will include the dependencies.
        // This name is substituted in place of [name] from step 1
        name: 'vendor',
    
        // A function that determines which modules to include into this chunk
        minChunks: module => module.context && module.context.includes('node_modules'),
        })
      ]
    };
    

    此插件获取路径包含 node_modules 的所有模块,并将它们移动到名为 vendor.[chunkhash].js 的单独文件中。

进行这些更改后,每次构建将生成两个文件而不是一个文件:main.[chunkhash].jsvendor.[chunkhash].js(对于 webpack 4,为 vendors~main.[chunkhash].js)。对于 webpack 4,如果依赖项很小,则可能不会生成供应商捆绑包 – 这很好

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                        Asset      Size  Chunks             Chunk Names
 ./main.00bab6fd3100008a42b0.js   82 kB       0  [emitted]  main
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

浏览器将单独缓存这些文件 – 并且仅重新下载更改的代码。

Webpack 运行时代码

不幸的是,仅提取供应商代码是不够的。如果您尝试更改应用代码中的某些内容

// index.js



// E.g. add this:
console.log('Wat');

您会注意到 vendor 哈希值也会更改

                           Asset   Size  Chunks             Chunk Names
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

                            Asset   Size  Chunks             Chunk Names
./vendor.e6ea4504d61a1cc1c60b.js  47 kB       1  [emitted]  vendor

发生这种情况是因为 webpack 捆绑包除了模块代码外,还具有运行时 – 一小段代码,用于管理模块执行。当您将代码拆分为多个文件时,此代码段开始包含块 ID 和相应文件之间的映射

// vendor.e6ea4504d61a1cc1c60b.js
script.src = __webpack_require__.p + chunkId + "." + {
    "0": "2f2269c7f0a55a5c1871"
}[chunkId] + ".js";

Webpack 将此运行时包含到最后生成的块中,在我们的例子中是 vendor。并且每次任何块更改时,此代码段也会更改,从而导致整个 vendor 块都更改。

为了解决这个问题,让我们将运行时移动到单独的文件中。在 webpack 4 中, 这可以通过启用 optimization.runtimeChunk 选项来实现

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    runtimeChunk: true
  }
};

在 webpack 3 中, 通过使用 CommonsChunkPlugin 创建额外的空块来实现

// webpack.config.js (for webpack 3)
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: module => module.context && module.context.includes('node_modules')
    }),
    // This plugin must come after the vendor one (because webpack
    // includes runtime into the last chunk)
    new webpack.optimize.CommonsChunkPlugin({
      name: 'runtime',
      // minChunks: Infinity means that no app modules
      // will be included into this chunk
      minChunks: Infinity
    })
  ]
};

进行这些更改后,每次构建将生成三个文件

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                            Asset     Size  Chunks             Chunk Names
   ./main.00bab6fd3100008a42b0.js    82 kB       0  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       1  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

按相反的顺序将它们包含到 index.html 中 – 就完成了

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
<script src="./vendor.26886caf15818fa82dfa.js"></script>
<script src="./main.00bab6fd3100008a42b0.js"></script>

延伸阅读

内联 webpack 运行时以节省额外的 HTTP 请求

为了使事情变得更好,请尝试将 webpack 运行时内联到 HTML 响应中。即,而不是这样

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>

这样做

<!-- index.html -->
<script>
!function(e){function n(r){if(t[r])return t[r].exports;…}} ([]);
</script>

运行时很小,内联它可以帮助您节省 HTTP 请求(对于 HTTP/1 非常重要;对于 HTTP/2 不太重要,但可能仍然会产生影响)。

以下是如何操作。

如果您使用 HtmlWebpackPlugin 生成 HTML

如果您使用 HtmlWebpackPlugin 生成 HTML 文件,则 InlineSourcePlugin 就是您所需要的

const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineSourcePlugin = require('html-webpack-inline-source-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      inlineSource: 'runtime~.+\\.js',
    }),
    new InlineSourcePlugin()
  ]
};

如果您使用自定义服务器逻辑生成 HTML

对于 webpack 4

  1. 添加 WebpackManifestPlugin 以了解生成的运行时块的名称

    // webpack.config.js (for webpack 4)
    const ManifestPlugin = require('webpack-manifest-plugin');
    
    module.exports = {
      plugins: [
        new ManifestPlugin()
      ]
    };
    

    使用此插件构建将创建一个如下所示的文件

    // manifest.json
    {
      "runtime~main.js": "runtime~main.8e0d62a03.js"
    }
    
  2. 以方便的方式内联运行时块的内容。例如,使用 Node.js 和 Express

    // server.js
    const fs = require('fs');
    const manifest = require('./manifest.json');
    const runtimeContent = fs.readFileSync(manifest['runtime~main.js'], 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
    
        <script>${runtimeContent}</script>
    
      `);
    });
    

或使用 webpack 3

  1. 通过指定 filename 使运行时名称静态化

    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          minChunks: Infinity,
          filename: 'runtime.js'
        })
      ]
    };
    
  2. 以方便的方式内联 runtime.js 内容。例如,使用 Node.js 和 Express

    // server.js
    const fs = require('fs');
    const runtimeContent = fs.readFileSync('./runtime.js', 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
    
        <script>${runtimeContent}</script>
    
      `);
    });
    

延迟加载您现在不需要的代码

有时,页面有更重要和不太重要的部分

  • 如果您在 YouTube 上加载视频页面,您更关心视频而不是评论。在这里,视频比评论更重要。
  • 如果您在新闻网站上打开一篇文章,您更关心文章的文本而不是广告。在这里,文本比广告更重要。

在这种情况下,通过首先仅下载最重要的内容,然后延迟加载其余部分来提高初始加载性能。为此,请使用 import() 函数代码拆分

// videoPlayer.js
export function renderVideoPlayer() {  }

// comments.js
export function renderComments() {  }

// index.js
import {renderVideoPlayer} from './videoPlayer';
renderVideoPlayer();

// …Custom event listener
onShowCommentsClick(() => {
  import('./comments').then((comments) => {
    comments.renderComments();
  });
});

import() 指定您要动态加载特定模块。当 webpack 看到 import('./module.js') 时,它会将此模块移动到单独的块中

$ webpack
Hash: 39b2a53cb4e73f0dc5b2
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.f7e53d8e13e9a2745d6d.js    60 kB       1  [emitted]  main
 ./vendor.4f14b6326a80f4752a98.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

并且仅在执行到达 import() 函数时才下载它。

这将使 main 捆绑包更小,从而缩短初始加载时间。更重要的是,它将改善缓存 – 如果您更改主块中的代码,评论块将不会受到影响。

延伸阅读

将代码拆分为路由和页面

如果您的应用有多个路由或页面,但只有一个包含代码的 JS 文件(单个 main 块),则您很可能在每次请求时都提供额外的字节。例如,当用户访问您网站的首页时

A WebFundamentals home page

他们不需要加载用于呈现不同页面上的文章的代码 – 但他们会加载它。此外,如果用户始终只访问首页,并且您更改了文章代码,webpack 将使整个捆绑包失效 – 并且用户将不得不重新下载整个应用。

如果我们按页面(或路由,如果是单页应用)拆分应用,用户将仅下载相关代码。此外,浏览器将更好地缓存应用代码:如果您更改首页代码,webpack 将仅使相应的块失效。

对于单页应用

要按路由拆分单页应用,请使用 import()(请参阅“延迟加载您现在不需要的代码”部分)。如果您使用框架,它可能已经有针对此问题的解决方案

对于传统多页应用

要按页面拆分传统应用,请使用 webpack 的 入口点。如果您的应用有三种类型的页面:首页、文章页面和用户帐户页面 – 它应该有三个入口

// webpack.config.js
module.exports = {
  entry: {
    home: './src/Home/index.js',
    article: './src/Article/index.js',
    profile: './src/Profile/index.js'
  }
};

对于每个入口文件,webpack 将构建一个单独的依赖树并生成一个捆绑包,其中仅包含该入口使用的模块

$ webpack
Hash: 318d7b8490a7382bf23b
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./home.91b9ed27366fe7e33d6a.js    18 kB       1  [emitted]  home
./article.87a128755b16ac3294fd.js    32 kB       2  [emitted]  article
./profile.de945dc02685f6166781.js    24 kB       3  [emitted]  profile
 ./vendor.4f14b6326a80f4752a98.js    46 kB       4  [emitted]  vendor
./runtime.318d7b8490a7382bf23b.js  1.45 kB       5  [emitted]  runtime

因此,如果只有文章页面使用 Lodash,则 homeprofile 捆绑包将不包含它 – 并且用户在访问首页时不必下载此库。

但是,单独的依赖树也有其缺点。如果两个入口点都使用 Lodash,并且您尚未将依赖项移动到供应商捆绑包中,则两个入口点都将包含 Lodash 的副本。为了解决这个问题,在 webpack 4 中,optimization.splitChunks.chunks: 'all' 选项添加到您的 webpack 配置中

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

此选项启用智能代码拆分。使用此选项,webpack 将自动查找公共代码并将其提取到单独的文件中。

或者,在 webpack 3 中, 使用 CommonsChunkPlugin – 它会将公共依赖项移动到新的指定文件中

module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'common',
      minChunks: 2    // 2 is the default value
    })
  ]
};

随意调整 minChunks 值以找到最佳值。通常,您希望保持较小的值,但如果块的数量增加,则可以增加该值。例如,对于 3 个块,minChunks 可能是 2,但对于 30 个块,它可能是 8 – 因为如果您将其保持在 2,则太多的模块将进入公共文件,从而过度膨胀它。

延伸阅读

使模块 ID 更稳定

在构建代码时,webpack 会为每个模块分配一个 ID。稍后,这些 ID 将在捆绑包内的 require() 中使用。您通常会在模块路径之前的构建输出中看到 ID

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.4e50a16675574df6a9e9.js    60 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

↓ 这里

[0] ./index.js 29 kB {1} [built]
[2] (webpack)/buildin/global.js 488 bytes {2} [built]
[3] (webpack)/buildin/module.js 495 bytes {2} [built]
[4] ./comments.js 58 kB {0} [built]
[5] ./ads.js 74 kB {1} [built]
+ 1 hidden module

默认情况下,ID 使用计数器计算(即,第一个模块的 ID 为 0,第二个模块的 ID 为 1,依此类推)。这样做的问题是,当您添加新模块时,它可能会出现在模块列表的中间,从而更改所有后续模块的 ID

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.5c82c0f337fcb22672b5.js    22 kB       0  [emitted]
   ./main.0c8b617dfc40c2827ae3.js    82 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime
   [0] ./index.js 29 kB {1} [built]
   [2] (webpack)/buildin/global.js 488 bytes {2} [built]
   [3] (webpack)/buildin/module.js 495 bytes {2} [built]

↓ 我们添加了一个新模块……

[4] ./webPlayer.js 24 kB {1} [built]

↓ 看看它做了什么!comments.js 现在具有 ID 5 而不是 4

[5] ./comments.js 58 kB {0} [built]

ads.js 现在具有 ID 6 而不是 5

[6] ./ads.js 74 kB {1} [built]
       + 1 hidden module

这会使包含或依赖于 ID 已更改的模块的所有块失效 – 即使它们的实际代码没有更改。在我们的例子中,0 块(包含 comments.js 的块)和 main 块(包含其他应用代码的块)失效 – 而实际上应该只有 main 块失效。

为了解决这个问题,请使用 HashedModuleIdsPlugin 更改模块 ID 的计算方式。它用模块路径的哈希值替换基于计数器的 ID

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.6168aaac8461862eab7a.js  22.5 kB       0  [emitted]
   ./main.a2e49a279552980e3b91.js    60 kB       1  [emitted]  main
 ./vendor.ff9f7ea865884e6a84c8.js    46 kB       2  [emitted]  vendor
./runtime.25f5d0204e4f77fa57a1.js  1.45 kB       3  [emitted]  runtime

↓ 这里

[3IRH] ./index.js 29 kB {1} [built]
[DuR2] (webpack)/buildin/global.js 488 bytes {2} [built]
[JkW7] (webpack)/buildin/module.js 495 bytes {2} [built]
[LbCc] ./webPlayer.js 24 kB {1} [built]
[lebJ] ./comments.js 58 kB {0} [built]
[02Tr] ./ads.js 74 kB {1} [built]
    + 1 hidden module

使用这种方法,模块的 ID 仅在您重命名或移动该模块时才会更改。新模块不会影响其他模块的 ID。

要启用该插件,请将其添加到配置的 plugins 部分

// webpack.config.js
module.exports = {
  plugins: [
    new webpack.HashedModuleIdsPlugin()
  ]
};

延伸阅读

总结

  • 缓存捆绑包,并通过更改捆绑包名称来区分版本
  • 将捆绑包拆分为应用代码、供应商代码和运行时
  • 内联运行时以节省 HTTP 请求
  • 使用 import 延迟加载非关键代码
  • 按路由/页面拆分代码,以避免加载不必要的内容