# Tree Shaking

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import 和 export。这个术语和概念实际上是由 ES2015 模块打包工具 rollup 普及起来的。

src/math.js

export function add(a, b) {
  return a + b
}

export function minus(a, b) {
  return a - b
}

src/index.js

import { add } from './math'

add(1, 2)

webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  mode: 'development',
  devtool: 'cheap-module-source-map',
  entry: './src/index.js',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  },
  target: 'web',
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html'
    }),
    new CleanWebpackPlugin()
  ]
}

查看打包后的文件:

dist/main.js

((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "add": () => (/* binding */ add),
/* harmony export */   "minus": () => (/* binding */ minus)
/* harmony export */ });
function add(a, b) {
  return a + b
}

function minus(a, b) {
  return a - b
}
/***/ })

发现虽然我们没有在 index.js 文件引入 minus 方法,但是打包后的文件中还是引入了 minus 这个方法。

修改 webpack.config.js 文件














 
 
 








const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  mode: 'development',
  devtool: 'cheap-module-source-map',
  entry: './src/index.js',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  },
  target: 'web',
  optimization: {
    usedExports: true,
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html'
    }),
    new CleanWebpackPlugin(),
  ]
}

查看打包后的文件






 










((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "add": () => (/* binding */ add)
/* harmony export */ });
/* unused harmony export minus */
function add(a, b) {
  return a + b
}

function minus(a, b) {
  return a - b
}

/***/ })

虽然我们配置了 tree-shaking 但是 minus 方法还是被打包进去了,这是因为当前我们是在开发环境下,如果把这些代码都去掉会导致 soure-map 不准确,不方便开发、调试。

生产环境:






 



















// webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  },
  target: 'web',
  optimization: {
    usedExports: true,
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html'
    }),
    new CleanWebpackPlugin()
  ]
}

查看打包后的文件,是不是干净的令人发指😄 :

// dist/main.js

(()=>{"use strict";console.log(3)})();

# side-effect

在一个纯粹的 ESM 模块世界中,很容易识别出哪些文件有 side effect。然而,我们的项目无法达到这种纯度, 比如我们导入了一些地方库 import _ from 'loadash',或者样式文件 import './index.css' 所以,此时有必要提示 webpack compiler 哪些代码是“纯粹部分”。

我们可以通过 package.json 的 "sideEffects" 属性,来实现。

{
  "name": "your-project",
  // 设置为 false 表示代码中不包含 side effect,可以安全的删除未用到的 export
  "sideEffects": false
}

TIP

"side effect(副作用)" 的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。举例说明,例如 polyfill,它影响全局作用域,并且通常不提供 export。

如果代码确实有一些副作用,可以改为提供一个数组:

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js"]
}

sideEffects 和 usedExports(更多被认为是 tree shaking)是两种不同的优化方式。

sideEffects 更为有效 是因为它允许跳过整个模块/文件和整个文件子树。

usedExports 依赖于 terser 去检测语句中的副作用。它是一个 JavaScript 任务而且没有像 sideEffects 一样简单直接。而且它不能跳转子树/依赖由于细则中说副作用需要被评估。尽管导出函数能运作如常,但 React 框架的高阶函数(HOC)在这种情况下是会出问题的。 具体详见官方文档 解释 tree shaking 和 sideEffects (opens new window)

文末推荐一篇文章 你的Tree-Shaking并没什么卵用 (opens new window) 技术在不断更新,文章中的一些描述已经过时了,但还是可以参考参考的,就当看 "历史书" 吧。

# 区分开发模式和生产模式

npm install --save-dev webpack-merge

src/math.js

export function add(a, b) {
  return a + b
}

export function minus(a, b) {
  return a - b
}

src/index.js

import './index.scss'
import { add } from './math'

let a = add(1, 2)
console.log(a)

