首页/文章/八股文

面试官:tree-shaking的原理是什么?

2023-12-06
284410 分钟
...

前言

在前端面试的过程中,前端工程化一直是考察面试者能力的一个重点,而在我们常用的项目打包工具中,无论是webpack还是rollup,都具备一项很强大的能力——tree-shaking,所以面试官也常常会问到tree-shaking的原理是什么,这时我们该如何回答呢?其实核心原理就是AST

在前端开发中,其实AST的应用有很多,比如我们前端项目的打包工具webpack、代码检查工具Eslint,代码转换工具babel都依赖了AST的语法分析和转换能力。

AST简单介绍

ASTAbstract Syntax Tree的缩写,这玩意儿的全称叫抽象语法树,它可以用来描述我们代码的语法结构

举个例子:


let a = 1;
function add() {}

我这里创建了一个文件ast.js,可以把整个文件理解为一个File节点,存放程序体,而里面就是我们javascript的语法节点了,我们javascript语法节点的根节点是Program,而我们在里面定了了两个节点,第一个节点是let a = 1,解析为ast是VariableDeclaration,也就是变量声明节点,第二个节点是function add() {},解析为ast是FunctionDeclaration,也就是函数声明节点

这里给大家推荐一个平台:AST Explorer,能很清晰看到JavaScript代码转化为AST后的结果,看下下面这张图就一目了然了:

AST的作用

那拿到了代码的ast信息我们能做什么呢?

  1. 代码分析与转换AST可以将我们的代码解析成一棵ast树,我们自然可以将这棵树进行处理和转换,这个最经典的应用莫过于babel了,将我们的高级语法ES6转换为ES5后,然后再把ast树转换成代码输出。除此之外,webpack的处理ES6的import和export也是依赖了ast的能力,以及我们的jsx的语法转换等。
  2. 语法检查和错误提示。我们把语法解析成ast树之后,自然就可以按照一定的语法规则去检查它的语法是否正确,一旦错误就可以抛出错误,提醒开发者去修正。比如我们使用的vscode就是利用AST 提供实时的语法检查和错误提示。而在前端项目中,应用的最广的语法检查工具就是ESlint了,基本就是前端项目必备。
  3. 静态类型检查。这个其实跟第二点有点像,不过第二点是侧重于语法检查,而这个是针对类型检查的,比如我们的Typescript会利用ast进行类型检查和推断。
  4. 代码重构。基于AST树,我们可以对代码进行自动重构。比如提取函数、变量重命名、语法升级、函数移动等。

其实在实际开发中,我们也可以利用做很多的事情,比如实现自动埋点自动国际化依赖分析和治理等等,有兴趣的小伙伴可以自行去探索。

而今天我主要介绍的就是AST的一大应用,就是我们webpack强大的Tree-Shaking能力。

Tree-shaking

Tree-shaking翻译过来的意思就是摇树。这棵树可以把它比喻为现实中的树,可以这样理解,摇树就是把发黄、没有作用还要汲取养分的叶子给给摇掉。把这个搬到javascript程序里面来就是,移除Javascript上下文中的未引用代码(dead-code)

废话不多说,直接看例子:


function add(a, b) {
    return a + b;
}
function multiple(a, b) {
    return a * b;
}
let firstNum = 3, secondNum = 4;
add(firstNum, secondNum);

在这段代码中,我们定义了两个函数addmultiple,两个变量firstNumsecondNum,然后调用了add方法并将firstNumsecondNum作为参数传入。

很明显,multiple方法是没有被调用到的,打包的时候其实是可以被删除掉的,以减少我们打包后的代码体积。

那么,如何删除multiple呢?这时候就该我们的ast就登场了!要实现这个功能,分三步走。

第一步:解析源代码生成ast

先看如下代码:

const acorn = require('acorn');
const fs = require('fs');
const path = require('path');
const buffer = fs.readFileSync(path.resolve(process.cwd(), 'test.js')).toString();
const body = acorn.parse(buffer, {
    ecmaVersion: 'latest',
}).body;

这里我们选中acorn(babel其实是基于acorn实现解析器的)来对我们的代码进行解析,运行前需要先执行npm install acorn安装下acorn,然后读取文件内容传入acorn,得到ast

我们可以用AST Explorer,来看一眼我们现在的AST

第二步:遍历ast,记录相关信息

一、先明确下我们要记录哪些信息?

我们的主要目的是收集到未引用的代码,然后将它们删除掉,所以我们最容易想到的需要收集的信息有两个:

  1. 收集所有的函数或变量类型节点
  2. 收集所有使用过的函数或变量类型节点

那我们先试试看:

