本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
在开发过程中,尤其是当我们面对一个陌生的模块或需要修改他人编写的项目时,往往会遇到理解代码逻辑困难、定位问题耗时等挑战,那今天推荐的这个工具可以帮助我们高效应对,短时间内快速定位到关键代码,辅助我们迅速搞清楚代码的结构与流程,从而节省我们在理解代码和定位问题上的时间。
工具介绍
code-inspector-plugin 是一款开源的基于 webpack/vite/rspack/nextjs/nuxt/umijs plugin 的提升开发效率的工具,点击页面上的 DOM,它能够自动打开你的 IDE 并将光标定位到 DOM 对应的源代码位置。
优点
开发提效
只需点击页面上的 DOM 元素,IDE 自动打开并精准定位到对应的源码位置,让开发、调试效率成倍提升!
简单易用
无需侵入源码,仅需引入打包工具即可轻松启用,接入过程快如闪电。
适配性强
全面兼容构建工具:支持 webpack、vite、rspack、rsbuild、esbuild、farm、nextjs、nuxt、umijs 等主流工具。
多框架支持:完美适配 vue、react、preact、solid、qwik、svelte、astro 等前端框架。
智能 IDE 识别:兼容 vscode、webstorm、atom、hbuilderX、PhpStorm、Pycharm、IntelliJ IDEA 等多款 IDE。
环境自动识别:仅在开发环境中生效,不影响生产环境,安全又高效。
用它,开发工作从此快人一步!
使用
安装
npm install code-inspector-plugin -D
yarn add code-inspector-plugin -D
pnpm add code-inspector-plugin -D配置
以 webpack 项目为例
const { codeInspectorPlugin } = require('code-inspector-plugin');module.exports = () => ({ plugins: [ codeInspectorPlugin({ bundler: 'webpack', }), ],});使用
目前使用 DOM 源码定位功能的方式有两种:
方式一(推荐)
按住快捷键(Mac 默认 Option + Shift,Windows Alt + Shift )可以查看控制台提示:
鼠标悬停时页面元素会被高亮遮罩,并显示信息。
点击元素,IDE 自动打开并定位到对应代码位置。
方式二
启用页面上的 代码审查开关按钮(需配置 showSwitch: true):
点击按钮切换模式:
彩色开关:代码审查模式开启,操作同方式一。
黑白开关:模式关闭
详见官网:https://inspector.fe-dev.cn/guide/start.html
实现原理
实现思路
- 参与源码编译:
在使用如 Webpack、Vite 或 Rspack 等打包工具进行编译时,code-inspector-plugin 插件会加入编译流程。
插件会对 Vue 或 JSX 代码进行 AST 分析,从中提取出 DOM 元素的源代码信息(包括文件路径、行号和列号),并将这些信息作为额外的属性添加到 DOM 元素中。
- 前端交互逻辑
一旦编译完成,插件会将一些交互功能嵌入网页,监听用户点击事件。
当用户点击某个 DOM 元素时,插件会从该元素的属性中提取文件路径、行号和列号,并通过 HTTP 请求将这些信息发送到后端服务器。
- 后台服务处理请求
后端启动一个 Node.js 服务,接收前端发送的 HTTP 请求。
接收到请求后,服务会根据文件路径、行号和列号打开相关的 IDE,并将光标定位到正确的位置。
- 自动打开 IDE 并定位源代码
- 后端服务利用 Node.js 的 spawn 或 exec 方法,启动 IDE,并将其定位到相应的源代码位置,帮助开发者快速修复或查看代码。
源码解析
接下来,根据作者的实现思路,来看看代码是如何实现的吧!
参与源码编译
入口
根据 bundler配置的打包工具参数(Vite、Webpack、Rspack、Esbuild)加载相应插件,并通过 .env.local 文件和 CODE_INSPECTOR 环境变量控制是否启用。
import { ViteCodeInspectorPlugin } from 'vite-code-inspector-plugin';import WebpackCodeInspectorPlugin from 'webpack-code-inspector-plugin';import { EsbuildCodeInspectorPlugin } from 'esbuild-code-inspector-plugin';export function CodeInspectorPlugin(options: CodeInspectorPluginOptions): any { // 没有 bundler 参数,报错 if (!options?.bundler) { console.log( chalk.red( 'Please specify the bundler in the options of code-inspector-plugin.' ) ); return; } ... if (options.bundler === 'webpack' || options.bundler === 'rspack') { // 使用 webpack 插件 return new WebpackCodeInspectorPlugin(params); } else if (options.bundler === 'esbuild') { return EsbuildCodeInspectorPlugin(params); } else { // 使用 vite 插件 return ViteCodeInspectorPlugin(params); }}export const codeInspectorPlugin = CodeInspectorPlugin;插件内部逻辑
以 webpack-plugin 为例,在 Webpack 构建中通过自定义 loader.js 和 inject-loader.js 来注入源代码信息:
applyLoader:添加自定义 loader 和 inject-loader 到 Webpack 配置。
WebpackCodeInspectorPlugin:检查是否处于开发环境,在开发模式下注册 loader, 这里判断了仅在开发环境下生效,有效降低用户接入的心智。
const applyLoader = (options: LoaderOptions, compiler: any) => { if (!isFirstLoad) { return; } isFirstLoad = false; // 适配 webpack 各个版本 const _compiler = compiler?.compiler || compiler; const module = _compiler?.options?.module; const rules = module?.rules || module?.loaders || []; rules.push( { test: options?.match ?? /.(vue|jsx|tsx|js|ts|mjs|mts)$/, exclude: /node_modules/, use: [ { loader: path.resolve(compatibleDirname, `./loader.js`), options, }, ], ...(options.enforcePre === false ? {} : { enforce: 'pre' }), // 默认指定当前插件优先执行 }, { ...(options?.injectTo ? { resource: options?.injectTo } : { test: /.(jsx|tsx|js|ts|mjs|mts)$/, exclude: /node_modules/, }), use: [ { loader: path.resolve(compatibleDirname, `./inject-loader.js`), options, }, ], enforce: 'post', } ); } class WebpackCodeInspectorPlugin { options: Options; constructor(options: Options) { this.options = options; } apply(compiler) { // 自定义 dev 环境判断 let isDev: boolean; if (typeof this.options?.dev === 'function') { isDev = this.options?.dev(); } else { isDev = this.options?.dev; } if (isDev === false) { return; } // 仅在开发环境下使用 if ( !isDev && compiler?.options?.mode !== 'development' && process.env.NODE_ENV !== 'development' ) { return; } ... applyLoader({ ...this.options, record }, compiler); } } export default WebpackCodeInspectorPlugin;两个 loader 内部逻辑
第一个 loader
处理不同类型的文件(Vue、JSX、Svelte),使用 transformCode 方法根据文件类型对内容进行转换。transformCode 会调用不同的转换函数(transformVue、transformJsx、transformSvelte)处理不同的语法。
export default async function WebpackCodeInspectorLoader(content: string) { // jsx 语法 const isJSX = isJsTypeFile(filePath) || (filePath.endsWith('.vue') && jsxParamList.some((param) => params.get(param) !== null)); if (isJSX) { return transformCode({ content, filePath, fileType: 'jsx', escapeTags }); } // vue jsx const isJsxWithScript = filePath.endsWith('.vue') && (params.get('lang') === 'tsx' || params.get('lang') === 'jsx'); if (isJsxWithScript) { const { descriptor } = parseSFC(content, { sourceMap: false, }); // 处理 <script> 标签内容 // 注意:.vue 允许同时存在 <script> 和 <script setup> const scripts = [ descriptor.script?.content, descriptor.scriptSetup?.content, ]; for (const script of scripts) { if (!script) continue; const newScript = transformCode({ content: script, filePath, fileType: 'jsx', escapeTags, }); content = content.replace(script, newScript); } return content; } // vue const isVue = filePath.endsWith('.vue') && params.get('type') !== 'style' && params.get('type') !== 'script' && params.get('raw') === null; if (isVue) { return transformCode({ content, filePath, fileType: 'vue', escapeTags }); } // svelte const isSvelte = filePath.endsWith('.svelte'); if (isSvelte) { return transformCode({ content, filePath, fileType: 'svelte', escapeTags }); } return content;}transformCode 函数根据 fileType 调用对应的转换函数 (transformVue, transformJsx, transformSvelte)。
import { transformJsx } from './transform-jsx';import { transformSvelte } from './transform-svelte';import { transformVue } from './transform-vue';export function transformCode(params: TransformCodeParams) { const { content, filePath, fileType, escapeTags = [] } = params; const finalEscapeTags = [ ...CodeInspectorEscapeTags, ...escapeTags, ]; try { if (fileType === 'vue') { return transformVue(content, filePath, finalEscapeTags); } else if (fileType === 'jsx') { return transformJsx(content, filePath, finalEscapeTags); } else if (fileType === 'svelte') { return transformSvelte(content, filePath, finalEscapeTags); } else { return content; } } catch (error) { return content; }}以编译 vue 语法为例(transformVue 方法) 使用 vue 内置的包 @vue/compiler-dom的 parse 将模板转换为 AST, transform 是在 AST 上进行加工,以及通过 magic-string 包来向 AST 中注入:路径名称 (data-insp-path) = 路径: 行: 列: html 标签。
import MagicString from 'magic-string';import type { TemplateChildNode, NodeTransform, ElementNode,} from '@vue/compiler-dom';import { parse, transform } from '@vue/compiler-dom';export function transformVue( content: string, filePath: string, escapeTags: EscapeTags) { const s = new MagicString(content); // parse 方法解析vue 模版为ast 对象 const ast = parse(content, { comments: true, }); ... if (pugMap.has(filePath) && templateNode) { ... } else { transform(ast, { nodeTransforms: [ ((node: TemplateChildNode) => { if ( !node.loc.source.includes(PathName) && node.type === VueElementType && !isEscapeTags(escapeTags, node.tag) ) { // 向 dom 上添加一个带有 filepath/row/column 的属性 const insertPosition = node.loc.start.offset + node.tag.length + 1; const { line, column } = node.loc.start; // 规则: 注入的路径名称data-insp-path = 路径:行:列:html标签 const addition = ` ${PathName}="${filePath}:${line}:${column}:${ node.tag }"${node.props.length ? ' ' : ''}`; s.prependLeft(insertPosition, addition); } }) as NodeTransform, ], }); } return s.toString();}上面 vue/jsx 编译完成后,其实相当于在源代码基础上为每个 dom 注入了一个 data-insp-path 属性,最终元素到页面上,对应的 dom 就会添加一个这样的属性,如下图所示:
第二个 loader(inject-loader)
启用 node 服务监听打开 vscode,把页面交互代码注入到入口文件里面。
通过该 loader,使用 web component 组件在开发环境注入到页面中,简化用户的使用,不需要用户手动向页面中添加交互逻辑的组件。
import { normalizePath, getCodeWithWebComponent } from 'code-inspector-core';export default async function WebpackCodeInjectLoader( content: string, source: any, meta: any ) { this.async(); this.cacheable && this.cacheable(true); const filePath = normalizePath(this.resourcePath); // 当前文件的绝对路径 const options = this.query; // start server and inject client code to entry file content = await getCodeWithWebComponent(options, filePath, content, options.record); this.callback(null, content, source, meta); }getCodeWithWebComponent 方法内部核心调用了 getWebComponentCode,将打包的自定义组件代码 client.umd.js 注入到入口 js 文件中,在加载入口 js 时立即执行,注入到页面中。
import { startServer } from './server';let compatibleDirname = '';if (typeof __dirname !== 'undefined') { compatibleDirname = __dirname;} else { compatibleDirname = dirname(fileURLToPath(import.meta.url));}// 这个路径是根据打包后来的,client.umd.js 是啥,后面说export const clientJsPath = path.resolve(compatibleDirname, './client.umd.js');const jsClientCode = fs.readFileSync(clientJsPath, 'utf-8');export function getInjectedCode(options: CodeOptions, port: number) { let code = `'use client';`; code += getEliminateWarningCode(); if (options?.hideDomPathAttr) { code += getHidePathAttrCode(); } code += getWebComponentCode(options, port); return `/* eslint-disable */\n` + code.replace(/\n/g, '');}export function getWebComponentCode(options: CodeOptions, port: number) { const { hotKeys = ['shiftKey', 'altKey'], showSwitch = false, hideConsole = false, autoToggle = true, behavior = {}, ip = false, } = options || ({} as CodeOptions); const { locate = true, copy = false } = behavior; return `;(function (){ if (typeof window !== 'undefined') { if (!document.documentElement.querySelector('code-inspector-component')) { var script = document.createElement('script'); script.setAttribute('type', 'text/javascript'); script.textContent = ${`${jsClientCode}`}; var inspector = document.createElement('code-inspector-component'); inspector.port = ${port}; inspector.hotKeys = '${(hotKeys ? hotKeys : [])?.join(',')}'; inspector.showSwitch = !!${showSwitch}; inspector.autoToggle = !!${autoToggle}; inspector.hideConsole = !!${hideConsole}; inspector.locate = !!${locate}; inspector.copy = ${typeof copy === 'string' ? `'${copy}'` : !!copy}; inspector.ip = '${getIP(ip)}'; document.documentElement.append(inspector); // 将自定义组件插入到页面上 } }})();`;}export async function getCodeWithWebComponent( options: CodeOptions, file: string, code: string, record: RecordInfo) { // start server await startServer(options, record); recordEntry(record, file); // 判断js文件,且是入口js文件,注入代码 if ( (isJsTypeFile(file) && getFilePathWithoutExt(file) === record.entry) || file === AstroToolbarFile ) { ... } else { code = `${injectCode};${code}`; } } return code;}经过这一步,把自定义组件代码 注入到了 app.js 中,在页面加载之后,我们可以看到页面上 被注入了 shadow dom 自定义组件
client.umd.js 是谁打包后的产物呢?
通过这个 vite 配置看出,它指定了入口文件为 src/client/index.ts,输出文件名为 client,并将库的全局变量名设置为 vueInspectorClient。
import { defineConfig } from 'vite';import { terser } from 'rollup-plugin-terser';// https://vitejs.dev/config/export default defineConfig({ build: { lib: { entry: ['src/client/index.ts'], formats: ['umd'], fileName: 'client', name: 'vueInspectorClient', }, minify: true, emptyOutDir: false, target: ['node8', 'es2015'], }, plugins: [ // @ts-ignore terser({ format: { comments: false } }) ]});接下来我们再来看src/client/index.ts的代码逻辑。
前端交互逻辑
code-inspector-plugin 插件的交互功能主要包含监听两部分:
监听组合键按住时,鼠标在 dom 上移动时会出现 DOM 遮罩层信息
点击遮罩层会获取 DOM
attribute上的源代码信息,向后台发送一个请求
自定义组件
实现了一个基于 LitElement 的开发者工具组件,主要功能是通过快捷键和鼠标交互快速定位 DOM 元素,并支持在代码编辑器(如 VS Code)中打开对应的源文件。核心功能包括:
热键功能:组件监听用户按下特定的热键(如
shiftKey,altKey),并在按下热键时启用代码检查功能。元素定位:当鼠标悬停在页面元素上时,组件会显示该元素的详细信息,如路径、行号、列号等。如果用户点击该元素,会打开 VSCode 或者复制代码路径到剪贴板。
UI 控制:有一个开关按钮,用来开启和关闭功能。开关的位置支持拖动,用户可以自由移动它。
样式和事件处理:组件在显示时会加入遮罩层,阻止元素选择(
userSelect: 'none'),并通过 HTTP 请求或者img请求方式来通知本地服务端打开 VSCode。
import { LitElement, css, html } from 'lit';export class CodeInspectorComponent extends LitElement { @property() hotKeys: string = 'shiftKey,altKey'; @property() port: number = DefaultPort; ... isTracking = (e: any) => { return ( this.hotKeys && this.hotKeys.split(',').every((key) => e[key.trim()]) ); }; // 渲染遮罩层 renderCover = (target: HTMLElement) => { // 设置 target 的位置 const { top, right, bottom, left } = target.getBoundingClientRect(); this.position = { top, right, bottom, left, ... }; ... // 增加鼠标光标样式 this.addGlobalCursorStyle(); // 防止 select if (!this.preUserSelect) { this.preUserSelect = getComputedStyle(document.body).userSelect; } document.body.style.userSelect = 'none'; // 获取元素信息 let paths = target.getAttribute(PathName) || (target as CodeInspectorHtmlElement)[PathName] || ''; if (!paths && target.getAttribute('data-astro-source-file')) { paths = `${target.getAttribute( 'data-astro-source-file' )}:${target.getAttribute( 'data-astro-source-loc' )}:${target.tagName.toLowerCase()}`; } const segments = paths.split(':'); const name = segments[segments.length - 1]; const column = Number(segments[segments.length - 2]); const line = Number(segments[segments.length - 3]); const path = segments.slice(0, segments.length - 3).join(':'); this.element = { name, path, line, column }; this.show = true; }; removeCover = () => { ... }; sendXHR = () => { const file = encodeURIComponent(this.element.path); const url = `http://${this.ip}:${this.port}/?file=${file}&line=${this.element.line}&column=${this.element.column}`; const xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.send(); xhr.addEventListener('error', () => { this.sendType = 'img'; this.sendImg(); }); }; // 通过img方式发送请求,防止类似企业微信侧边栏等内置浏览器拦截逻辑 sendImg = () => { const file = encodeURIComponent(this.element.path); const url = `http://${this.ip}:${this.port}/?file=${file}&line=${this.element.line}&column=${this.element.column}`; const img = document.createElement('img'); img.src = url; }; // 请求本地服务端,打开vscode trackCode = () => { if (this.locate) { if (this.sendType === 'xhr') { this.sendXHR(); } else { this.sendImg(); } } if (this.copy) { ... } }; copyToClipboard(text: string) { ... } // 移动按钮 moveSwitch = (e: MouseEvent) => { ... }; handleMouseup = () => { this.hoverSwitch = false; }; // 鼠标移动渲染遮罩层位置 handleMouseMove = (e: MouseEvent) => { ... }; // 鼠标点击唤醒遮罩层 handleMouseClick = (e: any) => { if (this.isTracking(e) || this.open) { if (this.show) { e.stopPropagation(); e.preventDefault(); // 唤醒 vscode this.trackCode(); // 清除遮罩层 this.removeCover(); if (this.autoToggle) { this.open = false; } } } }; // disabled 无法触发 click 事件 handlePointerDown = (e: any) => { ... }; // 监听键盘抬起,清除遮罩层 handleKeyUp = (e: any) => { if (!this.isTracking(e) && !this.open) { this.removeCover(); } }; // 记录鼠标按下时初始位置 recordMousePosition = (e: MouseEvent) => { this.mousePosition = { baseX: this.inspectorSwitchRef.offsetLeft, baseY: this.inspectorSwitchRef.offsetTop, moveX: e.pageX, moveY: e.pageY, }; this.dragging = true; e.preventDefault(); }; // 结束拖拽 handleMouseUp = () => { this.dragging = false; }; // 切换开关 switch = (e: Event) => { if (!this.moved) { this.open = !this.open; e.preventDefault(); e.stopPropagation(); } this.moved = false; }; protected firstUpdated(): void { if (!this.hideConsole) { this.printTip(); } ... window.addEventListener('click', this.handleMouseClick, true); window.addEventListener('pointerdown', this.handlePointerDown, true); document.addEventListener('keyup', this.handleKeyUp); document.addEventListener('mouseleave', this.removeCover); document.addEventListener('mouseup', this.handleMouseUp); this.inspectorSwitchRef.addEventListener( 'mousedown', this.recordMousePosition ); this.inspectorSwitchRef.addEventListener('click', this.switch); } disconnectedCallback(): void { window.removeEventListener('mousemove', this.handleMouseMove); window.removeEventListener('mousemove', this.moveSwitch); ... } render() { const containerPosition = { display: this.show ? 'block' : 'none', top: `${this.position.top - this.position.margin.top}px`, left: `${this.position.left - this.position.margin.left}px`, ... }; ... return html` <div class="code-inspector-container" id="code-inspector-container" style=${styleMap(containerPosition)} > ... <div id="element-info" class="element-info ${this.infoClassName.vertical}${this .infoClassName.horizon}" style=${styleMap({ width: this.infoWidth })} > <div class="element-info-content"> <div class="name-line"> <div class="element-name"> <span class="element-title"><${this.element.name}></span> <span class="element-tip">click to open IDE</span> </div> </div> <div class="path-line">${this.element.path}</div> </div> </div> </div> <div id="inspector-switch" class="inspector-switch ${this.open ? 'active-inspector-switch' : ''}${this.moved ? 'move-inspector-switch' : ''}" style=${styleMap({ display: this.showSwitch ? 'flex' : 'none' })} > ${this.open ? html` <svg t="1677801709811" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1110" xmlns:xlink="http://www.w3.org/1999/xlink" width="1em" height="1em" > ... </svg>`} </div> `; } ...后台服务处理请求
实现了一个 HTTP 服务器,通过接收到的请求打开指定的代码文件并跳转到某行某列。具体功能如下:
createServer:创建一个 HTTP 服务器,监听请求,解析请求中的 file(文件路径)、line(行号)、column(列号)参数,打开文件并跳转到指定位置。
startServer:检查是否已有端口信息(record.port)。如果没有,它会调用 createServer 来获取一个可用端口并启动服务器。
// 启动本地接口,访问时唤起vscodeimport http from 'http';import portFinder from 'portfinder';import launchEditor from './launch-editor';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); }); // 寻找可用接口 portFinder.getPort({ port: DefaultPort }, (err: Error, port: number) => { if (err) { throw err; } server.listen(port, () => { callback(port); }); });}export async function startServer(options: CodeOptions, record: RecordInfo) { if (!record.port) { if (!record.findPort) { record.findPort = new Promise((resolve) => { createServer((port: number) => { resolve(port); }, options); }); } record.port = await record.findPort; }}自动打开 IDE 并定位源代码
这里源码就不展示了,感兴趣的可以自己去看看源码。
源码核心解决了 2 个问题:
- 如何打开用户的 IDE 并定位到源代码?
默认方式:通过安装 通过 node 的 spwan 或者 exec 启动一个子进程,执行 code -g 文件路径:行:列 打开 vscode 并定位到对应的文件路径、行、列位置。
自定义编辑器路径方式: 通过 {IDE路径} -g {path}:{line}:{column}打开并定位编辑器。
- 用户系统安装了多种 IDE,如何智能地启动适合的代码编辑器?
匹配用户当前设备上正在运行的进程,在 IDE 列表中匹配打开,同时针对 web 项目,设置了优先匹配 vscode、 webstorm。
使用中遇到的问题
1、按住快捷键点击页面发送请求了,但是 vscode 没被打开
要将 code 命令添加到 PATH 中,以便可以从终端启动 VS Code
解决:cmd+shfit+p 选择 shell 命令: 在 path 中安装 code 命令
2、开发环境 code-inspector-plugin 在安卓低端机上会报 globalThis is not defined, 这个文件报的 append-code-${port}.js
解决:找源码作者兼容处理了一下,升级最新包即可。
3、默认 enforcePre 为 true 导致 eslint-plugin 校验错误
解决:设置 enforcePre:false。
- 移动端项目使用开关方式,体验不佳
解决:使用最新包即可,已推源码作者 增加了移动端的交互体验,目前已支持开关位置、拖拽;在移动端开发环境,「长按」可以展示对应的组件,「点击」能打开对应的 vscode 组件。
参考
官网:https://inspector.fe-dev.cn/guide/start.html
掘金原文:https://juejin.cn/post/7326002010084311079
github 源码:https://github.com/zh-lx/code-inspector
想了解更多转转公司的业务实践,点击关注下方的公众号吧!