webpack.common.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  },
  target: 'web',
  module: {
    rules: [
      {
        test: /\.s[ac]ss$/i,
        use: ["style-loader", 
        {
          loader: "css-loader",
          options: {
            modules: true
          }
        },
        "sass-loader", "postcss-loader"],
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html'
    }),
    new CleanWebpackPlugin()
  ]
}

webpack.dev.js

const webpack = require('webpack')
const { merge } = require('webpack-merge')
const common = require('./webpack.common.js')

module.exports = merge(common, {
  mode: 'development',
  devtool: 'cheap-module-source-map',
  devServer: {
    contentBase: './dist',
    port: 8085,
    hot: true,
    hotOnly: true
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
})

webpack.prod.js

const { merge } = require('webpack-merge')
const common = require('./webpack.common.js')

module.exports = merge(common, {
  mode: 'production',
  optimization: {
    usedExports: true
  }
})

package.json

"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "start": "webpack serve --open --config webpack.dev.js",
  "build": "webpack --config webpack.prod.js"
}

# CodeSplitting

代码分离把代码分离到不同的 bundle 中,然后可以按需加载或并行加载这些文件。代码分离可以用于获取更小的 bundle,以及控制资源加载优先级,如果使用合理,会极大影响加载时间。

  • 入口起点:使用 entry 配置手动地分离代码。
  • 防止重复:使用 Entry dependencies 或者 SplitChunksPlugin 去重和分离 chunk。
  • 动态导入:通过模块的内联函数调用来分离代码。

# 入口起点

这种方式实际就是我们前面提到的配置 多入口文件 这里不再赘述。

src/index.js

import _ from 'lodash'

src/math.js

import _ from 'lodash'

webpack.common.js

entry: {
  index: './src/index.js',
  math: './src/math.js'
},

查看打包后的文件大小

index.js 文件和 math.js 文件的大小都是 587 KB ,我们在这两个文件中除了引入 lodash 外,并没有这么做其他操作,说明打包后的文件都把 lodash 库打包进去了。

这种方式虽然手动实现了代码分离,但是存在一些问题,如:

  • 如果入口 chunk 之间包含一些重复的模块,那些重复模块都会被引入到各个 bundle 中。
  • 这种方法不够灵活,并且不能动态地将核心应用程序逻辑中的代码拆分出来。

# 防止重复

配置 dependOn option 选项,这样可以在多个 chunk 之间共享模块。

对 webpack 配置文件做如下修改:




 
 
 
 
 
 
 
 
 


// webpack.common.js

entry: {
  index: {
    import: './src/index.js',
    dependOn: 'shared',
  },
  math: {
    import: './src/math.js',
    dependOn: 'shared',
  },
  shared: 'lodash'
}

重新执行打包命令,查看 dist 目录,可以看到多了一个 shared 文件夹。 index.js 和 math.js 文件体积大大减少了。

要在一个 HTML 页面上使用多个入口时,还需设置 optimization.runtimeChunk: 'single',不然可能会出现 问题 (opens new window)

webpack.common.js

optimization: {
  runtimeChunk: 'single',
},

# SplitChunksPlugin

webpack.common.js

optimization: {
  splitChunks: {
    // 有效值为 all,async 和 initial
    chunks: 'all',
  },
}

运行打包命令,从控制台输出可以看出 lodash 被单独打包成了 vendors-node_modules_lodash_lodash_js.js 文件

asset vendors-node_modules_lodash_lodash_js.js 532 KiB [emitted] (id hint: vendors) 1 related asset
asset index.js 44.1 KiB [emitted] (name: index) 1 related asset
asset math.js 44.1 KiB [emitted] (name: math) 1 related asset
asset index.html 283 bytes [emitted]
Entrypoint index 576 KiB (674 KiB) = vendors-node_modules_lodash_lodash_js.js 532 KiB index.js 44.1 KiB 2 auxiliary assets
Entrypoint math 576 KiB (674 KiB) = vendors-node_modules_lodash_lodash_js.js 532 KiB math.js 44.1 KiB 2 auxiliary assets
runtime modules 57.4 KiB 28 modules
cacheable modules 532 KiB
  ./src/index.js 97 bytes [built] [code generated]
  ./src/math.js 82 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 531 KiB [built] [code generated]