const acorn = require('acorn');
const fs = require('fs');
const path = require('path');
const buffer = fs.readFileSync(path.resolve(process.cwd(), './src/index.js')).toString();
const body = acorn.parse(buffer, {
    ecmaVersion: 'latest',
}).body;

const Generator = require('./generator');

const gen = new Generator();

const decls = new Map();

const calledDecls = [];

这里我引入了一个Generator,其作用就是将每个ast节点转化成对应的代码,来看下Generator的实现:

  1. 先定义好Generator类,将其导出。

class Generator {
}

module.exports = Generator;
  1. 定义run方法以及visitNodevisitNodes方法。
  • run:调用visitNodes方法生成代码。
  • visitNode:根据节点类型,调用对应的方法进行对应处理。
  • visitNodes:就是处理数组类型的节点用的,内部循环调用了visitNode方法。

class Generator {
     run(body) {
        let str = '';
        str += this.visitNodes(body);
        return str;
    }
    visitNodes(nodes) {
        let str = '';
        for (const node of nodes) {
            str += this.visitNode(node);
        }
        return str;
    }
    visitNode(node) {
        let str = '';
        switch (node.type) {
            case 'VariableDeclaration':
                str += this.visitVariableDeclaration(node);
                break;
            case 'VariableDeclarator':
                str += this.visitVariableDeclarator(node);
                break;
            case 'Literal':
                str += this.visitLiteral(node);
                break;
            case 'Identifier':
                str += this.visitIdentifier(node);
                break;
            case 'BinaryExpression':
                str += this.visitBinaryExpression(node);
                break;
            case 'FunctionDeclaration':
                str += this.visitFunctionDeclaration(node);
                break;
            case 'BlockStatement':
                str += this.visitBlockStatement(node);
                break;
            case 'CallExpression':
                str += this.visitCallExpression(node);
                break;
            case 'ReturnStatement':
                str += this.visitReturnStatement(node);
                break;
            case 'ExpressionStatement':
                str += this.visitExpressionStatement(node);
                break;
        }
        return str;
    }
}
  1. 再分别介绍下每个处理节点类型的方法实现

实现visitVariableDeclaration方法

class Generator {
     
    visitVariableDeclaration(node) {
        let str = '';
        str += node.kind + ' ';
        str += this.visitNodes(node.declarations);
        return str + '\n';
    }
     
}

visitVariableDeclaration就是处理let firstNum = 3这种变量声明的节点,node.kind就是let/const/var,然后变量可以一次声明多个,比如我们test.js里面写的let firstNum = 3, secondNum = 4;,这样node.declarations就有两个节点了。

class Generator {
     
    visitVariableDeclaration(node) {
        let str = '';
        str += node.kind + ' ';
        str += this.visitNodes(node.declarations);
        return str + '\n';
    }
     
}

实现visitVariableDeclarator方法

class Generator {
     
    visitVariableDeclarator(node, kind) {
        let str = kind ? (kind + ' ') : '';
        str += this.visitNode(node.id);
        str += '=';
        str += this.visitNode(node.init);
        return str + ';' + '\n';
    }
     
}

visitVariableDeclarator就是上面VariableDeclaration的子节点,可以接收到父节点的kind,比如let firstNum = 3它的id就是变量名firstNum,init就是初始化的值3

实现visitLiteral方法

class Generator {
     visitLiteral(node) {
        return node.raw;
    }
}

Literal字面量,比如let firstNum = 3里面的3就是一个字符串字面量,除此之外,还有数字字面量布尔字面量等等,这个直接返回它的raw属性就行啦。

实现visitIdentifier方法

class Generator {
     visitIdentifier(node) {
        return node.name;
    }
}

Identifier标识符,比如变量名、属性名、参数名等都是,比如let firstNum = 3firstNum,直接返回它的name属性即可。

实现visitBinaryExpression方法

BinaryExpression二进制表达式,比如我们的加减乘除运算都是,比如a + b的ast如下:

我们需要拿到它的左右节点和中间的标识符拼接起来。

class Generator {
     visitBinaryExpression(node) {
        let str = '';
        str += this.visitNode(node.left);
        str += node.operator;
        str += this.visitNode(node.right);
        return str + '\n';
    }
}

实现visitFunctionDeclaration方法

FunctionDeclaration即为函数声明节点,稍微复杂一些,因为我们要拼接一个函数出来。

function add(a, b) {
    return a + b;
}

比如我们这个add函数转成ast如下图:

class Generator {
     visitFunctionDeclaration(node) {
        let str = 'function ';
        str += this.visitNode(node.id);
        str += '(';
        for (let paramIndex = 0; paramIndex < node.params.length; paramIndex++) {
            str += this.visitNode(node.params[paramIndex]);
            str += ((node.params[paramIndex] === undefined) ? '' : ',')
        }
        str = str.slice(0, str.length - 1);
        str += '){\n';
        str += this.visitNode(node.body);
        str += '}';
        return str + '\n';
    }
}

