ctc-track-plugin
Version:
uniapp 小程序埋点劫持
1 lines • 13.9 kB
Source Map (JSON)
{"version":3,"sources":["../src/inject-click-handler.ts"],"sourcesContent":["/**\n * @description 自动注入埋点代码的Vite插件,在Vue单文件组件的click事件中自动添加埋点统计代码\n * @version 1.0.0\n * @author CTC开发团队\n * @refer https://juejin.cn/post/7357609459345506330?earchId=20250730133928053080265BFB46DB6163\n */\n\n// inject-click-handler.ts vite插件 - 用于自动注入点击事件埋点\nimport { parse } from 'vue/compiler-sfc';\nimport { Plugin } from 'vite';\n\n// 定义替换规则接口\ninterface Replacement {\n source: string; // 原始代码片段\n replaceSource: string; // 替换后的代码片段\n}\n\n/**\n * 查找节点中的click事件属性\n * @param node AST节点\n * @returns click事件属性对象,如果未找到则返回undefined\n */\nconst findClickEvent = (node) => node.props.find(\n (prop) =>\n prop.type === 7 && // 指令类型(v-on:click或@click)\n prop.name === 'on' && // 事件指令名称\n prop.arg?.content === 'click', // 事件类型为click\n)\n\n/**\n * 获取标签的文本内容\n * @param node AST节点\n * @returns 标签的文本内容\n */\nconst findTagTextContent = (node) => {\n const tagText = node.children.find((item) => item.type === 2)?.content ?? '';\n return tagText;\n}\n\n/**\n * 判断表达式是否为函数调用格式的正则表达式\n * 包含两个匹配模式(使用 | 分隔):\n *\n * 模式1: ^\\s*[a-zA-Z_$][\\w$]*\\s*\\([^)]*\\)\\s*$\n * - 匹配带括号的函数调用,如 \"handleClick()\", \"submit(event)\"\n *\n * 模式2: ^\\s*[a-zA-Z_$][\\w$]*\\s*$\n * - 匹配不带括号的函数名,如 \"handleClick\", \"submit\"\n *\n * 用于识别Vue模板中的click事件表达式类型\n * 决定是否需要为函数添加括号或保持原有格式\n */\nconst FUNCTION_CALL_REGEX = /^\\s*[a-zA-Z_$][\\w$]*\\s*\\([^)]*\\)\\s*$|^\\s*[a-zA-Z_$][\\w$]*\\s*$/;\n\n/**\n * 检测并修复函数调用表达式格式\n * @param expContent 表达式内容\n * @returns 修复后的表达式\n */\nconst fixFunctionCallExpression = (expContent: string): string => {\n const isFunctionCall = FUNCTION_CALL_REGEX.test(expContent);\n return isFunctionCall && !/\\([^)]*\\)/.test(expContent)\n ? `${expContent}()`\n : expContent; // 类似 isExpand = false的表达式\n}\n\n/**\n * 创建埋点事件字符串并替换节点\n * @param node AST节点\n * @param clickEvent click事件对象\n * @param eventContent 埋点事件内容\n * @param replacements 替换列表\n */\nconst createTrackingEventAndReplace = (\n node: any,\n clickEvent: any,\n eventContent: string,\n replacements: Replacement[]\n) => {\n const expContent = clickEvent.exp?.content || '';\n const fixedExp = fixFunctionCallExpression(expContent);\n\n const newEventString = `@click.stop=\"${eventContent};${fixedExp}\"`;\n\n // 创建新节点字符串(替换原事件)\n const newNodeStr = node.loc.source\n .replace(clickEvent.loc.source, newEventString)\n .trim();\n\n replacements.push({\n source: node.loc.source,\n replaceSource: newNodeStr,\n });\n}\n\n\n\n/**\n * 创建自动注入点击埋点的Vite插件\n * 该插件会在构建时自动分析Vue单文件组件,为具有click事件的元素注入埋点代码\n * @returns Vite插件对象\n */\nexport default (): Plugin => ({\n name: 'inject-click-handler', // 插件名称\n /**\n * 转换函数,在构建过程中处理文件\n * @param code 文件原始代码\n * @param id 文件ID(路径)\n * @returns 转换后的代码或null\n */\n transform(code, id) {\n try {\n // 只处理.vue文件\n if (!/.vue$/.test(id)) return null;\n\n // 解析Vue单文件组件的AST\n const parseCode = parse(code);\n if (!parseCode) return null;\n if (!parseCode.descriptor?.template?.content) return null;\n\n // 获取模板内容\n const { content } = parseCode.descriptor.template;\n\n let $code = content; // 修改后的模板代码\n const replacements: Replacement[] = []; // 存储所有需要替换的代码片段\n\n /**\n * 递归遍历AST节点,查找需要注入埋点的元素\n * @param node 当前AST节点\n */\n const traverse = (node: any) => {\n // 处理当前节点:检查是否有属性\n if (node?.props?.length) {\n // 查找data-manual-track属性(手动埋点标识)\n const mdProp = node.props.find(\n (prop) =>\n (prop.type === 6 && prop.name === 'data-manual-track') || // 静态属性\n (prop.type === 7 &&\n prop.name === 'bind' &&\n prop.arg?.content === 'data-manual-track'), // 动态绑定属性\n );\n\n // 如果元素有data-manual-track属性,则处理手动埋点\n if (mdProp) {\n const tagName = node.tag; // 获取标签名\n\n // 获取埋点内容(静态值或表达式)\n const mdContent =\n mdProp.type === 6\n ? `'${mdProp.value?.content || ''}'` // 静态属性值\n : mdProp.type === 7\n ? mdProp.exp?.content || '' // 动态表达式\n : '';\n\n // 查找现有的click事件\n const clickEvent = findClickEvent(node)\n\n let newEventString = '';\n\n const text = findTagTextContent(node);\n\n // 如果元素已有click事件,则在原有事件前面添加埋点代码\n if (clickEvent) {\n // 构造手动埋点事件内容\n const eventContent = `sendMd({\n content:${mdContent},\n tag:'${tagName}',\n text:'${text}'\n })`;\n\n // 创建埋点事件并替换节点\n createTrackingEventAndReplace(node, clickEvent, eventContent, replacements);\n } else {\n // 元素没有click事件时,直接添加埋点click事件\n newEventString = `@click.stop=\"sendMd({\n content:${mdContent},\n tag:${tagName},\n text:'${text}'\n })\"`;\n\n // 查找标签结束位置\n const closingBracketIndex = node.loc.source.lastIndexOf('>');\n // 判断是否为自闭合标签(如<img />)\n const isSelfClosing =\n node.loc.source[closingBracketIndex - 1] === '/';\n\n // 在开始标签末尾添加click事件\n let newNodeStr;\n if (isSelfClosing) {\n // 自闭合标签:在/>前添加事件\n newNodeStr =\n node.loc.source.slice(0, closingBracketIndex - 1).trim() +\n ` ${newEventString} />`;\n } else {\n // 普通标签:在>前添加事件\n newNodeStr =\n node.loc.source.slice(0, closingBracketIndex).trim() +\n ` ${newEventString}>`;\n }\n\n // 添加到替换列表\n replacements.push({\n source: node.loc.source,\n replaceSource: newNodeStr,\n });\n }\n } else {\n const tagName = node.tag;\n if (tagName === 'navigator' && node.type === 1) {\n // 如果是navigator元素,因为uni.addInterceptor没法监听到对应的跳转,所以单独处理\n const url =\n node.props.find((item) => item.name === 'url')?.value\n ?.content ??\n node.props.find((item) => item.rawName === ':url')?.exp\n ?.content;\n\n // 创建新节点字符串(新增事件)\n // 捕获标签内的所有属性(除了 > 之外的任何字符)\n // $1 是正则表达式中第一个捕获组的内容(即原有的属性)\n const newNodeStr = node.loc.source\n .replace(\n /<navigator([^>]*)>/,\n `<navigator$1 @click.stop=\"sendPageMd('${url}',{},'${tagName}')\">`,\n )\n .trim();\n\n replacements.push({\n source: node.loc.source,\n replaceSource: newNodeStr,\n });\n }\n\n\n // 查找元素是否有click事件\n const clickEvent = findClickEvent(node)\n\n // 获取元素的文本内容(用于自动埋点)\n const text =\n findTagTextContent(node);\n\n // 如果元素已有click事件,则添加自动埋点\n if (clickEvent) {\n // 构造自动埋点事件内容\n const eventContent = `sendMd({\n tag: '${tagName}',\n text:'${text}'\n })`;\n\n // 创建埋点事件并替换节点\n createTrackingEventAndReplace(node, clickEvent, eventContent, replacements);\n }\n }\n }\n\n // 递归遍历子节点(深度优先策略)\n if (node.children) {\n node.children.forEach((child: any) => traverse(child));\n }\n };\n\n // 开始遍历AST根节点\n traverse(parseCode.descriptor.template.ast);\n\n // 按节点长度倒序替换(避免嵌套节点替换冲突问题)\n // 先替换长的节点,再替换短的节点,确保嵌套结构正确处理\n replacements\n .sort((a, b) => b.source.length - a.source.length)\n .forEach(({ source, replaceSource }) => {\n $code = $code.replace(source, replaceSource);\n });\n\n // 返回修改后的代码和SourceMap\n return {\n code: code.replace(content, $code), // 替换模板内容\n map: null, // 不生成SourceMap\n };\n } catch (e) {\n // 捕获并记录埋点注入过程中的错误\n console.error(`埋点注入失败: ${e}`);\n return null; // 返回null,保持原始代码不变\n }\n },\n});\n"],"mappings":"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","names":["inject_click_handler_exports","__export","inject_click_handler_default","__toCommonJS","import_compiler_sfc","findClickEvent","node","prop","_a","findTagTextContent","item","FUNCTION_CALL_REGEX","fixFunctionCallExpression","expContent","createTrackingEventAndReplace","clickEvent","eventContent","replacements","fixedExp","newEventString","newNodeStr","code","id","_b","parseCode","content","$code","traverse","_c","_d","_e","_f","_g","mdProp","tagName","mdContent","text","closingBracketIndex","isSelfClosing","url","child","a","b","source","replaceSource","e"]}