代码分割
通过 Webpack 实现前端项目整体模块化默认最终会将我们所有的代码打包到一起。如果应用非常复杂,模块非常多,就会导致打包的结果过大。
这种 All in One 的方式并不合理,更为合理的方案是把打包的结果按照一定的规则分离到多个 bundle 中,然后根据应用的运行需要按需加载, 来降低启动成本,提高响应速度。
目前主流的 HTTP 1.1 本身就存在一些缺陷,例如:
- 同一个域名下的并行请求是有限制的;
- 每次请求本身都会有一定的延迟;
- 每次请求除了传输内容,还有额外的请求头,大量请求的情况下,这些请求头加在一起也会浪费流量和带宽。
为了解决打包结果过大导致的问题,Webpack 设计了一种分包功能:Code Splitting(代码分割)。
Code Splitting 通过把项目中的资源模块按照我们设计的规则打包到不同的 bundle 中,从而降低应用的启动成本,提高响应速度。
Webpack 实现分包的方式主要有两种:
- 根据业务不同配置多个打包入口,输出多个打包结果;
- 结合 ES Modules 的动态导入(Dynamic Imports)特性,按需加载模块。
多入口打包
多入口打包一般适用于传统的多页应用程序,最常见的划分规则就是一个页面对应一个打包入口,对于不同页面间公用的部分,再提取到公共的结果中。
提取公共模块
多入口打包本身非常容易理解和使用,但是它也存在一个小问题,就是不同的入口中一定会存在一些公共使用的模块,如果按照目前这种多入口打包的方式,就会出现多个打包结果中有相同的模块的情况。
需要把这些公共的模块提取到一个单独的 bundle 中。Webpack 中实现公共模块提取非常简单,只需要在优化配置中开启 splitChunks 功能就可以了,具体配置如下:
// ./webpack.config.js
module.exports = {
entry: {
index: "./src/index.js",
album: "./src/album.js",
},
output: {
filename: "[name].bundle.js", // [name] 是入口名称
},
optimization: {
splitChunks: {
// 自动提取所有公共模块到单独 bundle
chunks: "all",
},
},
// ... 其他配置
};
动态导入
除了多入口打包的方式,Code Splitting 更常见的实现方式还是结合 ES Modules 的动态导入特性,从而实现按需加载。
按需加载是开发浏览器应用中一个非常常见的需求。一般我们常说的按需加载指的是加载数据或者加载图片,但是我们这里所说的按需加载,指的是在应用运行过程中,需要某个资源模块时,才去加载这个模块。这种方式极大地降低了应用启动时需要加载的资源体积,提高了应用的响应速度,同时也节省了带宽和流量。
Webpack 中支持使用动态导入的方式实现模块的按需加载,而且所有动态导入的模块都会被自动提取到单独的 bundle 中,从而实现分包。
按需加载路由: webpack 中提供了 require.ensure() 来实现按需加载。以前引入路由是通过 import 这样的方式引入,改为 const 定义的方式进行引入。
const home = (r) =>
require.ensure([], () => r(require("../../common/home.vue")));
魔法注释
默认通过动态导入产生的 bundle 文件,它的 name 就是一个序号,这并没有什么不好,因为大多数时候,在生产环境中我们根本不用关心资源文件的名称。
但是如果你还是需要给这些 bundle 命名的话,就可以使用 Webpack 所特有的魔法注释去实现。具体方式如下:
// 魔法注释
import(/* webpackChunkName: 'posts' */ "./posts/posts").then(
({ default: posts }) => {
mainElement.appendChild(posts());
}
);
Tree Shaking
Tree shaking 是一种通过清除多余代码方式来优化项目打包体积的技术,它可以将各个模块中没有使用的方法过滤掉,只对有效代码进行打包。因为 ES6 模块的出现,ES6 模块依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,这就是 Tree shaking 的基础。
Tree Shaking 工作原理
Tree Shaking 可以剔除掉一个文件中未被引用掉部分(在 production 环境下才会提出),并且只支持 ES Modules 模块的引入方式,不支持 CommonJS 的引入方式。Tree Shaking 不仅支持 import/export 级别,而且也支持 statement(声明)级别。
原因:ES Modules 是静态引入的方式,CommonJS 是动态的引入方式,Tree Shaking 只支持静态引入方式。
在开发环境下需要在 webpack 中配置,但是在生产环境下,由于已有默认配置可以不配置 optimization,但是 sideEffects 依然需要配置
Tree Shaking 可以实现删除项目中未被引用的代码,如果你使用 Webpack 4 的话,开启生产环境就会自动启动这个优化功能。
如果项目中使用了 babel 的话, @babel/preset-env 默认将模块转换成 CommonJs 语法,因此需要设置 { module: false },webpack2 后已经支持 ESModule。
CommonJS 的动态特性模块
在 ES6 以前,我们可以使用 CommonJS 引入模块:require(),这种引入是动态的,也意味着我们可以基于条件来导入需要的代码:
let dynamicModule;
// 动态导入
if (condition) {
myDynamicModule = require("foo");
} else {
myDynamicModule = require("bar");
}
CommonJS 的动态特性模块意味着 tree shaking 不适用。因为它是不可能确定哪些模块实际运行之前是需要的或者是不需要的。在 ES6 中,进入了完全静态的导入语法:import。只能通过导入所有的包后再进行条件获取。如下:
import foo from "foo";
import bar from "bar";
if (condition) {
// foo.xxxx
} else {
// bar.xxx
}
ES6 的 import 语法完美可以使用 tree shaking,因为可以在代码不运行的情况下就能分析出不需要的代码。
如何使用 Tree shaking
从 webpack 2 开始支持实现了 Tree shaking 特性,webpack 2 正式版本内置支持 ES2015 模块(也叫做 harmony 模块)和未引用模块检测能力。新的 webpack 4 正式版本,扩展了这个检测能力,通过 package.json 的 sideEffects 属性作为标记,向 compiler 提供提示,表明项目中的哪些文件是 “pure(纯的 ES2015 模块)”,由此可以安全地删除文件中未使用的部分。
本项目中使用的是 webpack4,只需要将 mode 设置为 production 即可开启 tree shaking
{
"entry": "./src/index.js",
"mode": "production", // 设置为 production 模式
"output": {
"path": path.resolve(__dirname, "dist"),
"filename": "bundle.js"
}
}
如果是使用 webpack2,可能你会发现 tree shaking 不起作用。因为 babel 会将代码编译成 CommonJs 模块,而 tree shaking 不支持 CommonJs。所以需要配置不转义:
{
"options": {
"presets": [["es2015", { "modules": false }]]
}
}
总结
tree shaking 不支持动态导入(如 CommonJS 的 require()语法),只支持纯静态的导入(ES6 的 import/export) webpack 中可以在项目 package.json 文件中,添加一个 “sideEffects” 属性,手动指定由副作用的脚本
Es6Module 在静态分析的时候怎么知道 函数是否需要 tree-shake 掉
require 引入的模块 webpack 能做 Tree Shaking 吗?
不能,Tree Shaking 需要静态分析,只有 ES6 的模块才支持。
什么样的函数会被 Tree Shaking 掉 ?
纯函数的什么? 为什么 vue react 都抛弃了 class 的形式
啥叫没有副作用 ?
组件库如何做按需加载
babel-plugin-import- es module 形式
tree-sharking 是通过在 Webpack 中配置 babel-plugin-import 插件来实现的。
注意点
tree sharking 对于如何 import 模块是有要求的,这就是为什么 react 中经常看到 import * as React from 'react' 的原因。
在 b.js 中通过import a from './a.js',来调用,那么就无法使用 tree sharking,这时候我们可以怎么办呢?可以这么写import * as a from './a.js'
sideEffects (副作用)
side effects 是指那些当 import 的时候会执行一些动作,但是不一定会有任何 export。比如 ployfill,ployfills 不对外暴露方法给主程序使用。
tree shaking 不能自动的识别哪些代码属于 side effects,因此手动指定这些代码显得非常重要,如果不指定可能会出现一些意想不到的问题。
在 webpack 中,是通过 package.json 的 sideEffects 属性来实现的。
{
"name": "tree-shaking",
"sideEffects": false
}
如果所有代码都不包含副作用,我们就可以简单地将该属性标记为 false,来告知 webpack,它可以安全地删除未用到的 export 导出。
如果你的代码确实有一些副作用,那么可以改为提供一个数组:
{
"name": "tree-shaking",
"sideEffects": ["./src/common/polyfill.js"]
}
两个原则:
被 import 的文件中没有 export 被使用,若包含副作用代码:
- sideEffects 为 false,则副作用也被删除。即 module 整个模块都不会被打包;
- sideEffects 为 true 或副作用列表中包含 module.js,则会仅保留其副作用代码。
被 import 的文件中属性、接口被使用,未被使用的其余 export 都会被删除;无论 sidesEffects 设置什么值,其中的副作用代码始终会被保留。
测试:
可以测试下 lodash-es,把其 package.json 中的 sideEffects 设置为 true,会发现虽然只使用一个子模块,但全部子模块都被打包处理了
8. Tree Shaking 和 sideEffects
结合 babel-loader 的问题
因为早期的 Webpack 发展非常快,那变化也就比较多,所以当我们去找资料时,得到的结果不一定适用于当前我们所使用的版本。而 Tree-shaking 的资料更是如此,很多资料中都表示“为 JS 模块配置 babel-loader,会导致 Tree-shaking 失效”。
针对这个问题,这里我统一说明一下:
首先你需要明确一点:Tree-shaking 实现的前提是 ES Modules,也就是说:最终交给 Webpack 打包的代码,必须是使用 ES Modules 的方式来组织的模块化。
为什么这么说呢?
我们都知道 Webpack 在打包所有的模块代码之前,先是将模块根据配置交给不同的 Loader 处理,最后再将 Loader 处理的结果打包到一起。
很多时候,我们为了更好的兼容性,会选择使用 babel-loader 去转换我们源代码中的一些 ECMAScript 的新特性。而 Babel 在转换 JS 代码时,很有可能处理掉我们代码中的 ES Modules 部分,把它们转换成 CommonJS 的方式,如下图所示:

