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