系列文章链接:

文件指纹

“文件指纹” 就是打包后的文件名的后缀,文件指纹的好处如下:

  • 版本管理,文件发生变化,文件指纹发生变化,只将发生变化的文件进行发布;
  • 没有修改文件指纹的文件可以继续使用浏览器缓存,减少网络带宽,加速页面访问。

“文件指纹” 的种类:

  • Hash:和整个项目的构建有关,只要项目中有文件发生变化,使用该配置的文件名的 “指纹” 就会发生变化;
  • ChunkHash:和 Webpack 打包的 chunk 有关,不同的 entry(多页应用时)会生成不同的 “指纹”,页面对应的文件发生变化才会影响该页面的 “指纹”;
  • ContentHash:根据具体文件的内容生成 “指纹”,在具体某一个页面下引用的多个文件中,如果使用 ChunkHash 会导致一个文件变化其他的文件 “指纹” 也发生变化,使用 ContentHash 可以保证文件内容不变不会 “指纹” 不会发生变化。

合理使用 “文件指纹”:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: {
    app: './pages/app.js',
    appAdmin: './pages/appAdmin.js'
  },
  output: {
    // 不同页面出口文件使用 chunkhash
    filename: '[name][chunkhash:8].js',
    path: __dirname + 'dist'
  },
  module: {
    rules: [
      // ...
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
        ],
      },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          // MD5 根据文件内容生成,字体文件同理
          name: '[name].[hash:8].[ext]'
        }
      }
      // ...
    ]
  }
  plugins: [
    // 使用该插件将 CSS 文件单独提取
    new MiniCssExtractPlugin({
      // 该 contenthash 同图片的 hash
      filename: '[name][contenthash:8].css'
    })
  ]
  // ...
}

“指纹” 配置不能和热更新插件 HotModuleReplacementPlugin 同时使用,因此也突出了 Webpack 配置根据环境(mode)区分的重要性。

代码压缩

在项目正式上线时,代码压缩是非常必要的,因为代码压缩后资源的字节会更少,文件大小会更小,这样在文件传输过程中也会节约带宽进而加快文件的访问速度。

JS 压缩

Webpack4 中内置了 uglifyjs-webpacl-plugin 插件,在 mode 配置为 production 时会默认实现 .js 文件的压缩,也可以手动安装该插件去配置关于压缩的其他参数,如并行压缩等(非必要)。

CSS 压缩

Webpack 旧版本中可以通过 css-loader 中配置参数来实现压缩,但是后来 css-loader 去掉了这个配置,所以在 Webpack4 中可以通过 OptimizeCssAssetsWebpackPlugin 插件来实现对 .css 文件的压缩。

安装依赖:

$ npm install cssnano optimize-css-assets-webpack-plugin -D

配置示例:

/* 在 plugins 中配置 */
const OptimizeCssPlugin = require('optimize-css-assets-webpack-plugin');

// 用于匹配 CSS 文件的处理器(默认)
const Cssnano = require('cssnano');

module.exports = {
  // ...
  plugins: [
    new OptimizeCssPlugin({
      assetNameRegExp: /\.css$/g,
      cssProcessor: Cssnano,
      cssProcessorOptions: {
        // 注释处理
        discardComments: {
          removeAll: true // 移除所有注释
        },
        normalizeUnicode: false // 防止 unicode-range 时产生乱码
      }
    })
  ]
}
/* 在 optimization 中配置 */
const OptimizeCssPlugin = require('optimize-css-assets-webpack-plugin');

// 用于匹配 CSS 文件的处理器(默认)
const Cssnano = require('cssnano');

module.exports = {
  // ...
  optimization: {
    // ...
    minimizer: [
      new OptimizeCssPlugin({
        assetNameRegExp: /\.css$/g,
        cssProcessor: Cssnano,
        cssProcessorOptions: {
          // 注释处理
          discardComments: {
            removeAll: true // 移除所有注释
          },
          normalizeUnicode: false // 防止 unicode-range 时产生乱码
        }
      })
    ]
    // ...
  }
}