webpack 5.36.2 compiled successfully in 356 ms

默认配置项

module.exports = {
  //...
  optimization: {
    splitChunks: {
      // 表明将选择哪些 chunk 进行优化
      chunks: 'async',
      // 生成 chunk 的最小体积
      minSize: 20000,
      // 在 webpack 5 中引入了 splitChunks.minRemainingSize 选项,通过确保拆分后剩余的最小 chunk 体积超过限制来避免大小为零的模块。 'development' 模式 中默认为 0。对于其他情况,splitChunks.minRemainingSize 默认为 splitChunks.minSize 的值,因此除需要深度控制的极少数情况外,不需要手动指定它。
      minRemainingSize: 0,
      // 拆分前必须共享模块的最小 chunks 数
      minChunks: 1,
      // 按需加载时的最大并行请求数
      maxAsyncRequests: 30,
      // 入口点的最大并行请求数
      maxInitialRequests: 30,
      // 强制执行拆分的体积阈值和其他限制,(minRemainingSize,maxAsyncRequests,maxInitialRequests)将被忽略
      enforceSizeThreshold: 50000,
      // 缓存组可以继承和/或覆盖来自 splitChunks.* 的任何选项。但是 test、priority 和 reuseExistingChunk 只能在缓存组级别上进行配置。将它们设置为 false以禁用任何默认缓存组。
      cacheGroups: {
        defaultVendors: {
          // 控制此缓存组选择的模块。省略它会选择所有模块。它可以匹配绝对模块资源路径或 chunk 名称。匹配 chunk 名称时,将选择 chunk 中的所有模块。
          test: /[\\/]node_modules[\\/]/,
          // 一个模块可以属于多个缓存组。优化将优先考虑具有更高 priority(优先级)的缓存组。默认组的优先级为负,以允许自定义组获得更高的优先级(自定义组的默认值为 0)
          priority: -10,
          // 如果当前 chunk 包含已从主 bundle 中拆分出的模块,则它将被重用,而不是生成新的模块。这可能会影响 chunk 的结果文件名。
          reuseExistingChunk: true,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

# 动态导入

动态代码拆分有两种:

  • 使用符合 ECMAScript 提案 的 import() 语法 来实现动态导入
  • 使用 webpack 特定的 require.ensure(require.ensure() 是 webpack 特有的,已被 import() 取代。)

WARNING

import() 调用会在内部用到 promises。如果在旧版本浏览器中(例如,IE 11)使用 import(),记得使用一个 polyfill 库(例如 es6-promise 或 promise-polyfill),来 shim Promise。

// src/index.js

const { default: _ } = import('lodash')

TIP

之所以需要 default,是因为 webpack 4 在导入 CommonJS 模块时,将不再解析为 module.exports 的值,而是为 CommonJS 模块创建一个 artificial namespace 对象

从控制台输出,可以看出 lodash 被单独打包了。

asset vendors-node_modules_lodash_lodash_js.js 532 KiB [emitted] (id hint: vendors) 1 related asset
asset index.js 46.6 KiB [emitted] (name: index) 1 related asset
asset index.html 176 bytes [emitted]
runtime modules 30.7 KiB 15 modules
cacheable modules 532 KiB
  ./src/index.js 336 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 531 KiB [built] [code generated]
webpack 5.36.2 compiled successfully in 334 ms

# SplitChunksPlugin

# 懒加载

代码懒加载实际上是先把代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。

src/math.js

console.log(
  'math.js module has loaded! See the network tab in dev tools...'
);

export function add(a, b) {
  console.log('加法操作的结果', a + b)
  return a + b
}

export default function minus(a, b) {
  console.log('减法操作的结果', a - b)
  return a - b
}

src/index.js

document.body.onclick = function() {
  import(/* webpackChunkName: "math" */ './math.js').then(module => {
    const minus = module.default
    const add = module.add

    add(1, 2)
    minus(3, 1)
  });
}

当我们点击 body 的时候会加载打包后的 math.js 文件。

# PreLoading 和 Prefetching

webpack v4.6.0+ 增加了对预获取和预加载的支持。

  • prefetch(预获取):将来某些导航下可能需要的资源
  • preload(预加载):当前导航下可能需要资源

prefetch 和 preload 的使用非常简单,只需要在 import 语法加点东西就行。

src/index.js


 






async function getComponent() {
  const { default: _ }  = await import(/* webpackPrefetch: true */ 'lodash')
}

document.body.onclick = () => {
  getComponent()
}

当我们点击 body 的时候会加载 lodash 库。

查看网页代码,在 head 中添加了如下代码:

指示着浏览器在闲置时间预取 lodash.js 文件。

PreLoading 和 Prefetching 的用法差不多,这里不再赘述。

TIP

不正确地使用 webpackPreload 会有损性能,请谨慎使用。

prefetch 指令和 preload 指令的不同之处:

  • preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
  • preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。
  • preload chunk 会在父 chunk 中立即请求,用于当下时刻。prefetch chunk 会用于未来的某个时刻。
  • 浏览器支持程度不同。

# CSS代码分割

src/index.scss

body {
  background-color: blue;
}

src/index.js

import './index.scss'

执行打包命令后,查看打包后的文件夹,可以看到 css 被打包进了 js 文件中

dist/index.js

// ...
___CSS_LOADER_EXPORT___.push([module.id, "body {\n  background-color: blue;\n  width: 100%;\n  height: 100%;\n}", "",{"version":3,"sources":["webpack://./src/index.scss"],"names":[],"mappings":"AAAA;EACE,sBAAA;EACA,WAAA;EACA,YAAA;AACF","sourcesContent":["body {\n  background-color: blue;\n  width: 100%;\n  height: 100%;\n}"],
"sourceRoot":""}]);
// ...

安装

npm install --save-dev mini-css-extract-plugin

webpack.common.js



 











 











 
 
 



const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = merge(common, {
	mode: 'production',
	output: {
		filename: '[name].[contenthash].js'
	},
	module: {
		rules: [
			{
				test: /\.s[ac]ss$/i,
				use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'sass-loader',
          'postcss-loader'
        ]
			}
		]
	},
	optimization: {
		usedExports: true
	},
	plugins: [
		new MiniCssExtractPlugin({
			filename: '[name].[contenthash].css'
		})
	]
});

# 浏览器缓存

src/index.js

import _ from 'lodash'

webpack.prod.js








 







const path = require('path')
const { merge } = require('webpack-merge')
const common = require('./webpack.common.js')

module.exports = merge(common, {
  mode: 'production',
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist')
  },
  optimization: {
    usedExports: true
  }
})

# ProvidePlugin

src/theme.js

export function setTheme() {
  $('body').css('background', 'orange')
}

src/index.js

import { setTheme } from  './theme'
import $ from 'jquery'

setTheme()

$('#root').append('hello jq')

打包后运行项目,发现浏览器控制台报错了

因为我们没有在 theme.js 中引入 jquery。我们可以使用 ProvidePlugin 来解决这个问题,使用 ProvidePlugin 可以自动加载模块,而不是必须在任何地方通过 import 或者 require 导入模块。

webpack.common.js


 





















 
 
 




const path = require('path')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: {
    index: './src/index.js'
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  },
  target: 'web',
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html'
    }),
    new webpack.ProvidePlugin({
      $: 'jquery'
    }),
    new CleanWebpackPlugin()
  ]
}

再次打包运行,页面运行成功了。

# 全局this指向

imports loader 允许使用依赖于特定全局变量的模块。

安装

npm install imports-loader --save-dev