rollup源码之tree shaking揭秘

我心飞翔 分类:javascript

什么是tree shaking

可以简单理解为,Dead Code Elimination,即死代码去除,在保持代码最终运行结果不变的情况下,去除无用代码

  1. 有效减小程序体积
  2. 减少运行时间

前言

在前端常见的打包工具中,几乎都默认集成了tree shaking,而作为第一个使用tree shaking技术的打包工具rollup,它的源码是非常有学习价值的。在学习tree shaking的过程中会涉及到一些概念,包括:

  1. SideEffect(副作用)
  2. AST抽象语法树

SideEffect(副作用)

什么是SideEffect(副作用),与tree-shaking又有什么关系?其实简单来讲,tree shaking 依赖了 SideEffect的概念来对代码进行优化,SideEffect也并不是一种代码,而只是一种概念,下面举个例子

// index.js
import './a.js'
import { a } from './b.js'
console.log(a)


// a.js
let a = 1;     // 删除
let b = 2;     // 保留
console.log(b) // 保留

window.c = 3    // 保留
d = 4           // 保留



// b.js
export const a = 1  // 保留
export const b = 2  // 导出但未使用,删除
 

以上便是tree-shaking所做的事情。
总结一下什么情况下才代表是SideEffect

  • global, document, window, 全局变量修改,如window.xxx = xx
  • console.loghistory.pushState等方法调用
  • 未声明的变量,如a = 1

用法

webpack中想使用 sideEffects,版本号需要 >= 4, 并在package.json中配置

webpack sideEffects配置


// package.json
// 指定整个项目都没有副作用
{
    "sideEffects": false
}
// 指定文件没有副作用
{
  "sideEffects": [
    "dist/*",
    "es/**/style/*",
    "lib/**/style/*"
  ]
}
 
module.exports = {
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
        },
        sideEffects: false || []
      }
    ]
  },
}
 

rollup中sideEffects配置

  1. /@PURE/注释忽略副作用
// inputOptions
treeshake: {
  annotations: true
}
 
  /*@__PURE__*/console.log('side-effect');

  class Impure {
    constructor() {
      console.log('side-effect')
    }
  }

  /*@__PURE__*/new Impure();

  //打包后为空
  ...
 
  1. 设置模块的副作用

如果import的模块没有用到,当moduleSideEffectstrue, 则默认为都是有副作用的,那么会保留模块内的副作用代码,如果moduleSideEffectsfalse,则默认为都没有副作用,模块import将直接被去除。

// inputOptions
treeshake: {
  moduleSideEffects: true // or false
}
 

// input file
import {unused} from 'external-a';
import 'external-b';
console.log(42);

// treeshake.moduleSideEffects === true
import 'external-a';
import 'external-b';
console.log(42);

// treeshake.moduleSideEffects === false
console.log(42);
 
  1. 设置对象属性访问的副作用
// inputOptions
treeshake: {
  propertyReadSideEffects: true // or false
}
 
const foo = {
  get bar() {
    console.log('effect');
    return 'bar';
  }
}
// propertyReadSideEffects === true 不会被去除
// propertyReadSideEffects === false 默认为无副作用,会被去除
const result = foo.bar;

const illegalAccess = foo.quux.tooDeep;
 

4、设置未声明变量的副作用

// inputOptions
treeshake: {
  unknownGlobalSideEffects: true // or false
}
 
// input
const jQuery = $;
const requestTimeout = setTimeout;
const element = angular.element;

// unknownGlobalSideEffects == true
const jQuery = $;
const element = angular.element;

// unknownGlobalSideEffects == false
const element = angular.element;
 

以上便是副作用的相关介绍,下面介绍另一个概念。

AST抽象语法树

目前前端方面能将JS编译成AST的工具有很多,如babylon@babel/parseracorn等。在webpack和rollup源码中其实都使用了acorn作为它们代码编译的工具。
关于acorn用法,大家可以到github中搜索。至于为什么使用这个库,我个人认为它更加纯粹,只关心将代码转换为AST,第二定制化更高,提供了很多配置项,支持插件。

大概看下JS代码经过acorn编译后ast的样子

image.png

我们可以看到有不少的节点类型,例如

  • VariableDeclaration
  • FunctionDeclaration
  • Identifier
  • ...

很明显AST就是一颗树,我们可以对其进行分析、修改各种操作,至于tree shaking如何实现,我们继续看。

开始

首先我们需要知道的是,rollup它到底如何操作经过acorn的语法树呢。其实跟大家想的一样,这个过程一定是这样的。