Html 压缩

压缩 .html 文件主要还是依靠 HtmlWebpackPlugin 插件,通过生产环境构建时配置一些参数来实现。

安装依赖:

$ npm install html-webpack-plugin -D

配置示例:

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

module.exports = {
  // ...
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html', // 模板文件路径
      filename: 'index.html', // 输出文件名称
      inject: true, // 将 js 资源放在 body 底部
      minify: {
        collapseWhitespace: true, // 是否删除空白符与换行符
        removeAttributeQuotes: true, // 是否移除引号
        minifyCSS: true, // 压缩 CSS
        minifyJS: true, // 压缩 JS
        removeComments: true // 是否移除 HTML 中的注释
      }
    })
  ]
  // ...
}

资源内联

资源内联就是将资源的代码放在 .html 文件中一起请求回来,资源内联优化的意义大致可以分为两个层面,代码层面和网络层面。

  • 在代码层面可以内联一些 meta 标签,便于维护管理文件,可以内联一些页面框架的初始化脚本、上报埋点相关的脚本,也可以将首屏使用的 CSS 内联,防止网络不好的情况下页面闪动;
  • 从网络层面,对一些小图片和字体资源进行内联可以减少请求次数,增加页面的响应速度。

Html 和 JS 的内联

内联 HtmlJS 文件需要依赖 raw-loader 加载器,raw-loader 的功能其实就是读取一个文件,然后把文件读取的内容以字符串形式返回并插入到对应的位置。

安装依赖(0.5.1 版本比较稳定):

$ npm install raw-loader@0.5.1 -D

使用示例:

<!DOCTYPE html>
<html lang="en">
<head>
  <!-- html 内联可以直接放在对应位置 -->
  ${ require('raw-loader!./meta.html') }
  <!-- js 内联需要包裹在 script 标签中,防止存在 ES6 代码需要添加 babel-loader -->
  <script>${ require('raw-loader!babel-loader!./xxx.js') }</script>
  <title>注入 Html 和 JS</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>

由于 Webpack 使用编译模板的插件是 HtmlWebpackPlugin,默认模板使用的是 ejs,所以支持上面 ${} 的模板语法。

CSS 内联

CSS 内联需要两个步骤:

  • 如果直接使用 style-loader 会把所有打包后的 CSS 样式都动态的注入 .html 文件中,所以需要使用 MiniCssExtractPlugin 插件优先对 CSS 进行抽离;
  • 将抽离后首屏的 .css 文件注入到 .html 文件中,借助 HtmlInlineCssWebpackPlugin 插件来实现。

安装依赖:

$ npm install mini-css-extract-plugin html-webpack-plugin html-inline-css-webpack-plugin -D

配置示例:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlInlineCssWebpackPlugin = require('html-inline-css-webpack-plugin');

module.exports = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      }
      // ...
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name][contenthash:8].css'
    }),
    new HtmlWebpackPlugin({
      template: './src/index.html',
      filename: 'index.html'
    }),
    new HtmlInlineCssWebpackPlugin()
    // ...
  ]
  // ...
}

需要注意的是 HtmlWebpackPlugin 插件应该在 HtmlInlineCssWebpackPlugin 之前,因为这两个插件的执行顺序有所依赖,必须先产生 index.html 文件后才能对 CSS 进行注入。

图片、字体的内联

一些小图标和字体如果体积非常小的情况下发出多个请求是没有必要的,所以最好是转换成 Base64 直接内联在 .html.css 文件中,可以通过 url-loader 在构建中实现。

安装依赖:

$ npm install url-loader -D

配置示例:

