背景及相关信息
在页面开发调试时,首先需要找到组件对应的源代码位置。对于一些大型项目,文件数量多、文件层级深、代码行数多,查找一个页面上组件对应的源代码位置,往往需要花费大量时间。
之前网上看到一篇 react 项目的点击页面元素打开 vscode 对应源代码位置的文章,大受启发,所以开发了 code-inspector-plugin
,支持场景更加广泛,支持 vite/webpack 中使用,且支持 vue3/vue2/react 等框架。开发环境只需要按住组合键,点击页面元素,就能自动跳转 vscode 对应的源代码位置。
欢迎大家安装体验,有帮助的希望动个小手帮忙点个 star。
- github 仓库:github.com/zh-lx/code-...
- 接入文档:inspector.fe-dev.cn/
- 快人一步,在线体验:
- vue online demo:Open in StackBlitz
- react online demo:Open in StackBlitz
效果预览:
实现解析
对于一个技术项目,我们基于最终要实现的功能目标,将功能进行拆解,逐一演进实现最终的功能,code-inspector-plugin
也是同理。
code-inspector-plugin
最终要实现的目标是,按住组合键时,点击页面上的 DOM 元素,能自动打开 vscode 并定位到元素对应的源代码位置。那么基于这个目标,我们可以先将功能拆解为两步,然后去完善:
点击页面元素
-> vscode 定位到对应代码
点击元素细节实现
code-inspector-plugin
插件不能影响到用户正常的页面开发,所以点击页面元素唤醒 vscode 的功能仅在代码定位模式开启时才触发,插件内部设定了两种开启代码定位模式方式:
- 按住组合键时(Mac 系统默认组合键是
Option + Shift
;Window 的默认组合键是Alt + Shift
) - 当插件配置了
showSwitch: true
时,会在页面上展示一个模式开关,打开开关可以开启代码定位模式
同时,当代码定位模式开启时,鼠标在页面上移动时会在对应 dom 上展示一个类似 chrome 调试的遮罩层,以帮助用户知道当前定位的是哪个元素。
这部分功能的实现上难度不大,就是基础的 html+js+css
,为了保证 js 逻辑和 css 样式不会影响到宿主页面,我采用了 web component 组件的方式来封装了这部分逻辑(基于 lit 实现的 web component),并通过 webpack/vite 插件,在 development 环境下将 web component 组件注入到页面中。具体的实现细节将不多讲了,源码位于 packages/core/src/client/index.ts 文件中。
如何打开 vscode
如何打开 vscode 并定位到具体的代码位置
点击页面元素后,我们打开 vscode 并定位到对应的代码。浏览器本身并不具备打开 vscode 的能力,所以要 vscode 应用需要在操作系统级别执行,打开 vscode 应用有两种方式:
- 在终端通过 vscode 应用路径直接打开应用
- 通过安装 vscode 提供的命令行工具,在终端通过
code
指令唤醒,launching-from-the-command-line
这里我们采用了第二种方式,通过 node 的 spwan
或者 exec
启动一个子进程,执行 code -g 文件路径:行:列
就能打开 vscode 并定位到对应的文件路径、行、列位置。
源码位于packages/core/src/server/launch-editor.ts文件中的 launchEditor
中,会自动识别当前系统使用的 IDE 并打开 IDE 定位到源码:
ts
function launchEditor(
fileName: string,
lineNumber: unknown,
colNumber: unknown,
_editor?: Editor
) {
// others code....
let [editor, ...args] = guessEditor(_editor);
// others code....
_childProcess = child_process.spawn(editor, args, { stdio: 'inherit' });
}
点击元素时如何通知 node 打开
解决了如何打开 vscode 并定位到具体的代码位置后,还有一个问题,点击了页面上元素后,如何通知 node 去 vscode 呢?
由于点击元素是在浏览器,打开 vscode 是在 node 后台进程,所以我们交互的方式只有发送 http 请求通知。我们在项目开发启动时,通过 webpack/vite 插件启动一个 node HttpServer,当点击元素后发送一个 http 请求到 HttpServer,HttpServer 接收到请求后就知道要打开 vscode 了。
该部分源码位于 packages/core/src/server/index.ts:
ts
export function startServer(
callback: (port: number) => any,
rootPath: string,
editor?: Editor
) {
if (started) {
callback(recordPort);
return;
}
started = true;
const server = http.createServer((req: any, res: any) => {
// 收到请求唤醒vscode
const params = new URLSearchParams(req.url.slice(1));
const file = path.join(rootPath, 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');
launchEditor(file, line, column, editor);
});
// 寻找可用接口
portFinder.getPort({ port: recordPort }, (err: Error, port: number) => {
if (err) {
throw err;
}
server.listen(port, () => {
recordPort = port;
callback(port);
});
});
}
如何获取元素对应的源代码位置
那么如何获取到点击元素对应的源代码位置呢?源代码位置对应了三个信息:源代码文件路径、代码所在行、代码所在列,这部分信息显然在代码编译阶段可能通过 babel 等代码编译工具获取到,这里对于 vue 文件主要通过 @vue/compiler-dom
和 @vue/babel-plugin-jsx
处理,对于 react 通过 babel
相关插件进行处理。
通过编译阶段,可以获取到源代码中 dom 部分的 ast 结构,ast 结构中会包含对应的文件路径、行、列,然后将页面上的 dom 和源代码信息对应有两种方式:
- 给每个 dom 注入一个 unique id,然后将 unique id 和源代码信息在内存中维护一个映射
- 直接将源代码信息作为 dom attribute 注入到 dom 上,点击 dom 时获取 attribute 即可
插件中采用了第 2 种方式,实现上更方便些,性能也更高一些,只需要在编译阶段将 源代码文件路径:行:列
作为属性添加到 ast 中,后续的编译过程中 vue 和 react 就能够自动将信息一直带到页面的 dom 上:
点击 dom 时,从 dom 上取出源代码信息带到 http 请求的参数上,通知 node HttpServer 即可拿到 dom 的源代码信息了。
该部分源码位于 packages/core/src/server/content-enhance.ts。(webpack 可以在 webpack loader 中作为入口,vite 可以在插件的 transform hook 上做这部分工作)。
ts
import MagicString from 'magic-string';
import { PathName } from '../shared/constant';
import type { TemplateChildNode, NodeTransform } from '@vue/compiler-dom';
import { parse, transform } from '@vue/compiler-dom';
import vueJsxPlugin from '@vue/babel-plugin-jsx';
import { parse as babelParse, traverse as babelTraverse } from '@babel/core';
import tsPlugin from '@babel/plugin-transform-typescript';
import importMetaPlugin from '@babel/plugin-syntax-import-meta';
import proposalDecorators from '@babel/plugin-proposal-decorators';
type FileType = 'vue' | 'jsx';
type EnhanceCodeParams = {
code: string;
filePath: string;
fileType: FileType;
};
export function enhanceCode(params: EnhanceCodeParams) {
const { code: content, filePath, fileType } = params;
try {
const s = new MagicString(content);
// vue 部分内置元素添加 attrs 可能报错,不处理
const escapeTags = [
'style',
'script',
'template',
'transition',
'keepalive',
'keep-alive',
'component',
'slot',
'teleport',
'transition-group',
'transitiongroup',
'suspense',
];
if (fileType === 'vue') {
// vue template 处理
const ast = parse(content, {
comments: true,
});
transform(ast, {
nodeTransforms: [
((node: TemplateChildNode) => {
if (
!node.loc.source.includes(PathName) &&
node.type === 1 &&
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,
],
});
} else if (fileType === 'jsx') {
// jsx 处理
const ast = babelParse(content, {
babelrc: false,
comments: true,
configFile: false,
plugins: [
importMetaPlugin,
[vueJsxPlugin, {}],
[tsPlugin, { isTSX: true, allowExtensions: true }],
[proposalDecorators, { legacy: true }],
],
});
babelTraverse(ast, {
enter({ node }: any) {
if (
node.type === 'JSXElement' &&
escapeTags.indexOf(
(node?.openingElement?.name?.name || '').toLowerCase()
) === -1 &&
node?.openingElement?.name?.name
) {
if (
node.openingElement.attributes.some(
(attr: any) =>
attr.type !== 'JSXSpreadAttribute' &&
attr.name.name === PathName
)
) {
return;
}
// 向 dom 上添加一个带有 filepath/row/column 的属性
const insertPosition =
node.openingElement.end -
(node.openingElement.selfClosing ? 2 : 1);
const { line, column } = node.loc.start;
const addition = ` ${PathName}="${filePath}:${line}:${column + 1}:${
node.openingElement.name.name
}"${node.openingElement.attributes.length ? ' ' : ''}`;
s.prependLeft(insertPosition, addition);
}
},
});
} else {
return content;
}
return s.toString();
} catch (error) {
return content;
}
}
整体架构
lua
📦packages
┣ 📂code-inspector-plugin -------------------------- 入口包
┣ 📂core ---------------------------------------- 核心代码处理
┣ 📂vite-plugin --------------------------------- vite 插件
┗ 📂webpack-plugin --------------------------- webpack 插件
项目采用了 monorepo 结构,为了降低用户的接入成本,通过 code-inspector-plugin
作为入口包,然后根据用户传入的 bundler
参数,判断使用 vite 还是 webpack 插件,vite 和 webpack 插件主要文件编译处理和 web component 代码注入的入口,核心逻辑都位于 core
中。
其他问题
- 需要单独区分开发和生产环境吗?不需要,插件内部自动识别了环境,仅在开发环境下生效。
- 支持哪些 IDE?VSCode | Visual Studio Code - Insiders | WebStorm | Atom | HBuilderX | PhpStorm | PyCharm | IntelliJ IDEA