性能文章>使用 webpack 进行 Web 性能优化(二)>

使用 webpack 进行 Web 性能优化(二)转载

2年前
325003

改善应用程序加载时间的下一件事(在优化应用程序大小之后)是缓存。 使用它将应用程序的某些部分保留在客户端上,并避免每次都重新下载它们。

进行缓存的常用方法:

  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
  },
};
 
 
<!-- index.html -->
<!doctype html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>

WebpackManifestPlugin 是一种更灵活的方法,如果你有一个复杂的服务器部分,它会很有用。 在构建期间,它会生成一个 JSON 文件,其中包含不带散列的文件名和带散列的文件名之间的映射。 在服务器上使用这个 JSON 来找出要使用的文件:

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

 

与实际应用程序代码相比,应用程序依赖项的更改频率往往较低。 如果您将它们移动到单独的文件中,浏览器将能够单独缓存它们——并且不会在每次应用程序代码更改时重新下载它们。

关键术语:在 webpack 术语中,带有应用程序代码的单独文件称为块。 稍后我们将使用此名称。
要将依赖项提取到单独的块中,请执行三个步骤:

  1. 用 [name].[chunkname].js 替换输出文件名:
     
    // webpack.config.js
    module.exports = {
      output: {
        // Before
        filename: 'bundle.[chunkhash].js',
        // After
        filename: '[name].[chunkhash].js',
      },
    };
     
    当 webpack 构建应用程序时,它会将 [name] 替换为块的名称。 如果我们不添加 [name] 部分,我们将不得不通过它们的哈希来区分块——这非常困难!
  2. 将输入字段转换为对象:
     
    // 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].js 和 vendor.[chunkhash].js(对于 webpack 4,vendors~main.[chunkhash].js)。 在 webpack 4 的情况下,如果依赖项很小,则可能不会生成vendor包——这很好:

 
$ 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

浏览器会单独缓存这些文件——并且只重新下载发生变化的代码。

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

 
// 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 运行时内联到 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 文件,则您只需要 InlineSourcePlugin:

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineSourcePlugin = require('html-webpack-inline-source-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      // Inline all files which names start with “runtime~” and end with “.js”.
      // That’s the default naming of runtime chunks
      inlineSource: 'runtime~.+\\.js',
    }),
    // This plugin enables the “inlineSource” option
    new InlineSourcePlugin(),
  ],
};

使用 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 将运行时名称设为静态:
     
    // webpack.config.js (for webpack 3)
    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          minChunks: Infinity,
          filename: 'runtime.js',
            // → Now the runtime file will be called
            // “runtime.js”, not “runtime.79f17c27b335abc7aaf4.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() 指定您要动态加载特定模块。 

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() 函数时才下载它。

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

注意:如果你用 Babel 编译这段代码,你会遇到语法错误,因为 Babel 不理解开箱即用的 import()。 为避免该错误,请添加 syntax-dynamic-import 插件。

 

将代码拆分为路由和页面

如果您的应用程序有多个路由或页面,但只有一个带有代码的 JS 文件(一个主块),那么您很可能在每个请求上都提供了额外的字节。 例如,当用户访问您网站的主页时:

使用 webpack 进行 Web 性能优化(二)- HeapDump性能社区

他们不需要加载代码来呈现不同页面上的文章——但他们会加载它。 此外,如果用户总是只访问主页,并且您对文章代码进行了更改,那么 webpack 将使整个**包无效——并且用户将不得不重新下载整个应用程序。

如果我们将应用程序拆分为页面(或路由,如果它是单页应用程序),用户将只下载相关代码。 另外,浏览器会更好地缓存应用程序代码:如果您更改主页代码,webpack 只会使相应的块无效。

对于单页应用

要按路由拆分单页应用程序,请使用 import()(请参阅“您现在不需要的延迟加载代码”部分)。 如果您使用框架,它可能有一个现有的解决方案:

  • react-router 文档中的“代码拆分”(针对 React)

  • vue-router 文档中的“延迟加载路由”(适用于 Vue.js)

对于传统多页应用

要按页面拆分传统应用程序,请使用 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,则主页和配置文件包将不包含它——用户在访问主页时不必下载此库。

但是,单独的依赖树有其缺点。 如果两个入口点使用 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——它将通用依赖项移动到一个新的指定文件中:

// webpack.config.js (for webpack 3)
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      // A name of the chunk that will include the common dependencies
      name: 'common',

      // The plugin will move a module into a common file
      // only if it’s included into `minChunks` chunks
      // (Note that the plugin ****yzes all chunks, not only entries)
      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 的块)和主块(带有其他应用程序代码的块)会失效——而只有主块应该是。

要解决此问题,请更改使用 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(),
  ],
};

 

延伸阅读:使用 webpack 进行 Web 性能优化(三)

点赞收藏
分类:标签:
风之石
请先登录,感受更多精彩内容
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
3
0