module.exports = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /\.(png|svg|gif|jpe?g)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 10240 // 图片小于 10k 转为 Base64
            }
          }
        ]
      },
      {
        test: /\.(woff2?|eot|ttf|otf)$/,
        use: [
          {
            loader: 'url-loader', // 字体小于 10k 转为 Base64
            options: {
              limit: 10240
            }
          }
        ]
      }
      // ...
    ]
  }
  // ...
}

这样的方式最大的问题是只能根据图片大小控制所有满足条件的图片和字体资源,而不能单独控制某一个资源,想要单独控制某一个资源可以使用自己编写 Webpack 插件或类似功能的第三方插件。

抽取公共依赖

在开发中的很多页面使用了相同的基础库,或者这些基础库之间引用了相同的依赖,或不同的组件中使用了相同的模块,以及 node_modules 中有些模块使用相同的依赖,这样直接打包会对公共部分重复打包,造成打包后的文件体积非常的大,这也是一个可以优化的点,可以将公共的部分按照优先级、权重、同步异步加载的方式进行抽取,进而对文件进行拆分,减小打包后文件的体积。

基础库分离

假如我们是做 React 开发,默认情况下是会对 reactreact-dom 构建并打包到 bundle 中去,可以通过 CDN 的方式进行引入,在打包的时候每一次都忽略 reactreact-dom 文件,以减小 bundle 的体积,我们可以通过 HtmlWebpackExternalsPlugin 插件来实现。

安装依赖:

$ npm install html-webpack-externals-plugin -D

配置示例:

const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');

module.exports = {
  // ...
  plugins: [
    // ...
    new HtmlWebpackExternalsPlugin({
      externals: [
        {
          module: 'react', // 模块名称
          entry: '//xxcnd.com/boudle/react.min.js', // cdn 地址
          global: 'React' // 全局变量名
        },
        {
          module: 'react-dom',
          entry: '//xxcnd.com/boudle/react-dom.min.js',
          global: 'ReactDom'
        }
      ]
    })
    // ...
  ]
  // ...
}

代码分割

Webpack4 中内置了代码分割的功能插件,非常强大,可以通过将公共依赖抽离成单独文件的方式减小 bundle 的体积,这也是官方建议使用的方式。

配置示例:

/* 默认参数 */
module.exports = {
  // ...
  optimization: {
    // ...
    splitChunks: {
      chunks: 'all',
      minSize: 30000,
      maxSize: 0,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitalRequests: 3,
      automaticNameDelimiter: '~',
      name: true
    }
    // ...
  }
  // ...
}

splitChunks 参数详解:

  • chunks
    • async:异步引入的库进行分离(默认);
    • inital:同步引入的库进行分离;
    • all:所有引入的库进行分离(推荐)。
  • minSize:抽离公共包最小字节数;
  • maxSize:抽离公共包最大字节数;
  • minChunks:抽离公共包最小使用次数;
  • maxAsyncRequests:浏览器同时请求同步资源的个数;
  • maxInitalRequests:浏览器同时请求异步资源的个数;
  • automaticNameDelimiter:抽离插件的文件名间隔符;
  • name:值为 true 根据模块名称和缓存组(cacheGroups)的键自动选择名称。

使用自定义缓存组 cacheGroups 配置示例:

/* 自定义缓存组拆分同步异步模块 */
module.exports = {
  // ...
  optimization: {
    // ...
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        'commons': {
          chunks: 'initial',
          name: 'commons',
          minChunks: 2,
          minSize: 0,
          reuseExistingChunk: true,
          priority: -5
        },
        'async-commons': {
          chunks: 'async',
          name: 'async-commons',
          minChunks: 2,
          minSize: 0,
          reuseExistingChunk: true,
          priority: 5
        },
        'vendors': {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'initial',
          minChunks: 2,
          priority: 10,
          enforce: true,
          reuseExistingChunk: true
        },
        'async-vendors': {
          test: /[\\/]node_modules[\\/]/,
          minChunks: 2,
          chunks: 'async',
          name: 'async-vendors',
          priority: 15,
          enforce: true,
          reuseExistingChunk: true
        }
      }
    }
    // ...
  }
  // ...
}

