前端使用AST分析项目代码,画张“地图”

573 阅读8分钟

本文主要:介绍如何给项目的代码或者元素画一张可查看的“地图”,产品或者运营要啥就往里面自个找就完事,那么下面说说我的理解吧。

什么是“代码地图”??what

主要是不知道叫啥名🐶,但是意思是“对于项目里的代码,我根据JSON能知道他的确切位置”,简单的说就是用JSON去描述项目代码的确切位置。具体来说就是我之前埋点文章里面的应用 webpack plugin生成路由-埋点映射

为什么要做??why

场景一:

如埋点里面所述,代码埋点核心的一个问题是:“产品或者运营想知道项目里埋了什么点?这些埋点每个都是干嘛用的?”。!!是不是灵魂拷问?

若是埋点文档不全或丢失,那就离谱妈妈给离谱开门了,你还不得一个个跟后端去对每一个打点产生的效果? 因此我在上篇埋点文章统一了埋点方法,并生成了【路由-埋点、埋点信息、埋点参数】的“代码地图”。

场景二

同样的问题,Sass平台权限的实现是基于RBAC权限模型的,那么前端所控制角色的视图权限,也是需要在代码里面侵入式地用指令或者方法来判断是否有权限(菜单权限,操作权限等)。

那在管理后台给用户所对应的角色配置权限时,怎么知道我手上有哪些权限? 这些权限又是运用到哪个菜单,哪个页面,哪个按钮呢?产品或者运营先你要权限配置时,你要怎么做呢,一个个查吗? 因此一份关于权限的“代码地图”是十分必要的!!

如图,拿到JSON后,对JSON可视化,就可轻松配置权限,前端项目公司管理业务模块下的权限: image.png

对于最终输出的“地图”信息,根据不同业务所需要的信息不同,我这里选取了业务模块,业务模块关联的路由,路由下子路由包含的关于具体代码的信息。技术只有解决的是业务问题!!因此得结合自己的业务实现去拼凑更丰富的信息。

“代码地图”实现 how

上面所看到,要生成一份“代码地图”得找到一个出发点,然后开始踏遍整个世界达到最终目标(有原神那味了~)。 一句话讲完:基于vue的项目(react的应该也一样8),很自然的选择了路由作为入口,从路由开始遍历每个路由模块下所依赖的页面,页面所依赖的组件,遍历过程中的记录每一个你要找的东西在哪里。

  1. 对路由做AST语法结构解析转换,得到路由-组件关系,且叫它componentMap
  2. 遍历componentMap进行AST解析,找到想找的代码[组件使用,方法调用]等,最后构建“路由-组件-元素”关系 elementMap

一步是对路由文件进行静态代码分析,一步是对vue组件等关联依赖进行静态代码分析。因此先用FileTransform对路由做转换。

FileTransform静态代码分析

最好先有babel AST相关认识: 深入Babel,这一篇就够了FileTransform深度优先遍历当前文件的依赖,没遍历到一个文件便把变量对应值收集到一个池里面,在最后计算导出的变量时,从变量池里面将依赖变量的值拿出来,最终组合成静态的对象。

  1. source code 解析成AST
  2. 收集依赖模块
  3. 生成变量池
  4. 组装目标模块导出值

source code 解析成AST

@babel/core 获取源代码,@babel/parser对源代码进行解析,生成AST,因为项目里面是ts文件,因此要添加预设preset,得到AST结构树,代码如下

filePath即路由文件的路径

parseAst(filePath) {
  const porps = {}
  const ext = path.extname(filePath)
  if (ext === '.ts') {
    porps.presets = ['@babel/preset-typescript']
  }
  const transFormResult = babel.transformFileSync(filePath, porps)
  const { code } = transFormResult
  return babelParser.parse(code, {
    sourceType: 'module'
  })
}

收集依赖模块

拿到AST后,就可以对每个node结点进行分析。这一步收集依赖主要是捋清楚, 目标模块和引入模块 的关系,如:

若是引入的模块又依赖其他模块,则递归下去继续收集,最终都会“打平”到对象里面

// 引入:
import { getMenuList, routesList2Map } from './utils'
import routes from './routes'
import documentRouteGroup from './modules/document'

// 收集到的依赖:
dependenciesMap =  {
  documentRouteGroup: './modules/document',
  getMenuList: './utils',
  routesList2Map: './utils',
  routes: './routes'
}

实现

ES6模块引入的三种方式

import { Ajax } from '../lib/utils';
import utils from '../lib/utils';
import * as utils from '../lib/utils';

babel importspecifier 文档可知,在AST中用于表示上面导入的三个变量的节点是不同的,分别叫做ImportSpecifier、ImportDefaultSpecifier和ImportNamespaceSpecifier。 这里拿 import utils from '../lib/utils';举例。 Babel接受得到AST并通过babel-traverse对其进行深度优先遍历,遍历到import模块引入则会自动调用ImportDeclaration方法,因此收集依赖的模块在此方法里面做就行。

import babelTraverse from 'babel-traverse'
// fileAst为上一步骤解析出来的AST
babelTraverse(this.fileAst, {
  ImportDeclaration: ({ node }) => {
    // 获取所有的依赖地址
    this.dependenciesMap = {
      ...(this.dependenciesMap || {}),
      ...this.getImportDeclarationKeyAndPath(node)
    }
  },
})

getImportDeclarationKeyAndPath是对node节点进行处理,最终获得当前模块变量以及导入模块的映射。

ImportDeclaration节点还有specifiers和source这两个特殊字段,specifiers表示import导入的变量组成的节点数组,source表示导出模块的来源节点。这里再说一下specifier中的imported和local字段,imported表示从导出模块导出的变量,local表示导入后当前模块的变量

getImportDeclarationKeyAndPath方法:

getImportDeclarationKeyAndPath(node) {
  const { source, specifiers } = node
  const keyPathMap = {}
  if (specifiers.length) {
    const { value: path } = source
    specifiers.forEach(specifier => {
      const { local: localNode, imported: importedNode } = specifier
      const { name: localName } = localNode || {}
      const { name: importedName } = importedNode || {}

      if (t.isImportDefaultSpecifier(specifier)) {
        keyPathMap[localName] = path
      } else if (t.isImportSpecifier(specifier)) {
        // localName2ImportedName:引入的依赖 变量名 和 本地的引用别名 关系
        this.localName2ImportedName[localName] = importedName
        if (localName === importedName) {
          keyPathMap[localName] = path
        } else {
          keyPathMap[localName] = {
            imported: importedName,
            path
          }
        }
      } else {
        this.errorLog(
          'getImportDeclarationKeyAndPath not cover',
          specifier,
          node
        )
      }
    })
  }
  return keyPathMap
 }

生成变量池

既然目标模块的依赖都捋清楚了,那下面就可以递归地去找到每个模块中定义的变量值,最终都记录在一个map对象结构里面(就叫变量池吧),举个例子:上面引入的documentRouteGroup路由文档模块的值,如下

// routes.ts
// 引入文档管理模块
import documentRouteGroup from './modules/document'
// 模块里定义
const documentRouteGroup = [
  {
    path: 'document-manage',
    component: load('document-manage/index'),
    meta: {
      name: '文档管理',
      order: 0,
      activePath: '/main/document-manage',
      authType: Permission.ViewDocumentManage
    }
  }
]
// AST计算出文档模块的变量值
variableMap =  {
  documentRouteGroup: [ { path: 'document-manage', component: [Object], meta: [Object] } ]
}

实现

根据AST的node结点信息,要怎么判断当前结点是变量的定义呢? 这里借助babel工具库babel-types,例如判断是否是let、const、var关键字定义,判断该结点是不是variableDeclaration类型即可,详细用法 实现思路是:既然可以知道每个结点是什么类型,那一直递归AST树去找到每个元素的值就好。举[在线工具](https://astexplorer.net/)的例子 。 获取结点值的方法:

// 直接且粗暴
getNodeValue(node) {
  if (t.isArrayExpression(node)) {
    return this.getArrayExpressionValue(node)
  } else if (t.isCallExpression(node)) {
    return this.getCallExpressionValue(node)
  } else if (t.isObjectExpression(node)) {
    return this.getObjectExpressionValue(node)
  } else if (t.isFunctionExpression(node)) {
    return this.getFunctionExpressionValue(node)
  } else if (t.isIdentifier(node)) {
    return this.getIdentifierValue(node)
  } else if (t.isBlockStatement(node)) {
    return this.getBlockStatementValue(node)
  } else if (t.isReturnStatement(node)) {
    return this.getReturnStatementValue(node)
  } else if (t.isNumericLiteral(node) || t.isStringLiteral(node)) {
    return node.value
  } else if (t.isImport(node)) {
    return 'Import'
  } else if (t.isMemberExpression(node)) {
    return this.getMemberExpressionValue(node)
  } else if (t.isVariableDeclarator(node)) {
    return this.getVariableDeclaratorValue(node)
  } else if (t.isVariableDeclaration(node)) {
    return this.getVariableDeclarationValue(node)
  } else if (t.isExpressionStatement(node)) {
    return this.getExpressionStatementValue(node)
  } else if (t.isAssignmentExpression(node)) {
    return this.getAssignmentExpressionValue(node)
  } else {
    this.errorLog('getNodeValue not cover node', node)
  }
  }

isArrayExpression单独拎出来结合[在线工具](https://astexplorer.net/)里面的ArrayExpression结点,getArrayExpressionValue实现: image.png

getArrayExpressionValue(node) {
  const arr = []
  const { elements } = node
  if (elements) {
    elements.forEach(ele => {
      const obj = this.getNodeValue(ele)

      if (obj !== undefined) {
        if (t.isIdentifier(ele)) {
          arr.push(this.getVariableValue(obj))
        } else {
          arr.push(obj)
        }
      } else {
        console.warn('ele cant to obj', ele)
      }
    })
  }
  return arr
}

一个个结点的处理,把所有情况覆盖后,就形成了所谓的变量池。

组装目标模块导出值

以上 有了目标模块所有依赖模块的值,那组装目标模块导出值岂不是分分钟的事? 那么路由文件是这样定义的:

// routes.ts
const routes = [
  //...省略
  {
    name: 'main',
    path: '/main',
    component: MainLayout,
    children: [
    	...documentRouteGroup
    ]
  }
  //...省略
]

最终值为:

[
  {
    "path": "document-manage",
    "component": {
      "funcName": "load",
      "value": [
        "document-manage/index"
      ]
    },
    "meta": {
      "name": "文档管理",
      "order": 0,
      "activePath": "/main/document-manage",
      "authType": "view_document_manage"
    }
  }
]

总结

目前为止,对路由文件进行了静态代码分析,得到了最终路由与组件的依赖对象,下一步就行遍历每个vue组件,对vue组件里面用到的目标元素进行收集啦!这个后面有时间再继续写吧,主要的核心还是AST,对于vue得借助vue-template-compiler工具进行解决,对于react也有对应的编译解析工具,掌握思路最重要!!

因为Webpack 的运行流程是一个串行的过程,对于封装成webpack plugin应用到项目中建议使用child_process,否则会阻塞。

希望对大家有帮助吧~

另外!AST分析节点还有什么办法吗? 目前我直接就是借助babel一把梭~~

参考

前端代码埋点折腾📝

深入Babel,这一篇就够了

埋点自动收集方案-埋点提取​ ​