- 网上有很多关于loader 实现埋点的dome。但真正用于业务一个dome 往往是不够的,还需要我们考虑很多实际场景。接下来这篇文章就是我应用于项目中的一个埋点loader,实际应用场景坑还是非常多。
转换规则是什么
- 首先不使用loader,是这样埋点的。方法也毕竟简单
- 从 @/services 里面 引入 buriedPoint 方法
- 然后在某些特定事件下触发 buriedPoint 函数发起请求。
import { buriedPoint } from '@/services'
const Index = () => {
return (
<View onClick={ () => buriedPoint({ code: 'A0001' }) }>点击埋点</View>
)
}
- import { buriedPoint } from ‘@/services’ 需要loader 自动导入
- 特殊注释,转换为 buriedPoint({ code: ’ xxx ’ }) 的形式
buriedPoint({ code: 'A0009' })
从 @/services 里面 导入 buriedPoint 方法

- ImportDeclaration:它表示一个ES6模块的导入声明。
- 循环ast 上的导入声明
traverse(ast, {
let isImport = false
let isImportName = false
let specifierNode = null
ImportDeclaration(path) {
pathNode = path
if(path.node.source.value === "@/services") {
isImport = true
for (const specifier of path.node.specifiers) {
if(specifier.local.name === "buriedPoint") {
isImportName = true
break;
}
}
specifierNode = path.node.specifiers
}
}
})
- path.node.source.value 拿到所有导入节点的导入路径,判断是否有 === “@/services”
- 如果有导入这个模块,再判断 specifier.local.name === “buriedPoint” 是否导入有当前方法

得到三个结论:没有导入模块;导入了模块,但是没有导入方法;导入模块也导入了方法(不管) - 没有导入模块
const createImport = () => {
const importNode = t.importDeclaration(
[t.importSpecifier(t.identifier("@/services"), t.identifier("buriedPoint"))],
t.stringLiteral("@/services")
)
return importNode
}
if(!isImport && pathNode) {
const importNode = createImport()
pathNode.insertBefore(importNode)
}
if(isImport && !isImportName && specifierNode) {
const item = t.importSpecifier(t.identifier("buriedPoint"), t.identifier("buriedPoint"))
specifierNode.push(item)
}
转换特殊注释
let isCheck = false
traverse(ast, {
enter(path) {
const leadingComments = path.node.leadingComments
const trailingComments = path.node.trailingComments
if (leadingComments) {
insertFunction( leadingComments, isCheck, path, 'before', ast)
}
if(trailingComments) {
insertFunction(trailingComments, isCheck, path, 'after', ast)
}
},
})
const createFunExpression = (code) => {
const obj = t.objectExpression([
t.objectProperty(t.stringLiteral('event_id'), t.stringLiteral(code))
])
const fun = t.callExpression(
t.identifier("buriedPoint"),
[obj]
)
return fun
}
const insertFunction = (comments, isCheck, path, type, ast) => {
comments.forEach(value => {
const v = value.value.trim()
if(
v.startsWith('!code-') ||
v.startsWith('! code-') ||
v.startsWith('!code- ') ||
v.startsWith('!code - ') ||
v.startsWith('!code -')
) {
const code = v.split('-')[1]?.trim()
if(code) {
!isCheck && checkImportPoint(ast)
isCheck = true
if(type === 'before') {
path.insertBefore(createFunExpression(code))
}else {
path.insertAfter(createFunExpression(code))
}
}
}
})
}
- 到这里,基本上已经完成了。但是还有个细节需要处理,就是这样的一个场景。如果当前文件已经转换了一次,进行第二次转换,那是不是每个注释下面都会多一个方法。每多执行一遍,就会多出一个方法。
- 找到函数表达式节点 === buriedPoint 的函数
- 然后再找出函数上下的注释节点
- 判断注释节点的值是不是 和 buriedPoint({ code: ‘’}) 的code值一样
- 如果不一样就把 当前注释放入 hasFunctionList
- 在注释 traverse 哪里做一次判断
let hasFunctionList = []
traverse(ast, {
ExpressionStatement(path) {
const exp = path.node.expression
if(exp.type === 'CallExpression' && exp.callee.name === "buriedPoint") {
const fnNode = path.node.expression
const p = fnNode.arguments[0].properties[0].value.value
const comments = [...(path.node.leadingComments || []), ...(path.node.trailingComments || [])]
for(let com of comments) {
if(com.value.includes(p)) {
hasFunctionList.push(com)
}
}
}
},
})
修改转换特殊注释
let hasFunctionList = []
const isEqualComment = (oldComment, newComment) => {
const curComment = newComment.filter(com => {
return !oldComment.some(subCom => {
return subCom.start === com.start && subCom.end === com.end && subCom.value === com.value
})
})
return curComment
}
traverse(ast, {
enter(path) {
const leadingComments = path.node.leadingComments
const trailingComments = path.node.trailingComments
if (leadingComments) {
insertFunction( isEqualComment(hasFunctionList, leadingComments), isCheck, path, 'before', ast)
}
if(trailingComments) {
insertFunction(isEqualComment(hasFunctionList, trailingComments), isCheck, path, 'after', ast)
}
},
})