当然了,Babel 具体会不会处理 ES Modules 代码,取决于我们有没有为它配置使用转换 ES Modules 的插件。
很多时候,我们为 Babel 配置的都是一个 preset(预设插件集合),而不是某些具体的插件。例如,目前市面上使用最多的 @babel/preset-env,这个预设里面就有转换 ES Modules 的插件。所以当我们使用这个预设时,代码中的 ES Modules 部分就会被转换成 CommonJS 方式。那 Webpack 再去打包时,拿到的就是以 CommonJS 方式组织的代码了,所以 Tree-shaking 不能生效。
那我们这里具体来尝试一下。为了可以更容易分辨结果,我们只开启 usedExports,完整配置如下:
// ./webpack.config.js
module.exports = {
mode: "none",
entry: "./src/main.js",
output: {
filename: "bundle.js",
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: "babel-loader",
options: {
presets: [["@babel/preset-env"]],
},
},
},
],
},
optimization: {
usedExports: true,
},
};
配置完成过后,我们打开命令行终端,运行 Webpack 打包命令,然后再找到 bundle.js,具体结果如下:

仔细查看你会发现,结果并不是像刚刚说的那样,这里 usedExports 功能仍然正常工作了,此时,如果我们压缩代码,这些未引用的代码依然会被移除。这也就说明 Tree-shaking 并没有失效。
那到底是怎么回事呢?为什么很多资料都说 babel-loader 会导致 Tree-shaking 失效,但当我们实际尝试后又发现并没有失效?
其实,这是因为在最新版本(8.x)的 babel-loader 中,已经自动帮我们关闭了对 ES Modules 转换的插件,你可以参考对应版本 babel-loader 的源码,核心代码如下:

