抽象语法树,真香

豆皮粉儿们,又见面了,今天这一期,由字节跳动数据平台的太郎酱,带大家走进AST的世界。

作者:太郎酱

什么是AST

抽象语法树(Abstract Syntax Tree, AST),是源代码的抽象语法结构的树状表示,与之对应的是具体语法树;之所以是抽象的,是因为抽象语法树并不会表示出真实语法中出现的每一个细节,而且是文法无关、不依赖于语言的细节;可以把AST想象成一套标准化的编程语言接口定义,只不过这一套规范,是针对编程语言本身的,小到变量声明,大到复杂模块,都可以用这一套规范描述,有兴趣的同学可以深入了解AST的概念和原理,本文的重点聚焦在JavaScript AST的应用。

65c7446a71980b8ad8f7a849925dcfce.png

为什么要谈AST

对于前端同学来说,日常开发中,和AST有关的场景无处不在;比如:webpack、babel、各种lint、prettier、codemod 等,都是基于AST处理的;掌握了AST,相当于掌握了控制代码的代码能力,可以帮助我们拓宽思路和视野,不管是写框架,还是写工具和逻辑,AST都会成为你的得力助手。

AST解析流程

先推荐一个AST在线转换网站:astexplorer.net , 收藏它,很重要;除了js,还有很多其他语言的AST库;不用做任何配置,就可以作为一个playground;

fc94aaa672700229e5a2faaa68621cf3.png

在讲解case之前,先了解下解析流程,分为三步:

1. source code --> ast (源代码解析为ast)2. traverse ast (遍历ast,访问树中的各个节点,对节点做各种操作)3. ast --> code (把ast转换为源码,打完收工)

源码解析成为AST的引擎有很多,转换出来的AST大同小异;

Use Cases

从一个变量声明说起,如下:

const dpf = 'DouPiFan';

把代码复制到astexplorer中,得到如下结果(结果已简化),这张图解释了从源码到AST的过程;

fb598c007351f1112d894d59c78ec729.png

选择不同的第三方库来生成AST,结果会有所差异,这里以babel/parse为例;前端同学对babel再熟悉不过了,通过它的处理,可以在浏览器中支持ES2015+的代码,这仅仅是babel的其中一个应用场景,官方对自己的定位是:Babel is a javascript compiler。

回到 babel-parser,它使用 Babylon 作为解析引擎,它是AST 到 AST 的操作,babel在Babylon的基础上,封装了解析(babel-parser)和生成(babel-generator)这两步,因为每次操作都会做这两步;对于应用而言,操作的重点就是AST节点的遍历和更新了;

第一个babel插件

我们以一个最简单的babel插件为例,来了解它的处理过程;

457de568cb97a28b35f762f5427f2910.png

当我们开发babel-plugin的时候,我们只需要在 visitor 中描述如何进行AST的转换即可。把它加入你的babel插件列表,就可以工作了,我们的第一个babel插件开发完成;

babel-plugin-import是如何实现的?

使用过antd的同学,都知道 babel-plugin-import插件,它是用来做antd组件的按需加载,配置之后的效果如下:

import { Button } from 'antd'
    ↓ ↓ ↓ ↓ ↓ ↓
import Button from 'antd/lib/button'

本文旨在抛砖引玉,对于插件的实现细节以及各种边界条件,可参考插件源码;

以AST的思维来思考,实现步骤如下:

1. 查找代码中的 import 语句,且必须是 import { xxx } from 'antd'2. 把步骤一找到的节点,转换为 import Button from 'antd/lib/button'

实现步骤

1. 打开神器:AST Explorer,把第一行代码复制到神器中2. 点击代码中的 import 关键字,会自动定位到对应的节点,结构如下:

ImportDeclaration {
    type: "ImportDeclaration",
    specifiers: [{  // 对应 {} 括号中的组件
        ImportSpecifier: {
            type: "ImportSpecifier",
            imported: {
                type: "Identifier",
                name: "Button"
            }
        }
    }] 
    source: {
        type: "StringLiteral",
        value: "antd"
    },
    ...
}

源码被转换成带有类型和属性的对象,不管是关键字、变量声明,还是字面量值,都有对应类型;

1. import 语句对应的类型是:ImportDeclaration2. { Button }对应的是 specifiers 数组,示例中只引入了 "Button",所以specifiers数组中的元素只有一个3. specifiers中的元素,也就是 Button,类型是 ImportSpecifier;4. 'antd' 在 source 节点中,类型是:StringLiteral,value为antd

再次说明:示例并非完整逻辑实现,细节和边界条件,可参考源码或自己完善;

针对AST的操作,和浏览器自带DOM API 类似;先确定要查找节点的类型,然后根据具体的条件,缩小搜索范围,最后对查找到的节点,进行增删改查;

