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

可以在 預(yù)覽網(wǎng)站[2] 體驗一下。
使用方式
這個插件功能很強大,代碼也寫得很漂亮,唯一的缺點就是文檔不是很完善,我閱讀了源碼總結(jié)了成功接入這個插件需要的幾個步驟,缺一不可。
簡單來說就是三步:
構(gòu)建時: 需要加一個 webpack loader去遍歷編譯前的的AST節(jié)點,在 DOM 節(jié)點上加上文件路徑、名稱等相關(guān)的信息 。需要用 DefinePlugin注入一下項目運行時的根路徑,后續(xù)要用來拼接文件路徑,打開 VSCode 相應(yīng)的文件。運行時:需要在 React 組件的最外層包裹 Inspector組件,用于在瀏覽器端監(jiān)聽快捷鍵,彈出 debug 的遮罩層,在點擊遮罩層的時候,利用fetch向本機服務(wù)發(fā)送一個打開 VSCode 的請求。本地服務(wù):需要啟動 react-dev-utils里的一個中間件,監(jiān)聽一個特定的路徑,在本機服務(wù)端執(zhí)行打開 VSCode 的指令。
下面簡單分析一下這幾步到底做了什么。
原理簡化
構(gòu)建時
首先如果在瀏覽器端想知道這個組件屬于哪個文件,那么不可避免的要在構(gòu)建時就去遍歷代碼文件,根據(jù)代碼的結(jié)構(gòu)解析生成 AST,然后在每個組件的 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>
這樣就可以在輸入快捷鍵的時候,開啟 debug 模式,讓 DOM 在 hover 的時候增加一個遮罩層并展示組件對應(yīng)的信息:
這一步通過 webpack loader 拿到未編譯的 JSX 源碼,再配合 AST 的處理就可以完成。
運行時
既然需要在瀏覽器端增加 hover 事件,添加遮罩框元素,那么肯定不可避免的要侵入運行時的代碼,這里通過在整個應(yīng)用的最外層包裹一個 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 模式之后,鼠標 hover 到你想要調(diào)試的組件,就會展現(xiàn)出遮罩框,再點擊一下,就會自動在 VSCode 中打開對應(yīng)的組件文件,并且跳轉(zhuǎn)到對應(yīng)的行和列。
那么關(guān)鍵在于,這個跳轉(zhuǎn)其實是借助 fetch 發(fā)送了一個請求到本機的服務(wù)端,利用服務(wù)端執(zhí)行腳本命令如 code src/Inspector/index.ts 這樣的命令來打開 VSCode,這就要借助我說的第三步,啟動本地服務(wù)并引入中間件了。
本地服務(wù)
還記得 create-react-app 或者 vue-cli 啟動的前端項目,在錯誤時會彈出一個全局的遮罩和對應(yīng)的堆棧信息,點擊以后就會跳轉(zhuǎn)到 VSCode 對應(yīng)的文件么?沒錯,react-dev-inspector 也正是直接借助了 create-react-app 底層的工具包 react-dev-utils 去實現(xiàn)。(沒錯 create-react-app 創(chuàng)建的項目自帶這個服務(wù),不需要手動加載這一步了)
react-dev-utils 為這個功能封裝了一個中間件:errorOverlayMiddleware[3]
其實代碼也很簡單,就是監(jiān)聽了一個特殊的 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 這個核心的打開編輯器的方法我們一會再詳細分析,現(xiàn)在可以先略過,只要知道我們需要開啟這個服務(wù)即可。
這是一個為 express 設(shè)計的中間件,webpack 的 devServer 選項中提供的 before 也可以輕松接入這個中間件,如果你的項目不用 express,那么你只要參考這個中間件去重寫一個即可,只需要監(jiān)聽接口拿到文件相關(guān)的信息,調(diào)用核心方法 launchEditor 即可。
只要保證這幾個步驟的完成,那么這個插件就接入成功了,可以通過在瀏覽器的控制臺執(zhí)行 fetch('/__open-stack-frame-in-editor?fileName=/Users/admin/app/src/Title.tsx') 來測試 react-dev-utils的服務(wù)是否開啟成功。
注入絕對路徑
注意上一步的請求中 fileName= 后面的前綴是絕對路徑,而 DOM 節(jié)點上只會保存形如 src/Title.tsx 這樣的相對路徑,源碼中會在點擊遮罩層的時候去取 process.env.PWD 這個變量,和組件上的相對路徑拼接后得到完整路徑,這樣 VSCode 才能順利打開。
這需要借助 DefinePlugin 把啟動所在路徑寫入到瀏覽器環(huán)境中:
new?DefinePlugin({
??"process.env.PWD":?JSON.stringfy(process.env.PWD),
});
至此,整套插件集成完畢,簡化版的原理解析就結(jié)束了。
源碼重點
看完上面的簡化原理解析后,其實大家也差不多能寫出一個類似的插件了,只是實現(xiàn)的細節(jié)可能不太相同。這里就不一一解析完整的源碼了,來看一下源碼中比較值得關(guān)注的一些細節(jié)。
如何在元素上埋點
在瀏覽器端能找到節(jié)點在 VSCode 里的對應(yīng)的路徑,關(guā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
}
這是簡化后的代碼,標準的 parse -> traverse -> generate 流程,在遍歷的過程中對 JSXOpeningElement這種節(jié)點類型做處理,把文件相關(guān)的信息放到節(jié)點上即可:
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),
??)
??//?在元素上增加這幾個屬性
??node.attributes.push(lineAttr,?columnAttr,?relativePathAttr)
??return?{?result:?node?}
}
獲取組件名稱
在運行時鼠標 hover 在 DOM 節(jié)點上,這個時候拿到的只是 DOM 元素,如何獲取組件的名稱?其實 React 內(nèi)部會在 DOM 上反向的掛上它所對應(yīng)的 fiber node 的引用,這個引用在 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)一個普通的 DOM 元素比如 div ,而不是對應(yīng)一個組件 fiber,我們肯定期望的是向上查找最近的組件節(jié)點后展示它的名字(這里使用的是 displayName 屬性),由于 fiber 是鏈表結(jié)構(gòu),可以通過向上遞歸查找 return 這個屬性,直到找到第一個符合期望的節(jié)點。
這里遞歸查找 fiber 的 return,就類似于在 DOM 節(jié)點中遞歸向上查找 parentNode 屬性,不停的向父節(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é)點
????fiber?=?fiber.return;
??}
??return?null;
};
fiber 上的屬性 type 在函數(shù)式組件的情況下對應(yīng)你書寫的函數(shù),在 class 組件的情況下就對應(yīng)那個類,取上面的的 displayName 屬性即可:
export?const?getFiberName?=?(fiber?:?Fiber):?string?|?null?=>?{
??return?getSuitableFiber(fiber)?.type?.displayName;
};
這里有些美中不足的是,大部分我們手寫的函數(shù)組件都不會人為的加上 displayName,這是我認為源碼可以優(yōu)化的點。
服務(wù)端跳轉(zhuǎn) VSCode 原理
雖然簡單來說,react-dev-utils 其實就是開了個接口,當(dāng)你 fetch 的時候幫你執(zhí)行 code filepath 指令,但是它底層其實是很巧妙的實現(xiàn)了多種編輯器的兼容的。
如何“猜”出用戶在用哪個編輯器?它其實實現(xià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 命令去列出進程名,通過進程名再去映射對應(yīng)的打開編輯器的指令。比如你的進程里有 /Applications/Visual Studio Code.app/Contents/MacOS/Electron,那說明你用的是 VSCode,就獲取了 code 這個指令。
之后調(diào)用 child_process 模塊去執(zhí)行命令即可:
child_process.spawn("code",?pathInfo,?{?stdio:?"inherit"?});
launchEditor 源碼地址[4]
詳細接入教程
構(gòu)建時只需要對 webpack 配置做點改動,加入一個全局變量,引入一個 loader 即可。
const?{?DefinePlugin?}?=?require('webpack');
{
??module:?{
????rules:?[
??????{
????????test:?/\.(jsx|js)$/,
????????use:?[
??????????{
????????????loader:?'babel-loader',
????????????options:?{
??????????????presets:?['es2015',?'react'],
????????????},
??????????},
??????????//?注意這個?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),
????}),
??]
}
如果你的項目是自己搭建而非 cra 搭建的,那么有可能你的項目中沒有開啟 errorOverlayMiddleware 中間件提供的服務(wù),你可以在 webpack 的 devServer 中開啟:
import?createErrorOverlayMiddleware?from?'react-dev-utils/errorOverlayMiddleware'
{
??devServer:?{
????before(app)?{
??????app.use(createErrorOverlayMiddleware())
????}
??}
}
此外需要保證你的命令行本身就可以通過 code 命令打開 VSCode 編輯器,如果沒有配置這個,可以參考以下步驟:
1、首先打開 VSCode。
2、使用 command + shift + p (注意 window 下使用 ctrl + shift + p) 然后搜索 code,選擇 install 'code' command in path。
最后,在 React 項目的最外層接入:
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é)
在大項目的開發(fā)和維護過程中,擁有這樣一個調(diào)試神器真的特別重要,再好的記憶力也沒法應(yīng)對日益膨脹的組件數(shù)量…… 接入了這個插件后,指哪個組件跳哪個組件,大大節(jié)省了我們的時間。
在解讀這個插件的源碼過程中也能看出來,想要做一些對項目整體提效的事情,經(jīng)常需要我們?nèi)娴牧私膺\行時、構(gòu)建時、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)閱讀
Web 現(xiàn)代應(yīng)用程序架構(gòu)下的性能優(yōu)化,漸進式的極致藝術(shù)。
我在工作中寫React,學(xué)到了什么?性能優(yōu)化篇
深入揭秘前端路由本質(zhì),手寫 mini-router
??看完三件事
如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我三個小忙:
點贊,讓更多的人也能看到介紹內(nèi)容(收藏不點贊,都是耍流氓-_-) 關(guān)注公眾號“前端勸退師”,不定期分享原創(chuàng)知識。 也看看其他文章
勸退師個人微信:huab119
也可以來我的GitHub博客里拿所有文章的源文件:
前端勸退指南:https://github.com/roger-hiro/BlogFN一起玩耍呀
