当前位置: 首页 > 科技观察

造轮利器:AST与前端编译

时间:2023-03-22 01:05:55 科技观察

本文为字节教育-成人创新前端团队成员的文章,经ELab授权发布。简介在计算机科学中,抽象语法树是源代码语法结构的抽象表示。它以树的形式表示编程语言的语法结构,树上的每个节点代表源代码中的一个结构。语法之所以“抽象”,是因为这里的语法并不代表真正语法中出现的每一个细节。——Wikipedia在前端基础设施中,ast可以说是不可或缺的。对ast进行操作其实就等同于对源码进行操作。ast的应用包括:开发辅助:eslint、prettier、ts检查代码变化:压缩、混淆、css模块代码转换:jsx、vue、ts转换为jsast生成通过词法分析和语法分析,可以得到一个ast。词法分析词法分析的过程是将代码馈送到有限状态机,结果是将代码字转换成记号(token)。token中包含的信息包括它的类型、属性值等,比如将consta=1+1转换成token,结果会是这样[{type:keyword,value:const},{type:identifier,value:a},{type:赋值运算符,value:=},{type:constant,value:1},{type:operator,value:+},{type:constant,value:1},]语法analysis面对一串代码,首先通过词法分析,得到第一个token,并为其创建一个ast节点。此时ast节点的属性和子节点是不完整的。为了弥补这些缺失的部分,移动到下一个单词,生成一个token,并转化为子节点,添加到已有的AST中,重复移动&生成的递归过程。让我们看看consta=1是如何变成一个ast的。读取const,生成一个VariableDeclaration节点reada,新建一个VariableDeclarator节点read=read1,创建一个NumericLiteral节点,将NumericLiteral赋值给VariableDeclarator的init属性,赋值VariableDeclarator给VariableDeclaration前端编译的声明属性前端技术和理念的发展不断进步,出现了各种新颖的代码和新的项目组织方式。但是在这些新技术中,有很多代码是不能在浏览器中直接执行的,比如typescript,jsx等,这时候我们的项目就需要通过编译,转换成可以在浏览器中直接执行的代码和包装。代码。以webpack为例,打包工具的作用是根据代码的import、export、require构建一棵依赖树,打成一个或多个bundle。它解决了模块化的问题,但是它内置的能力只能支持javascript和json文件,而我们平时遇到的ts、jsx、vue文件需要先通过编译工具进行编译。比如我们要使用webpack打包一个包含ts文件的项目,配置如下。//webpack.config.jsconstpath=require('path');module.exports={//...module:{rules:[{test:/.ts$/,use:'babel-loader',options:{预设:['@babel/typescript']}}],},};配置的意思是:webpack解析.ts文件时,先使用babel-loader进行转换,然后打包。操作ast进行代码编译编译工具常见的编译工具有这几种babel:目前最主流的编译工具,javascript编写。esbuild:Go语言开发的打包工具(含编译功能),用于Vite在开发环境中进行编译。swc:一个用rust编写的编译工具。在提供直接操作AST的能力方面,babel和swc使用的是visitor模式,在插件编写上有很多共性。最大的区别是语言。esbuild不提供直接操作AST的能力,但是可以通过接入其他编译工具达到操作AST的效果。编译过程代码编译过程分为三个步骤。上面提到了解析、转换、生成parse的过程,将代码从字符串转为树结构的ast。Transform就是遍历ast节点,在遍历过程中对ast进行修改。generate是将修改后的ast重新生成成代码。编译插件一般一提到babel,我们就会想到把新标准js转换成兼容性更高的旧标准js。如果babel默认的编译效果不能满足我们的需求,我们怎么干预编译过程,将ast修改成我们想要的ast。这时候就需要用到babel插件了,也就是babel插件。就像上面的配置constpath=require('path');module.exports={module:{rules:[{test:/.ts$/,use:'babel-loader',options:{presets:[//presets是已经配置好的插件集合,即"presets"'@babel/preset-typescript']}}],},};除了上面的typescript转javscript的插件,我们还使用了很多其他的插件/presets都会用到,比如@babel/react转换react文件react-refresh/babel热刷新babel-plugin-react-css-modulescss模块化避免样式污染istanbul收集代码覆盖......你好插件!Babel将代码转换成AST后,会遍历AST。遍历时会应用插件提供的visitor访问对应的AST节点,从而达到修改AST的目的。我们在写插件的时候,首先要知道我们要访问的ast节点对应的是什么类型。如果我们要修改函数类型的ast节点,我们先来看看这段代码经过babel转换后会生成什么样的ast。https://astexplorer.net/functiona(){};constb=function(){}constc=()=>{};ast:(删除部分分解结构后)[{"type":"FunctionDeclaration","id":{"type":"Identifier","name":"a"}},{"type":"VariableDeclaration","declarations":[{"type":"VariableDeclarator","id":{"type":"Identifier","name":"b"},"init":{"type":"FunctionExpression",}}],"kind":"const"},{"type":"VariableDeclaration","declarations":[{"type":"VariableDeclarator","id":{"type":"Identifier","name":"c"},"init":{"type":"箭头函数表达式",}}],"kind":"const"}]新建一个my-plugin.js,在里面写入如下代码,将visitor暴露在外面,babel会在遍历到的时候调用对应的visitor//my-plugin对应的节点。jsmodule.exports=()=>({visitor:{//访问一种ast节点FunctionDeclaration:{enter:enterFunction,//当babel对ast进行深度优先遍历时,//我们有两次进入和退出的机会访问同一个节点。exit:exitFunction},//访问各种AST节点"FunctionDeclaration|FunctionExpression|ArrowFunctionExpression":{enter:enterFunction}//使用“别名”访问Function:enterFunction}})functionenterFunction(){console.log('hello插入!')};函数exitFunction(){};accessplugin//.babelrc{"plugins":['./my-plugin.js']}实践下面我们来写一个完整的babelPlugin,看看如何修改ast:打印出函数的执行时间代码:asyncfunctiona(){functionb(){for(leti=0;i<10000000;i+=Math.random()){}}b();awaitnewPromise(resolve=>{setTimeout(resolve,1000);})}运行效果:bcost:219//functionbtakes219millisecondsanonymouscost:0//anonymousfunctioninpromise花费0毫秒acost:1222//函数a耗时1222毫秒。实现思路:在函数的第一行,插入一个ast节点,定义一个变量,记录函数刚刚执行的时间。在函数的最后一行,当返回时,打印出当前时间和开始时间之间的差值。Adding&insertingnodes除了手写一个ast节点,我们还可以使用babel提供的两个辅助工具来生成ast节点@babel/types一个工具集,可以用来创建和验证节点等,这里我们需要使用varfnName_start_time=Date.now()创建一个新的ast节点。import*astfrom'babel-types';functionfunctionEnter(path,state){constnode=path.node;constfnName=节点名称||node.id?.name||'匿名的';//创建一个新的变量声明constast=t.variableDeclarator(//变量名t.identifier(`${fnName}_start_time`),//Date.now()t.callExpression(t.memberExpression(t.identifier('Date'),t.identifier('now')),//参数为空[]));}@babel/template通过模板可以直接将代码转成ast,也可以替换模板中的节点,方便快捷。从“babel-template”导入模板;constvarName=`${fnName}_start_time`;//直接生成constast=template(`const${varName}=Date.now()`)();//或者constast=template('constfnName=Date.now()')({fnName:t.identifier(varName)})生成我们想要的ast节点后,我们可以将其插入到我们现有的节点中。functionfunctionEnter(path,state){constnode=path.node;constfnName=节点名称||node.id?.name||'匿名的';constvarName=`${fnName}_start_time`;conststart=template(`const${varName}=Date.now()`)();constend=template(`console.log('${fnName}cost:',Date.now()-${varName})`)();如果(!node.body){返回;}//插入到容器中,函数第一行添加constfnName_start_time=Date.now()path.get('body').unshiftContainer('body',start);path.get('body').pushContainer('body',end);}module.exports=()=>({visitor:{Function:enterFunction}})主动遍历&停止遍历&状态虽然我们对应的代码在函数的第一行和最后一行添加了,但是我们需要的功能还没有完全实现——如果函数在最后一行执行完之前就返回了,那么耗时数据就打印不出来了。找出函数返回的ast节点,打印出函数返回前的耗时。functiona(){if(Math.random()>0.5){//需要捕获的返回值return'a';}functionb(){//需要跳过的返回return'b';}}对于active的遍历方法,我们把returnStatement的访问者放到了Function的访问者中。当我们进行主动遍历时,我们需要跳过子节点中函数节点的遍历,因为我们的目的只是在遍历函数a节点时访问它的返回值,并不想修改子函数的返回值节点。functionfunctionEnter(path,state){//主动遍历path.traverse({//访问遍历子函数,跳过子函数及其子节点遍历Function(innerPath){innerPath.skip();},//访问类型为ReturnStatement的子节点ReturnStatement:returnEnter//passstate},{fnName})}functionreturnEnter(path,state){//读取状态const{fnName}=state;//代码是resutnxxx;创建新的constfnName_result=xxx的节点constresultVar=t.identifier(`${fnName}_result`);constreturnResult=template(`constRESULT_VAR=RESULT`)({RESULT_VAR:resultVar,RESULT:path.node.argument||t.identifier('undefined')})//插入兄弟节点path.insertBefore(returnResult);//修改returnxxx为//return(console.log('time-consuming'),fnName_result)constvarName=`${fnName}_start_time`;constend=template(`console.log('${fnName}cost:',Date.now()-${varName})`)();constast=t.sequenceExpression([end.expression,resultVa]);path.node.argument=ast;}最终效果//原始代码functiona(){functionb(){for(leti=0;i<10000000;i+=Math.random()){}functionc(){for(leti=0;i<10000000;i+=Math.random()){}}returnc();}b();for(leti=0;i<10000000;i+=Math.random()){}}//经过babel编译的代码functiona(){vara_start_time=Date.now();函数b(){varb_start_time=Date.now();对于(vari=0;i<10000000;i+=Math.random()){}functionc(){varc_start_time=Date.now();对于(var_i=0;_i<10000000;_i+=Math.random()){}console.log('ccost:',Date.now()-c_start_time);}varb_result=c();returnconsole.log('bcost:',Date.now()-b_start_time),b_result;console.log('bcost:',Date.now()-b_start_time);}b();for(vari=0;i<10000000;i+=Math.random()){}console.log('acost:',Date.now()-a_start_time);}//运行后控制台打印结果成本:290b成本:603a成本:895