相关文章
基于注释的日志上报方案
一、日志采集发展史
直接在代码中插入埋点事件
优势:没有技术成本、无脑埋
劣势:耦合度高、影响代码可读性、阻碍业务迭代、增加维护成本、引入错误风险
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <template> <div> <button @click="handleClick">下单</button> <button @click="handleExposure">打开弹窗</button> </div> </template>
<script> import report from '@/utils/report.js'
export default { methods: { handleClick() { const par = { ... } report.click('eventName', par) }, handleExposure() { const par = { ... } report.exposure('eventName', par) } } } </script>
|
通过指令的形式进行埋点
优势:埋点与业务相对隔离
劣势:耦合度高、影响代码可读性、限定技术栈
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
import Vue from 'vue'; import App from './App.vue'; import report from '@/utils/report.js'
Vue.directive('track-click', { inserted(el, binding) { el.addEventListener('click', function() { const { eventName, par } = binding.value; report.click(eventName, par) }); } });
new Vue({ render: h => h(App), }).$mount('#app');
|
1 2 3 4 5 6 7 8
| <template> <div> <button v-track-click="{eventName: 'xxx', par}">下单</button> </div> </template> <script></script> <style scoped></style>
|
通过装饰器进行埋点
优势:进一步解耦,以注解的形式实现
劣势:只能作用于类的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| import React from 'react'; import report from '@/utils/report.js'
function withClickTracking(eventName) { return function(target, key, descriptor) { const originalMethod = descriptor.value; descriptor.value = function(...args) { report.click(eventName, args) return originalMethod.apply(this, args); }; return descriptor; }; }
class MyComponent extends React.Component {
@withClickTracking('eventName') handleClick() { }
render() { return ( <div> <button onClick={this.handleClick.bind(this)}>下单</button> </div> ); } }
export default MyComponent;
|
二、埋点方案复盘
为了解决上述问题并确保通用性,我们可以尝试通过注释的形式来实现埋点上报功能。通过在需要执行上报的方法上添加特定格式的注释,我们可以在编译过程中解析这些注释,并将对应的上报逻辑添加到方法体内。这种方式不限制于特定的技术栈、语法或框架,同时也能够减少代码耦合,提高代码的可维护性和通用性。
大致过程
- 在需要执行上报的方法上添加注释,指定一个约定的上报方法名,以标注事件类型,并可传递固定参数(如事件编码)。
- 编译过程中将带有上报注释的方法添加到对应方法体内。同时,将函数的参数对象传递给上报方法,确保上报方法具有完整的作用域。
- 提供一个全局上报函数,建议将其绑定到 window 对象上。由于不同技术栈的全局方法注入形式不同,此举有助于保持一致性。特别是在函数式组件中,没有 this 对象,需要手动导入实例或使用上下文对象。
- 实现日志上报
三、详细实现
添加注释
选项式api
1 2 3 4 5 6
| export default { methods: { // @report('click', 'bbb') addTodo() {}, }, }
|
组合式api
1 2 3 4 5 6
| export default defineComponent({ setup() { // @report('click', 'bbb') const addTodo = () => {}; } })
|
编译
详细实现细节参考之前的两篇文章
提取js文件函数注释
提取vue和jsx文件函数注释
实现结果打印如下
在上述文档中,我们介绍了如何在不同文件和语法环境下提取注释,以及如何提取带有注释的方法名。接下来,我们将在每个方法体内追加一个相应的函数。
追加日志上报函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| const handleFunction = (node, name, leadingComments = []) => { if (leadingComments.length > 0) { const comments = leadingComments .map((comment) => comment.value .trim() .split("\n") .map((line) => line.trim().replace(/^\*+\s*/, "")) .filter(Boolean) ) .flat(); const reportComment = comments.find( (comment) => comment.indexOf("@report") !== -1 ); if (reportComment) { const bodyNodes = node.body.body;
const reportStatement = babel.template.statement.ast( `window._g_report_ && window._g_report_(arguments, ${results.args})` ); bodyNodes.unshift(reportStatement); } } };
|
执行效果体验如下
至此,我们的核心工作已经完成,接下来把它放在编译时期来实现把,例如使用webpack
使用webpack插件自动化处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| const fs = require('fs'); const path = require('path');
class ReplaceFilePlugin { constructor(options) { this.options = options; }
apply(compiler) { compiler.hooks.emit.tapAsync('ReplaceFilePlugin', (compilation, callback) => { const outputPath = compilation.options.output.path;
for (const filename in compilation.assets) { if (filename.match(/\.(js|vue|jsx)$/)) { const asset = compilation.assets[filename]; const filepath = path.resolve(outputPath, filename); let content = asset.source();
content = this.options.process(content);
fs.writeFileSync(filepath, content); } }
callback(); }); } }
module.exports = ReplaceFilePlugin;
|
解析器实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
| const { parse } = require("@babel/parser"); const { parse: vueParse } = require("@vue/compiler-sfc"); const fs = require("fs");
const isFunctionExpression = (type) => { return type === "FunctionExpression" || type === "ArrowFunctionExpression"; };
const parseAST = (node, handleFunction) => { if (node.type === "File") { node.program.body.forEach((n) => parseAST(n, handleFunction)); } else if (node.type === "ExportDefaultDeclaration") { parseAST(node.declaration, handleFunction); } else if (node.type === "CallExpression") { node.arguments.forEach((arg) => parseAST(arg, handleFunction)); } else if (node.type === "ObjectMethod") { if (node.key.name === "setup") { node.body.body.forEach((n) => parseAST(n, handleFunction)); } else { handleFunction(node, node.key.name, node.leadingComments); } } else if (node.type === "ObjectExpression") { node.properties.forEach((property) => parseAST(property, handleFunction)); } else if (node.type === "FunctionDeclaration") { handleFunction(node, node.id.name, node.leadingComments); } else if (node.type === "VariableDeclaration") { const declaration = node.declarations[0]; if (declaration.init && isFunctionExpression(declaration.init.type)) { handleFunction(declaration.init, declaration.id.name, node.leadingComments); } else { parseAST(declaration.init, handleFunction); } } else if (node.type === "ObjectProperty") { if (isFunctionExpression(node.value.type)) { handleFunction(node, node.key.name, node.leadingComments); } else if (node.value.type === "ObjectExpression") { node.value.properties.forEach((property) => parseAST(property, handleFunction) ); } } };
const getScriptContent = (descriptor) => { return descriptor.script ? descriptor.script.content : descriptor.scriptSetup ? descriptor.scriptSetup.content : descriptor.source ? descriptor.source : ""; };
module.exports.parseVueComments = (content, handleFunction) => { const { descriptor } = vueParse(content); const scriptContent = parse(getScriptContent(descriptor), { sourceType: "module", plugins: ["jsx"], }); parseAST(scriptContent, handleFunction); return scriptContent };
|
使用插件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const { parseVueComments } from './parseAST' const ReplaceFilePlugin = require('./ReplaceFilePlugin');
module.exports = { plugins: [ new ReplaceFilePlugin({ process: (content) => { return parseVueComments(content); } }) ] };
|
上报方法
详细实现细节参考之前的一篇文章
日志上报的方法