先拿到node.id,即add,然后处理函数的参数params,由于存在多个params需要循环处理,用逗号拼接,然后再调用visitNode方法拼接node.body函数体

实现visitBlockStatement方法

BlockStatement块语句,就是我们的大括号包裹的部分。比如add函数里面的块语句的ast如下:

class Generator {
    visitBlockStatement(node) {
        let str = '';
        str += this.visitNodes(node.body);
        return str;
    }
}

只需要用visitNodes函数拼接它的node.body即可。

实现visitCallExpression方法

CallExpression也就是函数调用,比如add(firstNum, secondNum),它比较重要的属性有:

  • callee:也就是 add
  • arguments:也就是调用的参数 firstNumsecondNum 其ast如下:

class Generator {
   visitCallExpression(node) {
        let str = '';
        str += this.visitIdentifier(node.callee);
        str += '(';
        for (const arg of node.arguments) {
            str += this.visitNode(arg) + ',';
        }
        str = str.slice(0, -1);
        str += ');';
        return str + '\n';
    }
}

只需要将它的callee以及参数arguments处理好用小括号()拼接起来即可。

实现visitReturnStatement方法

ReturnStatement返回语法,比如return a + b这种,它的ast如下:

它的实现也比较简单,直接拼接node.argument就好啦:

class Generator {
   visitReturnStatement(node) {
        let str = '';
        str = str + '  return ' + this.visitNode(node.argument);
        return str + '\n';
    }
}

实现visitExpressionStatement方法

ExpressionStatement表达式语句,它的特点是执行完之后有返回值,比如add(firstNum, secondNum);,它是在CallExpression外面包裹了一层ExpressionStatement,执行完之后返回函数的调用结果,其ast如下:

所以实现也比较简单,我们只需要处理它的expression返回就好了。

class Generator {
    return this.visitNode(node.expression);
}

这样,我们就完整实现Generator了,接下来就可以开始遍历ast了。


const acorn = require('acorn');
const fs = require('fs');
const path = require('path');
const buffer = fs.readFileSync(path.resolve(process.cwd(), './src/index.js')).toString();
const body = acorn.parse(buffer, {
    ecmaVersion: 'latest',
}).body;

const Generator = require('./generator');

const gen = new Generator();

const decls = new Map();

const calledDecls = [];

body.forEach(node => {
    if (node.type === 'FunctionDeclaration') {
        const code = gen.run([node]);
        decls.set(gen.visitNode(node.id), code);
        return;
    }
    if (node.type === 'VariableDeclaration') {
        for (const decl of node.declarations) {
            decls.set(gen.visitNode(decl.id), gen.visitVariableDeclarator(decl, node.kind));
        }
        return;
    }
    if (node.type === 'ExpressionStatement') {
        if (node.expression.type === 'CallExpression') {
            const callNode = node.expression;
            calledDecls.push(gen.visitIdentifier(callNode.callee));
            for (const arg of callNode.arguments) {
                if (arg.type === 'Identifier') {
                    calledDecls.push(arg.name);
                }
            }
        }
    }
    if (node.type === 'Identifier') {
        calledDecls.push(node.name);
    }
})
console.log('decls', decls);
console.log('calledDecls', decls);

使用node tree-shaking.js运行一下,结果如下:

很明显,我们decls总共有四个节点函数或变量类型节点,而被调用的calledDecls只有三个,很明显multiple函数没被调用,可以被tree-shaking掉,拿到这些信息之后,接下来我们开始生成tree-shaking后的代码。

第三步:根据第二步得到的信息,生成新代码


code = calledDecls.map(c => decls.get(c)).join('');
console.log(code);

我们直接遍历calledDecls生成新的源代码,打印结果如下:

咦!跟我们最开始的文件内容一比,发现multiple虽然被移除掉了,但我们的函数调用语句add(firstNum, secondNum);却丢了,那我们简单处理下。

我们声明一个code数组:


const calledDecls = [];

const code = [];

然后把不是FunctionDeclarationVariableDeclaration的信息都存储一下:


body.forEach(node => {
    if (node.type === 'FunctionDeclaration') {
        const code = gen.run([node]);
        decls.set(gen.visitNode(node.id), code);
        return;
    }
    if (node.type === 'VariableDeclaration') {
        for (const decl of node.declarations) {
            decls.set(gen.visitNode(decl.id), gen.visitVariableDeclarator(decl, node.kind));
        }
        return;
    }
    if (node.type === 'ExpressionStatement') {
        if (node.expression.type === 'CallExpression') {
            const callNode = node.expression;
            calledDecls.push(gen.visitIdentifier(callNode.callee));
            for (const arg of callNode.arguments) {
                if (arg.type === 'Identifier') {
                    calledDecls.push(arg.name);
                }
            }
        }
    }
    if (node.type === 'Identifier') {
        calledDecls.push(node.name);
    }
    
    code.push(gen.run([node]));
})