graph TD
代码 --> Acorn编译 --> AST --> 遍历AST节点 --> 分析或转换

那么在rollup内部其实为每个AST节点都编写了对应的节点类,如图

image.png

其中也包含了节点的基类NodeBase

export class NodeBase implements ExpressionNode {
	context: AstContext;
	end!: number;
	esTreeNode: acorn.Node;
	included = false; // tree shaking  是否打包到输出文件
	keys: string[];
	parent: Node | { context: AstContext; type: string };
	scope!: ChildScope;
	start!: number;
	type!: keyof typeof NodeType;
        
        
        constructor() {
            // ...
        }
        
        hasEffects(){
            // ...
        }
        include(){
            // ...
        }
        
        // ...方法
}
 

大多数子类都实现了hasEffects,include方法,否则使用NodeBase基类中的方法。
并且每个子类中都有included属性。它们的含义如下。

  • 属性 included: 是否打包到输出文件,如果为false,就被tree shaking掉
  • 方法 hasEffects: 判断节点是否有副作用
  • 方法 include: 结合hasEffects,设置included = true
  • 方法 shouldBeIncluded: 如果没有被includeed,内部会继续调用hasEffects

hasEffects

用于判断该节点是否存在副作用

export class Identifier extends NodeBase implements PatternNode {
   // ... 属性
   	hasEffects(): boolean {
		return (
                        // 
			(this.context.options.treeshake as 
                        NormalizedTreeshakingOptions).unknownGlobalSideEffects && 
                        // 是否是全局变量, 如果出现未声明变量会被提升到全局
			this.variable instanceof GlobalVariable &&
			// 判断可以被访问到的变量是否有副作用
			this.variable.hasEffectsWhenAccessedAtPath(EMPTY_PATH)
		);
	}
}
 

为什么要介绍Identifier节点的hasEffects呢,原因很简单,因为不管是全局变量,全局方法,在遍历的最后其实都会走到Identifier节点。这个方法做的事情很简单

  • unknownGlobalSideEffects为true, 关于该参数用法前面有讲过,就不多赘述。
  • this.variable instanceof GlobalVariable为true,判断是否是全局变量,例如未声明的变量windowthis(严格模式有不同表现)
  • this.variable.hasEffectsWhenAccessedAtPath(EMPTY_PATH)为true,表示可以访问到的全局变量,例如window, console

讲完这些其实已经不需要再看内部实现了,大家只需要记住如何判断是否有副作用即可。

include

这里先暂时略过,后面会分析到

shouldBeIncluded

如果没有被includeed,内部会继续调用hasEffects

示例

通过上面简单的了解,我们就可以开始rollup源码的分析了,本篇不会大幅度粘贴代码,这样反而容易造成困惑,我会尽量用图来方便理解。我们先从一段代码开始。

// index.js
import { f } from './b.js'
console.log(f)
 
"use strict"
c = 1
window.d = 1

const obj = { // tree shaking
  a: 2
}

function func() {
  c = 2
  function innerFunc(){ // tree shaking
      console.log(3)
  }
}

func() 


export const e = 1;  // tree shaking


export const f = 1;

 

分析

rollup在生成所有Module后会调用所有模块的include方法,关于模块生成部分本文不会进行讲解,我们只需要知道,调用Module类的include方法之后就可以为每个AST节点设置included的值,如果节点为true该节点代码最终会被保留。

注意:所有节点的included默认为false,默认都不会被打包生成, 只有主动执行include才会被保留。

代码解释如下:

  • Module include
    • AST shouldBeIncluded: 判断整个模块是否包含副作用
    • AST include:设置ASTincluded
class Module {
    // ...
    
    include(): void {
            const context = createInclusionContext();
            if (this.ast!.shouldBeIncluded(context)) this.ast!.include(context, false);
    }
    
    // ...
}
 

shouldBeIncluded执行后,会从Program开始递归每个AST节点,只要有一行代码出现了副作用,直接return,中断掉后续hasEffects递归,也是一种简单的优化。然后执行include方法。

class Program extends NodeBase {
	body!: StatementNode[];
	hasEffects(context: HasEffectsContext) {
		// 如果之前已经判断出现过副作用,直接返回true
		if (this.hasCachedEffect) return true;
		for (const node of this.body) {
			if (node.hasEffects(context)) {
                              // 缓存副作用判断结果
				return (this.hasCachedEffect = true);
			}
		}
		return false;
	}
        