通过查阅 babel-loader 模块的源码,我们发现它已经在 injectCaller 函数中标识了当前环境支持 ES Modules。
然后再找到我们所使用的 @babal/preset-env 模块源码,部分核心代码如下:

在这个模块中,根据环境标识自动禁用了对 ES Modules 的转换插件,所以经过 babel-loader 处理后的代码默认仍然是 ES Modules,那 Webpack 最终打包得到的还是 ES Modules 代码,Tree-shaking 自然也就可以正常工作了。
我们也可以在 babel-loader 的配置中强制开启 ES Modules 转换插件来试一下,具体配置如下:
// ./webpack.config.js
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { modules: 'commonjs' }]
]
}
}
}
]
},
optimization: {
usedExports: true
}
}
给 Babel preset 添加配置的方式比较特别,这里很多人都会配错,一定要注意。它需要把预设数组中的成员定义成一个数组,然后这个数组中的第一个成员就是所使用的 preset 的名称,第二个成员就是给这个 preset 定义的配置对象。
我们在这个对象中将 modules 属性设置为 "commonjs",默认这个属性是 auto,也就是根据环境判断是否开启 ES Modules 插件,我们设置为 commonjs 就表示我们强制使用 Babel 的 ES Modules 插件把代码中的 ES Modules 转换为 CommonJS。
完成以后,我们再次打开命令行终端,运行 Webpack 打包。然后找到 bundle.js,结果如下:

