同事:架构太复杂了,源码文件找半天。 我:源码溯源了解一下?
背景
相信刚入行,或是刚入行的小伙伴们,对于企业级代码与架构,以及扑面而来业务需求。想要在短时间内从对应的页面定位到组件时,是很难办到的事情,尤其是突然交给一个陌生的项目的需求,问题也会比较突出。
尤其是对于鼠鼠我本人来说,也是深有体会:司内的源码架构:自研微前端+monorepo架构,本身架构设计本身就比较复杂,在项目规模达到一定程度,或是项目开发时间长,人员变动大,就会导致有很多问题出现,就比如ld统计过 .vue文件已经8000个了,代码中有2250对重复的源代码文件。总计重复代码行数: 69578 行 🤯🤯🤯
在这样的情况下,一款能够快速定位源码的插件呼之欲出🎉🎉🎉
通过本篇文章,大家能学习到:
- 如何编写一个简易的vite插件
- vite插件的生命周期是怎么样的
- 源码溯源,快速定位:实现思路,原理
首先准备好实验环境:vue+vite+pnpm 让cursor快速生成一个项目即可
![]()
在正式将源码定位之前,我想讲讲一个简易的vite插件该如何实现,这对我们后面的学习会有比较有效的帮助
如何写Vite插件
再讲如何编写vite插件之前,需要先了解一下如何将自己编写的vite插件在Vite的构建流程中生效:
Vite插件本质是一个对象,通过到处一个对象函数,放入Vite配置项数组中即可实现效果:
![]()
在配置文件中:
![]()
那么作为Vite的自定义插件,和webpack一样,需要使用各种生命周期钩子,才能实现对应的效果:
这里介绍一下主流的生命周期钩子:
主流钩子
配置阶段:
config(config, env ):
-
触发时机:当vite读取配置时触发
-
常用场景:修改或扩展配置对象
configResolved(resolvedConfig):
-
触发时机:当配置解析完成时触发
-
常用场景:获取最终配置,初始化插件状态
该阶段主要用于插件初始化或读取用户配置,不是必须
构建阶段
buildStart:
-
触发时机: 构建开始
-
常用场景: 初始化状态,打印日志,准备数据
buildEnd:
-
触发时机: 构建结束
-
常用场景:收尾,打印统计
closeBundle:
-
触发时机:构建完成并生成文件后
-
常用场景:做最终清理或发布的操作
主要用于插件需要做全局初始化或构建后操作的场景
模块解析和加载阶段
resolveId(id,importer)
-
触发时机:解析模块路径时
-
常用场景:重写模块路径,生成虚拟模块
load(id)
-
触发时机:模块加载内容
-
常用场景:返回模块代码,生成虚拟模块
moduleParsed
-
触发时机:模块 AST 解析完成
-
常用场景:分析模块 AST ,做统计或收集信息
核心点:虚拟模块一般用
resolveId+load,处理源码前可以分析 AST。
模块transform阶段(最常用)
thransform(code,id)
-
触发时机:模块加载后,打包前
-
常用场景:核心 hook,用于修改 源码 、注入代码、操作 Vue/ JSX ****AST
transformIndexHtml
-
触发时机: HTML 文件处理阶段
-
常用场景:修改 HTML 模版,例如注入script,link
transform 是最主流的钩子,几乎所有插件都至少用它做一次源码修改。
整个构建生命周期流程图来看是这样的:
![]()
针对LLM返回给我们的主流钩子使用频率来看,我们优先掌握的肯定就是:模块 transform 阶段,因为这个阶段是能够直接接触的源代码,更容易在源代码上动手脚的阶段。
模块 transform 阶段
好记性不如烂笔头,让我们实战来看看,这个阶段能够做什么呢?
什么是transform阶段
在Vite的构建过程中,一个文件会从源码 -> 浏览器可执行文件,会经历很多处理环节。比如:
- TS-> js
- JSX -> JS
- VUE单文件组件拆成JS,CSS
- 去掉console.log
- 注入HMR代码
- 压缩
而 transform 就是 Vite 插件体系里专门负责“把代码转成新代码”的阶段。
transform的函数签名
transform(code, id) {
return {
code: '新的代码',
map: null, // 或 sourcemap
}
}
- Code: 当前拿到的文件 源码
- id:当前文件的绝对路径
返回值:
- 返回一个字符串:
return transformedCode
说明只修改了代码,不管 source map,由 Vite 自动处理部分情况。
⚠️ 但 source map 会丢失或错误。
- 返回一个包含code+map的对象
return {
code: transformedCode,
map: null // 或 SourceMap 对象
}
- Vite 会继续把 map 传给下一环
- 最终映射会合并到 browser source map
- 对 HMR Debug 友好
若map为null时,让vite自己处理
- 返回为null或undefined
- 表示我不处理这个模块,让下个插件处理。即:跳过这个阶段的
何时会触发transform
-
开发( dev server) :Vite 在浏览器请求模块时,先
resolveId→load(读文件)→transform→ 返回给浏览器(并缓存结果)。 -
构建(build) :Rollup 打包流程,Vite 基于 Rollup 插件接口执行,顺序类似:
resolveId→load→transform→ 打包。 - 对于 SFC(例如 Vue 单文件组件),一个
.vue会被拆成多个请求(script/template/style),每个子模块都会走transform,因此你会看到同一个文件被多次 transform(通过id的 query 区分)。
![]()
源码溯源
为什么需要源码溯源插件
谈到为什么需要源码 溯源。就得提到司内的源码架构:自研微前端+monorepo架构,本身架构设计本身就比较复杂,在项目规模达到一定程度,或是项目开发时间长,人员变动大,就会导致有很多问题出现,就比如ld统计过 .vue文件已经8000个了,代码中有2250对重复的源代码文件。总计重复代码行数: 69578 行, 所以我们拟设计一款Vite插件配合油猴脚本,能够识别一个页面的所有组件,通过click,能够快速定位到对应的component。
设计思路是什么?
目标:
我们想要实现一个所见即所得模式,即能够清楚的看到一个页面由哪些组件组成,并且可以看到对应的组件渲染了页面的哪些地方,并且点击对应模块后,能够立马弹出组件对应的绝对路径,方便直接去寻找到对应的组件。
具体体现成什么样呢?这里起一个简单的小项目给大家看看
![]()
是一个很简单的小架构,当我们想要知道头部组件在对应源代码的哪个位置时,我们点击他:
![]()
第一个就是头部组件对应的组件路径,下面的就是其父组件,方便我们了解嵌套关系。
具体思路:
首先我们需要知道一件事情,浏览器最后渲染的内容,拿到的源文件是经过构建工具的转译,压缩,打包后的源代码,与自己实际开发是天壤之别,所以针对打包后的源代码溯源是不切实际的。所以我们的思路是:
- 需要在构建阶段,针对对应文件进行处理
- 具体处理就是将对应文件的绝对路径,通过某些方式,在构建后,保存到 源代码
- 再通过油猴插件,在浏览器中执行脚本,该脚本核心代码就是提取到点击模块对应的保存的绝对路径进行转译渲染出来,成为图片中的样式。
具体实现:
-
编写自定义vite插件,插件用处:在每个组件的根元素中添加自定义属性,内容为该文件绝对路径的编码形式存储在此。
-
将根元素的自定义属性值广播到子组件的类型中,任何你想点击/调试的元素都带有足够的信息
-
编写js脚本,核心在于提取到点击对应元素,能够快速识别转译出路径,并渲染到弹窗。
vite插件如何编写?
在编写插件前,我们需要明确我们插件需要做什么:
- 每个Vue文件中的根元素,添加对应的自定义属性,属性值填的是对应路径的编码。
那么针对这个需求,我们首先需要分析,要使用哪个生命周期钩子才能实现对应的效果?
搜索过后,发现thransform(code,id) 这个钩子能够帮助我们实现我们想要的效果。
transform 是 Vite 插件体系里的编译钩子。每当 Vite 正在加载某个模块(无论是 .ts、.vue 还是别的可处理资源),都会把“源代码字符串 + 模块 id(含绝对路径/查询参数)”传进每个插件的 transform(code, id) ,让插件有机会在官方编译器运行前对源码 做一次改写、替换或分析。
最后效果如下:
![]()
具体源代码实现:
export function cscMark(): Plugin {
return {
name: 'csc-mark',
enforce: 'pre',
transform(code, id) {
if (!id.endsWith('.vue')) {
return null;
}
const { template } = parse(code, { filename: id }).descriptor;
if(template) {
const elm = template.ast.children.find(item => item.type === NodeTypes.ELEMENT) as ElementNode | undefined;
if(elm) {
const tagString = `<${elm.tag}`;
const insertIndex = elm.loc.source.indexOf(tagString) + tagString.length;
const newSource
= `${elm.loc.source.slice(0, insertIndex)} csc-mark="${LZString.compressToBase64(id)}"${elm.loc.source.slice(insertIndex)}`;
code = code.replace(elm.loc.source, newSource);
}
}
return code;
}
};
}
-
遍历每个vue组件
-
获得code里面template的内容
-
通过ast拿到根元素:elm
-
通过LZString.compressToBase64( id ) 将绝对路径赋值进去。注:该钩子参数id就是遍历该文件的绝对路径
-
返回新代码给后续编译构建使用
如何将路径广播到子组件?
我们需要有个钩子,能够在上述标签打完之后,再逐一遍历该文件内的其他组件。将编码后的id注入class中。那么哪个钩子能够实习这种功能呢?
经过调研后发现:
Vue插件中,有个钩子能够帮助我们
export default defineConfig(({ mode }) => ({
plugins: [vue({
template: {
compilerOptions: {
nodeTransforms: [
自己编写的函数
],
},
},
}),cscMark() ],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: {
host: '0.0.0.0',
port: 4173,
open: true
},
define: {
__APP_ENV__: JSON.stringify(mode)
}
}));
用法:
在编译模板时,对每个 AST 节点执行自己编写的特定函数
🌰
<template>
<div csc-mark="路径1">
<h1>标题</h1>
<ks-dialog>弹窗</ks-dialog>
</div>
</template>
Vue插件编译器会解析:
- 读取.vue文件
- 解析 template 部分
- 生成 AST(抽象语法树)
最后生成:
ROOT (type: 0)
└── <div> (ELEMENT, type: 1)
├── csc-mark="路径1" (ATTRIBUTE)
├── <h1> (ELEMENT, type: 1)
│ └── "标题" (TEXT)
└── <ks-dialog> (ELEMENT, type: 1)
└── "弹窗" (TEXT)
Vue 编译器会深度优先遍历 AST,对每个节点调用自定义的函数。
那这个自定义函数该如何去进行编写呢?
export const cscMarkNodeTransform = (node, context) => {
if (node.type === NodeTypes.ELEMENT && context.parent) {
if ([NodeTypes.ROOT, NodeTypes.IF_BRANCH].includes(context.parent.type)) {
const firstElm = context.parent.children.find(item => item.type === NodeTypes.ELEMENT) as ElementNode | undefined;
const addText = firstElm && firstElm.props.find(item => item.name === 'csc-mark')?.value?.content || '';
if (addText) {
addClass(node, addText, 'class');
}
} else if (context.parent.props?.find(item => item.name === 'csc-mark')?.value?.content) {
const addText = context.parent.props.find(item => item.name === 'csc-mark')?.value?.content || '';
if (addText) {
addClass(node, addText, 'class');
}
}
}
};
- 在 cscMarkNodeTransform 中,只有当当前 node 是 NodeTypes.ELEMENT 且存在 context.parent 时才会继续处理,避免对非元素节点或无父节点的情况做多余操作
- 当父节点是 ROOT 或 IF_BRANCH 时,会查找父节点的首个子元素,读取其 csc-mark 属性的内容,并将该内容通过 addClass 加在当前节点的 class 上,从而把顶层 csc-mark 标记扩散到具体元素。
- 如果父节点本身带有 csc-mark 属性,就直接读取父节点的该属性内容并同样调用 addClass,以确保嵌套元素 继承 父级 csc-mark 定义的类名。
页面效果呈现:
![]()
油猴脚本编写:
脚本作用:
- 添加检查button,只有点击button时,才会开启溯源功能
- 点击后高亮所有带有css-vite-mark-类名的元素
- 点击元素时,收集并显示嵌套组件及组件绝对路径
核心代码及解释
1.组件层次结构的收集:
- 这个函数从点击的元素开始向上遍历DOM树,收集所有带有标记的父元素,构建组件层次结构。
// 函数:收集从顶层到当前元素的 csc-mark 属性列表
function collectCscMarkHierarchy(element) {
let cscMarkList = [];
while (element) {
if (element.hasAttribute('csc-mark')) {
cscMarkList.push({ element, mark: element.getAttribute('csc-mark') });
}
element = element.parentElement;
}
return cscMarkList;
}
2.路径解码:
这部分代码从类名中提取压缩的路径部分,然后使用LZString.decompressFromBase64解码还原为实际绝对路径。
// 处理源码路径部分代码
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);
}
});
3.交互机制
用户点击该元素时,收集组件嵌套,并渲染对话框
// 函数:处理点击事件并显示 csc-mark 层级
function handleClick(event) {
let element = event.target;
// 遍历 DOM 树查找最近的具有 csc-mark 属性的祖先元素
while (element && !element.hasAttribute('csc-mark')) {
element = element.parentElement;
}
if (element && element.hasAttribute('csc-mark')) {
event.stopPropagation();
event.preventDefault();
const cscMarkList = collectCscMarkHierarchy(element);
showCustomDialog(cscMarkList);
}
}
具体使用流程:
- 启动开发服务器
- 通过油猴插件添加脚本
3. 点击inspect按钮
![]()
- 之后想要修改哪个模块就可以进行点击
![]()
⚠️使用该油猴脚本时需要注意匹配到你对应的项目路径
![]()
总结:
通过上述方法可以实现一个简易的源码定位系统了,能够帮助我们在很多复杂项目中快速定位到自己需要修改的模块所对应的,通过这么一个比较小的需求,能够快速帮助大家对vite的生命周期,以及自定义插件,油猴插件的基本使用,有个较为清晰的了解。综合性比较强,需求完成后对大家的开发效率也会有很大的提升,大家感兴趣的可以进我的github上看对应的插件源码和脚本代码:溯源代码
扩展点:
- 如何在webpack上,通过编写对应插件,实现相应的功能
- 目前只能够在页面上知道对应模块使用的组件,不知道这个组件能够对应哪个页面
- 可以修改一些样式,让整体更加美观
- 一步到位,点击对应模块能够自动跳转的编辑器中