likes
comments
collection
share

从零开始的Webpack原理剖析(三)——抽象语法树AST

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

前言

前两篇文章,我们分析了webpack加载CommonJs模块,ES module和异步加载模块的原理,那么问题又来了,大家记不记得,前文提到过一点,如果当前模块在打包前,是一个ES module模块,那么就先执行require.r方法,给其添加标识为ES module。那webpack是怎么识别当前模块的代码是CommonJs还是Es module规范的呢?老规矩,不卖关子,先说结论webpack会将当前代码转化成AST语法树,然后在AST中进行查找,只要找到了转换后import/export对应的值,那么就认为当前代码是一个ES module,没想到吧,就是如此简单粗暴。那么究竟什么是AST呢?别急,让我把这些看似高大上的概念,一点点娓娓道来。

抽象语法树(Abstract Syntax Tree)

概念

AST就是抽象语法树的缩写,是源代码语法结构的一种抽象表示,它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

用途

AST到底能干啥呢?其实已经渗透进我们日常的代码书写中了。比如代码高亮,代码风格检查,错误提示(Eslint),代码自动补全等,这些个工具,都是借助Javascript Parser,生成一棵树(AST),通过操纵这颗树,精准的定位到声明语句、赋值语句、运算语句等等,实现对代码的分析、优化等操作。甚至一套代码,多端适配,也离不开ast的功劳。那么Javascript Parser又是个啥呢?很简单就是把Javascript代码转化成抽象语法树的解析器。

说了这么一大堆,你可能还是有点一头雾水,别急我们看一张图,就能明白抽象语法树,到底长啥样子。我们可以打开astexpoler这个网站,在左边写上简单的一句代码,那么在右边就是使用了parser解析之后生成的抽象语法树,如下图所示。我们可以很清楚的看到var ast = 'ast tree'这简单的一行代码,被解析成了树状结构,观察其内容,我们不难发现,每个代码都有属于自己的type,比如var声明是VariableDeclaration,初始化值的信息被记录在init属性中,这便是经过Parser转换后的AST,是不是有点眼熟?没错这东西是不是和Vue的虚拟DOM长得差不多嘞,都是对我们源代码进行了描述和信息填充~

从零开始的Webpack原理剖析(三)——抽象语法树AST

小练习

上文我们也提到过,可以根据AST,对源代码进行修改,好多代码报错和功能修复的插件,都是基于此来实现的,那么,我们就从一个最简单的例子,来加深对AST的认识吧!我们要做如下准备:

step1: mkdir ast_test && cd ast_test && touch index.js && npm init -y // 进行js文件创建和package.json初始化
step1: esprima estraverse escodegen -S
// index.js中代码
let esprima = require('esprima'); // 把JS源代码转成AST语法树的包
let estraverse = require('estraverse'); // 遍历语法树的包,可以修改树上的节点
let escodegen = require('escodegen'); // 把AST语法树重新转换,还原成源代码的包

const sourceCode = 'let ast = "ast tree"' // 写一段简单的源代码
const ast = esprima.parse(sourceCode) // 将源代码转移成ast
let indent = 0 // 设置缩进,以便观察打印结果的时候,比较直观
const padding = () => ' '.repeat(indent)
// 遍历ast
estraverse.traverse(ast, {
  enter (node) {
    console.log(padding() + node.type + '进入')
    if (node.type === 'VariableDeclarator') {
      // 根据node.type判断,进行源代码修改
      node.id.name = 'newAstDeclarator'
    }
    indent += 2
  },
  leave (node) {
    indent -= 2
    console.log(padding() + node.type + '离开')
  }
})
// 将改变后的ast,重新转化还原成源代码
console.log(escodegen.generate(ast))

我们执行 node index.js命令,查看输出结果,能够清晰的看到遍历ast每个节点的过程,并且发现我们之前输入的let ast = "ast tree"变成了let newAstDeclarator = 'ast tree',声明的变量名,在我们遍历ast的时候,已经被成功地修改了!

从零开始的Webpack原理剖析(三)——抽象语法树AST

Babel插件编写