此时,你就会发现 usedExports 没法生效了。即便我们开启压缩代码,Tree-shaking 也会失效。
总结一下,这里通过实验发现,最新版本的 babel-loader 并不会导致 Tree-shaking 失效。如果你不确定现在使用的 babel-loader 会不会导致这个问题,最简单的办法就是在配置中将 @babel/preset-env 的 modules 属性设置为 false,确保不会转换 ES Modules,也就确保了 Tree-shaking 的前提。
sideEffects
Webpack 4 中新增了一个 sideEffects 特性,它允许我们通过配置标识我们的代码是否有副作用,从而提供更大的压缩空间。
TIPS:模块的副作用指的就是模块执行的时候除了导出成员,是否还做了其他的事情。
这个特性一般只有我们去开发一个 npm 模块时才会用到。因为官网把对 sideEffects 特性的介绍跟 Tree-shaking 混到了一起,所以很多人误认为它们之间是因果关系,其实它们没有什么太大的关系。
我们先把 sideEffects 特性本身的作用弄明白,你就更容易理解为什么说它跟 Tree-shaking 没什么关系了。
这里我先设计一个 sideEffects 能够发挥效果的场景,案例具体结构如下:
.
├── src
│ ├── components
│ │ ├── button.js
│ │ ├── heading.js
│ │ ├── index.js
│ │ └── link.js
│ └── main.js
├── package.json
└── webpack.config.js
基于上一个案例的基础上,我们把 components 模块拆分出多个组件文件,然后在 components/index.js 中集中导出,以便于外界集中导入,具体 index.js 代码如下:
// ./src/components/index.js
export { default as Button } from './button'
export { default as Link } from './link'
export { default as Heading } from './heading'
这也是我们经常见到一种同类文件的组织方式。另外,在每个组件中,我们都添加了一个 console 操作(副作用代码),具体代码如下:
// ./src/components/button.js
console.log('Button component~') // 副作用代码
export default () => {
return document.createElement('button')
}
我们再到打包入口文件(main.js)中去载入 components 中的 Button 成员,具体代码如下:
// ./src/main.js
import { Button } from './components'
document.body.appendChild(Button())
那这样就会出现一个问题,虽然我们在这里只是希望载入 Button 模块,但实际上载入的是 components/index.js,而 index.js 中又载入了这个目录中全部的组件模块,这就会导致所有组件模块都会被加载执行。
我们打开命令行终端,尝试运行打包,打包完成过后找到打包结果,具体结果如下:

根据打包结果发现,所有的组件模块都被打包进了 bundle.js。
此时如果我们开启 Tree-shaking 特性(只设置 useExports),这里没有用到的导出成员其实最终也可以被移除,打包效果如下:

