likes
comments
collection
share

号外!号外!Tree Shaking 通过秘籍

作者站长头像
站长
· 阅读数 21

号外!号外!Tree Shaking 通过秘籍

Tree Shaking简介

Tree Shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code),通俗点来讲就是你没有使用的代码,打包的时候不会把他添加到最终产物中,从而减少最终产物大小,实现浏览器加载js的体积变小。

Tree Shaking原理可以大致分为以下几个步骤

1.获取并解析模块信息

通过主路口获取到文件路径后加载文件

const code = await readFile(path, { encoding:'utf-8' });

然后初始化一个Model实例

   const module = new Module({
            path,
            code
   })

在Model实例中我们会把读取到的code源代码转换为AST语法树,并按语句块进行读取每一个语句块都是一个Statement对象,同时还会分析是否有import导入语句有的话还会去加载导入文件重新生成一个Model

constructor({path,bundle,code,loader,isEntry = false}:ModuleOptions){
        this.id=path;
        this.bundle = bundle;
        this.moduleLoader = loader;
        this.isEntry = isEntry;
        this.path = path;
        this.code = code;
        this.magicString = new MagicString(code);
        this.imports = {};
        this.exports = {};
        this.reexports = {};
        this.declarations = {};
        try {
            const ast = parse(code) as any;   
            const nodes = ast.body as StatementNode[];
            // 以语句Statement的维度来拆分Module,保存statement的集合,供之后分析
            this.statements = nodes.map((node) => {

                const magicString = this.magicString.snip(node.start, node.end);
           
                return new Statement(node, magicString, this);
              });
        } catch (e) {
            console.log(e);
            throw e;
        }
         // 分析 AST 节点
        this.analyseAST();
    }

Statement对象大概是这么一个数据结构里面存放了当前这个语句块引用的节点集合references,和当前这个语句块的变量声明对象Declaration

class Statement{
    node:StatementNode;
    magicString:MagicString;
    module:Module;
    scope:Scope;
    start:number;
    next:number;
    isImportDeclaration:boolean;
    isExportDeclaration:boolean;
    isReexportDeclaration:boolean;
    isFunctionDeclaration:boolean;
    isIncluded:boolean = false;
    defines:Set<string> = new Set();
    modifies:Set<string> = new Set();
    dependsOn:Set<string> = new Set();
    // 引用依赖的节点集合
    references:Reference[] = [];
    constructor(node:StatementNode,magicString:MagicString,module:Module){
        this.magicString = magicString;
        this.node = node;
        this.module = module;
        this.scope = new Scope({
            statement:this
        });
        this.start = node.start;
        this.next = 0;
        this.isImportDeclaration = isImportDeclaration(node); //判读这句代码是否为导入语句
        this.isExportDeclaration = isExportDeclaration(node); //判读这句代码是否为导出语句
        // 是否为导入在导出语句
        this.isReexportDeclaration = this.isExportDeclaration && !!(node as ExportAllDeclaration).source;
        // 是否为函数节点
        this.isFunctionDeclaration = isFunctionDeclaration(node as FunctionDeclaration)
    }
}

调用analyseAST方法遍历Statement语句块对象中的AST语法树用来创建每一个语句块需要的Reference节点对象和Declaration变量声明对象

 this.statements.forEach((statement)=>{
            // 对statement进行分析
            statement.analyse();
           
        })
analyse(){
        if(this.isImportDeclaration) return;
        // 1.构建作用域链,记录Declaration 节点表
        buildScope(this);
        // 2.寻找引用的依赖节点,记录Reference 节点表
        findReference(this)
    }

当我们获取并解析模块信息后最终我们会得到这样一个层级的对象 Model->Statement->Declaration->Reference

2.构建依赖关系图

在Module对象里面我们有一个bind方法主要做了两件事

   bind(){
        // 记录标识符对应的模块对象
        this.bindDependencies();
        // 除此之外,根据之前记录的 Reference 节点绑定对应的 Declaration 节点
        this.bindReferences();
    }

3.模块拓扑排序

其实就是根据引用关系给Module做排序 例如A->B 那么排序后 B->A B的代码在文件中就会出现在A的前面

号外!号外!Tree Shaking 通过秘籍

//模块A
import { a, add } from './utils.js';

export const c = add(a, 2);

//模块B
export const a = 1;
export const b = 211;
export const add = function (num1, num2) {
  return num1 + num2;
};

//最终打包出现的代码
const a = 1;
const add = function (num1, num2) {
  return num1 + num2;
};

export const c = add(a, 2);

这个过程中还要考虑循环依赖的问题

