我的第一个页面脚手架——AST抽象语法树初探

885 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第10天,点击查看活动详情

事件篇

有段时间我在写一个vue项目,每个新建的页面包含一些固定的组件(比如导航栏等),设定一些固定的属性方法(比如从路由对象的meta中取页面标题)以及引用一些全局的对象等,每次都要进行复制粘贴,虽然不麻烦但是也挺繁琐,因此我打算写个程序直接新建页面的时候把它们一并都处理好,程序也比较简单,只要写好模板用nodejs的fs模块做文件处理就好了:

const PAGE_TEMPLATE = `
<template>
  <div id="page">
    <ss-head-navbar :title="title"></ss-head-navbar>
  </div>
</template>
<script>
import app from "_u/app.js";
import NavBar from "_c/head-navbar/head-navbar";
export default {
  name:"user-info",
  components: {
    "ss-head-navbar": NavBar
  },
  beforeMount() {
    this.title = this.$route.meta.title;
  },
  data() {
    return {
      title: ""
    }
  }
}
</script>`

var fs = require("fs");
const readline = require('readline').createInterface({
  input: process.stdin,
  output: process.stdout
})

readline.question(`请输入页面文件路径(如:userInfo/userInfo.vue):`, path => {
  var pathArray = path.split("/");
  // 获取文件名和目录名
  var fileName = pathArray[pathArray.length - 1];
  var directoryName = path.replace(fileName, "");
  // 获取扩展名
  var extension = fileName.match(/\..*$/)?.[0];
  if (!extension) {
    // 如果没有后缀则加上后缀
    path += ".vue"
  }
  else if (extension != ".vue") {
    // 如果后缀名不是vue
    console.warn("后缀名不是.vue,替换成.vue")
    path.replace(/\..*$/, ".vue")
  }
  fs.writeFile(`src/pages/${path}`, PAGE_TEMPLATE, (err) => {
    if (err) {
      fs.mkdir(`src/pages/${directoryName}`, (err) => {
        if (err) throw err
        fs.writeFile(`src/pages/${path}`, PAGE_TEMPLATE, (err) => {
          if (err) throw err;
        })
      })
    };
    console.log('文件已创建');
  });
  readline.close()
})

既然做到了这一步,那么是否可以继续进一步,将新建的页面写到路由里呢?

疑惑篇

这个项目用的是vue-router,也就是说路由实际上就是传入了一个类似这样的对象:

let routes = [
  {
    path: '/promotion/goods/list',
    name: 'promotion_goods',
    component: () => import('@/pages/promotion/goods/list.vue'),
    meta: {
      title: "消费列表页"
    }
  }
]

也就是说,新生成的页面需要在这里加入新的路由对象,将新页面的文件路径填入routes数组中。

这时候,就需要AST(抽象语法树)来帮忙了:

计算机科学中,抽象语法树Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于 if-condition-then 这样的条件跳转语句,可以使用带有三个分支的节点来表示。

换句话说,抽象语法树就是代码的代码,用代码的形式来描述代码,从而做到“用代码写代码”的功能。

那么,这么一个JS对象,它的语法树是什么样的呢?可以用下面这个网站来查看:astexplorer.net/

简单探究了下掘金上AST相关的文章,解决方案就来了。

解决篇

也就是说,我们可以读取当前JS文件的AST树,解析之后在树上接上新加的节点,之后就可以再次生成新的JS代码了。

这里需要引入四个包:

  • @babel/parser 用于解析原代码AST树
  • @babel/traverse 用于遍历AST树
  • @babel/type 用于添加指定类型的节点
  • @babel/generator 用于根据AST树生成新的代码
const { parse } = require("@babel/parser");
const generate = require("@babel/generator");
const traverse = require("@babel/traverse");
const { objectExpression, objectProperty, stringLiteral,arrowFunctionExpression } = require("@babel/types");

const code = `
let routes = [
  {
    path: '/promotion/goods/list',
    name: 'promotion_goods',
    component: () => import('@/pages/promotion/goods/list.vue'),
    meta: {
      title: "消费页"
    }
  }
]
`

const ast = parse(code);

// 遍历当前代码的AST树
traverse.default(ast, {
  // 只遍历其中的数组
  ArrayExpression(path) {
    // 在数组中根据结构添加对象
    path.node.elements.push(objectExpression([
      objectProperty(stringLiteral("path"), stringLiteral("/promotion/goods/detail")),
      objectProperty(stringLiteral("name"), stringLiteral("promotion_detail")),
      objectProperty(stringLiteral("component"), arrowFunctionExpression([],stringLiteral('@/pages/promotion/goods/detail.vue'))),
      objectProperty(stringLiteral("name"), objectExpression([
        objectProperty(stringLiteral("title"), stringLiteral(escape("消费详情页"))),
      ])),
    ]));
  },
})

const output = generate.default(ast);
console.log(unescape(output.code));

最后生成的结果就是这样:

let routes = [{
  path: '/promotion/goods/list',
  name: 'promotion_goods',
  component: () => import('@/pages/promotion/goods/list.vue'),
  meta: {
    title: "消费页"
  }
}, {
  "path": "/promotion/goods/detail",
  "name": "promotion_detail",
  "component": () => "@/pages/promotion/goods/detail.vue",
  "name": {
    "title": "消费详情页"
  }
}];

其中比较困难的是生成新节点这部分:

objectExpression([
  objectProperty(stringLiteral("path"), stringLiteral("/promotion/goods/detail")),
  objectProperty(stringLiteral("name"), stringLiteral("promotion_detail")),
  objectProperty(stringLiteral("component"), arrowFunctionExpression([], stringLiteral('@/pages/promotion/goods/detail.vue'))),
  objectProperty(stringLiteral("name"), objectExpression([
    objectProperty(stringLiteral("title"), stringLiteral(escape("消费详情页"))),
  ])),
])

这里需要对照着刚才那个页面中的AST树,找到对应节点的构造函数,然后通过层层嵌套,生成新的节点:

  • 对象表达式对应objectExpression
  • 对象中的一个键值对对应objectProperty
  • 字符串对应stringLiteral
  • 胖箭头函数对应arrowFunctionExpression,注意构造函数第一个参数是数组,代表参数,后一个才是函数体

最终再按照之前的方式使用nodejs的fs对象对JS文件进行改写,一个简单的页面脚手架就完工了!