但是由于这些成员所属的模块中有副作用代码,所以就导致最终 Tree-shaking 过后,这些模块并不会被完全移除。
可能你会认为这些代码应该保留下来,而实际情况是,这些模块内的副作用代码一般都是为这个模块服务的,例如这里我添加的 console.log,就是希望表示一下当前这个模块被加载了。但是最终整个模块都没用到,也就没必要留下这些副作用代码了。
所以说,Tree-shaking 只能移除没有用到的代码成员,而想要完整移除没有用到的模块,那就需要开启 sideEffects 特性了。
sideEffects 作用
我们打开 Webpack 的配置文件,在 optimization 中开启 sideEffects 特性,具体配置如下:
// ./webpack.config.js
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js'
},
optimization: {
sideEffects: true
}
}
TIPS:注意这个特性在 production 模式下同样会自动开启。
那此时 Webpack 在打包某个模块之前,会先检查这个模块所属的 package.json 中的 sideEffects 标识,以此来判断这个模块是否有副作用,如果没有副作用的话,这些没用到的模块就不再被打包。换句话说,即便这些没有用到的模块中存在一些副作用代码,我们也可以通过 package.json 中的 sideEffects 去强制声明没有副作用。
那我们打开项目 package.json 添加一个 sideEffects 字段,把它设置为 false,具体代码如下:
{
"name": "09-side-effects",
"version": "0.1.0",
"author": "zce <w@zce.me> (https://zce.me)",
"license": "MIT",
"scripts": {
"build": "webpack"
},
"devDependencies": {
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
},
"sideEffects": false
}
这样就表示我们这个项目中的所有代码都没有副作用,让 Webpack 放心大胆地去“干”。
完成以后我们再次运行打包,然后同样找到打包输出的 bundle.js 文件,结果如下:

此时那些没有用到的模块就彻底不会被打包进来了。那这就是 sideEffects 的作用。
这里设置了两个地方:
- webpack.config.js 中的 sideEffects 用来开启这个功能;
- package.json 中的 sideEffects 用来标识我们的代码没有副作用。
目前很多第三方的库或者框架都已经使用了 sideEffects 标识,所以我们再也不用担心为了一个小功能引入一个很大体积的库了。例如,某个 UI 组件库中只有一两个组件会用到,那只要它支持 sideEffects,你就可以放心大胆的直接用了。
sideEffects 注意
使用 sideEffects 这个功能的前提是确定你的代码没有副作用,或者副作用代码没有全局影响,否则打包时就会误删掉你那些有意义的副作用代码。
例如,我这里准备的 extend.js 模块:
// ./src/extend.js
// 为 Number 的原型添加一个扩展方法
Number.prototype.pad = function (size) {
const leadingZeros = Array(size + 1).join(0)
return leadingZeros + this
}
在这个模块中并没有导出任何成员,仅仅是在 Number 的原型上挂载了一个 pad 方法,用来为数字添加前面的导零,这是一种很早以前常见的基于原型的扩展方法。
我们回到 main.js 中去导入 extend 模块,具体代码如下:
// ./src/main.js
import './extend' // 内部包含影响全局的副作用
console.log((8).pad(3)) // => '0008'
因为这个模块确实没有导出任何成员,所以这里也就不需要提取任何成员。导入过后就可以使用它为 Number 提供扩展方法了。
这里为 Number 类型做扩展的操作就是 extend 模块对全局产生的副作用。
此时如果我们还是通过 package.json 标识我们代码没有副作用,那么再次打包过后,就会出现问题。我们可以找到打包结果,如下图所示:

