一、从一个需求开始
前几天接到一个新的需求,不是特别复杂如下图:
要求如下:
- 合并树结构下的所有事件与方法到同一个文本编辑器,动态生成方法名称与入参以及系统注释
- 文本编辑器内容变更,通知源数据变更
- 文本编辑器可以编辑当前入参与返参并需要解析出来
- 获取文本编辑器当前焦点,高亮显示当前编辑的树结构
对于一个有多年切图经验的cv仔,大致一设计,就有了实现思路:
- function生成可以通过字符串拼接搞定
- 变更通知在Vue下通过Watch可以监听到具体的数据变化,但是苦于前几天排查Watch造成的各种问题,尤其是通知的不确定性造成的性能问题,对Watch抱有一定的敬畏之心,顾此处没有采用,而是采用发布订阅模式,变更之后定向通知到订阅的function下
- 后面两个字符串的解析问题必须是正则匹配去搞啊
既然有了实现方案,那就说干就干,首先递归解析树结构生成文本编辑器内容
实现订阅发布
//发布变更
notify(methods, data) {
if (this.sub[methods]) {
this.sub[methods].map(item => {
item.notify(data);
return item;
});
}
}
//订阅
/**
*
* @param {sub} 订阅方法
* @param {notify} 通知callback
*/
subscribe(param) {
if (typeof param.notify != "function") {
return console.error("callback必须为function");
}
if (!this[param.sub]) {
return console.error("不存在["+param.sub+"]订阅方法");
}
if (!this.sub[param.sub]) {
this.sub[param.sub] = [];
}
this.sub[param.sub].push(param);
// console.log(
// "事件【" + param.sub + "】订阅成功,通知到【" + param.notify + "】"
// );
}
}
在按照Vue模板解析语法实现一套Function解析,整体齐活
解析结果如下:
到目前为止,我们大致已经实现了当初的需求,可是这个解析模板真的能实现我们所有的语法解析么?
二、思考
- JS语法过于活泛尤其是在function内部如下图,该如何去解析返回?
function test(name){
()=>{
()=>{
(function(){
return
})()
}
}
}
- function 名称被修改之后如何判断,原则上函数名称系统生成不可变更
function test(name){
return name
}
//修改成
function getName(name){
return name
}
基于上面两个痛点问题,如果单纯使用正则匹配的方式去实现,需要实现及其复杂且繁琐的匹配规则,而且场景覆盖可能也没有那么的完善,需要不断在实际运行中不断去完善解析规则,那么我们应该如何去处理?
答案是可以通过AST去替代我们自主完成的语法解析。
三、AST实现 JS 语法解析
在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。对前端开发而言AST你可能不熟悉但是这却是你日常开发必不可少的,如babel实现高版本JS原发转化为低版本语法,在这里我们就不详细介绍AST相关知识了,感兴趣可以参考陈栋老师的文章【JavaScript AST实现原理揭秘】。接下来我们一起看看如何通过AST去解析获取方法名称以及返回值。
1.引入AST解析依赖包,这里我们采用babel/parser
`
const parser = require("@babel/parser");
let configJsTree = parser.parse(`${StrFun}`,{
sourceType: "module",
plugins: [
"jsx",
"flow",
],
});
通过@babel/parser解析之后我们得到如下的树结构
我们先来看看每个字段都代表了什么,才能继续向下分析
字段名称 | 解释 |
---|---|
CommentBlock | 块注释 |
CommentLine | 单行注释 |
FunctionDeclaration | 块状域,存放完整的代码块 如 { return a} |
ReturnStatement | 返回域 程序中的return |
Identifier | (标志)对象,用来作为函数的唯一标志 |
ExpressionStatement | 表达式 |
VariableDeclarator | 等同于var |
BinaryExpression | 表达式 |
ArrowFunctionExpression | 箭头函数 |
本次我们分析能用到的有 Identifier,FunctionDeclaration,ReturnStatement等,接下来我们实现语法的解析 |
let body = configJsTree.program.body;
let functionList = {};
body.forEach(item => {
//如果是函数体
if (item.type == "FunctionDeclaration") {
//获取方法名称
let functionName = item.id.name;
//获取入参
let param = [];
if (item.params) {
item.params.forEach(jj => {
param.push(jj.name);
});
}
if (param.length > 0) {
param = param.join(",");
} else {
param = "";
}
let hasReturn = false;
//获取返参,存在return 并且不是空 return
if (
item.body.body.some(kk => kk.type == "ReturnStatement" && kk.argument)
) {
hasReturn = true;
}
//获取程序开始位置,以及代码行数
let position = `${item.loc.start.line},${item.loc.end.line}`;
//获取方法体,由于AST需要逐层解析过于复杂,且业务无需该操作,顾解析当前方法体采用正则匹配
let ContentString = res.split("\n");
ContentString = ContentString.slice(
Number(position.split(",")[0]) - 1,
Number(position.split(",")[1])
);
ContentString = ContentString.join("\n");
let content = parseStrinToFunctionContent(ContentString);
functionList[functionName] = {
functionName,
param,
hasReturn,
position,
function: content
};
}
});
解析结果
函数名称变更监听
...
if(this.oldFunctionList){
//如果老数据列表不存在当前函数名称,启动check
if(!this.oldFunctionList[functionName]){
for(let i in this.oldFunctionList){
if(this.oldFunctionList[i].position==position&&this.oldFunctionList[i].functionName!=functionName){
functionName=this.oldFunctionList[i].functionName
item.id.name=this.oldFunctionList[i].functionName
nameBeChanged=true
}
}
}
}
....
//AST重置回JavaScript,并更新视图
if(nameBeChanged){
const generate = require("@babel/generator").default
configJsTree.program.body=body
const result = generate(configJsTree, {minified: true})
this.notify("updateEditor",result.code)
}
到目前为止我们已经实现了上面的大部分要求,包括函数名称变更检测以及还原,参数解析,函数名称解析等。