code-inspector 源码浅析
前言
code-inspector
是一个源代码定位插件,它可以通过点击页面上的 DOM 元素,自动启动 IDE 并将光标定位到对应 DOM 元素的源代码位置。
去年,一位同事曾向我推荐过这个插件,不过当时我已经非常熟悉项目目录结构了,所以没有觉得特别需要它。最近,社区的朋友又提起了code-inspector
,正好我对其背后的实现机制感到好奇,遂深入研究了一番它的源码设计。
源码浅析
code-inspector
插件的实现过程中涉及到了大量的兼容适配,涵盖了跨平台、跨框架、跨编辑器等诸多方面。如果要一一细致分析,既不切实际也浪费时间,因此本文仅重点分析其核心机制。
实现原理
严格来讲 code-inspector
的实现原理和思路并不复杂,难点在于他考虑到了各种环境的兼容性。
我们本篇采用 vue + vite 环境进行讲解。
整体流程如下:
首先 code-inspector
作为一个 vite / webpack 插件,在打包器收集依赖代码后,会将代码传递给插件,此时code-inspector
可以拿到源码和文件路径。获得源码之后,插件使用 AST 解析库处理这些源码,将 DOM 元素的相关信息(如:文件路径、行号、列号)注入到每个 DOM 元素中。 向客户端注入带有监听事件的 web component,用户通过触发这些事件来发起源码定位请求。 利用 Node.js 启动一个 HTTP 服务,接收从浏览器端发送的请求。 服务端收到请求后,通过 child_process.spawn
启动和控制 IDE,打开相应文件并定位到指定代码行。
目录设计
整个项目采用 monorepo 架构,源码都放在 packages
目录下
code-inspector-plugin
主要承担插件统一入口的作用,同时判断了是否是开发环境。vite-plugin
与webpack-plugin
主要实现了不同打包器的插件开发和适配。core
是整个插件的核心逻辑,包含客户端逻辑和服务端逻辑。
.
├── LICENSE
├── README.md
├── assets ------------------------------- logo 文件
├── demos -------------------------------- 各个框架的 demo
├── docs --------------------------------- 文档目录
├── package.json
├── packages ----------------------------- 源码实现
│ ├── code-inspector-plugin ------------ 插件入口
│ ├── core ----------------------------- 核心包: 服务端与客户端逻辑
│ ├── vite-plugin ---------------------- vite 插件
│ └── webpack-plugin ------------------- webpack 插件
└── pnpm-workspace.yaml
源码逻辑梳理
code-inspector-plugin
逻辑比较简单,只是判断了一下是否是环境和根据用户传入 bundler
类型调用不同的插件包,这里不再展开讲了,直接进行 vite-plugin 包的讲解。
以下是精简之后的 vite 插件代代码:
export function ViteCodeInspectorPlugin(options?: Options) {
const record: RecordInfo = {
port: 0,
entry: '',
nextJsEntry: '',
ssrEntry: '',
};
return {
name: PluginName,
// ... 省略 apply 逻辑
async resolveId(id) {
// 返回客户端注入的虚拟模块 id
if (id === ViteVirtualModule_Client) {
return `\0${ViteVirtualModule_Client}`;
}
return null;
},
load(id) {
// 注入虚拟模块中的客户端代码
if (id === `\0${ViteVirtualModule_Client}`) {
return getClientInjectCode(record.port, options);
}
return null;
},
async transform(code, id) {
if (id.match('node_modules')) {
return code;
}
// start server and inject client code to entry file
code = await getCodeWithWebComponent(options, id, code, record);
// ... 省略 jsx 与 svelte 等逻辑
// vue
const isVue =
filePath.endsWith('.vue') &&
params.get('type') !== 'style' &&
params.get('raw') === null;
if (isVue) {
return transformCode({ content: code, filePath, fileType: 'vue' });
}
return code;
},
};
}
虚拟模块
code-inspector
利用 vite 插件的虚拟模块添加了客户端的逻辑:load
函数 与 resolveId
部分
resolveId
钩子会在解析入口文件 和 遇到尚未加载解析的 id 时都会被调用,当遇到尚未加载的 id 时会调用 load
钩子。
load
钩子会返回一个虚拟模块代码,包含了整个客户端的逻辑。
transform
钩子每当解析到一个依赖文件时触发,同时中可以对源码进行修改,所以在这个阶段可以注入 import
虚拟模块的逻辑,当遇到没有加载模块时会触发 resolveId 钩子完成整个注入逻辑。
注入客户端代码
注入逻辑在 transform
中的 getCodeWithWebComponent
函数中。
export async function getCodeWithWebComponent(
options: CodeOptions,
file: string,
code: string,
record: RecordInfo
) {
// start server
await startServer(options, record);
//....省略其他逻辑
recordNormalEntry(record, file);
//....省略其他逻辑
// 注入 web component 组件代码
if (
(isJsTypeFile(file) &&
[record.entry, record.nextJsEntry, record.ssrEntry].includes(
getFilenameWithoutExt(file)
)) ||
file === AstroToolbarFile
) {
if (options.bundler === 'vite') {
code = `import '${ViteVirtualModule_Client}';\n${code}`;
} else {
const clientCode = getClientInjectCode(record.port, {
...(options || {}),
});
code = `${code}\n${clientCode}`;
}
}
return code;
}
这个方法同时调用了 startServer
启用了 code-inspector
的后端接口服务
recordNormalEntry
会判断是否是入口文件,判断逻辑也很简单,直接通过判断是否是 js 文件和 record.entry 是否为空,当满足条件时将 record.entry 设置为这个文件名。
同样,底下的注入 web component 逻辑也是一样,判断是否是入口文件,当是入口文件时在文件头部加入 import '${ViteVirtualModule_Client}';\n${code}
使 vite 会加载虚拟模块完成 web component 的注入。
注入元素属性
transformCode
方法根据不同的框架进行了调用,我们这里直接看 transformVue
方法。
通过 @vue/compiler-dom
的 parse
方法将 vue 源码解析成 ast , 同时使用 transform
方法遍历所有 html 元素(包括 vue 组件 tag), 然后使用 MagicString 对源码进行修改:往元素上添加一个包含 文件路径、行号、列号等信息的属性。
import { parse, transform } from '@vue/compiler-dom';
//....省略其他导入
export function transformVue(content: string, filePath: string) {
const s = new MagicString(content);
const ast = parse(content, {
comments: true,
});
transform(ast, {
nodeTransforms: [
((node: TemplateChildNode) => {
console.log(node)
if (
!node.loc.source.includes(PathName) &&
node.type === VueElementType &&
escapeTags.indexOf(node.tag.toLowerCase()) === -1
) {
// 向 dom 上添加一个带有 filepath/row/column 的属性
const insertPosition = node.loc.start.offset + node.tag.length + 1;
const { line, column } = node.loc.start;
const addition = ` ${PathName}="${filePath}:${line}:${column}:${
node.tag
}"${node.props.length ? ' ' : ''}`;
s.prependLeft(insertPosition, addition);
}
}) as NodeTransform,
],
});
return s.toString();
}
启用服务
startServer
调用了 createServer
方法,同时判断了是否已经创建过了服务,避免重复创建。
export function createServer(callback: (port: number) => any, options?: CodeOptions) {
const server = http.createServer((req: any, res: any) => {
// 收到请求唤醒vscode
const params = new URLSearchParams(req.url.slice(1));
const file = decodeURIComponent(params.get('file') as string);
const line = Number(params.get('line'));
const column = Number(params.get('column'));
res.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Private-Network': 'true',
});
res.end('ok');
// 调用 hooks
options?.hooks?.afterInspectRequest?.(options, { file, line, column });
// 打开 IDE
launchEditor(file, line, column, options?.editor, options?.openIn, options?.pathFormat);
});
上面 createServer 逻辑比较简单,通过 http.createServer 创建了一个服务,同时监听当前端口请求,当遇到请求时调用 lunchEditor
打开编辑器。
打开编辑器
这里就不看源码了,打开编辑器主要使用 nodejs 的 child_process.spawn
调用编辑器,同时根据编辑器的要求传入参数。
以下是 macOS 下打开 webstrom 并定位到指定位置的示例代码:
import child_process from 'child_process';
const webstormPath = '/Applications/WebStorm.app/Contents/MacOS/webstorm'
const line = '3'
const column = '10'
const filePath = '/Users/ziyang/.npmrc'
child_process.spawn(webstormPath, ['--line', line, '--column', column, filePath]);
需要注意的是,不同的平台(win, mac)路径和调用方式是不一样的,其次不同的编辑器参数也不一样,所以 code-inspector
做了很多兼容工作。
客户端逻辑
客户端主要是通过 lit
库实现了一个 web component,代码量比较大,我们就不细看了,这里主要看一个请求逻辑。
// 请求本地服务端,打开vscode
trackCode = () => {
if (this.locate) {
const file = encodeURIComponent(this.element.path);
const url = `http://localhost:${this.port}/?file=${file}&line=${this.element.line}&column=${this.element.column}`;
// 通过img方式发送请求,防止类似企业微信侧边栏等内置浏览器拦截逻辑
const img = document.createElement('img');
img.src = url;
}
if (this.copy) {
const path = formatOpenPath(
this.element.path,
String(this.element.line),
String(this.element.column),
this.copy
);
this.copyToClipboard(path[0]);
}
};
这里没有使用 xhr 或者 fetch 的方式请求,而是直接通过 img.src 的方式进行请求。
结语
通过学习 code-inspector
的源码,收获还是很多的。在这个过程中,我还特意研究了 pnpm 的 monorepo 配置、vite 插件开发流程、magic-string 库的使用等多个方面的技术。
当我准备本文时,发现原作者已在掘金发布了一篇功能介绍和原理解析的文章,里面的内容更加细致,是非常好的学习参考资料,链接放在文末。
相关连接
原作者介绍+原理解析:https://juejin.cn/post/7326002010084311079 code-inspector 官网: https://inspector.fe-dev.cn/