我们看到,对 Number 的扩展模块并不会打包进来。
缺少了对 Number 的扩展操作,我们的代码再去运行的时候,就会出现错误。这种扩展的操作属于对全局产生的副作用。
这种基于原型的扩展方式,在很多 Polyfill 库中都会大量出现,比较常见的有 es6-promise,这种模块都属于典型的副作用模块。
除此之外,我们在 JS 中直接载入的 CSS 模块,也都属于副作用模块,同样会面临这种问题。
所以说不是所有的副作用都应该被移除,有一些必要的副作用需要保留下来。
最好的办法就是在 package.json 中的 sideEffects 字段中标识需要保留副作用的模块路径(可以使用通配符),具体配置如下:
{
"name": "09-side-effects",
"version": "0.1.0",
"author": "zce <w@zce.me> (https://zce.me)",
"license": "MIT",
"scripts": {
"build": "webpack"
},
"devDependencies": {
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
},
"sideEffects": [
"./src/extend.js",
"*.css"
]
}
这样 Webpack 的 sideEffects 就不会忽略确实有必要的副作用模块了。
8. JS-Tree-Shaking
1. 什么是Tree Shaking?
2. 不再需要 UglifyjsWebpackPlugin
在webpack v4中,不再需要配置UglifyjsWebpackPlugin。(详情请见:文档) 取而代之的是,更加方便的配置方法。
只需要配置mode为"production",即可显式激活 UglifyjsWebpackPlugin 插件。
我们的webpack.config.js配置如下:
const path = require("path");
module.exports = {
entry: {
app: "./src/app.js",
},
output: {
publicPath: __dirname + "/dist/",
path: path.resolve(__dirname, "dist"),
filename: "[name].bundle.js",
},
mode: "production",
};
我们在util.js文件中输入以下内容:
// util.js
export function a() {
return 'this is function "a"';
}
export function b() {
return 'this is function "b"';
}
export function c() {
return 'this is function "c"';
}
然后在app.js中引用util.js的function a()函数:
// app.js
import { a } from "./vendor/util";
console.log(a());
命令行运行webpack打包后,打开打包后生成的/dist/app.bundle.js文件。然后,查找我们a()函数输出的字符串,如下图所示:

如果将查找内容换成 this is function "c" 或者 this is function "b", 并没有相关查找结果。说明Js Tree Shaking成功。
3. 如何处理第三方JS库?
对于经常使用的第三方库(例如 jQuery、lodash 等等),如何实现
Tree Shaking?下面以 lodash.js 为例,进行介绍。
3.1 尝试 Tree Shaking
安装 lodash.js : npm install lodash --save
在 app.js 中引用 lodash.js 的一个函数:
// app.js
import { chunk } from "lodash";
console.log(chunk([1, 2, 3], 2));
命令行打包。如下图所示,打包后大小是 70kb。显然,只引用了一个函数,不应该这么大。并没有进行Tree Shaking。

3.2 第三方库的模块系统 版本
本文开头讲过,js tree shaking 利用的是 es 的模块系统。而 lodash.js 没有使用 CommonJS 或者 ES6 的写法。所以,安装库对应的模块系统即可。
安装 lodash.js 的 es 写法的版本:npm install lodash-es --save
小小修改一下app.js:
// app.js
import { chunk } from "lodash-es";
console.log(chunk([1, 2, 3], 2));
再次打包,打包结果只有 3.5KB(如下图所示)。显然,tree shaking成功。

友情提示:在一些对加载速度敏感的项目中使用第三方库,请注意库的写法是否符合 es 模板系统规范,以方便webpack进行tree shaking。
终
__pure__
js 代码压缩过程中,某些从其他依赖中引入的函数,压缩工具很难判断其是否有副作用,因此可能会将某些不需要的代码保存下来。 针对 uglifyjs 或者 terserjs,可以通过 /*@__PURE__*/ 或者 /*#__PURE__*/ 这样的标签来显式声明定义是不包含副作用的。压缩工具在获取到这个信息之后,就可以放心的将未被使用的定义代码直接删除了。
在 terser online 中尝试如下代码,观察编译结果的区别:
(function () {
const unused = window.unknown();
const used = "used";
console.log(used);
})();
上面的代码中,因为 window.unknown() 这个函数的调用细节对 terser 是不透明的,压缩工具无法判明使用是否会存在副作用。虽然 unused 这个变量没有被使用到,但是为了避免副作用丢失,terser 只能将 window.unknown() 调用保留下来。最终生成的压缩代码为:
!(function () {
window.unknown();
console.log("used");
})();
而如果将代码加上显式声明:
(function () {
const unused = /*#__PURE__*/ window.unknown();
const used = "used";
console.log(used);
})();
那么,terser 就可以放心的将整个调用删除。最终的压缩结果为:
console.log("used");
webpack 如何实现动态加载
webpack 动态加载就两种方式:import()和 require.ensure,不过他们实现原理是相同的。
我觉得这道题的重点在于动态的创建 script 标签,以及通过 jsonp 去请求 chunk,推荐的文章是: https://juejin.cn/post/6844903888319954952