Tree Shaking原理 -【webpack进阶系列】

首发地址:https://mp.weixin.qq.com/s/tq...

系列文章推荐:Source Map知多少 - 【Webpack系列】

    

    网上相关的文章鱼龙混杂,很多理解是有误的,希望大家在学习时,实践出真知。文中如有纰漏,欢迎指正~

What

在webpack对模块进行打包时,将模块中未被使用的冗余代码剔除,仅打包有效代码,精简生成包的体积。

How

1. ESModule

    前提是模块必须采用ES6Module语法,因为treeShaking依赖ES6的静态语法:import 和 export。如果项目中使用了babel的话, @babel/preset-env默认将模块转换成CommonJs语法,因此需要设置module:false,webpack2后已经支持ESModule。

2. webpack对模块打标记 && 压缩工具uglifyjs-webpack-plugin

  预备知识:

  1)压缩工具的作用:混淆,压缩,最小化,删除不可达代码等;

  2)treeShaking依赖于对模块导出和被导入的分析:

    optimization.providedExports:确定每个模块的导出,用于其他优化或代码生成。默认所有模式都开启;

    optimization.usedExports:确定每个模块下被使用的导出。生产模式下默认开启,其他模式下不开启。

  3)webpack对代码进行标记,把import & export标记为3类:

    - 所有import标记为/* harmony import */

    - 被使用过的export标记为/harmony export([type])/,其中[type]和webpack内部有关,可能是binding,immutable等;

    - 没有被使用的export标记为/* unused harmony export [FuncName] */,其中[FuncName]为export的方法名,之后使用Uglifyjs(或者其他类似的工具)进行代码精简,把没用的都删除。

后面的说明围绕下例展开:

//my-module.js
//my-module.js
export const name = 123;
export const age = 9999;

//index.js
import {name, age} from './test.js';
console.log(name);

开发模式和生产模式的默认配置存在差异,其打包方式也存在差异,这里分开讨论:

development模式

optimization.minimizer中使用UglifyJSPlugin无效,需在plugins中实例化。由于treeShaking依赖usedExports,而开发环境未开启,这里对两种情况分别做讨论:

[optimization.usedExports:false]

1)webpack打包(uglifyWebpackPlugin处理前)

全部export被标记为/* harmony export (binding) */

// my-module.js
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "name", function() { return name; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "age", function() { return age; });
var name = 123;
var age = 9999;

2)经过UglifyJSPlugin压缩后不会删除未被使用的导出;

// my-module.js
/* harmony export (binding) */ i.d(e, "name", function() {return t;}), 
/* harmony export (binding) */ i.d(e, "age", function() {return o;});
var t = 123, o = 9999;

结论:当usedExports:false时,无法对未使用的接口做处理。

[optimization.usedExports: true]

1)webpack打包(uglifyWebpackPlugin处理前)

未被使用的export会被标记为/* unused harmony export name */,不会使用__webpack_require__.d进行exports绑定;

// my-module.js
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return name; });
/* unused harmony export age */
var name = 123;
var age = 9999;

可以看到age被标记为unused,同时没有使用__webpack_require__.d链接exports。__webpack_require__.d的作用如下:

// define getter function for harmony exports
__webpack_require__.d = function(exports, name, getter) {
  if(!__webpack_require__.o(exports, name)) {
    Object.defineProperty(exports, name, { enumerable: true, get: getter });
  }
};

2)经过UglifyJSPlugin压缩后,未使用的接口代码会被删除(如果被别的模块import导入但未被使用,同样会被剔除)。原理显而易见,age未被__webpack_require__.d引用,所以压缩工具可以将其安全移除。

// my-module.js
/* harmony export (binding) */ i.d(e, "a", function() {return t;});
/* unused harmony export age */
var t = 123;

不建议在开发环境使用压缩插件。

production模式

1)webpack打包(uglifyWebpackPlugin处理前)

    由于生产环境内置了ModuleConcatenationPlugin插件,实现"预编译",让webpack根据模块间的关系依赖图中,将所有的模块连接成一个模块,称为"作用域提升"。对于代码缩小体积有很大的提升,也能侧面解决副作用的问题;每个模块会被标记//CONCATENATED MODULE

//被打包到一个作用域内
(function(module, __webpack_exports__, __webpack_require__) {
  //...
  //CONCATENATED MODULE: ./src/my-module.js
  var test_name = 123;
  var age = 9999;
  //CONCATENATED MODULE: ./src/index.js
  console.log(test_name);
}

2)开启uglifyWebpackPlugin:

   compress: true;函数的调用会被用函数体替换,使用变量处用其对应值代替,将未使用的变量删除。压缩替换后如下:

function(e, n, o) {
     //...
      // CONCATENATED MODULE: ./src/index.js
      console.log(123);
  }

可以看到导入的age接口未被使用因此被删除,同时优化了多余的中间变量,代码得到精简。

【扩展】ESModule和CommonJs模块经webpack处理后(未启用压缩工具)的形式比较:

    可以看到ESModule中压缩工具可以根据静态分析得知哪些变量被引用,哪些未被引用,未被引用的就可以安全删除;而CommonJs中导出的变量都挂载在exports上,没法由静态分析得知是否被引用。

