Appearance
源码定位插件
背景
我接手了一个公司 8 个月前的项目,由于上一个 coder 任务工期较短,很多组件都没有进行拆分复用,都是 cv 写的,导致项目中有很多重复的组件,不同页面功能相同的组件比比皆是,导致维护变得很困难,尤其是组件定位。
那我们平时是如何定位到组件在项目中的位置的呢? 一般的流程是:打开页面所在的路由路径,去 router 配置文件去找对应的页面路由,全局搜索一些关键字进行定位。
但是对于页面中组件套组件,这样找起来可太痛苦了,所以想到一个办法来解决这个问题:借助构建功能来帮助我们定位源码位置。(思路来源于: https://inspector.fe-dev.cn/ )
过程大致如下:
- 构建阶段(借助 vite 插件)
- 通过插件的
transform钩子可以识别到.vue文件 - 对 vue 文件的根节点添加上路径标记(采用自定义属性,例如
code-path: '/xxx/xx.vue') - 对路径进行压缩,好处有:
- 文件体积优化(如果只是在开发环境下,不影响):减小构建产物体积、减小网络传输开销
- 运行时性能提升:加速 dom 操作,可以提升 dom diff 效率
- 安全性:原始路径可能包含服务器目录结构(如
src/admin/internal/),压缩后混淆路径可降低源码结构暴露风险,增强安全性
- 通过插件的
- 编译阶段(借助 vue 的 sfc 模版编译)
- vue 模版编译接收到修改后的代码,通过 ast 识别带有自定义属性的元素,找到当前文件的所有根节点(vue3 可能有多个根节点),节点上判断是否有自定义属性,如果有就添加给节点的
class上新增类名:code-locate-path-flag:xxx
- vue 模版编译接收到修改后的代码,通过 ast 识别带有自定义属性的元素,找到当前文件的所有根节点(vue3 可能有多个根节点),节点上判断是否有自定义属性,如果有就添加给节点的
- 运行时阶段(控制台预览/油猴脚本可视化查看)
- 在控制台预览就不建议对路径进行压缩:直接在控制台元素那部分可以直接选中元素进行路径查看
- 油猴脚本:识别页面上所有带特定
class的元素,将其他解码出源路径,点击Inspect按钮,高亮所有标识元素
具体实现
1、安装依赖
- lz-string:代码压缩
- @vue/compiler-sfc:模版编译
2、编写 vite 插件
创建 plugin/code-locate.ts 文件,目的:在 vue 文件的根节点添加自定义属性,属性值为压缩后的路径
ts
import LZString from 'lz-string'
import { Plugin } from 'vite'
import {
AttributeNode,
ElementNode,
NodeTypes,
TransformContext
} from '@vue/compiler-core'
import { parse } from '@vue/compiler-sfc'
const CUSTOM_ATTR = 'code-locate-path'
// vite插件,给每个页面的根节点(template下的一级节点)添加自定义属性code-locate-path
export function CodeLocatePlugin({
isScript = true
}: {
isScript: boolean
}): Plugin {
return {
name: 'vite-plugin-code-locate',
enforce: 'pre',
transform(code: string, id: string) {
if (!id.endsWith('.vue')) {
return null
}
const { template } = parse(code).descriptor
/**
- template?.ast
- template?.attrs:<template> 标签本身的属性(如 lang、src)
- template?.content:原始的模板代码字符串,即 <template> 标签内的原始内容
- template?.lang:模板使用的语言(如 pug、html),取自 <template lang="...">
- template?.loc:记录模板在原文件中的位置信息,用于错误提示或代码映射。
- start:起始位置(行、列、偏移量)。
- end:结束位置(行、列、偏移量)。
- source:原始内容字符串。
- template?.map:模板的代码映射,用于生成源代码映射。
- template?.src:外部模板文件的路径,取自 <template src="...">
- template?.type:节点类型,vnode的type属性
*/
if (template && template.ast && template.ast.children) {
// type: NodeTypes.ELEMENT; 1
/**
* <template>
* <div></div>
* </template>
* rootElms获取的是div节点
*/
let rootElms = template.ast.children.filter((child) => {
return child.type === 1 // 1是元素节点
})
if (rootElms.length > 0) {
// 依次处理每个根节点
rootElms.forEach((rootElm) => {
// console.log('[🚀code-locate-plugin] 已经找到根元素:', rootElm.tag)
const tagString = `<${rootElm.tag}`
const insertIndex =
rootElm.loc.source.indexOf(tagString) + tagString.length
const compressedPath = isScript ? LZString.compressToBase64(id) : id
// console.log('[🚀code-locate-plugin] 原始路径', id)
// console.log('[🚀code-locate-plugin] 压缩路径', compressedPath)
// 添加自定义属性(进行字符串切割,中间添加属性)
// LZString.compressToBase64 路径压缩
const newSource = `${rootElm.loc.source.slice(
0,
insertIndex
)} ${CUSTOM_ATTR}="${compressedPath}"${rootElm.loc.source.slice(
insertIndex
)}`
// 替换原始内容
code = code.replace(rootElm.loc.source, newSource)
})
}
}
return code
}
}
}3、对 vue 模版编译处理
目的:在 vue 编译阶段,识别到根组件的自定义路径属性,然后将这个自定义路径属性添加到 class 中,这一步其实主要是便于给油猴脚本抓取数据用的
为什么不在 vite 的时候进行标记?因为 vue3 的组件可能是多个根节点,这样可以利用 AST 进行递归添加 class 属性,比 vite 在构建时方便。
ts
// 在vue对sfc模版编译阶段进行处理,给组件添加class来标记路径
// 分两种类型节点:1、vue文件根节点(直接复用自定义属性中的值)2、文件其他同级根节点
export function CodeLocateNodeTransform(
node: ElementNode, // 当前处理的ast节点
context: TransformContext
): void {
/**
* NodeTypes:
* ROOT=0
* ELEMENT=1
* IF_BRANCH=10:v-if、v-else-if、v-else 所在的条件分支节点
* */
// context.parent:当前节点的父节点
if (node.type === 1 && context.parent) {
// 处理根节点和带v-if的父节点
if ([0, 10].includes(context.parent.type)) {
context.parent.children.forEach((child) => {
if (child.type === 1) {
const customAttr =
child &&
child.props &&
child.props.find((item) => item.name === CUSTOM_ATTR)
// 自定义属性值
const customAttrValue =
customAttr &&
'value' in customAttr &&
customAttr.value &&
customAttr.value.content
if (customAttrValue) {
// 统一处理,不区分组件类型
addClass(node, customAttrValue, 'class')
}
}
})
} else if ((context.parent as ElementNode).props) {
// 给同级根节点添加class类名
const parentMarkAttr = (context.parent as ElementNode).props.find(
/**
* item类型
* {
type: 6,
name: 'code-locate-path',
value: {
type: 2,
content: 'PQVQzgpgTmwDYEMB2AXAlsgngV2AEQjAGsUB7AB2HKlIBNsBjdALwXVKQFooiALTTrTQBzNCgRxOvbAFtkwMFAbAAbmggB3WGF6kUa2hFKr1G4AAVsAIzhodAFQTEAkiggyAdCuwQgA=',
...
},
...
*/
(item) => item.name === CUSTOM_ATTR
)
// 获取到自定义属性值
const parentMarkAttrValue =
parentMarkAttr &&
(parentMarkAttr as AttributeNode).value &&
(parentMarkAttr as AttributeNode).value!.content
if (parentMarkAttrValue) {
// 为子组件添加标记
addClass(node, parentMarkAttrValue, 'class')
}
}
}
}
// 用于给元素添加类名
// 有类名就添加,没有就创建
function addClass(
node: ElementNode,
customAttrValue: string,
className: string // attr为class的
) {
const prefix = 'code-locate-path-flag'
const addInfo = `${prefix}:${customAttrValue}`
const classNode =
node.props &&
(node.props.find(
(prop) => prop.type === 6 && prop.name === className
) as AttributeNode)
/**
* classNode: {
type: 6, // NodeTypes.ATTRIBUTE 节点属性
name: 'class',,
value: {
type: 2,
content: 'btn-text',
loc: { start: [Object], end: [Object], source: '"btn-text"' }
},
loc: ...
}
*/
if (classNode) {
classNode.value!.content += ` ${addInfo}`
} else {
// 没有class属性,去手动构造节点属性
node.props.push({
type: 6, // ATTRIBUTE
name: className,
nameLoc: node.loc,
value: {
type: 2, // TEXT
content: addInfo,
loc: node.loc
},
loc: node.loc
})
}
}4、使用 vite 插件和 vue 模版编译处理函数
ts
import { defineConfig } from 'vite'
import { CodeLocateNodeTransform, CodeLocatePlugin } from './plugin/code-locate'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
nodeTransforms: [CodeLocateNodeTransform]
}
}
}), // 提供 Vue 3 单文件组件支持
CodeLocatePlugin({ isScript: false })
]
})5、油猴脚本
这个文件内容还是很多的,就说一些核心功能:
1、元素识别与查找
这部分代码负责在 DOM 中搜索带有 code-locate-path-flag: 前缀的类名,这些类名包含了压缩过的源文件路径。
js
// 返回code-locate-path-flag:的class值
function getCssMarkClass(element) {
const classList = element.classList
for (let i = 0; i < classList.length; i++) {
const className = classList[i]
if (className.startsWith('code-locate-path-flag:')) {
return className
}
}
return ''
}
// 返回class中包含code-locate-path-flag:的元素
function getCssMarkClassElement(root) {
const allElements = root.querySelectorAll('*')
// 过滤出 classList 中包含以 code-locate-path-flag: 开头的类的元素
return Array.from(allElements).filter(function (element) {
return Array.from(element.classList).some(function (className) {
return className.startsWith('code-locate-path-flag:')
})
})
}2、组件层次结构收集
这个函数从点击的元素开始向上遍历 DOM 树,收集所有带有标记的父元素,构建组件层次结构。
js
// 函数:收集从顶层到当前元素的 code-locate-path 属性列表
function collectCssMarkHierarchy(element) {
const cssMarkList = []
while (element) {
const cssClassName = getCssMarkClass(element)
if (cssClassName) {
cssMarkList.push({ element, originMark: cssClassName })
}
element = element.parentElement
}
return cssMarkList
}3、路径解码与显示
从类名中提取压缩的路径部分,然后使用 LZString.decompressFromBase64 解码还原为实际文件路径。
js
// 处理源码路径部分代码
cssMarkList.forEach((item) => {
const tag = item.element.tagName.toLowerCase()
try {
const encodedPath = item.originMark.substring(prefix.length)
const filePath = LZString.decompressFromBase64(encodedPath)
decodedPaths.push({ tag, filePath })
} catch (e) {
console.error('解码路径失败:', e)
}
})