cacheGroups 参数详解:

  • commons:所有代码中的公共依赖(同步);
    • test:匹配依赖代码的文件夹,通常匹配 node_modules
    • reuseExistingChunk:允许重用现有模块,而不是在模块完全匹配时创建新模块;
    • priority:权重,当被多个规则重用时会根据权重打包到对应策略的文件中;
    • enforce:设置为 true 强制按照该规则拆分出一个文件,忽略文件大小;
  • async-commons:所有代码中的公共依赖(异步);
  • vendors:依赖代码(node_modules)中的公共依赖(同步);
  • async-vendors:依赖代码(node_modules)中的公共依赖(异步);

注意:在使用 cacheGroups 属性进行代码分割后,产生的新 chunks 名称必须在页面 HtmlWebpackPlugin 实例的 chunks 属性中进行一一对应的配置。

HtmlWebpackPlugin 配置:

/* HtmlWebpackPlugin 使用前需安装 */
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // ...
  plugins: [
    // ...
    new HtmlWebPackPlugin({
      template: './src/index.html',
      filename: 'index.html',
      // 包含 splitChunks.cacheGroups 中 key 值和页面的出口文件名称
      chunks: [
        'commons',
        'async-commons',
        'vendors',
        'async-vendors',
        'index'
      ]
      // ...
    })
    // ...
  ]
  // ...
}

tree-shaking 优化

配置 tree-shaking

这个优化的名字是非常形象的,像摇晃树一样,把多余的枯叶都晃掉,其实指的就是一个模块中有多个方法,在打包的 uglify 阶段擦除掉没有使用(被标记)的方法,通过不打包无用代码的方式来减小 bundle 的体积,进而减小资源的加载时间。

这个优化的思想借鉴了 rollup,并在 Webpack2 中进行了实现,通过插件配置,目前 Webpack4 版本已经内置了 tree-shaking 优化,在 mode 被配置为 production(生产环境)时默认生效,如果需要在开发环境中使用配置如下。

/* .babelrc */
{
  "presets": [
    "@babel/preset-env",
  ]
}

想要使 tree-shaking 生效的注意点:

  • 引入模块必须使用 ES6 的模块化语法,因为 tree-shaking 的实现依赖于 ES6 模块化的静态特性;
  • 导出的函数不能存在 “副作用”,即导出的函数需要是纯函数,否则默认的 tree-shaking 也会失效。

tree-shaking 原理简介

说到 tree-shaking 的原理是应该先了解 DCEdead code elimination)的概念,就是指 “死” 代码消除。

DCE 有以下情况:

  • 代码不会被执行,不可到达;
  • 代码只会影响死变量,只写不读;
  • 代码执行的结果不会被用到。
/* 代码不会被执行,不可到达 */
if (false) {
  console.log('dead code');
}
/* 代码只会影响死变量,只写不读 */
let hello = 'nihao';
/* 代码执行的结果不会被用到 */
// tool.js
export const fn1 = () => {
  return 'hello';
}

fn1();

export const fn2 = () => {
  return 'world';
}

// main.js
import { fn2 } from './tool.js';

fn2();

tree-shaking 就是通过检查具有上面特性的代码并做相应处理来达到擦除多余代码的目的,并且依赖 ES6 模块化的静态特性,原因是哪些代码是多余的哪些代码是有用的在编译阶段就需要确定下来,ES6 模块的静态特性正好符合编译阶段对代码的分析,CommonJS 的模块化规范就明显不适合,因为模块的引入是动态的,由运行时决定。

依赖模块静态化特性的原因:

  • 只能在顶层使用 import 引用模块;
  • 引用的变量都是常量;
  • 引入的模块对象的不可更改(immutable)特性。

深度 tree-shaking