// my-module.js
【ESModule写法】
/*! exports provided: age, haha, unnnn */
/*! exports used: unnnn */
(function(module, __webpack_exports__, __webpack_require__) {

  /* unused harmony export age */
  /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return name; });
  var age = 123;
})
>>压缩工具原理:很容易就可得知age未被引用则删除,而name被使用则保留<<

【CommonJs写法】
/*! no exports provided */
/*! exports used: name */
(function(module, __webpack_exports__, __webpack_require__) {
  module.exports.name = 11;
  module.exports.age = 18; 
})
>>由上面看,无法使用压缩工具分析删除无用代码(age)<<

总结

  1)开启ScopeHoisting:所有代码打包到一个作用域内,然后使用压缩工具根据变量是否被引用进行处理,删除未被引用的代码;

  2)未开启ScopeHoisting:每个模块保持自己的作用域,由webpack的treeShaking对export打标记,未被使用的导出不会被webpack链接到exports(即被引用数为0),然后使用压缩工具将被引用数为0的变量清除。(类似于垃圾回收的引用计数机制?)

举个例子来比较开启作用域提升与否造成的打包方式差异经过压缩后的区别:

//index.js
import  './a.js';

//a.js
import createMathOperation from './b.js';
let a = 123;
var add = createMathOperation(a);
export default add;

//b.js
function createMathOperation(val) {
  return val;
}
export default createMathOperation;

1)未开启ScopeHoisting:由于b.js中的函数接口被a.js导入并使用,所以会被标记为/* harmony default export */,代码不会被删除;a.js中的函数调用也保留。

2)开启ScopeHoisting:打包到一个作用域,根据变量是否被引用决定是否删除,作用域提升(未压缩处理)后代码如下:

// CONCATENATED MODULE: ./src/b.js
function createMathOperation(val) {
  return val;
}
/* harmony default export */ var a = (createMathOperation);
// CONCATENATED MODULE: ./src/a.js
var a_a = 123;
var add = a(a_a);

createMathOperation的调用会被a_a替代;经压缩处理后,仅a_a = 123被保留;

由上面的例子可以发现,作用域提升有助于精简代码。

但是按照我们的想法a.js中的export没有被使用,理应把整个模块代码删除,但由上面的测试结果看,仍然有无关代码被打包进来。

接下来介绍的sideEffects就是为了解决这个问题。

sideEffects

What

在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。比如console.log()polyfillsimport a CSS file等。由于编译器并不知道其是否会影响运行效果,故而不做处理。

How

在package.json中设置如何处理副作用:

 // package.json
//false:无副作用,模块无export被使用时,可直接跳过(删除)该模块
 //true:有副作用,保留副作用代码
 "sideEffects": [Boolean], 
 or
 //[file1,file2]:指定有副作用的文件,在webpack作用域提升时就不会引入
 //accepts relative, absolute, and glob patterns to the relevant files
  "sideEffects": ['*.css', 'src/tool.js'],

情景1:

import { name } from './module.js';
//name没有使用,等同于副作用导入形式
import './module.js'

module.js中没有export被使用,若包含副作用代码:

  - sideEffects为false,则副作用也被删除。即module整个模块都不会被打包;

  - sideEffects为true或副作用列表中包含module.js,则会仅保留其副作用代码。

情景2:

import { name } from './module.js'; 
console.log(name)

module.js中name接口被使用,未被使用的其余export都会被删除;无论sidesEffects设置什么值,其中的副作用代码始终会被保留。

【扩展】

package.jsonwebpack.optimization中sideEffects的区别?

    后者表示是否识别第三方库 package.json 中的 sideEffects,以跳过无副作用的情况下没有export被使用的模块。生产模式下默认开启(true),其他模式不开启。

    比如一个大的三方库big-module,包含多个子模块(a,b,c);子模块是在big-module的入口模块中re-exported。使用者可以使用部分导出,比如import { a, b } from "big-module"。根据EcmaScript规范,全部子模块都必须被evaluated,因为它们可能包含副作用(可以测试下lodash-es,把其package.json中的sideEffects设置为true,会发现虽然只使用一个子模块,但全部子模块都被打包处理了)。

    在big-module的 package.json中配置"sideEffects": false,表明模块都是无副作用的,仅导出export接口。这使得webpack可以优化re-exports,例如将import { a, b } from "big-module-with-flag"重写为import { a } from "big-module-with-flag/a"

/*
 * The exports from the child modules are re-exported
 * in the entry module (index.js) of the library 
 */
export { a } from "./a";
export { b } from "./b";
export { c } from "./c";

    比如lodash提供了ESModule方式导出的版本:lodash-es;使用时就可以按需加载使用,如import {clone} from 'lodash-es';而不必把lodash整个包打进来。

最后

1)treeShaking可以删除未被导出使用的代码,而sideEffects决定了副作用的处理方式,可以进一步提高有效代码的纯粹度;

2)__webpack_require__.d用的很巧妙,工具方法的抽象,复用性;

3)另外模块化是服务于人的,方便维护;对机器来说,代码放在一起,少了链接加载过程,执行会更快。

推荐阅读:ES6精读【划重点系列】(一)

                内部DSL,你不可不知

                职场生存之道

            

Tree Shaking原理 -【webpack进阶系列】_第1张图片

Tree Shaking原理 -【webpack进阶系列】_第2张图片

你可能感兴趣的:(javascript,前端,webpack,性能优化,es6)