只需要点击页面上的元素,就能够自动打开 vscode 定位到源代码,支持多框架!
作者:快手基础平台前端 o翔哥o
https://juejin.cn/post/7326002010084311079
不知道你是否遇到过产品或者测试给你一个页面让你改一点东西,你却找不到页面源代码在哪里的场景?对于一些大型项目,文件数量多、文件层级深、代码行数多,查找一个页面上组件对应的源代码位置,往往需要花费大量时间。
为了解决这个问题,我开发了 code-inspector-plugin
插件,只需要点击页面上的元素,就能够自动打开 vscode 定位到源代码。已经在快手内部30+项目中接入了使用,取得了不错的反响。效果如下图所示:
点击下述的 demo,也可以快速在线体验效果:
vue online demo[1] react online demo[2] preact online demo[3] solid online demo[4]
接入:想要使用的小伙伴,可以参考code-inspector-plugin接入文档[5]接入使用 github 源码:觉得插件好用的可以辛苦动下小手帮作者 github 点个 star:code-inspector[6]
code-inspector-plugin 的优点
其实 code-inspector-plugin
是之前我看到过一篇 react 点击页面元素定位源代码的文章,受到启发后实现的。但是相比而言, code-inspector-plugin
在支持场景的丰富性以及接入的便捷程度上,都得到了巨大的提升,具备以下优势:
支持的打包器更加广泛:支持 webpack/vite/rspack
以及umi
等一切基于上述三个打包器实现的打包工具支持的框架及场景更加广泛:支持 vue2/vue3/react/preact/solid
框架以及next/nuxt
等SSR场景(以及一切以vue2/vue3/react/preact/solid
框架为基础封装的 SSR 场景),支持在微前端中使用。支持多种系统及 IDE:支持 Mac、Windows 和 Linux 系统,支持 vscode、webstorm、atom、hbuilderX、IDEA、phpsotrm 等多种 IDE,也支持自定义 IDE 的支持 接入更加简便,对代码无侵入:无论是在什么项目中,只需要在 webpack/vite/rspack
的配置中添加code-inspector-plugin
插件即可,不需要修改任何源代码或者其他的配置自动识别环境:插件内部会针对 webpack/vite/rspack
开发环境下的一些内置信息,自动识别环境,仅在开发环境下生效,不会影响生产环境
code-inspector-plugin 实现原理
下面我们重点解析一下 code-inspector-plugin
的实现原理,插件的整体功能可以简单拆解为以下几部分:
参与源码编译:打包工具(webpack/vite/rspack)编译时, code-inspector-plugin
插件会参与编译过程,对于vue/jsx
语法会进行 ast 解析,获取到 dom 部分的源代码所在的文件路径、行、列
信息,并将这些信息作为 dom 上的attribute
额外添加进去。运行时交互代码:编译完成后,插件会向网页中注入监听按键定位源代码的交互逻辑,当用户点击定位 dom 时,能够获取 dom 的 attribute
上的文件路径、行、列
信息,将信息发送一个 http 请求给后台启动一个 node server 服务:在后台启动一个 node server 服务,用于接收上一步发送过来的 http 请求 识别并打开 IDE:node server 收到请求后,根据请求带过来的 文件路径、行、列
信息,使用 node 的spawn
或者exec
子进程打开 IDE,并将鼠标定位到 IDE 对应的位置
编译 vue/jsx 源代码
要参与源代码的编译过程,对于 vite
项目,我们可以通过 vite 插件的 transform
函数入口中实现;对于 webpack/rspack
项目,可以实现一个 loader
实现。不同的打包工具只是对应的入口不同,而对于 vue/jsx
语法的编译和解析过程都是公用的。
编译 vue 语法
对于 vue 语法的编译,我们可以使用 vue 内置的包 @vue/compiler-dom
实现,以及通过 magic-string
包来向 ast 注入额外的信息,简化的代码如下:
import { parse, transform } from '@vue/compiler-dom';
import MagicString from 'magic-string';
// content 是由 vite transform 函数或者 webpack/rspack loader 传过来的源代码
const s = new MagicString(content);
// vue/react 部分内置元素添加 attrs 可能报错,不处理
const escapeTags = [
'style',
'script',
'template',
'transition',
'keepalive',
'keep-alive',
'component',
'slot',
'teleport',
'transition-group',
'transitiongroup',
'suspense',
"fragment"
];
if (fileType === 'vue') {
// vue template 处理
const ast = parse(content, {
comments: true,
});
transform(ast, {
nodeTransforms: [
((node: TemplateChildNode) => {
// node.type === 1 说明是元素(排除掉 text、comment 等)
if (
!node.loc.source.includes('data-insp-path') &&
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;
// filePath 也是 vite transform 函数或者 webpack/rspack loader 传过来的
const addition = ` data-insp-path="${filePath}:${line}:${column}:${
node.tag
}"${node.props.length ? ' ' : ''}`;
s.prependLeft(insertPosition, addition);
}
}) as NodeTransform,
],
});
return s.toString();
}
编译 tsx 代码
对于 tsx 语法的编译和解析使用 babel
实现,并且需要引入一些 babel 相关的包,完成对于 ts、vueJsx 等场景的兼容,简化的代码如下:
import MagicString from 'magic-string';
import type { TemplateChildNode, NodeTransform } 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';
// content 是由 vite transform 函数或者 webpack/rspack loader 传过来的源代码
const s = new MagicString(content);
// vue/react 部分内置元素添加 attrs 可能报错,不处理
const escapeTags = [
'style',
'script',
'template',
'transition',
'keepalive',
'keep-alive',
'component',
'slot',
'teleport',
'transition-group',
'transitiongroup',
'suspense',
"fragment"
];
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 === 'data-insp-path'
)
) {
return;
}
// 向 dom 上添加一个带有 filepath/row/column 的属性
const insertPosition =
node.openingElement.end -
(node.openingElement.selfClosing ? 2 : 1);
const { line, column } = node.loc.start;
// filePath 也是 vite transform 函数或者 webpack/rspack loader 传过来的
const addition = ` data-insp-path="${filePath}:${line}:${column + 1}:${
node.openingElement.name.name
}"${node.openingElement.attributes.length ? ' ' : ''}`;
s.prependLeft(insertPosition, addition);
}
},
});
return s.toString();
}
上面 vue/jsx
编译完成后,其实相当于在源代码基础上为每个 dom 注入了一个 data-insp-path
属性,最终元素到页面上,对应的 dom 就会添加一个这样的属性,如下图所示:
运行时交互注入
code-inspector-plugin
插件的交互功能主要包含监听两部分:
监听组合键按住时,鼠标在 dom 上移动时会出现 DOM 遮罩层信息 点击遮罩层会获取 DOM attribute
上的源代码信息,向后台发送一个请求
这部分功能的实现上难度不大,就是基础的 html+js+css
,为了保证 js 逻辑和 css 样式不会影响到宿主页面,我采用了 web component 组件的方式来封装了这部分逻辑(基于 lit 实现的 web component)。具体的实现细节将不多讲了,源码位于 packages/core/src/client/index.ts[7] 文件中。
为了简化用户的使用,不需要用户手动向页面中添加交互逻辑的组件,我通过 webpack/vite/rspack
插件,在 development 环境下将 web component 组件注入到页面中。
本地的 Node Server 服务
Node Server 同样是插件在 webpack/vite/rspack
开始编译的时候启动的,用于监听用户发送 http 请求。
我们设置了一个默认的端口 6666
,为了防止端口冲突,我们需要使用 portFinder
继续向下寻找一个可用的接口去启动服务:
import http from 'http';
import portFinder from 'portfinder';
import path from 'path';
import launchEditor from './launch-editor';
const DefaultPort = 6666;
export function startServer(callback: (port: number) => any, editor?: Editor) {
const server = http.createServer((req: any, res: any) => {
// 收到请求唤醒vscode
const params = new URLSearchParams(req.url.slice(1));
const file = 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: DefaultPort }, (err: Error, port: number) => {
if (err) {
throw err;
}
server.listen(port, () => {
callback(port);
});
});
}
识别并打开 IDE
Node Server 接收到了请求后,需要打开用户的 IDE 并定位到源代码,这一步是如何实现的呢?
市面上大多数的 IDE,多支持通过 {IDE路径} -g {path}:{line}:{column}
的终端命令,打开 IDE 并将鼠标光标定位到指定的位置,部分 IDE 还支持在全局安装命令行工具简化使用。以 vscode 为例,有两种方式:
在终端通过 vscode 应用路径直接打开应用 通过安装 vscode 提供的命令行工具,在终端通过 code
指令唤醒,launching-from-the-command-line[8]
这里我们采用了第二种方式,通过 node 的 spwan
或者 exec
启动一个子进程,执行 code -g 文件路径:行:列
就能打开 vscode 并定位到对应的文件路径、行、列位置,简化代码如下:
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' });
}
除了如何打开 IDE 的问题,另一个要解决的问题是,如果用户设备上安装了多种 IDE,我们要打开哪个 IDE?
这个功能我们是基于 react-devtools[9] 的源码实现了,它会去匹配用户当前设备上正在运行的进程,在 IDE 列表中匹配打开。在此基础上我们优化并丰富了这部分的功能,支持了以下特性:
优化了 IDE 的匹配顺序:因为对于 web 项目,大多数开发者使用的 IDE 是 vscode 或者 webstorm,所以我们会优先匹配这两个 IDE 支持用户指定 IDE:支持用户通过在 .env.local
文件中指定声明要打开的 IDE,除了内置支持识别的 IDE 外,用户也可以用 IDE 可执行路径方式指定(意味着支持所有 IDE)
代码架构设计
上面的实现原理中,我们讲述了 code-inspector-plugin
插件的核心内容,除了这部分之外,还想分享下我们在代码可维护性和用户使用体验方面所做的努力。
分包提升可维护性
上述核心内容的实现,绝大部分是与 vite/webpack/rspack
等打包器无关的,打包器插件只是作为代码编译和交互代码注入的入口承载。所以我们采用 monorepo 架构,将核心代码都提取到了 core
中,monorepo 的包如下:
📦packages
┣ 📂code-inspector-plugin -------------------------- 入口包
┣ 📂core ---------------------------------------- 核心代码处理
┣ 📂vite-plugin --------------------------------- vite 插件
┗ 📂webpack-plugin --------------------------- webpack 插件
其中,vite-plugin
和 webpack-plugin
分别作为 vite 和 webpack 的入口。(rspack 由于在插件系统的设计上完全支持了 webpack,所以可以直接使用 webpack-plugin 作为 rspack 的入口,如果后面二者出现差异,会考虑再分一个 rspack-plugin
的包)。
同时为了降低用户在多种打包器中的接入心智,我们使用 code-inspector-plugin
将 vite/webpack/rspack
等不同项目的插件进行了整合作为唯一入口,用户只需要通过 bundler
参数指定项目的打包器即可,其他配置完全一致。
降低用户接入成本
在降低用户成本方面,我们主要做了两件事情:
为了让用户不需要修改任何的源代码,我们对于页面交互的代码,直接通过插件注入,不需要用户手动引入任何的组件,对用户代码无任何侵入。 对于 webpack 和 rspack 的项目,像启动 node 服务这种逻辑是在插件中实现的,而参与源代码的编译需要在 loader
中实现。虽然让用户同时接入一个plugin
和一个loader
成本也没有那么高,但是为了最大程度降低用户接入成本,我们插件会在 webpack/rspack 编译前,自动将loader
添加到module.rules
中,用户只需要接入一个plugin
即可,免去了loader
的接入成本。
最后
感谢大家此文的阅读,觉得有帮助的小伙伴可以在自己的项目中接入插件进行体验,有任何的使用建议或者问题欢迎在 https://github.com/zh-lx/code-inspector 进行反馈!
参考资料
[1]vue online demo: https://stackblitz.com/edit/vitejs-vite-4pseos?file=vite.config.ts
[2]react online demo: https://stackblitz.com/edit/vitejs-vite-svtwrr?file=vite.config.ts
[3]preact online demo: https://stackblitz.com/edit/vitejs-vite-iyawbf?file=vite.config.ts
[4]solid online demo: https://stackblitz.com/edit/solidjs-templates-6u76jn?file=vite.config.ts
[5]code-inspector-plugin接入文档: https://inspector.fe-dev.cn/
[6]code-inspector: https://github.com/zh-lx/code-inspector
[7]packages/core/src/client/index.ts: https://github.com/zh-lx/code-inspector/blob/main/packages/core/src/client/index.ts
[8]launching-from-the-command-line: https://code.visualstudio.com/docs/setup/mac#_launching-from-the-command-line
[9]react-devtools: https://github.com/facebook/react/blob/29fbf6f62625c4262035f931681c7b7822ca9843/packages/react-devtools-core/src/editor.js
向下滑动查看