不知道你是否遇到过产品或者测试给你一个页面让你改一点东西,你却找不到页面源代码在哪里的场景?对于一些大型项目,文件数量多、文件层级深、代码行数多,查找一个页面上组件对应的源代码位置,往往需要花费大量时间。
为了解决这个问题,我开发了 code-inspector-plugin
插件,只需要点击页面上的元素,就能够自动打开 vscode 定位到源代码。已经在快手内部30+项目中接入了使用,取得了不错的反响。效果如下图所示:
点击下述的 demo,也可以快速在线体验效果:
- 接入:想要使用的小伙伴,可以参考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 提供的命令行工具,在终端通过
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://juejin.cn/post/7326002010084311079