本文主要:介绍如何给项目的代码或者元素画一张可查看的“地图”,产品或者运营要啥就往里面自个找就完事,那么下面说说我的理解吧。
什么是“代码地图”??what
主要是不知道叫啥名🐶,但是意思是“对于项目里的代码,我根据JSON能知道他的确切位置”,简单的说就是用JSON去描述项目代码的确切位置。具体来说就是我之前埋点文章里面的应用 webpack plugin生成路由-埋点映射。
为什么要做??why
场景一:
如埋点里面所述,代码埋点核心的一个问题是:“产品或者运营想知道项目里埋了什么点?这些埋点每个都是干嘛用的?”。!!是不是灵魂拷问?
若是埋点文档不全或丢失,那就离谱妈妈给离谱开门了,你还不得一个个跟后端去对每一个打点产生的效果? 因此我在上篇埋点文章统一了埋点方法,并生成了【路由-埋点、埋点信息、埋点参数】的“代码地图”。
场景二
同样的问题,Sass平台权限的实现是基于RBAC权限模型的,那么前端所控制角色的视图权限,也是需要在代码里面侵入式地用指令或者方法来判断是否有权限(菜单权限,操作权限等)。
那在管理后台给用户所对应的角色配置权限时,怎么知道我手上有哪些权限? 这些权限又是运用到哪个菜单,哪个页面,哪个按钮呢?产品或者运营先你要权限配置时,你要怎么做呢,一个个查吗? 因此一份关于权限的“代码地图”是十分必要的!!
如图,拿到JSON后,对JSON可视化,就可轻松配置权限,前端项目公司管理业务模块下的权限:
对于最终输出的“地图”信息,根据不同业务所需要的信息不同,我这里选取了业务模块,业务模块关联的路由,路由下子路由包含的关于具体代码的信息。技术只有解决的是业务问题!!因此得结合自己的业务实现去拼凑更丰富的信息。
“代码地图”实现 how
上面所看到,要生成一份“代码地图”得找到一个出发点,然后开始踏遍整个世界达到最终目标(有原神那味了~)。 一句话讲完:基于vue的项目(react的应该也一样8),很自然的选择了路由作为入口,从路由开始遍历每个路由模块下所依赖的页面,页面所依赖的组件,遍历过程中的记录每一个你要找的东西在哪里。
- 对路由做AST语法结构解析转换,得到路由-组件关系,且叫它
componentMap - 遍历
componentMap进行AST解析,找到想找的代码[组件使用,方法调用]等,最后构建“路由-组件-元素”关系elementMap
一步是对路由文件进行静态代码分析,一步是对vue组件等关联依赖进行静态代码分析。因此先用FileTransform对路由做转换。
FileTransform静态代码分析
最好先有babel AST相关认识: 深入Babel,这一篇就够了。
FileTransform深度优先遍历当前文件的依赖,没遍历到一个文件便把变量对应值收集到一个池里面,在最后计算导出的变量时,从变量池里面将依赖变量的值拿出来,最终组合成静态的对象。
- source code 解析成AST
- 收集依赖模块
- 生成变量池
- 组装目标模块导出值
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实现:
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一把梭~~
参考