我點(diǎn)擊頁面元素,VSCode 乖乖打開了組件?原理揭秘。
前言
在大型項(xiàng)目開發(fā)中,經(jīng)常會遇到這樣一個(gè)場景,QA 丟給你一個(gè)出問題的鏈接,但是你完全不知道這個(gè)頁面 & 組件對應(yīng)的文件位置。
這時(shí)候如果可以點(diǎn)擊頁面上的組件,在 VSCode 中自動跳轉(zhuǎn)到對應(yīng)文件,并定位到對應(yīng)行號豈不美哉?
react-dev-inspector[1] 就是應(yīng)此需求而生。
使用非常簡單方便,看完這張動圖你就秒懂:

可以在 預(yù)覽網(wǎng)站[2] 體驗(yàn)一下。
使用方式
這個(gè)插件功能很強(qiáng)大,代碼也寫得很漂亮,唯一的缺點(diǎn)就是文檔不是很完善,我閱讀了源碼總結(jié)了成功接入這個(gè)插件需要的幾個(gè)步驟,缺一不可。
簡單來說就是三步:
構(gòu)建時(shí): 需要加一個(gè) webpack loader去遍歷編譯前的的AST節(jié)點(diǎn),在 DOM 節(jié)點(diǎn)上加上文件路徑、名稱等相關(guān)的信息 。需要用 DefinePlugin注入一下項(xiàng)目運(yùn)行時(shí)的根路徑,后續(xù)要用來拼接文件路徑,打開 VSCode 相應(yīng)的文件。運(yùn)行時(shí):需要在 React 組件的最外層包裹 Inspector組件,用于在瀏覽器端監(jiān)聽快捷鍵,彈出 debug 的遮罩層,在點(diǎn)擊遮罩層的時(shí)候,利用fetch向本機(jī)服務(wù)發(fā)送一個(gè)打開 VSCode 的請求。本地服務(wù):需要啟動 react-dev-utils里的一個(gè)中間件,監(jiān)聽一個(gè)特定的路徑,在本機(jī)服務(wù)端執(zhí)行打開 VSCode 的指令。
下面簡單分析一下這幾步到底做了什么。
原理簡化
構(gòu)建時(shí)
首先如果在瀏覽器端想知道這個(gè)組件屬于哪個(gè)文件,那么不可避免的要在構(gòu)建時(shí)就去遍歷代碼文件,根據(jù)代碼的結(jié)構(gòu)解析生成 AST,然后在每個(gè)組件的 DOM 元素上掛上當(dāng)前組件的對應(yīng)文件位置和行號,所以在開發(fā)環(huán)境最終生成的 DOM 元素是這樣的:
<div
??data-inspector-line="11"
??data-inspector-column="4"
??data-inspector-relative-path="src/components/Slogan/Slogan.tsx"
??class="css-1f15bld-Description?e1vquvfb0"
>
??<p
????data-inspector-line="44"
????data-inspector-column="10"
????data-inspector-relative-path="src/layouts/index.tsx"
??>
????Inspect?react?components?and?click?will?jump?to?local?IDE?to?view?component
????code.
??p>
div>
這樣就可以在輸入快捷鍵的時(shí)候,開啟 debug 模式,讓 DOM 在 hover 的時(shí)候增加一個(gè)遮罩層并展示組件對應(yīng)的信息:
這一步通過 webpack loader 拿到未編譯的 JSX 源碼,再配合 AST 的處理就可以完成。
運(yùn)行時(shí)
既然需要在瀏覽器端增加 hover 事件,添加遮罩框元素,那么肯定不可避免的要侵入運(yùn)行時(shí)的代碼,這里通過在整個(gè)應(yīng)用的最外層包裹一個(gè) Inspector 來盡可能的減少入侵。
import?React?from?'react'
import?{?Inspector?}?from?'react-dev-inspector'
const?InspectorWrapper?=?process.env.NODE_ENV?===?'development'
????Inspector
??:?React.Fragment
export?const?Layout?=?()?=>?{
??//?...
??return?(
????<InspectorWrapper
??????keys={['control',?'shift',?'command',?'c']}?//?default?keys
??????...??//?Props?see?below
????>
?????<Page?/>
????InspectorWrapper>
??)
}
這里也可以自定義你喜歡的快捷鍵,用來開啟 debug 模式。
開啟了 debug 模式之后,鼠標(biāo) hover 到你想要調(diào)試的組件,就會展現(xiàn)出遮罩框,再點(diǎn)擊一下,就會自動在 VSCode 中打開對應(yīng)的組件文件,并且跳轉(zhuǎn)到對應(yīng)的行和列。
那么關(guān)鍵在于,這個(gè)跳轉(zhuǎn)其實(shí)是借助 fetch 發(fā)送了一個(gè)請求到本機(jī)的服務(wù)端,利用服務(wù)端執(zhí)行腳本命令如 code src/Inspector/index.ts 這樣的命令來打開 VSCode,這就要借助我說的第三步,啟動本地服務(wù)并引入中間件了。
本地服務(wù)
還記得 create-react-app 或者 vue-cli 啟動的前端項(xiàng)目,在錯(cuò)誤時(shí)會彈出一個(gè)全局的遮罩和對應(yīng)的堆棧信息,點(diǎn)擊以后就會跳轉(zhuǎn)到 VSCode 對應(yīng)的文件么?沒錯(cuò),react-dev-inspector 也正是直接借助了 create-react-app 底層的工具包 react-dev-utils 去實(shí)現(xiàn)。(沒錯(cuò) create-react-app 創(chuàng)建的項(xiàng)目自帶這個(gè)服務(wù),不需要手動加載這一步了)
react-dev-utils 為這個(gè)功能封裝了一個(gè)中間件:errorOverlayMiddleware[3]
其實(shí)代碼也很簡單,就是監(jiān)聽了一個(gè)特殊的 URL:
//?launchEditorEndpoint.js
module.exports?=?"/__open-stack-frame-in-editor";
//?errorOverlayMiddleware.js
const?launchEditor?=?require("./launchEditor");
const?launchEditorEndpoint?=?require("./launchEditorEndpoint");
module.exports?=?function?createLaunchEditorMiddleware()?{
??return?function?launchEditorMiddleware(req,?res,?next)?{
????if?(req.url.startsWith(launchEditorEndpoint))?{
??????const?lineNumber?=?parseInt(req.query.lineNumber,?10)?||?1;
??????const?colNumber?=?parseInt(req.query.colNumber,?10)?||?1;
??????launchEditor(req.query.fileName,?lineNumber,?colNumber);
??????res.end();
????}?else?{
??????next();
????}
??};
};
launchEditor 這個(gè)核心的打開編輯器的方法我們一會再詳細(xì)分析,現(xiàn)在可以先略過,只要知道我們需要開啟這個(gè)服務(wù)即可。
這是一個(gè)為 express 設(shè)計(jì)的中間件,webpack 的 devServer 選項(xiàng)中提供的 before 也可以輕松接入這個(gè)中間件,如果你的項(xiàng)目不用 express,那么你只要參考這個(gè)中間件去重寫一個(gè)即可,只需要監(jiān)聽接口拿到文件相關(guān)的信息,調(diào)用核心方法 launchEditor 即可。
只要保證這幾個(gè)步驟的完成,那么這個(gè)插件就接入成功了,可以通過在瀏覽器的控制臺執(zhí)行 fetch('/__open-stack-frame-in-editor?fileName=/Users/admin/app/src/Title.tsx') 來測試 react-dev-utils的服務(wù)是否開啟成功。
注入絕對路徑
注意上一步的請求中 fileName= 后面的前綴是絕對路徑,而 DOM 節(jié)點(diǎn)上只會保存形如 src/Title.tsx 這樣的相對路徑,源碼中會在點(diǎn)擊遮罩層的時(shí)候去取 process.env.PWD 這個(gè)變量,和組件上的相對路徑拼接后得到完整路徑,這樣 VSCode 才能順利打開。
這需要借助 DefinePlugin 把啟動所在路徑寫入到瀏覽器環(huán)境中:
new?DefinePlugin({
??"process.env.PWD":?JSON.stringfy(process.env.PWD),
});
至此,整套插件集成完畢,簡化版的原理解析就結(jié)束了。
源碼重點(diǎn)
看完上面的簡化原理解析后,其實(shí)大家也差不多能寫出一個(gè)類似的插件了,只是實(shí)現(xiàn)的細(xì)節(jié)可能不太相同。這里就不一一解析完整的源碼了,來看一下源碼中比較值得關(guān)注的一些細(xì)節(jié)。
如何在元素上埋點(diǎn)
在瀏覽器端能找到節(jié)點(diǎn)在 VSCode 里的對應(yīng)的路徑,關(guān)鍵就在于編譯時(shí)的埋點(diǎn),webpack loader 接受代碼字符串,返回你處理過后的字符串,用作在元素上增加新屬性再合適不過,我們只需要利用 babel 中的整套 AST 能力即可做到:
export?default?function?inspectorLoader(
??this:?webpack.loader.LoaderContext,
??source:?string
)?{
??const?{?rootContext:?rootPath,?resourcePath:?filePath?}?=?this;
??const?ast:?Node?=?parse(source);
??traverse(ast,?{
????enter(path:?NodePath)?{
??????if?(path.type?===?"JSXOpeningElement")?{
????????doJSXOpeningElement(path.node?as?JSXOpeningElement,?{?relativePath?});
??????}
????},
??});
??const?{?code?}?=?generate(ast);
??return?code
}
這是簡化后的代碼,標(biāo)準(zhǔn)的 parse -> traverse -> generate 流程,在遍歷的過程中對 JSXOpeningElement這種節(jié)點(diǎn)類型做處理,把文件相關(guān)的信息放到節(jié)點(diǎn)上即可:
const?doJSXOpeningElement:?NodeHandler<
??JSXOpeningElement,
??{?relativePath:?string?}
>?=?(node,?option)?=>?{
??const?{?stop?}?=?doJSXPathName(node.name)
??if?(stop)?return?{?stop?}
??const?{?relativePath?}?=?option
??//?寫入行號
??const?lineAttr?=?jsxAttribute(
????jsxIdentifier('data-inspector-line'),
????stringLiteral(node.loc.start.line.toString()),
??)
??//?寫入列號
??const?columnAttr?=?jsxAttribute(
????jsxIdentifier('data-inspector-column'),
????stringLiteral(node.loc.start.column.toString()),
??)
??//?寫入組件所在的相對路徑
??const?relativePathAttr?=?jsxAttribute(
????jsxIdentifier('data-inspector-relative-path'),
????stringLiteral(relativePath),
??)
??//?在元素上增加這幾個(gè)屬性
??node.attributes.push(lineAttr,?columnAttr,?relativePathAttr)
??return?{?result:?node?}
}
獲取組件名稱
在運(yùn)行時(shí)鼠標(biāo) hover 在 DOM 節(jié)點(diǎn)上,這個(gè)時(shí)候拿到的只是 DOM 元素,如何獲取組件的名稱?其實(shí) React 內(nèi)部會在 DOM 上反向的掛上它所對應(yīng)的 fiber node 的引用,這個(gè)引用在 DOM 元素上以 __reactInternalInstance 開頭命名,可以這樣拿到:
/**
?*?https://stackoverflow.com/questions/29321742/react-getting-a-component-from-a-dom-element-for-debugging
?*/
export?const?getElementFiber?=?(element:?HTMLElement):?Fiber?|?null?=>?{
??const?fiberKey?=?Object.keys(element).find(
????key?=>?key.startsWith('__reactInternalInstance$'),
??)
??if?(fiberKey)?{
????return?element[fiberKey]?as?Fiber
??}
??return?null
}
由于拿到的 fiber可能對應(yīng)一個(gè)普通的 DOM 元素比如 div ,而不是對應(yīng)一個(gè)組件 fiber,我們肯定期望的是向上查找最近的組件節(jié)點(diǎn)后展示它的名字(這里使用的是 displayName 屬性),由于 fiber 是鏈表結(jié)構(gòu),可以通過向上遞歸查找 return 這個(gè)屬性,直到找到第一個(gè)符合期望的節(jié)點(diǎn)。
這里遞歸查找 fiber 的 return,就類似于在 DOM 節(jié)點(diǎn)中遞歸向上查找 parentNode 屬性,不停的向父節(jié)點(diǎn)遞歸查找。
//?這里用正則屏蔽了一些組件名?這些正則匹配到的組價(jià)名不會被檢測到
export?const?debugToolNameRegex?=?/^(.*?\.Provider|.*?\.Consumer|Anonymous|Trigger|Tooltip|_.*|[a-z].*)$/;
export?const?getSuitableFiber?=?(baseFiber?:?Fiber):?Fiber?|?null?=>?{
??let?fiber?=?baseFiber;
??while?(fiber)?{
????//?while?循環(huán)向上遞歸查找?displayName?符合的組件
????const?name?=?fiber.type?.displayName;
????if?(name?&&?!debugToolNameRegex.test(name))?{
??????return?fiber;
????}
????//?找不到的話?就繼續(xù)找?return?節(jié)點(diǎn)
????fiber?=?fiber.return;
??}
??return?null;
};
fiber 上的屬性 type 在函數(shù)式組件的情況下對應(yīng)你書寫的函數(shù),在 class 組件的情況下就對應(yīng)那個(gè)類,取上面的的 displayName 屬性即可:
export?const?getFiberName?=?(fiber?:?Fiber):?string?|?null?=>?{
??return?getSuitableFiber(fiber)?.type?.displayName;
};
這里有些美中不足的是,大部分我們手寫的函數(shù)組件都不會人為的加上 displayName,這是我認(rèn)為源碼可以優(yōu)化的點(diǎn)。
服務(wù)端跳轉(zhuǎn) VSCode 原理
雖然簡單來說,react-dev-utils 其實(shí)就是開了個(gè)接口,當(dāng)你 fetch 的時(shí)候幫你執(zhí)行 code filepath 指令,但是它底層其實(shí)是很巧妙的實(shí)現(xiàn)了多種編輯器的兼容的。
如何“猜”出用戶在用哪個(gè)編輯器?它其實(shí)實(shí)現(xiàn)定義好了一組進(jìn)程名對應(yīng)開啟指令的映射表:
const?COMMON_EDITORS_OSX?=?{
??'/Applications/Atom.app/Contents/MacOS/Atom':?'atom',
??'/Applications/Visual?Studio?Code.app/Contents/MacOS/Electron':?'code',
??...
}
然后在 macOS 和 Linux 下,通過執(zhí)行 ps x 命令去列出進(jìn)程名,通過進(jìn)程名再去映射對應(yīng)的打開編輯器的指令。比如你的進(jìn)程里有 /Applications/Visual Studio Code.app/Contents/MacOS/Electron,那說明你用的是 VSCode,就獲取了 code 這個(gè)指令。
之后調(diào)用 child_process 模塊去執(zhí)行命令即可:
child_process.spawn("code",?pathInfo,?{?stdio:?"inherit"?});
launchEditor 源碼地址[4]
詳細(xì)接入教程
構(gòu)建時(shí)只需要對 webpack 配置做點(diǎn)改動,加入一個(gè)全局變量,引入一個(gè) loader 即可。
const?{?DefinePlugin?}?=?require('webpack');
{
??module:?{
????rules:?[
??????{
????????test:?/\.(jsx|js)$/,
????????use:?[
??????????{
????????????loader:?'babel-loader',
????????????options:?{
??????????????presets:?['es2015',?'react'],
????????????},
??????????},
??????????//?注意這個(gè)?loader?babel?編譯之前執(zhí)行
??????????{
????????????loader:?'react-dev-inspector/plugins/webpack/inspector-loader',
????????????options:?{?exclude:?[resolve(__dirname,?'想要排除的目錄')]?},
??????????},
????????],
??????}
????],
??},
??plugins:?[
????new?DefinePlugin({
??????'process.env.PWD':?JSON.stringify(process.env.PWD),
????}),
??]
}
如果你的項(xiàng)目是自己搭建而非 cra 搭建的,那么有可能你的項(xiàng)目中沒有開啟 errorOverlayMiddleware 中間件提供的服務(wù),你可以在 webpack 的 devServer 中開啟:
import?createErrorOverlayMiddleware?from?'react-dev-utils/errorOverlayMiddleware'
{
??devServer:?{
????before(app)?{
??????app.use(createErrorOverlayMiddleware())
????}
??}
}
此外需要保證你的命令行本身就可以通過 code 命令打開 VSCode 編輯器,如果沒有配置這個(gè),可以參考以下步驟:
1、首先打開 VSCode。
2、使用 command + shift + p (注意 window 下使用 ctrl + shift + p) 然后搜索 code,選擇 install 'code' command in path。
最后,在 React 項(xiàng)目的最外層接入:
import?React?from?'react'
import?{?Inspector?}?from?'react-dev-inspector'
const?InspectorWrapper?=?process.env.NODE_ENV?===?'development'
????Inspector
??:?React.Fragment
export?const?Layout?=?()?=>?{
??//?...
??return?(
????<InspectorWrapper
??????keys={['control',?'shift',?'command',?'c']}?//?default?keys
??????...??//?Props?see?below
????>
?????<Page?/>
????InspectorWrapper>
??)
}
總結(jié)
在大項(xiàng)目的開發(fā)和維護(hù)過程中,擁有這樣一個(gè)調(diào)試神器真的特別重要,再好的記憶力也沒法應(yīng)對日益膨脹的組件數(shù)量…… 接入了這個(gè)插件后,指哪個(gè)組件跳哪個(gè)組件,大大節(jié)省了我們的時(shí)間。
在解讀這個(gè)插件的源碼過程中也能看出來,想要做一些對項(xiàng)目整體提效的事情,經(jīng)常需要我們?nèi)娴牧私膺\(yùn)行時(shí)、構(gòu)建時(shí)、Node 端的很多知識,學(xué)無止境。
參考資料
react-dev-inspector: https://github.com/zthxxx/react-dev-inspector
[2]預(yù)覽網(wǎng)站: https://react-dev-inspector.zthxxx.me/
[3]errorOverlayMiddleware: https://github.com/facebook/create-react-app/blob/master/packages/react-dev-utils/errorOverlayMiddleware.js
[4]launchEditor 源碼地址: https://github.com/facebook/create-react-app/blob/master/packages/react-dev-utils/launchEditor.js
最后
關(guān)注公眾號【前端宇宙】,每日獲取好文推薦 添加微信,入群交流