最后输出的时候,把code里面的信息也带上:


code = calledDecls.map(c => decls.get(c)).concat(code).join('');
console.log(code);

然后运行一下,打印结果如下:

这样我们一个简易版本的tree-shaking就完成了,当然,webpack的tree-shaking的能力远比这个强大的多,我们只是写了个最简单版本,实际项目要比这复杂得多:

  • 处理文件依赖import/export
  • 作用域scope的处理
  • 递归tree-shaking,因为可能去除了某些代码后,又会产生新的未被引用的代码,所以需要递归处理
  • 等等..

小结

本文通过ast的语法分析能力,分析JavaScript代码中未被引用的函数或变量,进而实现了一个最简单版本的tree-shaking,希望大家看完都能有所收获哦~

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
八股文2025-10-13
cookie跨域介绍
Cookie 跨域问题详解 在 Web 开发中, Cookie 是最常见的客户端存储机制之一,用于记录用户登录状态、偏好设置、会话信息等。然而,当涉及到 跨域请求 时,Cookie 的行为往往变得复杂,尤其在现代浏览器的安全策略下,跨域 Cookie 的传递和写入都有严格的限制。 本文将从基础概念开始,逐步讲解 Cookie 的跨域机制、SameSite 属性、CORS 配置及常见问题解决方案。 一、Cookie 的基础概念 Cooki...
学习面试
八股文2025-10-09
JavaScript 闭包详解
JavaScript 闭包(Closure)详解 一、什么是闭包 闭包(Closure) 是 JavaScript 中一个非常核心且常被问到的概念。简单来说: 闭包是一个函数,它可以“记住”并访问其定义时所在的词法作用域,即使这个函数在其作用域之外被调用。 换句话说,当一个函数“嵌套”在另一个函数中,并且 内部函数引用了外部函数的变量 时,就形成了闭包。 示例: 虽然 已经执行完毕,但 依然可以访问 中的 。这就是闭包。 二、闭包的形成...
学习面试
八股文2025-10-07
开始性能优化之旅
事件循环机制 一、JavaScript 引擎的本质 核心职责 : 解析 JavaScript 语法 管理变量和内存 执行代码逻辑 不涉及 : 线程管理(Worker除外) I/O 操作 定时器控制 网络请求 常见引擎:V8(Chrome)、SpiderMonkey(Firefox)、JavaScriptCore(Safari) 二、宿主环境的扩展能力 宿主提供的多线程能力 : | 线程类型 | 功能 | 对应 API | | | | |...
学习面试
八股文2025-09-30
前端首屏优化
话说,我在面试的时候,80%的情况下,都会被问到首屏优化问题,烦,恨 TCP Slow Start(慢启动)概念 咱先不说标题的数字哪里来的,先说一个概念,就是TCP的慢启动。 你们想哈,在浏览器和服务器开始建立连接的时候,服务器是并不知道浏览端网络的带宽、拥塞状况。假设你本地的带宽是1M,如果一开始服务器就发送2M的文件,那浏览器压根就不是人类,忍忍或者挤挤就能接收到的,这样就可能会引发丢包和重传。 想想,这个该有什么办法解决这个问题...
学习面试
八股文2025-09-28
Function.prototype和Object.prototype
一道面试题引发的原型链血案,彻底搞懂 Function.prototype 和 Object.prototype 前言 有这样一道题: 目录 问题分析与答案揭晓 让我们先公布正确答案,然后深入分析原因: 为什么 是 而不是 ? 这是因为函数和实例的原型链路径完全不同! 原型链的本质机制 JavaScript中的两条原型链 在JavaScript中,存在两条不同的原型链: &nbsp; &nbsp; &nbsp; 关键理解:F.proto...
学习面试
八股文2025-09-10
让你彻底明白什么是闭包
今天我们来聊一个听起来很高大上,但实际上你可能天天在用(只是不知道它名字)的概念—— 闭包 。 一、一个你肯定写过的闭包 先别管定义,来看这段代码,你是不是再熟悉不过了? 恭喜你!这就是一个经典的闭包!是不是很简单? 二、为什么会有闭包?—— 背包的故事 想象一下,JavaScript 中的函数就像一个小机器人,当它被创建时,会背着一个神奇的 背包 。 这个背包里装着什么呢?装着它 出生时 所在环境的所有变量! 当我们调用 时,返回的那...
学习面试

评论

请登录后发表评论

去登录
加载评论中...

目录