不知道你是否遇到過產(chǎn)品或者測試給你一個頁面讓你改一點(diǎn)東西,你卻找不到頁面源代碼在哪里的場景?對于一些大型項(xiàng)目,文件數(shù)量多、文件層級深、代碼行數(shù)多,查找一個頁面上組件對應(yīng)的源代碼位置,往往需要花費(fèi)大量時間。
為了解決這個問題,我開發(fā)了 code-inspector-plugin
插件,只需要點(diǎn)擊頁面上的元素,就能夠自動打開 vscode 定位到源代碼。已經(jīng)在快手內(nèi)部30+項(xiàng)目中接入了使用,取得了不錯的反響。效果如下圖所示:
點(diǎn)擊下述的 demo,也可以快速在線體驗(yàn)效果:
- 接入:想要使用的小伙伴,可以參考code-inspector-plugin接入文檔[5]接入使用
- github 源碼:覺得插件好用的可以辛苦動下小手幫作者 github 點(diǎn)個 star:code-inspector[6]
code-inspector-plugin 的優(yōu)點(diǎn)
其實(shí) code-inspector-plugin
是之前我看到過一篇 react 點(diǎn)擊頁面元素定位源代碼的文章,受到啟發(fā)后實(shí)現(xiàn)的。但是相比而言, code-inspector-plugin
在支持場景的豐富性以及接入的便捷程度上,都得到了巨大的提升,具備以下優(yōu)勢:
- 支持的打包器更加廣泛:支持
webpack/vite/rspack
以及 umi
等一切基于上述三個打包器實(shí)現(xiàn)的打包工具 - 支持的框架及場景更加廣泛:支持
vue2/vue3/react/preact/solid
框架以及 next/nuxt
等SSR場景(以及一切以 vue2/vue3/react/preact/solid
框架為基礎(chǔ)封裝的 SSR 場景),支持在微前端中使用。 - 支持多種系統(tǒng)及 IDE:支持 Mac、Windows 和 Linux 系統(tǒng),支持 vscode、webstorm、atom、hbuilderX、IDEA、phpsotrm 等多種 IDE,也支持自定義 IDE 的支持
- 接入更加簡便,對代碼無侵入:無論是在什么項(xiàng)目中,只需要在
webpack/vite/rspack
的配置中添加 code-inspector-plugin
插件即可,不需要修改任何源代碼或者其他的配置 - 自動識別環(huán)境:插件內(nèi)部會針對
webpack/vite/rspack
開發(fā)環(huán)境下的一些內(nèi)置信息,自動識別環(huán)境,僅在開發(fā)環(huán)境下生效,不會影響生產(chǎn)環(huán)境
code-inspector-plugin 實(shí)現(xiàn)原理
下面我們重點(diǎn)解析一下 code-inspector-plugin
的實(shí)現(xiàn)原理,插件的整體功能可以簡單拆解為以下幾部分:
- 參與源碼編譯:打包工具(webpack/vite/rspack)編譯時,
code-inspector-plugin
插件會參與編譯過程,對于 vue/jsx
語法會進(jìn)行 ast 解析,獲取到 dom 部分的源代碼所在的 文件路徑、行、列
信息,并將這些信息作為 dom 上的 attribute
額外添加進(jìn)去。 - 運(yùn)行時交互代碼:編譯完成后,插件會向網(wǎng)頁中注入監(jiān)聽按鍵定位源代碼的交互邏輯,當(dāng)用戶點(diǎn)擊定位 dom 時,能夠獲取 dom 的
attribute
上的 文件路徑、行、列
信息,將信息發(fā)送一個 http 請求給后臺 - 啟動一個 node server 服務(wù):在后臺啟動一個 node server 服務(wù),用于接收上一步發(fā)送過來的 http 請求
- 識別并打開 IDE:node server 收到請求后,根據(jù)請求帶過來的
文件路徑、行、列
信息,使用 node 的 spawn
或者 exec
子進(jìn)程打開 IDE,并將鼠標(biāo)定位到 IDE 對應(yīng)的位置
編譯 vue/jsx 源代碼
要參與源代碼的編譯過程,對于 vite
項(xiàng)目,我們可以通過 vite 插件的 transform
函數(shù)入口中實(shí)現(xiàn);對于 webpack/rspack
項(xiàng)目,可以實(shí)現(xiàn)一個 loader
實(shí)現(xiàn)。不同的打包工具只是對應(yīng)的入口不同,而對于 vue/jsx
語法的編譯和解析過程都是公用的。
編譯 vue 語法
對于 vue 語法的編譯,我們可以使用 vue 內(nèi)置的包 @vue/compiler-dom
實(shí)現(xiàn),以及通過 magic-string
包來向 ast 注入額外的信息,簡化的代碼如下:
import { parse, transform } from '@vue/compiler-dom';
import MagicString from 'magic-string';
// content 是由 vite transform 函數(shù)或者 webpack/rspack loader 傳過來的源代碼
const s = new MagicString(content);
// vue/react 部分內(nèi)置元素添加 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 函數(shù)或者 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
實(shí)現(xiàn),并且需要引入一些 babel 相關(guān)的包,完成對于 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 函數(shù)或者 webpack/rspack loader 傳過來的源代碼
const s = new MagicString(content);
// vue/react 部分內(nèi)置元素添加 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 函數(shù)或者 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
編譯完成后,其實(shí)相當(dāng)于在源代碼基礎(chǔ)上為每個 dom 注入了一個 data-insp-path
屬性,最終元素到頁面上,對應(yīng)的 dom 就會添加一個這樣的屬性,如下圖所示:
運(yùn)行時交互注入
code-inspector-plugin
插件的交互功能主要包含監(jiān)聽兩部分:
- 監(jiān)聽組合鍵按住時,鼠標(biāo)在 dom 上移動時會出現(xiàn) DOM 遮罩層信息
- 點(diǎn)擊遮罩層會獲取 DOM
attribute
上的源代碼信息,向后臺發(fā)送一個請求
這部分功能的實(shí)現(xiàn)上難度不大,就是基礎(chǔ)的 html+js+css
,為了保證 js 邏輯和 css 樣式不會影響到宿主頁面,我采用了 web component 組件的方式來封裝了這部分邏輯(基于 lit 實(shí)現(xiàn)的 web component)。具體的實(shí)現(xiàn)細(xì)節(jié)將不多講了,源碼位于 packages/core/src/client/index.ts[7] 文件中。
為了簡化用戶的使用,不需要用戶手動向頁面中添加交互邏輯的組件,我通過 webpack/vite/rspack
插件,在 development 環(huán)境下將 web component 組件注入到頁面中。
本地的 Node Server 服務(wù)
Node Server 同樣是插件在 webpack/vite/rspack
開始編譯的時候啟動的,用于監(jiān)聽用戶發(fā)送 http 請求。
我們設(shè)置了一個默認(rèn)的端口 6666
,為了防止端口沖突,我們需要使用 portFinder
繼續(xù)向下尋找一個可用的接口去啟動服務(wù):
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 并定位到源代碼,這一步是如何實(shí)現(xiàn)的呢?
市面上大多數(shù)的 IDE,多支持通過 {IDE路徑} -g {path}:{line}:{column}
的終端命令,打開 IDE 并將鼠標(biāo)光標(biāo)定位到指定的位置,部分 IDE 還支持在全局安裝命令行工具簡化使用。以 vscode 為例,有兩種方式:
- 在終端通過 vscode 應(yīng)用路徑直接打開應(yīng)用
- 通過安裝 vscode 提供的命令行工具,在終端通過
code
指令喚醒,launching-from-the-command-line[8]
這里我們采用了第二種方式,通過 node 的 spwan
或者 exec
啟動一個子進(jìn)程,執(zhí)行 code -g 文件路徑:行:列
就能打開 vscode 并定位到對應(yīng)的文件路徑、行、列位置,簡化代碼如下:
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 的問題,另一個要解決的問題是,如果用戶設(shè)備上安裝了多種 IDE,我們要打開哪個 IDE?
這個功能我們是基于 react-devtools[9] 的源碼實(shí)現(xiàn)了,它會去匹配用戶當(dāng)前設(shè)備上正在運(yùn)行的進(jìn)程,在 IDE 列表中匹配打開。在此基礎(chǔ)上我們優(yōu)化并豐富了這部分的功能,支持了以下特性:
- 優(yōu)化了 IDE 的匹配順序:因?yàn)閷τ?web 項(xiàng)目,大多數(shù)開發(fā)者使用的 IDE 是 vscode 或者 webstorm,所以我們會優(yōu)先匹配這兩個 IDE
- 支持用戶指定 IDE:支持用戶通過在
.env.local
文件中指定聲明要打開的 IDE,除了內(nèi)置支持識別的 IDE 外,用戶也可以用 IDE 可執(zhí)行路徑方式指定(意味著支持所有 IDE)
上面的實(shí)現(xiàn)原理中,我們講述了 code-inspector-plugin
插件的核心內(nèi)容,除了這部分之外,還想分享下我們在代碼可維護(hù)性和用戶使用體驗(yàn)方面所做的努力。
分包提升可維護(hù)性
上述核心內(nèi)容的實(shí)現(xiàn),絕大部分是與 vite/webpack/rspack
等打包器無關(guān)的,打包器插件只是作為代碼編譯和交互代碼注入的入口承載。所以我們采用 monorepo 架構(gòu),將核心代碼都提取到了 core
中,monorepo 的包如下:
📦packages
┣ 📂code-inspector-plugin -------------------------- 入口包
┣ 📂core ---------------------------------------- 核心代碼處理
┣ 📂vite-plugin --------------------------------- vite 插件
┗ 📂webpack-plugin --------------------------- webpack 插件
其中,vite-plugin
和 webpack-plugin
分別作為 vite 和 webpack 的入口。(rspack 由于在插件系統(tǒng)的設(shè)計上完全支持了 webpack,所以可以直接使用 webpack-plugin 作為 rspack 的入口,如果后面二者出現(xiàn)差異,會考慮再分一個 rspack-plugin
的包)。
同時為了降低用戶在多種打包器中的接入心智,我們使用 code-inspector-plugin
將 vite/webpack/rspack
等不同項(xiàng)目的插件進(jìn)行了整合作為唯一入口,用戶只需要通過 bundler
參數(shù)指定項(xiàng)目的打包器即可,其他配置完全一致。
降低用戶接入本
在降低用戶成本方面,我們主要做了兩件事情:
- 為了讓用戶不需要修改任何的源代碼,我們對于頁面交互的代碼,直接通過插件注入,不需要用戶手動引入任何的組件,對用戶代碼無任何侵入。
- 對于 webpack 和 rspack 的項(xiàng)目,像啟動 node 服務(wù)這種邏輯是在插件中實(shí)現(xiàn)的,而參與源代碼的編譯需要在
loader
中實(shí)現(xiàn)。雖然讓用戶同時接入一個 plugin
和一個 loader
成本也沒有那么高,但是為了最大程度降低用戶接入成本,我們插件會在 webpack/rspack 編譯前,自動將 loader
添加到 module.rules
中,用戶只需要接入一個 plugin
即可,免去了 loader
的接入成本。
原文地址:https://juejin.cn/post/7326002010084311079