前言
没有任何废话,直接讲解webpack中是如何利用抽象语法树进行代码转化,以及简单的实现以下几个功能:
自动给函数添加try catch;
箭头函数转化为普通函数;
编写自动埋点函数插件;
简单实现eslint插件;
简单实现uglify代码压缩插件;
一、抽象语法树概念
webapck和Lint等很多工具的核心都是通过Abstract Syntax Tree 抽象语法树这个概念来实现对代码的检查、分析等操作;
通过熟悉抽象语法树这个概念可以进行编写类似的工具;
二、用途
代码语法的检查、代码风格的检查、代码的格式化、代码的高亮、代码错误提示、代码自动补全等等
如JSLint、JSHint对代码错误或风格的检查,发现一些潜在的错误
IDE的错误提示、格式化、高亮、自动补全等等
代码混消压缩
UgIifyJS2等
优化变更代码,改变代码结构使达到想要的结构
代码打包工具webpack、rollup等等
CommonJS、AMD、CMD、UMD等代码规范之间的转化
CoffeeScript、TypeScript、JSX等转化为原生Javascript
三、抽象语法树定义
原理都是通过JavaScript Parser把代码转化为一颗抽象语法树(AST,这颗树定义了代码的结构,通过操纵这颗树,我们可以精准的定位到声明语句、赋值语句、运算语句等等,实现对代码的分析、优化、变更等操作;
在计算机科学中,抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。
Javascript的语法是为了给开发者更好的编程而设计的,但是不适合程序的理解。所以需要转化为AST来使之更适合程序分析,浏览器编译器一般会把源码转化为AST来进行进一步的分析等其他操作。
四、JS解析器
(一) 作用
JavaScript Parser 把js源码转化为抽象语法树的解析器。
浏览器会把js源码通过解析器转为抽象语法树,再进一步转化为字节码或直接生成机器码。
一般来说每个js引擎都会有自己的抽象语法树格式,Chrome的v8引擎,firefox的SpiderMonkey引擎等等,MDN提供了详细SpiderMonkey AST format的详细说明,算是业界的标准。
(二) 常见用的JS解析器
esprima
traceur
acom ( webpack内部使用 )
shift
(三) 工具网址
可以根据不同的解析器解析出不同的AST树
(四) 代码转化流程
五、简单使用
(一) 通过esprima转化
npm i esprima estraverse escodegen -D
let esprima = require("esprima"); let estraverse = require("estraverse"); let escodegen = require("escodegen"); let sourceCode = "var a = 1;" let ast = esprima.parse(sourceCode); console.log(ast); // 遍历每一个节点信息,访问器模式访问每个属性进行打印 let indent = 0; const prefixPadding = () => " ".repeat(indent); estraverse.traverse(ast, { enter(node) { console.log(prefixPadding() + "进入:" + node.type); indent += 2; }, leave(node) { indent -= 2; console.log(prefixPadding() + "离开:" + node.type); } })
// 打印结果 Script { type: 'Program', body: [ VariableDeclaration { type: 'VariableDeclaration', declarations: [Array], kind: 'var' } ], sourceType: 'script' } // 遍历节点 进入:Program 进入:VariableDeclaration 进入:VariableDeclarator 进入:Identifier 离开:Identifier 进入:Literal 离开:Literal 离开:VariableDeclarator 离开:VariableDeclaration 离开:Program
六、babel转化流程
(一) babel工具作用
访问者模式Visitor对于某个对象或者一组对象,不同的访问者,产生的结果不同,执行操作也不同
@babel/core Babel的编译器,核心API都在这里面,比如常见的transform、parse
babylon Babel的解析器
babel-types用于AST节点的Lodash式工具库,它包含了构造、验证以及变换AST节点的方法,对编写处理AST逻辑非常有用(重要方法,可以通过传参创建不同的节点)
babe-template可以将普通字符串转化成AST,提供更便捷的使用
babel-traverse用于对AST的遍历,维护了整棵树的状态,并且负责替换、移除和添加节点
babel-types-api
babeljs.io babel可视化编译器
(二) 理解插件
其实插件就是一个对象它会有一个visitor访问器,访问器的属性就是对应着AST语法树类型节点的时候就会调用此函数并且把节点路径作为函数参数传入,打印的节点和astexplorer.net/转化后的数据是一致的
// 定义插件 let classToFunctionPlugin = { visitor: { // 进入的时候调用 enter: { ClassDeclaration(path){} }, // 离开的时候调用 leave: { ClassDeclaration(path){} } } } let classToFunctionPlugin = { visitor: { // 如果直接是一个函数则是进入的时候调用 // es6类型声明的type就是ClassDeclaration ClassDeclaration(path) { // 访问当前节点内容 console.log(path.node); } } } // 使用插件 let targetCode = core.transform(sourceCode, { plugins: [classToFunctionPlugin] })
(三) 理解访问器path参数
在 Babel 的插件中,path
是一个对象,代表了你正在访问的 AST 节点的上下文。它就像一个导航工具,可以让你:
获取当前节点的信息:比如它的类型、名称、属性等。
修改节点:例如更改节点的值,添加或删除节点。
遍历子节点:通过
path
可以继续深入访问子节点。访问父节点:访问父节点的path上下文或者父节点Node
它记录了大量的信息如下,其中node信息就是通过解析器解析的AST内容,所以可以说path上下文信息中包含了AST语法树信息;
并且每个单独的path也是一个树形结构;
[ 'contexts', 'state', 'opts', '_traverseFlags', 'skipKeys', 'parentPath', 'container', 'listKey', 'key', 'node', 'type', 'parent', 'hub', 'data', 'context', 'scope']
let sourceCode = ` let name = "tiantian"; function age() { return 5; } `
转换为AST如下(Node信息)
通过属性访问器
const testPlugin = { visitor: { // 发现函数声明的时候打印出它的路径上下文 Program(path) { console.log("全局path上下文:", path); }, FunctionDeclaration(path) { console.log("函数path上下文:", path); } } } let targetSource = babelCore.transform(sourceCode, { plugins: [ testPlugin // 插件 ] })
(四) 理解babel转化的语法树节点
function Person(name) { this.name = name; } Person.prototype.getName = function() { return this.name; }
1. type:表示节点的类型信息
上述代码从外到内都会使用一个type值表示它的信息
它内部又有两个另外的类型type:函数声明和函数表达式
2. 不同type内部组成不同
函数声明节点描述属性:
表达式语句属性:
3. 案例
var a = 1; 通过babel/parser转化为ast语法树
(五) 转化流程
仔细观察转化前代码和转化后代码的AST树区别;
尽可能的复用转化前AST树的旧节点,如果实在没有则需要通过babel-types工具进行创建新节点;
根据新的AST树生成新代码;
(六) es6类转化为构造函数babel转化案例
现在简单写一个class转化为构造函数的插件
1. 示例
// es6 class Person { constructor(name) { this.name = name; } getName() { return this.name; } } // 上面转化为下面代码 function Person(name) { this.name = name; } Person.prototype.getName = function() { return this.name; }
2. 对比两者语法树
es6类型 body中只有一个类型声明
es5构造函数body中有两个节点,一个是函数声明一个是函数表达式,只需要通过babel工具将上面AST转化为下面的AST树格式即可;
3. babel代码转化和插件简单使用和理解
如果没有配置插件默认代码不会进行任何转化
let core = require("@babel/core") let types = require("babel-types"); const sourceCode = ` class Person { constructor(name) { this.name = name; } getName() { return this.name; } }` let targetCode = core.transform(sourceCode, { plugins: [] }) console.log(targetCode.code); // 输出相同代码
4. AST数转化逻辑
保存旧的AST树语法可复用的节点信息
Person
class的函数
根据class构造器转化
每个函数都有具体的信息记录根据这个函数的详情将它转化为普通的函数声明,由于是新的函数声明所以可以通过types工具传入左边的信息值创建出右侧函数声明节点
根据class方法转化原型表达式
替换节点信息: class只有一个body节点需要替换为构造函数类型的两个节点
5. 代码
let core = require("@babel/core") let types = require("babel-types"); const sourceCode = ` class Person { constructor(name) { this.name = name; } getName() { return this.name; } }` // 转化插件 let classToFunctionPlugin = { visitor: { ClassDeclaration(nodePath) { let { node } = nodePath; // 步骤1:获取class类中可以复用的节点信息 // 1.1 获取标识符 Person let { id } = node; // 1.2 获取构造器和类的方法 let classMethods = node.body.body; // 步骤2:方法转化为构造函数的方法 let newBody = []; classMethods.forEach(method => { if (method.kind === "constructor") { // 2.1 将构造器方法转化通过babel-types创建函数声明AST节点信息 let conFunction = types.functionDeclaration(id, method.params, method.body, method.generator, method.async); newBody.push(conFunction); } else { // 2.2 普通函数放入到原型上,所以需要创建函数表达式 // left值为 Person.prototype 创建表达式 let left = types.memberExpression(types.memberExpression(id, types.identifier("prototype")), method.key); // right值为 function() { return this.name } 创建函数表达式 let right = types.functionExpression(null, method.params, method.body, method.generator, method.async); let prototypeFn = types.assignmentExpression("=", left, right); newBody.push(prototypeFn); } }) // nodePath.replaceWith() 替换为单节点 // 步骤三: 将节点路径替换成多节点,因为构造函数写法有两个节点; nodePath.replaceWithMultiple(newBody); } } } let targetCode = core.transform(sourceCode, { plugins: [classToFunctionPlugin] }) console.log(targetCode.code);
(七) 自动给函数声明添加try catch
let autoTryCatchPlugin = { visitor: { // 如果访问到了函数声明则进行处理 FunctionDeclaration(nodePath) { let { node } = nodePath; let { id } = node; // 保存函数内部内容: return a + b; let blockStatement = node.body; // 如果函数第一个部分就是try catch则直接终止 if (blockStatement.body && types.isTryStatement(blockStatement.body[0])) { return } // 根据字符串创建AST语法树节点 let catchStatement = template.statement("console.log(error)")(); // 创建catch节点信息 let catchClause = types.catchClause(types.identifier("error"), types.blockStatement([catchStatement])); // 结合创建try catch表达式信息 let tryStatement = types.tryStatement(node.body, catchClause); // 结合创建终的内部函数节点 let func = types.functionExpression(id, node.params, types.blockStatement([ tryStatement ]), node.generator, node.async); // 替换之前函数的body nodePath.replaceWith(func); } } } // 输入 function add(a,b) { return a + b; }; // 输出 (function add(a, b) { try { return a + b; } catch (error) { console.log(error); } });
(八) 转化箭头函数的this
函数this作用域就两种,一种是全局环境,一种是函数环境。
// 转化前 const sum = (a, b) => { console.log(this) } // 转化后 var _this = this; const sum = function (a, b) { console.log(_this); }; // JS作用域就两种全局另外就是函数,所以箭头函数转化为普通函数就是不断向上找如果是函数或者全局环境则找到了 let transformArrowFunctionPlugin = { visitor: { ArrowFunctionExpression(nodePath) { let {node} = nodePath; editFunctionEnvironment(nodePath); node.type = "FunctionExpression"; } } } function editFunctionEnvironment(nodePath) { // 节点路径上记录这父节点信息,不断往上找找到第一个普通的函数或者全局 const thisEnvFn = nodePath.findParent(p => { return (p.isFunction() && !p.isArrowFunctionExpression()) || p.isProgram(); }); // 将箭头函数所有使用this的地方进行存储到一个数组中 let thisPaths = getScopeInfoInformation(nodePath); let thisBindFlag = "_this"; // 如果有地方使用到了则需要在thisEvnFn环境上添加一个语句 let _this = this; if (thisPaths.length > 0) { // 在外层有this函数环境中声明一句 let _this = this thisEnvFn.scope.push({ id: types.identifier("_this"), init: types.thisExpression() }) // 遍历所有使用到这个函数的this路径节点,把所有thisExpression变为_this标识符 thisPaths.forEach(thisChild => { let thisRef = types.identifier(thisBindFlag); thisChild.replaceWith(thisRef); }) } } function getScopeInfoInformation(nodePath) { let thisPaths = []; // 变量箭头函数节点信息,将所有使用到了this表达式的节点进行储存 nodePath.traverse({ ThisExpression(thisPath){ thisPaths.push(thisPath); } }) return thisPaths; }
七、编写自动注入埋点函数插件
(一) 需求
现在有一个logger.js文件,里面默认暴露了一个logger方法。 这个方法能自动进行埋点统计,现在需求是将某些方法里自动注入这个函数,例如
function sum(a,b) { return a + b; } // 转换为 function sum(a,b) { logger(sum); return a + b; } const minus = (a, b) => a - b // 转换为 const minus = (a, b) => { logger(minus); return a - b; } class My { classMethod: { console.log(1) } } // 转换为 class My { classMethod: { logger(classMethod); console.log(1) } }
(二) 通过使用分析
插件函数执行传参配置引入的库名称,此时为"logger"也就是需要在源码顶部添加一句
import "logger" from "logger.js"
const babelCore = require("@babel/core"); const types = require("@babel/types"); // 自定义编写的插件,返回一个vister对象,可以通过穿参进行配置 const autoLoggerPlugin = require("./autoLoggerPlugin"); const sourceCode = ` function sum(a, b) { return a + b; }; const div = function(a, b) { return a / b; } const minus = (a, b) => a - b; class Computer { getSum() { return 100; } } ` let target = babelCore.transform(sourceCode, { plugins: [ autoLoggerPlugin({libName: "logger"}) ] })
(三) 处理全局代码判断是否引入logger函数
1. 常用api
path.traverse可以继续遍历当前上下文信息
state可以在遍历的过程中保存和传递状态
path.get可以简化值的获取,不用一层层的去获取值
path.stop可以值终止遍历
2. import logger frome "logger" AST信息
3. 流程
判断全局是否有引入logger
引入了则利用,没有引入则创建引入
挂载到共享,给遍历函数节点使用
Program: { // 必须在进入的时候处理,深度优先 enter(path, state) { // 代表 import logger from "logger" 前面的logger,也就是需要添加的函数名 let loggerFnName = null; path.traverse({ // 1. 遍历是否有引入 ImportDeclaration(importPath) { // 1.1 判断引入的库名称是否和配置名称相同 // 通过path.get可以快速获取引入名称 if(options.libName === importPath.get("source").node.value) { // 1.2相同则说明已经手动引入了,直接获取到该标识符信息并赋值给loggerFnName // import xxx from "logger" 则会将xxx赋值给loggerFnName // specifiers的值为数组通过.0可以获取第一项 loggerFnName = importPath.get("specifiers.0").node.local.name; // 停止Program遍历ImportDeclaration path.stop(); } } }) // 2. 遍历完后如果没有值则说明没有引入需要手动创建 if (!loggerFnName) { let libName = options.libName; // logger // 通过babel提供的这个函数快速创建import语句插入到全局Program中,并且把名字传递给loggerFnName loggerFnName = importModuleHelper.addDefault(path, libName, { // 如果该作用域已经有声明了logger则换一个变量代替 // 如果有 var logger = xxx; 则会生成 import _logger2 from "logger"; nameHint: path.scope.generateUid(libName) }).name } // 3. 通过上面两个步骤后一定有loggerId标识符,挂载到state中去提供给后面的访问器使用 // 可以理解为访问器共享参数,但是要注意访问器的执行顺序 console.log(loggerFnName); state.loggerFnName = loggerFnName; } }
(四) 根据函数类型插入到函数体
1. 常用API
通过@babel/template快速创建AST语法树信息
通过 @ babel/helper-module-imports快速创建引入模块
replaceWith可以替换节点
'xxx'|'xxx'|'xxx'可以在一个访问器中访问多种类型
2. 代码流程
"FunctionDeclaration|FunctionExpression|ArrowFunctionExpression|ClassMethod"(path, state) { const { node } = path; let loggerFnName = state.loggerFnName; // 1. 通过之前处理的loggerFnName创建函数表达式 logger('函数名')插入到函数中 // let loggerNode = types.expressionStatement( // types.callExpression( // types.identifier(loggerFnName), // // 参数列表因为各个不同的函数声明获取name方式不一样这里用callFnName简化 // [types.stringLiteral("callFnName")] // ) // ) // 或者使用@babel/template简化生成AST,注意需要执行 let loggerNode = template.statement(`${loggerFnName}("callFnName")`)(); // 如果函数内部是语句块 {} 则可以直接插入 if (types.isBlockStatement(node.body)) { node.body.body.unshift(loggerNode); } else { // 非代码块形式 (a, b) => a - b const newNode = types.blockStatement([ loggerNode, types.returnStatement(node.body) ]); path.get('body').replaceWith(newNode); } }
(五) 整体插件代码
const importModuleHelper = require('@babel/helper-module-imports'); const template = require("@babel/template"); const types = require("@babel/types"); function autoLoggerPlugin(options) { return { visitor: { Program: { enter(path, state) { // 代表 import logger from "logger" 前面的logger,也就是需要添加的函数名 let loggerFnName = null; path.traverse({ // 1. 遍历是否有引入 ImportDeclaration(importPath) { // 1.1 判断引入的库名称是否和配置名称相同 // 通过path.get可以快速获取引入名称 if(options.libName === importPath.get("source").node.value) { // 1.2相同则说明已经手动引入了,直接获取到该标识符信息并赋值给loggerFnName // import xxx from "logger" 则会将xxx赋值给loggerFnName // specifiers的值为数组通过.0可以获取第一项 loggerFnName = importPath.get("specifiers.0").node.local.name; // 停止Program遍历ImportDeclaration path.stop(); } } }) // 2. 遍历完后如果没有值则说明没有引入需要手动创建 if (!loggerFnName) { let libName = options.libName; // logger // 通过babel提供的这个函数快速创建import语句插入到全局Program中,并且把名字传递给loggerFnName loggerFnName = importModuleHelper.addDefault(path, libName, { // 如果该作用域已经有声明了logger则换一个变量代替 // 如果有 var logger = xxx; 则会生成 import _logger2 from "logger"; nameHint: path.scope.generateUid(libName) }).name } // 3. 通过上面两个步骤后一定有loggerId标识符,挂载到state中去提供给后面的访问器使用 // 可以理解为访问器共享参数,但是要注意访问器的执行顺序 console.log(loggerFnName); state.loggerFnName = loggerFnName; } }, "FunctionDeclaration|FunctionExpression|ArrowFunctionExpression|ClassMethod"(path, state) { const { node } = path; let loggerFnName = state.loggerFnName; // 1. 通过之前处理的loggerFnName创建函数表达式 logger('函数名')插入到函数中 // let loggerNode = types.expressionStatement( // types.callExpression( // types.identifier(loggerFnName), // // 参数列表因为各个不同的函数声明获取name方式不一样这里用callFnName简化 // [types.stringLiteral("callFnName")] // ) // ) // 或者使用@babel/template简化生成AST,注意需要执行 let loggerNode = template.statement(`${loggerFnName}("callFnName")`)(); // 如果函数内部是语句块 {} 则可以直接插入 if (types.isBlockStatement(node.body)) { node.body.body.unshift(loggerNode); } else { // 非代码块形式 (a, b) => a - b const newNode = types.blockStatement([ loggerNode, types.returnStatement(node.body) ]); path.get('body').replaceWith(newNode); } } } } } module.exports = autoLoggerPlugin;
八、eslint插件
(一) 作用
检查代码中是否有console语句,如果有则报错(需要容器执行)
主要了解:
babel在编译过程中如何打印错误
如何创建错误帧和控制错误打印层级
(二) 代码
const babelCore = require('@babel/core'); const types = require('@babel/types'); const esLintRemoveConsole = require('./esLintRemoveConsole'); const sourceCode = ` let a = 1; console.log(a); console.log(a); console.log(a); `; let target = babelCore.transform(sourceCode, { plugins: [ esLintRemoveConsole({isAutoFix: true}) ], }); console.log(target.code);
module.exports = function (opt) { return { // 在处理访问器前执行创建接受错误的容器 pre(file) { // 命名空间 file.set("catchErrors", []); }, visitor: { CallExpression(path, state) { const node = path.node; const catchErrors = state.file.get("catchErrors"); if (node.callee && node.callee.object && node.callee.object.name === "console") { // 设置错误打印的最大层级为1 const rawStackTraceLimit = Error.stackTraceLimit; Error.stackTraceLimit = 1; catchErrors.push(path.buildCodeFrameError("代码中不能出现console", Error)); // 恢复 Error.stackTraceLimit = rawStackTraceLimit; // 自动删除 if (opt.isAutoFix) { path.parentPath.remove(); } } } }, // 处理完后把收集的错误进行控制台打印 post(file) { const errors = file.get("catchErrors"); console.error(...errors); } } }
九、uglify代码压缩插件
(一) 作用
如何获取到所有的作用域里面的变量和函数声明,并且简化变量名(通过别名可以获取多种作用域)
(二) 代码
const babelCore = require('@babel/core'); const types = require('@babel/types'); const uglifyPlugin = require('./uglifyPlugin'); const sourceCode = ` var thisIsAlongName = "name"; function fn() { var thisIsAlongName = "innerName" }; fn(); ` let target = babelCore.transform(sourceCode, { plugins: [uglifyPlugin()] }) console.log(target.code.replace(/[\r\n]/g, "").replace(/\s{2}/g, " "));
module.exports = function (opt) { return { visitor: { // 捕获所有的作用域 Scopable(path) { Object.entries(path.scope.bindings).forEach(([key, binding]) => { // 生成新名字替换 const newVarName = path.scope.generateUid("_"); binding.path.scope.rename(key, newVarName); }) } } } }
十、如何定制babelloader插件
(一) 理解按需引入插件
入口文件中只引入了两个函数
import { add, min } from "lodash"; add(1,2); min([1,2])
如果通过打包处理后,会将整个lodash文件信息打包进入口文件,体积会很大;
安装"babel-plugin-import"然后在webpack.config.js中配置
rules: [ { test: /.js$/, use: [ { loader: "babel-loader", options: { plugins: [ // 配置按需引入的库名字 // libraryDirectory文件夹路径,默认为/lib // 但是lodash的子模块是在根目录所以配置为"" ['import', { libraryName: "lodash", libraryDirectory: "" }] ] } } ] } ]
再次打包后产出的main.js就会小很多
(二) 原理
lodash库也是通过一个个小模块组合的,通过这个插件可以将
import {add, min} from "lodash"; // 改为 import add from "lodash/add"; import min from "lodash/min";
(三) 项目配置使用
在webpack中配置的参数需要用插件中的state.opts进行接受;
通过path.resolve可以引入自己定义的babel插件
const path = require('path'); module.exports = { mode: "development", devtool: false, entry: "./index.js", module: { rules: [ { test: /.js$/, use: [ { loader: "babel-loader", options: { plugins: [ // 官方插件 // ['import', { libraryName: "lodash", libraryDirectory: "" }] // 自定义插件 [ path.resolve("./src/按需加载插件/importPlugin.js"), // 这个配置信息会在state.opts里面 { libraryName: "lodash", libraryDirectory: "" } ] ] } } ] } ] } }
入口文件
import { add, min } from "lodash"; add(1,2); min([1,2])
插件
const template = require("@babel/template"); module.exports = function () { return { visitor: { ImportDeclaration(path, state) { // { libraryName: "lodash", libraryDirectory: "" } const { libraryName, libraryDirectory } = state.opts; const node = path.node; let libName = node.source.value; if (libName === libraryName) { let importSpecifier = node.specifiers.filter(i => i.type === "ImportSpecifier"); // [min, add] let importsNames = importSpecifier.map(i => i.local.name); // 根据名称创建默认导入 // import min from 'lodash/min'; // import add from 'lodash/add'; let newImports = importsNames.map(name => { return template.statement(` import ${name} from '${libraryName}${libraryDirectory}/${name}'; `)() }) // 删除 import {add, min} from "lodash"; path.replaceWithMultiple(newImports); } } } } }