// babel插件模板
export default function({types: t}) {
    return {
        // Visitor 中的每个函数接收2个参数:path 和 state
        visitor: {
            ImportDeclaration(path, state) {
                const { node } = path;
                // source的值为antd
                if(node.source.value === 'antd'){
                    const specifiers = node.specifiers
                    // 遍历 specifiers 数组
                    const result = specifiers.map((specifier) => {
                        const local = specifier.local
                        // 构造 source
                        const source = t.stringLiteral(`${node.source.value}/lib/${local.name}`)
                        // 构造 import 语句
                        return t.importDeclaration([t.importDefaultSpecifier(local)], source)
                    })
                    console.log(result)
                    path.replaceWithMultiple(result)
                }
            }
        }
    }
}

验证方法也很简单,把这段代码复制到AST Explorer中,查看输出结果即可;到这里,这个“简易”插件实现完成;

再来回顾一下实现思路:

1. 对比源码在语法树中的差异,明确要做哪些转换和修改2. 分析类型,可以在babel官方,找到类型说明3. 在插件模板中,通过visitor访问对应的类型节点,进行增删改查

Codemod

上面讲解了ast在babel中的基本操作方法,再来看看codemod。

使用antd3的同学,都接触过antd3到antd4的codemod,这是一个帮助我们自动化的,把antd3的代码转换到antd4的一个工具库;因为它的本质是进行代码转换,所以基于babel实现codemod,是完全ok的。但除了代码转换,还需要有命令行操作,源代码读取,批量执行转换,日志输出等功能,他更是一个功能集合,代码转换是其中很重要的一部分;所以,推荐另外一个工具 jscodeshift。他的定位是一个transform runner,所以,我们的核心工作是,定义一系列的transform,也就是转换规则,剩下的命令行、源码读取、批量执行、日志输出都可以交给jscodeshift。

准备工作

先定义一个transform,和babel插件很像

import { Transform } from "jscodeshift";
const transform: Transform = (file, api, options) => {
  return null;
};
export default transform;
动手实践

我们尝试把Button组件的"type"属性替换为"status",并把width属性,添加到style中:

// 输入
const Component = () => {
  return (
      <Button 
          type="dange"
          width="20"
      />
  )
}
// 输出
const Component = () => {
  return (
      <Button 
          statue="dange"
          style={{
              width: 20
          }}
      />
  )
}
差异对比

1. react组件的属性类型为:JSXIdentifier,属性"type"修改为"status"2. 如果组件有"width"属性,把该属性移动到"style"属性中

查找Button组件的代码如下:

import { Transform } from "jscodeshift";
const transform = (file, api, options) => {
    const j = api.jscodeshift;
    // 查找jsx节点,通过find方法的第二个参数进行过滤
    return j(file.source).find(j.JSXOpeningElement, {
        name: {
            type: 'JSXIdentifier',
            name: 'Button'
        }
    })
};
export default transform;
属性替换

接下来,添加属性替换逻辑,把type替换为status

export default function transformer(file, api) {
  const j = api.jscodeshift;
  return j(file.source)
    .find(j.JSXOpeningElement, {
      name: {
        type: 'JSXIdentifier',
        name: 'Button'
      }
    }).forEach(function(path){
        var attributes = path.value.attributes;
        attributes.forEach(function(node, index){
          const attr = node.name.name;
          if(attr === 'type'){
            // attr为type时,把属性名替换为 status
            node.name.name = 'status'
          }
        })
     })
    .toSource();
}

在查找JSX元素时,jscodeshift可以直接获取:j(file.source).findJSXElements() ,这里使用find代替,find的第二个参数,可以描述过滤条件;

jscodeshift支持链式调用,查找到节点后,使用forEach遍历,当组件的属性名为type时,把属性名替换为"status",这里只考虑了一种情况,还存在 JSXNamespaceName 的场景,比如: <Button n:a />

处理width

存在width时,获取width的值,然后删除该节点;

接下来是创建style节点,类型是 jsxAttribute,把width的值设置回style

...
attributes.forEach(function(node, index){
    const attr = node.name.name;
    if(attr === 'width'){
        // 获取width的值
        width = node.value.value;
        // 删除 width 属性
        attributes.splice(index, 1)
     }
     let width;
     if(width){
        // 构造 style 节点
        var node = j.jsxAttribute(
          // 设置attr的名称为: style
          j.jsxIdentifier('style'), 
          // 构造 jsxExpressionContainer { }
          // 构造 objectExpression 
          j.jsxExpressionContainer(j.objectExpression([
            j.objectProperty(
              j.identifier('width'),
              j.stringLiteral(width),
            ),
          ])),
        )
        // 插入style节点
        attributes.splice(index, 0, node)
    }
}
...

总结

上面分别介绍了基于babel的实现和jscodeshift的实现,思路一样,比较简单,但是要花费额外的时间和精力才能达到比较完美的状态,尤其是面对大规模的代码处理时,边界条件较多,需要考虑的非常全面;但这个投入是值得的,可以把大部分工作自动化的处理;

另外,babel的特长是在ast的处理,jscodeshift更像是功能完备的工具集合,可以把精力聚焦在转换器的实现,请根据实际场景选择合适的工具。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值