号外!号外!Tree Shaking 通过秘籍

由于我们采用的是后序的遍历方式来遍历依赖,也就是说如果一旦一个模块被记录到了analysedModule,那么也就说明他的所有依赖模块都被分析完了。


    for (const dependency of module.dependencyModules) {
              // 检测到循环依赖
              if (parent[dependency.id]) {
                if (!analysedModule[dependency.id]) {
                  cyclePathList.push(getCyclePath(dependency.id, module.id));
                }
                continue;
              }
              parent[dependency.id] = module.id;
              analyseModule(dependency);
    }

4.Tree Shaking 标记需要包含的语句

只要是被标记的语句块就不会被删除,首先我们在Module中拿到所有的导出模块。

getExports():string[]{
        return [
            ...Object.keys(this.exports),
            ...Object.keys(this.reexports),
            ...this.exportAllModules
            .map(module=>module.getExports())
            .flat()
        ]
    }

然后通过导出模块的name名拿到对应的Declaration变量申明对象然后调用对象里面的use方法给当前这个Declaration对象打标记证明已经用到过了


   use(){
        // 标记该节点被使用
        this.isUsed = true;
        // 对应的statement节点也应该被标记
        if(this.statement){
            this.statement.mark(this.name)
        }
    }

然后Declaration变量申明对象对应的Statement语句块对象也要打上标记,调用Statement对象的mark方法


    mark(name){
        if(this.isIncluded){
            return
        }
        this.isIncluded = true;
        this.references.forEach((ref:Reference)=>ref.declaration && ref.declaration.use())
    }

在第二步构建依赖关系图的时候我们就已经把Reference 节点绑定了对应的 Declaration 节点, 这个时候我们在调用Statement语句块依赖的节点集合进行一次循环,把这些Reference节点对应的Declaration变量声明对象打上使用的标记, 然后再把Declaration变量声明对象所属的Statement语句块对象也打上标记,打上标记后就证明这个Statement语句块和Declaration变量对象已经被使用了,直到把所有Reference节点对象的Declaration变量对象和Statement语句块全部打上标记, 在后续生成代码的时候就不会被删除了。


 render():{ code: string }{
        // 单生成逻辑,拼接模块ast节点,产出代码
        let msBundle = new MagicString.Bundle({ separator: '\n' });
        // 按照模块拓扑顺序生成代码
        this.graph.orderedModules.forEach((module)=>{
            let data = {
                content: module.render()
            };
            msBundle.addSource(data)
        })
        return {
            code: msBundle.toString(),
        };
    }

Module对象里面的render会对Statement语句块对象去做判断如果没有打标记这会在由code生成的MagicString对象中删除该条语句,在做一些其他操作后返回code字符串

MagicString一个操作字符串的库,可以对字符串进行增加,删除,修改等等操作

  render() {
        const source = this.magicString.clone().trim();
        this.statements.forEach((statement) => {
          // 1. Tree Shaking
          if (!statement.isIncluded) {
            source.remove(statement.start, statement.next);
            return;
          }
          // 2. 重写引用位置的变量名 -> 对应的声明位置的变量名
          statement.references.forEach((reference) => {
            const { start, end } = reference;
            const declaration = reference.declaration;
            if (declaration) {
              const name = declaration.render();
              source.overwrite(start, end, name!);
            }
          });
          // 3. 擦除/重写 export 相关的代码
          if (statement.isExportDeclaration && !this.isEntry) {
            // export { foo, bar }
            if (
              statement.node.type === 'ExportNamedDeclaration' &&
              statement.node.specifiers.length
            ) {
              source.remove(statement.start, statement.next);
            }
            // remove `export` from `export const foo = 42`
            else if (
              statement.node.type === 'ExportNamedDeclaration' &&
              (statement.node.declaration!.type === 'VariableDeclaration' ||
                statement.node.declaration!.type === 'FunctionDeclaration')
            ) {
              source.remove(
                statement.node.start,
                statement.node.declaration!.start
              );
            }
            // remove `export * from './mod'`
            else if (statement.node.type === 'ExportAllDeclaration') {
              source.remove(statement.start, statement.next);
            }
          }
        });
        return source.trim();
      }

经过这4步后没有被引用的代码在打包过程中,就不会生成到最终产物里面去。

启用TreeShaking也需要满足一些要求

  1. 使用ES2015的模块语法(即import和export),目的是为供程序静态分析
  2. 确保没有compiler将ES2015模块语法转换为CommonJs模块

完整代码链接github.com/chaorenluo/… 觉得有用的话献上你的start

转载自:https://juejin.cn/post/7142764145128472589
评论
请登录