Babel是前端工程化中,非常重要的一个知识点,如果对Babel还没有了解,可以先抽空去看下我之前写的这篇文章,小白都能看懂的最新Babel配置探索——保姆级教学,来打一下基础,然后再回过头来看这一章节的内容,关于配置相关的内容,这里就不过多介绍。这里要着重说明一下,编写babel插件需要用到很多babel包提供的API,也要知道ast中不同类型节点的名称都叫啥,所以第一次接触,肯定会有些比较吃力,我们这里只需要大致明白,babel插件是如何编写的,大概是一个什么样的流程即可,毕竟日常工作中,大部分人是接触不到这方面东西的,如果有感兴趣的小伙伴,可以去深入探究。

箭头函数转换成普通函数

我们首先来写一下最经典的将箭头函数转化为普通函数,如何去写呢?这里我们要用到Babel提供的一些库:@babel/parser:用来将源代码转化成ast,@babel/traverse:遍历ast,@babel/generate:将遍历完的ast转化成源代码,没错,和我们刚才提到的那3个包,作用都是类似的,只不过这几个是babel团队自己写的包,他们都遵循着相同的规范来转化成ast,而@babel/core作为核心包,集成了这几个包和其他等辅助包的功能,所以我们在开发自己插件的时候,直接引入@babel/core核心包就可以了。

npm install @babel/core @babel/types @babel/plugin-transform-arrow-functions -D
// index.js,清空之前的代码,我们先尝试下使用官方的箭头函数插件
const core = require('@babel/core') // babel核心包
const arrowFunctionPlugin = require('@babel/plugin-transform-arrow-functions')// 箭头函数插件
// 箭头函数源代码
const sourceCode = `
const sum = (a, b) => a + b
`
// 转化源代码
const result = core.transform(sourceCode, {
  plugins: [arrowFunctionPlugin]
})
console.log(result.code)

同样,我们执行node index.js后可以发现,箭头函数被成功的转化成了普通的函数。使用完官方的插件,我们便开始自己实现一个同样效果的插件吧~

// index.js
const core = require('@babel/core') // babel核心包
// 我们把官方插件先给注释掉,自己来实现
//const arrowFunctionPlugin = require('@babel/plugin-transform-arrow-functions')
// 自己实现的插件
const arrowFunctionPlugin = {
  visitor: {
    // 如果是箭头函数,就会进到这里边来,参数是箭头函数的节点路径对象
    ArrowFunctionExpression(path) {
      path.node.type = 'FunctionDeclaration'
    }
  }
}
// 箭头函数源代码
const sourceCode = `
const sum = (a, b) => {
  return a + b
}
`
// 转化源代码
const result = core.transform(sourceCode, {
  plugins: [arrowFunctionPlugin]
})
console.log(result.code)

我们来详细讲解一下自己定义的arrowFunctionPlugin这几行代码的含义,首先,babel插件都是有着固定的格式的,即对象中有visitor这个key,那么为啥叫visitor呢?顾名思义,是根据访问者模式进行插件编写的,那么什么是访问者模式呢?我们不要说那些拗口定义和概念,可以结合代码语境,理解为:对于不同的访问者,访问相同的数据结构,经过处理后,不会影响数据结构并且得到不同的结果,啥意思呢?比如你和你对象作为两个访问者,去的是相同的商场进行购物,但是最后买到的东西却不一样。我们继续看,前文提到过,可以查询ast规范(不过最简单的还是上astexplore.net这个网站上查询相应的node名称,如下图)

从零开始的Webpack原理剖析(三)——抽象语法树AST

那么在visitor访问器中,我们访问箭头函数type对应的名称,并将其中的type改为一般函数的type,即FunctionExpression即可,之后我们还是执行node index.js,命令查看结果,发现被成功的解析成了如下所示的代码:

var sum = function sum(a, b) {
  return a + b;
};

就这么简单么?看起来好像可以了,但是如果我们的源代码写的更精简一点,比如省略掉return,写成const sum = (a, b) => a + b,再去执行,发现结果很奇怪,变成了四不像代码:

var sum = function sum(a, b) a + b;

很显然,此时此刻还需要包裹一个块级作用域,并且添加return关键字才能变成一个正常的函数,那么怎么处理呢?我们继续优化代码。