如果函数中存在副作用,默认的 tree-shaking 之所以失效了是因为只能够在编译阶段做词法分析,而不能做作用域(scope)分析,如下面代码。

// tool.js
import lodash from 'lodash-es';

export const fn1 = () => {
  console.log('hello');
}

export const fn2 = (arg) => {
  console.log(lodash.isArray(arg));
}

// main.js
import { fn1 } from './tool.js';

fn1();

在上面案例中并没有使用 fn2,但是由于 fn2 函数中有副作用,即引用了 lodash,所以还是对 lodashfn2 进行了打包,这种情况下如果想要继续实现 tree-shaking,需要借助 WebpackDeepScopePlugin 插件来实现。

安装依赖:

$ npm install webpack-deep-scope-plugin -D

配置示例:

const WebpackDeepScopePlugin = require('webpack-deep-scope-plugin');

module.exports = {
  // ...
  plugins: [
    // ...
    new WebpackDeepScopePlugin()
    // ...
  ]
  // ...
}

Scope Hoisting 优化

由于浏览器对模块化语法支持依然不好,为了保证代码可以在各个浏览器中可执行,所以使用 Webpack 进行构建。

/* 构建前 */
// a.js
export default 'xxx';

// b.js
import index from './a.js';
console.log(index);
/* 构建后 */
(function (module, __webpack_exports__, __webpack_require__) {
  "use strict"
  // 模块 b 构建内容,省略...
})

(function (module, __webpack_exports__, __webpack_require__) {
  "use strict"
  // 模块 a 构建内容,省略...
})

Webpack 转换后的模块会包裹一层自执行函数,构建后的代码会存在大量的闭包,其中 import 会被转换成 __webpack_require__ 的调用,export 会被转换成 __webpack_exports__ 对象属性的的赋值。

会导致的问题:

  • 大量函数闭包包裹的代码会导致体积增大,模块越多越明显;
  • 运行代码时创建的函数作用域变多,内存开销变大。

Scope Hoisting 又被称为作用域提升,借鉴于 rollup,在 Webpack3 中被提出,将所有模块的代码按照引用顺序存放在一个函数作用域里,然后适当的重命名来防止变量命名冲突,用来减少函数声明代码,减小内存开销。

Webpack4 中当 modeproduction(生产环境)时会默认开启 Scope Hoisting,当想在开发环境或 Webpack3 中配置开启,需要依赖 Webpack 的内置插件 ModuleConcatenationPlugin 来实现。

配置示例:

const Webpack = require('webpack');

module.exports = {
  // ...
  plugins: [
    // ...
    new Webpack.optimize.ModuleConcatenationPlugin()
    // ...
  ]
  // ...
}

开启 Scope Hoisting 后,在多个模块中都会引用的模块会单独提取出来形成闭包函数,否则会将多个引用的模块按照引用顺序放在同一个闭包函数中。

资源懒加载

Webpack 所特有的 require.ensure() 可以实现懒加载,符合 CommonJS 规范,目前已经被 ES6+ 的动态 import() 取代,属于代码分割的一部分,对于大型 Web 单页面应用来讲,将所有代码都放在一个 bundle 文件中是没有必要的,特别是某些代码块不是经常被用到,大大降低了首屏的加载速度,使用动态 import 优化可以使类似这样的代码在使用时才去加载,使得初始化的时候代码体积更小。

Webpack 中要解析动态 import() 语法需要依赖 babel 中的 @babel/plugin-syntax-dynamic-import 插件。

安装依赖:

$ npm install @babel/preset-env @babel/plugin-syntax-dynamic-import -D

配置示例:

/* .babelrc */
{
  "presets": [
    "@babel/preset-env"
  ],
  "plugins": [
    "@babel/plugin-syntax-dynamic-import"
  ]
}

只要使用了动态 import() 语法加载的模块,在 Webpack 构建时都会打包出单独的 chunk,构建的代码内当需要加载模块时是通过 JSONP 的方式去加载的。

未完待续…