        // 出现副作用后会执行该方法
        include(context: InclusionContext, includeChildrenRecursively: IncludeChildren) {
            this.included = true;
            for (const node of this.body) {
                    if (includeChildrenRecursively || node.shouldBeIncluded(context)) {
                            node.include(context, includeChildrenRecursively);
                    }
            }
	}
}
 

下面开始分析

1. 未声明的变量

"use strict"
c = 1
 

AST如下

image.png

可以看到以上代码的节点类型都是ExpressionStatement, 那么很容易定位到在rollup源码中的位置
src\ast\nodes\ExpressionStatement.ts, 其他代码暂且不看,我们主要关注的是他如何处理副作用的

export default class ExpressionStatement extends StatementBase {
	directive?: string;
	expression!: ExpressionNode;

	shouldBeIncluded(context: InclusionContext) {
            // 判断如果不是"use strict"表达式
            if (this.directive && this.directive !== 'use strict')
                // 优化掉无用表达式
                    return this.parent.type !== NodeType.Program;
            // 正常的表达式继续递归
            return super.shouldBeIncluded(context);
	}
}
 

继续调用shouldBeIncluded,会走到AssignmentExpression下的hasEffects
,发现最后其实调用的还是IdentifierhasEffects,整个流程如下

Untitled Diagram (1).png

class AssignmentExpression extends NodeBase {
	hasEffects(context: HasEffectsContext): boolean {
		if (!this.deoptimized) this.applyDeoptimizations();
		return (
                        // 判断右表达式是否有副作用
			this.right.hasEffects(context) ||
                        // 判断左表达式是否有副作用
			this.left.hasEffects(context) ||
                        // 判断对变量赋值是否有副作用,例如对全局变量的赋值。
			this.left.hasEffectsWhenAssignedAtPath(EMPTY_PATH, context)
		);
	}
}
 

最后不断的递归hasEffectsExpressionStatement节点的shouldBeIncluded返回true,随后递归调用include方法,将每个子节点的included设置为true
需要注意的是GlobalVariablehasEffectsWhenAssignedAtPath,会永远返回true,所以对全局变量赋值的语句必定是有副作用的。
(推荐对着源码看,不然这里会看不懂)

2. 全局变量

window.d = 1
 

区别与第一个不大,唯一的不同应该是在isGlobalMember返回值的判断上。

function isGlobalMember(path: ObjectPath): boolean {
	if (path.length === 1) {
		// 如果是undefine或者是全局对象,例如window, 注意path.length === 1
		return path[0] === 'undefined' || getGlobalAtPath(path) !== null;
	}
	return getGlobalAtPath(path.slice(0, -1)) !== null;
}
 

如果是window,document等对象,都会返回true,也就是说全局变量的hasEffects方法返回的都是false。那么它是怎么被保留下来的呢,其实在前面我们说过了一个方法,叫hasEffectsWhenAssignedAtPath,由于GlobalVariable类并没有这个方法,所以会向父类查找,可以看到父类Variable,在该方法上永远返回true

class Variable implements ExpressionEntity {
    hasEffectsWhenAssignedAtPath(_path: ObjectPath, _context: HasEffectsContext) {
            return true;
    }
}
 

3. 未使用的变量

const obj = { // tree shaking
  a: 2
}
 

AST如下

image.png

由于VariableDeclaration类并没有hasEffects方法,则会使用父类中的hasEffects

class NodeBase implements ExpressionNode {
    shouldBeIncluded(context: InclusionContext): boolean {
            return this.included || (!context.brokenFlow && this.hasEffects(createHasEffectsContext()));
    }
    hasEffects(context: HasEffectsContext): boolean {
            for (const key of this.keys) {
                    const value = (this as GenericEsTreeNode)[key];
                    if (value === null || key === 'annotations') continue;
                    if (Array.isArray(value)) {
                            for (const child of value) {
                                    if (child !== null && child.hasEffects(context)) return true;
                            }
                    } else if (value.hasEffects(context)) return true;
            }
            return false;
    }
}
 

那么最后其实还是这个过程

Untitled Diagram (3).png
虽然对它进行了递归,但是我们可以发现它并没有使用到任何全局变量,所以include方法不会被执行,included也都为false

4. 函数

function func() {
  c = 2
  function innerFunc(){ // tree shaking
      console.log(3)
  }
}

func() 
 

AST如下

image.png

正在编写中...

参考文献

  • 深入浅出 sideEffects
  • rollup官方文档

回复

我来回复
  • 暂无回复内容