第二章:webpack 进阶用法(2)
演示仓库地址(可以翻 commit 记录):https://github.com/wangpeng1994/webpack-demo
- Tree Shaking的使用和原理分析
- Scope Hoisting的使用和原理分析
- 代码分割和动态import
1. Tree Shaking的使用和原理分析
概念:一个模块可能有多个方法,只要其中某个方法被用到了,则整个文件都会被打到 bundle 里面去,而 tree shaking (摇树优化)则只把使用到的方法打入 bundle,没用到的方法在编译时会标记为无用代码,会在 uglify 阶段被擦除掉。
使用:webpack 在 production 模式下默认开启,要求模块必须是 ES6 模块语法,CommonJS 的方式不支持。
开启 Tree Shaking 后,DCE 死码消除(Dead code elimination)特性会移除对程序运行结果没有任何影响的代码(死代码),如:
- 代码不会被执行,不可到达 if (false) { ... }
- 代码执行的结果不会被用到
- 代码只会影响死变量(只写不读)
测试关闭 tree-shaking
先把 webpack 的配置 mode 改为 none
,然后:
tree-shaking.js:
export function a() {
return 'This is function a';
}
export function b() {
return 'This is function b';
}
search.js 中引用:
import { a } from './tree-shaking';
webpack 在 none
模式下不会开启 tree-shaking 特性,所以即使引入 a
后没有实际调用,打包后到对应的输出文件 search_64736273.js
中查找,依然发现 tree-shaking.js 模块被打包进来了:
测试开启 tree-shaking
现在 mode 改为 production
。
显然,因为 tree-shaking.js 模块中导出的 a
函数并没有实际调用,所以被 shaking 掉了,并没有出现在打包结果中。
现在尝试在 search.js 中调用一下 a
函数:
import { a } from './tree-shaking';
a();
结果发现还是被 shaking 掉了!为什么?因为 a
函数其实是“死”代码,虽然可以被执行,但并不能对外界产生影响(既没改变外界变量(没副作用),其输出也没被外界所使用)。
如何不被 shaking 掉?在 search.js 中使用时疯狂互动一下:
import { a } from './tree-shaking';
const text = a();
console.log(text);
最终发现即使开启了 tree-shaking 之后,a
也依然坚挺地存在(只是 a 过于简单,production 打包时被优化直接进行了替换):
但会发现 tree-shaking.js 模块中导出的 b
函数还是被干掉了,因为没用到,一如刚才只是简单调用 a()
但未能产生实际互动而被干掉一样。
2. Scope Hoisting的使用和原理分析
虽然这也是 webpack 在 production
模式下自动干的事情,但了解一下这个概念,还是有助于深入了解 webpack 的,至于为什么要深入了解 webpack,若找不到理由,不去了解也罢。
2.1 问题由来
webpack 构建后的代码存在大量闭包代码,它们主要是模块初始化函数,本质是因为浏览器不支持模块化机制所以才需要它们。未开启 Scope Hoisting(作用域提升)时,每个 module 都会被独立包裹一层。导致体积增大,运行代码时创建的函数作用域变多,内存开销变大。
编译之前。
common.js:
export function common() {
return 'common module';
}
helloworld.js:
export function helloworld() {
console.log('helloworld() is called');
return 'Hello webpack';
}
index.js:
import { common } from '../../common';
import { helloworld } from './helloworld';
common();
document.write(helloworld());
打包出来的是一个 IIFE(立即执行函数表达式,匿名闭包),modules
是一个数组,每一项是一个模块初始化函数,__webpack_require__
函数用来加载 module,调用 __webpack_require__(0)
加载 entry module,启动程序。简单起见下面省略了一些模块定义的代码,但依然可以看到存在 3 个被包裹着的 module:
(function(modules) {
// The module cache
var installedModules = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// Execute the module function
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// Flag the module as loaded
module.l = true;
// Return the exports of the module
return module.exports;
}
// Load entry module and return exports
return __webpack_require__(0);
})
([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _common__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
/* harmony import */ var _helloworld__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2);
Object(_common__WEBPACK_IMPORTED_MODULE_0__["common"])();
document.write(Object(_helloworld__WEBPACK_IMPORTED_MODULE_1__["helloworld"])());
/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "common", function() { return common; });
function common() {
return 'common module';
}
/***/ }),
/* 2 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "helloworld", function() { return helloworld; });
function helloworld() {
console.log('helloworld() is called');
return 'Hello webpack';
}
/***/ })
]);
2.2 scope hoisting 原理和使用
开启 scope hoisting 后,webpack 会将所有 module 的代码按照引用顺序放在一个函数作用域里,然后适当地重命名一些变量防止变量名冲突。通过 scope hoisting 可以减少函数声明代码和内存开销。
webpack 在 production
模式下会自动开启 scope hoisting,但为了避免演示时 js 代码被自动压缩、难以辨别,所以这里将 mode 更改为 none
(否则默认值是 production
),然后手动引入 new webpack.optimize.ModuleConcatenationPlugin()
插件(注意!源代码中的必须使用的是 ES6 的 module 语法)。
对比一下末尾处同样位置的模块包裹函数,由于 common
和 helloworld
模块都只被引用了一次(被 index.js 引用),所以被提升到了同一个模块初始化函数中:
/***/ 12:
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
// CONCATENATED MODULE: ./common/index.js
function common() {
return 'common module';
}
// CONCATENATED MODULE: ./src/index/helloworld.js
function helloworld() {
console.log('helloworld() is called');
return 'Hello webpack';
}
// CONCATENATED MODULE: ./src/index/index.js
common();
document.write(helloworld());
/***/ })
假如 common
模块同时被 search.js 引用:
import { common } from '../../common';
// common();
现在 common
模块被引用了两次(index.js 和 search.js),看一下 index.js 的编译结果,由于 common 模块被引用了两次(index.js 和 search.js),所以即使开启了 ModuleConcatenationPlugin,还是没提升,存在单独的模块包裹中:
/***/ 0:
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "common", function() { return common; });
function common() {
return 'common module';
}
/***/ }),
/***/ 14:
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
// EXTERNAL MODULE: ./common/index.js
var common = __webpack_require__(0);
// CONCATENATED MODULE: ./src/index/helloworld.js
function helloworld() {
console.log('helloworld() is called');
return 'Hello webpack';
}
// CONCATENATED MODULE: ./src/index/index.js
Object(common["common"])();
document.write(helloworld());
/***/ })
search.js 的编译结果(略)也类似,虽然 common
在 search.js 中引用后属于死代码,但由于不是 production
模式,所以 webpack 未开启 tree-shaking,因此依然会和 index.js 一样对 common
单独包裹。
上面所说的模块都是指 module,而不是 chunk 概念,概念参见第一章,另外上面例子中使用了双 entry,会打包出对应的 index.js 和 search.js:
const path = require('path');
const webpack = require('webpack');
module.exports = {
mode: 'none',
entry: {
index: path.join(__dirname, 'src/index.js'),
search: path.join(__dirname, 'src/search.js')
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name]_[chunkhash:8].js',
},
plugins: [
new webpack.optimize.ModuleConcatenationPlugin()
]
}
3. 代码分割和动态import
当某些代码在某些条件下才会被使用到,那么分割代码就有意义,将代码库分割成 chunks(语块)可以做到按需加载。
适用场景:
- 抽离相同代码到一个共享块(之前介绍过)
- 脚本懒加载,使得初始下载的代码更小
懒加载 js 脚本有两种 方式:
- CommonJS:require.ensure
- ES6 动态 import(目前还没有原生支持,需要 babel 转换)
推荐使用第二种方式,先安装 babel 插件:
npm install @babel/plugin-syntax-dynamic-import -D
然后在 .babelrc
使用该插件:
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-syntax-dynamic-import"
]
}
定义被懒加载的组件:
import React from 'react';
export default () => <div>动态 import</div>;
search.js:
import React from 'react';
import ReactDOM from 'react-dom';
import beauty from './images/beauty.jpg';
import './index.less';
class Search extends React.Component {
constructor() {
super(...arguments);
this.state = {
Text: null
}
}
loadComponent() {
// 可以通过下面这种注释来主动命名 text chunk 块,编译时会被 webpack 识别
import(/* webpackChunkName: "text" */'./text.js').then(Text => {
this.setState({
Text: Text.default
});
});
}
render() {
const { Text } = this.state;
return (
<div className="search-text">
{
Text ? <Text /> : null
}
点击图片可以懒加载 Text 组件<img src={beauty} onClick={this.loadComponent.bind(this)} />
</div>
);
}
}
ReactDOM.render(
<Search />,
document.getElementById('root')
);
使用动态 import 后,被懒加载的模块会自动分割成 chunk 块(其它别管,只看红框):
image.png
此时只有点击图片时,才会异步加载 text 代码块(通过 jsoup)。