前言
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
是整个插件的核心逻辑,包含客户端逻辑和服务端逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13
| . ├── LICENSE ├── README.md ├── assets ├── demos ├── docs ├── package.json ├── packages │ ├── code-inspector-plugin │ ├── core │ ├── vite-plugin │ └── webpack-plugin └── pnpm-workspace.yaml
|
源码逻辑梳理
code-inspector-plugin
逻辑比较简单,只是判断了一下是否是环境和根据用户传入 bundler
类型调用不同的插件包,这里不再展开讲了,直接进行 vite-plugin 包的讲解。
以下是精简之后的 vite 插件代代码:
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
| export function ViteCodeInspectorPlugin(options?: Options) { const record: RecordInfo = { port: 0, entry: '', nextJsEntry: '', ssrEntry: '', }; return { name: PluginName, async resolveId(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; } code = await getCodeWithWebComponent(options, id, code, record); 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
函数中。
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
| export async function getCodeWithWebComponent( options: CodeOptions, file: string, code: string, record: RecordInfo ) { await startServer(options, record); recordNormalEntry(record, file); 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 对源码进行修改:往元素上添加一个包含 文件路径、行号、列号等信息的属性。
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
| 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 ) { 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
方法,同时判断了是否已经创建过了服务,避免重复创建。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| export function createServer(callback: (port: number) => any, options?: CodeOptions) { const server = http.createServer((req: any, res: any) => { 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'); options?.hooks?.afterInspectRequest?.(options, { file, line, column }); launchEditor(file, line, column, options?.editor, options?.openIn, options?.pathFormat); });
|
上面 createServer
逻辑比较简单,通过 http.createServer
创建了一个服务,同时监听当前端口请求,当遇到请求时调用 lunchEditor
打开编辑器。
打开编辑器
这里就不看源码了,打开编辑器主要使用 nodejs 的 child_process.spawn
调用编辑器,同时根据编辑器的要求传入参数。
以下是 macOS 下打开 webstrom 并定位到指定位置的示例代码:
1 2 3 4 5 6 7 8
| 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,代码量比较大,我们就不细看了,这里主要看一个请求逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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}`; 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 库的使用等多个方面的技术。
当我准备本文时,发现原作者已在掘金发布了一篇功能介绍和原理解析的文章,里面的内容更加细致,是非常好的学习参考资料,连接放在文末。
相关连接