豆皮粉儿们,又见面了,明天这一期,由字节跳动数据平台的太郎酱,带大家走进AST的世界。
作者:太郎酱
什么是AST
形象语法树(Abstract Syntax Tree, AST),是源代码的形象语法结构的树状示意,与之对应的是具体语法树;之所以是形象的,是因为形象语法树并不会示意出实在语法中呈现的每一个细节,而且是文法无关、不依赖于语言的细节;能够把AST设想成一套标准化的编程语言接口定义,只不过这一套标准,是针对编程语言自身的,小到变量申明,大到简单模块,都能够用这一套标准形容,有趣味的同学能够深刻理解AST的概念和原理,本文的重点聚焦在JavaScript AST的利用。
为什么要谈AST
对于前端同学来说,日常开发中,和AST无关的场景无处不在;比方:webpack、babel、各种lint、prettier、codemod 等,都是基于AST解决的;把握了AST,相当于把握了控制代码的代码能力,能够帮忙咱们拓宽思路和视线,不论是写框架,还是写工具和逻辑,AST都会成为你的得力助手。
AST解析流程
先举荐一个AST在线转换网站: astexplorer.net , 珍藏它,很重要;除了js,还有很多其余语言的AST库;不必做任何配置,就能够作为一个playground;
在解说case之前,先理解下解析流程,分为三步:
- source code --> ast (源代码解析为ast)
- traverse ast (遍历ast,拜访树中的各个节点,对节点做各种操作)
- ast --> code (把ast转换为源码,打完出工)
源码解析成为AST的引擎有很多,转换进去的AST大同小异;
Use Cases
从一个变量申明说起,如下:
const dpf = 'DouPiFan';
把代码复制到astexplorer中,失去如下后果(后果已简化),这张图解释了从源码到AST的过程;
抉择不同的第三方库来生成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插件为例,来理解它的处理过程;
当咱们开发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的思维来思考,实现步骤如下:
- 查找代码中的 import 语句,且必须是 import { xxx } from 'antd'
- 把步骤一找到的节点,转换为 import Button from 'antd/lib/button'
实现步骤
- 关上神器: AST Explorer,把第一行代码复制到神器中
- 点击代码中的 import 关键字,会主动定位到对应的节点,构造如下:
ImportDeclaration { type: "ImportDeclaration", specifiers: [{ // 对应 {} 括号中的组件 ImportSpecifier: { type: "ImportSpecifier", imported: { type: "Identifier", name: "Button" } } }] source: { type: "StringLiteral", value: "antd" }, ...}
源码被转换成带有类型和属性的对象,不论是关键字、变量申明,还是字面量值,都有对应类型;
- import 语句对应的类型是: ImportDeclaration
- { Button }对应的是 specifiers 数组,示例中只引入了 "Button",所以specifiers数组中的元素只有一个
- specifiers中的元素,也就是 Button,类型是 ImportSpecifier;
- '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中,查看输入后果即可;到这里,这个“繁难”插件实现实现;
再来回顾一下实现思路:
- 比照源码在语法树中的差别,明确要做哪些转换和批改
- 剖析类型,能够在babel官网,找到类型阐明
- 在插件模板中,通过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 }} /> )}
差别比照
- react组件的属性类型为:JSXIdentifier,属性"type"批改为"status"
- 如果组件有"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更像是性能齐备的工具汇合,能够把精力聚焦在转换器的实现,请依据理论场景抉择适合的工具。