在开发过程中,尤其是当我们面对一个陌生的模块或需要修改他人编写的项目时,往往会遇到理解代码逻辑困难、定位问题耗时等挑战,那今天推荐的这个工具可以帮助我们高效应对,短时间内快速定位到关键代码,辅助我们迅速搞清楚代码的结构与流程,从而节省我们在理解代码和定位问题上的时间。
code-inspector-plugin 是一款开源的基于 webpack/vite/rspack/nextjs/nuxt/umijs plugin 的提升开发效率的工具,点击页面上的 DOM,它能够自动打开你的 IDE 并将光标定位到 DOM 对应的源代码位置。
只需点击页面上的 DOM 元素,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
)可以查看控制台提示:
启用页面上的 代码审查开关按钮(需配置 showSwitch: true
):
点击按钮切换模式:
详见官网:https://inspector.fe-dev.cn/guide/start.html
接下来,根据作者的实现思路,来看看代码是如何实现的吧!
根据 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;
处理不同类型的文件(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 就会添加一个这样的属性,如下图所示:
启用 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
插件的交互功能主要包含监听两部分:
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
来获取一个可用端口并启动服务器。
// 启动本地接口,访问时唤起vscode
import 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;
}
}
这里源码就不展示了,感兴趣的可以自己去看看源码。
源码核心解决了 2 个问题:
默认方式:通过安装 通过 node 的 spwan
或者 exec
启动一个子进程,执行 code -g 文件路径:行:列
打开 vscode 并定位到对应的文件路径、行、列位置。
自定义编辑器路径方式: 通过 {IDE路径} -g {path}:{line}:{column}
打开并定位编辑器。
匹配用户当前设备上正在运行的进程,在 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