// index.js
const core = require('@babel/core') // babel核心包
const types = require('@babel/types') // 遍历ast过程中,封装的对节点进行快捷操作各种方法
// 我们把官方插件先给注释掉,自己来实现
//const arrowFunctionPlugin = require('@babel/plugin-transform-arrow-functions')
// 自己实现的插件
const arrowFunctionPlugin = {
  visitor: {
    // 如果是箭头函数,就会进到这里边来,参数是箭头函数的节点路径对象
    ArrowFunctionExpression(path) {
      path.node.type = 'FunctionExpression'
      // 获取节点的body
      let body = path.node.body
      // 使用types包中的方法,当前的body有没有块级作用域声明
      if (!types.isBlockStatement(body)) {
        // 使用types包中的方法,快速的构建生成节点内容
        path.node.body = types.blockStatement([types.returnStatement(body)]);
      }
    }
  }
}
// 箭头函数源代码
const sourceCode = `
const sum = (a, b) => {
  return a + b
}
`
// 转化源代码
const result = core.transform(sourceCode, {
  plugins: [arrowFunctionPlugin]
})
console.log(result.code)

同样,还是可以根据箭头函数和普通函数ast的不同(如下图),进行相应的逻辑判断,说的通俗一点,就是找两者的不同,然后用@babel/types中提供的的API进行补齐ast。我们再次执行node index.js命令,查看结果,发现这时已经成功将箭头函数转移成了普通函数。 从零开始的Webpack原理剖析(三)——抽象语法树AST

到此为止就大功告成了么?是不是遗漏了一个很重要的事情呢?没错,就是万恶之源,this的处理,我们都知道,箭头函数中是没有this的,会一层一层的向父级作用域寻找,那么我们先看看官方插件最后的编译结果是什么样的呢?我们先修改先courceCode的值,使用官方的箭头函数插件,然后npm index.js观察执行结果:

// 修改sourceCode的值
const sourceCode = `
  const sum = (a, b) => {
    console.log(this);
    const minus = (c,d)=>{
      console.log(this);
      return 2;
    }
    return a + b;
  }
`
// 执行结果:
var _this = this;

var sum = function sum(a, b) {
  console.log(_this);

  var minus = function minus(c, d) {
    console.log(_this);
    return 2;
  };

  return a + b;
};

我们可以发现,编译的结果,是在最外层声明了一个_this变量,凡是箭头函数中内部使用到this的地方,均取这个_this作为箭头函数中的this。那么问题来了,我们如何在ast中进行对this的处理呢?我们先想一下思路:首先,要确定,最终要用哪里的this,然后向上找不是箭头函数的普通函数或者根节点,找到后,将这个地方的this赋值为_this,并用其替换箭头函数中的所有this,我们知道思路即可,同样,我们先查询this关键字,在ast中是叫什么type从零开始的Webpack原理剖析(三)——抽象语法树AST

具体的代码直接来看,dealThis方法是按照我们刚才的思路,进行对this的处理,每一步都详细的写了注释:

// index.js
const core = require('@babel/core') // babel核心包
const types = require('@babel/types') // 遍历ast过程中,封装的对节点进行快捷操作各种方法
// 我们把官方插件先给注释掉,自己来实现
//const arrowFunctionPlugin = require('@babel/plugin-transform-arrow-functions')
// 自己实现的插件
const arrowFunctionPlugin = {
  visitor: {
    // 如果是箭头函数,就会进到这里边来,参数是箭头函数的节点路径对象
    ArrowFunctionExpression(path) {
      path.node.type = 'FunctionExpression'
      // 新增逻辑:处理箭头函数中的this
      dealThis(path)
      // 获取节点的body
      let body = path.node.body
      // 使用types包中的方法,当前的body有没有块级作用域声明
      if (!types.isBlockStatement(body)) {
        // 使用types包中的方法,快速的构建生成节点内容
        path.node.body = types.blockStatement([types.returnStatement(body)]);
      }
    }
  }
}
function dealThis(path) {
  // 首先我们要确定,使用哪个地方的this,因为箭头函数没有this,所以需要层层向父级寻找
  const thisEnv = path.findParent(parent => {
    // 如果父级是一个普通函数,或者父级已经是根节点了,那么就返回true
    return types.isFunctionDeclaration(parent) || parent.isProgram()
  })
  // 设置一个变量,来存储this
  let thisBindings = '_this'
  // 创建一个路径数组
  let thisPaths = []
  // 遍历此路径的所有子路径,如果箭头函数中使用了this(即可以访问到ast中的ThisExpression),就把当前路径push进路径数组中
  path.traverse({
    ThisExpression (thisPath) {
      thisPaths.push(thisPath)
    }
  })
  if (thisPaths.length > 0) {
    // 如果路径数组中有数据,那么说明箭头函数内部使用了this
    // 在thisEnv这个节点的作用域中,添加之前我们存储this的变量_this,即添加代码为 var _this = this
    if (!thisEnv.scope.hasBinding(thisBindings)) {
      thisEnv.scope.push({
        id: types.identifier(thisBindings),
        init: types.thisExpression()
      })
    }
    // 将箭头函数内部用到this的地方,统一转化为_this
    thisPaths.forEach(thisPath => {
      thisPath.replaceWith(types.identifier(thisBindings))
    })
  }
}
// 箭头函数源代码
const sourceCode = `
  const sum = (a, b) => {
    console.log(this);
    const minus = (c,d)=>{
      console.log(this);
      return 2;
    }
    return a + b;
  }
`
// 转化源代码
const result = core.transform(sourceCode, {
  plugins: [arrowFunctionPlugin]
})
console.log(result.code)

我们在执行node index.js命令,发现此时的结果,已经达到了我们预期的目的从零开始的Webpack原理剖析(三)——抽象语法树AST

到此为止,我们的箭头函数插件,总算是完成了整个流程跟进下来,虽然功能可能还不是最完美的,不过一般的功能转化是能够满足的,是不是有一种,思路我确实能跟得上,但是用哪些API,访问节点的名称叫什么却不知道,有种一拳打在棉花上的感觉呢?很正常的,毕竟我们几乎没有接触过相关方面的知识,我们只需要其中的流程即可。如果想详细的学习插件开发,可以看下github中的Babel插件开发手册(虽然时间比较久远了,但不失为一个入门的好途径)。

自定义一个代码检查插件(类似于eslint)

有了之前的经验,我们直接来上代码,新增了一些知识,都在注释里边有写明:

// 新建一个空白文件名为eslint-plugin.js
const core = require('@babel/core')
const sourceCode = `
var a = 'xiaoming';
console.log(a);
var b = 'xiaohong';
`;
const eslintPlugin = ({ fix }) => {
  return {
    // 在访问器访问之前的钩子
    pre(file) {
      // 声明一个errors数组,来存放error
      file.set('errors', []);
    },
    visitor: {
      // console.log的type是CallExpression,所以访问这个
      CallExpression(path, state) {
        // 获取errors数组
        const errors = state.file.get('errors');
        const { node } = path
        // 找到console
        if (node.callee.object && node.callee.object.name === 'console') {
          // 设置堆栈深度,默认是10,为0的话,就不打印堆栈报错信息,为了整洁
          Error.stackTraceLimit = 0;
          // 将错误帧存入errors数组中
          errors.push(path.buildCodeFrameError(`代码中不能有console`, Error));
          // 外部如果传参fix为true的话,就修复代码,此处逻辑为移出console代码
          if (fix) {
            path.parentPath.remove();
          }
        }
      }
    },
    // 在访问器访问之后的钩子
    post(file) {
      console.log(...file.get('errors'));
    }
  }
}
const { code } = core.transformSync(sourceCode, {
  plugins: [eslintPlugin({ fix: true })]
})
console.log(code)

再次执行node eslint-plugin.js发现结果如下:会把console.log标注出来,并且自动删除console.log,因为我们设置了堆栈深度,所以不会显示一大坨堆栈报错信息,非常干净清爽。

从零开始的Webpack原理剖析(三)——抽象语法树AST

结语

有了这篇文章的基础之后,再去看babel中插件的源码,不说完全能看懂,但是大致的流程也能看个七七八八,还是那句话,因为在日常工作中,接触的比较少,所以我们只需要大致明白编写babel插件的原理即可,这样不至于在面试的时候,问起来一脸懵的状态,如果在工作中有需要,那么再单独去学习也完全来得及。

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