在大型項目開發中,常常會遇到這樣一個場景,QA 丟給你一個出問題的連接,可是你徹底不知道這個頁面 & 組件對應的文件位置。css
這時候若是能夠點擊頁面上的組件,在 VSCode 中自動跳轉到對應文件,並定位到對應行號豈不美哉?html
react-dev-inspector 就是應此需求而生。前端
使用很是簡單方便,看完這張動圖你就秒懂:vue
能夠在 預覽網站 體驗一下。node
簡單來講就是三步:react
webpack loader
去遍歷編譯前的的 AST
節點,在 DOM 節點上加上文件路徑、名稱等相關的信息 。DefinePlugin
注入一下項目運行時的根路徑,後續要用來拼接文件路徑,打開 VSCode 相應的文件。Inspector
組件,用於在瀏覽器端監聽快捷鍵,彈出 debug 的遮罩層,在點擊遮罩層的時候,利用 fetch
向本機服務發送一個打開 VSCode 的請求。react-dev-utils
裏的一箇中間件,監聽一個特定的路徑,在本機服務端執行打開 VSCode 的指令。下面簡單分析一下這幾步到底作了什麼。webpack
首先若是在瀏覽器端想知道這個組件屬於哪一個文件,那麼不可避免的要在構建時就去遍歷代碼文件,根據代碼的結構解析生成 AST,而後在每一個組件的 DOM 元素上掛上當前組件的對應文件位置和行號,因此在開發環境最終生成的 DOM 元素是這樣的:git
<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 的時候增長一個遮罩層並展現組件對應的信息: github
這一步經過 webpack loader
拿到未編譯的 JSX
源碼,再配合 AST
的處理就能夠完成。web
既然須要在瀏覽器端增長 hover 事件,添加遮罩框元素,那麼確定不可避免的要侵入運行時的代碼,這裏經過在整個應用的最外層包裹一個 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 到你想要調試的組件,就會展示出遮罩框,再點擊一下,就會自動在 VSCode 中打開對應的組件文件,而且跳轉到對應的行和列。
那麼關鍵在於,這個跳轉實際上是藉助 fetch 發送了一個請求到本機的服務端,利用服務端執行腳本命令如 code src/Inspector/index.ts
這樣的命令來打開 VSCode,這就要藉助我說的第三步,啓動本地服務並引入中間件了。
還記得 create-react-app
或者 vue-cli
啓動的前端項目,在錯誤時會彈出一個全局的遮罩和對應的堆棧信息,點擊之後就會跳轉到 VSCode 對應的文件麼?沒錯,react-dev-inspector
也正是直接藉助了 create-react-app
底層的工具包 react-dev-utils
去實現。(沒錯 create-react-app
建立的項目自帶這個服務,不須要手動加載這一步了)
react-dev-utils
爲這個功能封裝了一箇中間件: errorOverlayMiddleware
其實代碼也很簡單,就是監聽了一個特殊的 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
這個核心的打開編輯器的方法咱們一會再詳細分析,如今能夠先略過,只要知道咱們須要開啓這個服務便可。
這是一個爲 express
設計的中間件,webpack 的 devServer
選項中提供的 before
也能夠輕鬆接入這個中間件,若是你的項目不用 express
,那麼你只要參考這個中間件去重寫一個便可,只須要監聽接口拿到文件相關的信息,調用核心方法 launchEditor
便可。
只要保證這幾個步驟的完成,那麼這個插件就接入成功了,能夠經過在瀏覽器的控制檯執行 fetch('/__open-stack-frame-in-editor?fileName=/Users/admin/app/src/Title.tsx')
來測試 react-dev-utils
的服務是否開啓成功。
注意上一步的請求中 fileName=
後面的前綴是絕對路徑,而 DOM 節點上只會保存形如 src/Title.tsx
這樣的相對路徑,源碼中會在點擊遮罩層的時候去取 process.env.PWD
這個變量,和組件上的相對路徑拼接後獲得完整路徑,這樣 VSCode 才能順利打開。
這須要藉助 DefinePlugin
把啓動所在路徑寫入到瀏覽器環境中:
new DefinePlugin({
"process.env.PWD": JSON.stringfy(process.env.PWD),
});
複製代碼
至此,整套插件集成完畢,簡化版的原理解析就結束了。
看完上面的簡化原理解析後,其實你們也差很少能寫出一個相似的插件了,只是實現的細節可能不太相同。這裏就不一一解析完整的源碼了,來看一下源碼中比較值得關注的一些細節。
在瀏覽器端能找到節點在 VSCode 裏的對應的路徑,關鍵就在於編譯時的埋點,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<Node>) {
if (path.type === "JSXOpeningElement") {
doJSXOpeningElement(path.node as JSXOpeningElement, { relativePath });
}
},
});
const { code } = generate(ast);
return code
}
複製代碼
這是簡化後的代碼,標準的 parse -> traverse -> generate
流程,在遍歷的過程當中對 JSXOpeningElement
這種節點類型作處理,把文件相關的信息放到節點上便可:
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 節點上,這個時候拿到的只是 DOM 元素,如何獲取組件的名稱?其實 React 內部會在 DOM 上反向的掛上它所對應的 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
可能對應一個普通的 DOM 元素好比 div
,而不是對應一個組件 fiber
,咱們確定指望的是向上查找最近的組件節點後展現它的名字(這裏使用的是 displayName
或者 name
屬性),因爲 fiber
是鏈表結構,能夠經過向上遞歸查找 return
這個屬性,直到找到第一個符合指望的節點。
這裏遞歸查找 fiber
的 return
,就相似於在 DOM 節點中遞歸向上查找 parentNode
屬性,不停的向父節點遞歸查找。
// 這裏用正則屏蔽了一些組件名 這些正則匹配到的組價名不會被檢測到
export const debugToolNameRegex = /^(.*?\.Provider|.*?\.Consumer|Anonymous|Trigger|Tooltip|_.*|[a-z].*)$/;
export const getSuitableFiber = (baseFiber?: Fiber): Fiber | null => {
let fiber = baseFiber
while (fiber) {
// while 循環向上遞歸查找 displayName 符合的組件
const name = fiber.type?.displayName ?? fiber.type?.name
if (name && !debugToolNameRegex.test(name)) {
return fiber
}
// 找不到的話 就繼續找 return 節點
fiber = fiber.return
}
return null
}
複製代碼
fiber
上的屬性 type
在函數式組件的狀況下對應你書寫的函數,在 class
組件的狀況下就對應那個類,取上面的的 displayName
或 name
屬性便可:
export const getFiberName = (fiber?: Fiber): string | undefined => {
const fiberType = getSuitableFiber(fiber)?.type
let displayName: string | undefined
// The displayName property is not guaranteed to be a string.
// It's only safe to use for our purposes if it's a string.
// github.com/facebook/react-devtools/issues/803
//
// https://github.com/facebook/react/blob/v17.0.0/packages/react-devtools-shared/src/utils.js#L90-L112
if (typeof fiberType?.displayName === 'string') {
displayName = fiberType.displayName
} else if (typeof fiberType?.name === 'string') {
displayName = fiberType.name
}
return displayName
}
複製代碼
雖然簡單來講,react-dev-utils
其實就是開了個接口,當你 fetch 的時候幫你執行 code filepath
指令,可是它底層實際上是很巧妙的實現了多種編輯器的兼容的。
如何「猜」出用戶在用哪一個編輯器?它其實實現定義好了一組進程名對應開啓指令的映射表:
const COMMON_EDITORS_OSX = {
'/Applications/Atom.app/Contents/MacOS/Atom': 'atom',
'/Applications/Visual Studio Code.app/Contents/MacOS/Electron': 'code',
...
}
複製代碼
而後在 macOS
和 Linux
下,經過執行 ps x
命令去列出進程名,經過進程名再去映射對應的打開編輯器的指令。好比你的進程裏有 /Applications/Visual Studio Code.app/Contents/MacOS/Electron
,那說明你用的是 VSCode
,就獲取了 code
這個指令。
以後調用 child_process
模塊去執行命令便可:
child_process.spawn("code", pathInfo, { stdio: "inherit" });
複製代碼
構建時只須要對 webpack 配置作點改動,加入一個全局變量,引入一個 loader 便可。
const { DefinePlugin } = require('webpack');
{
module: {
rules: [
{
test: /\.(jsx|js)$/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['es2015', 'react'],
},
},
// 注意這個 loader babel 編譯以前執行
{
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
中間件提供的服務,你能夠在 webpack 的 devServer
中開啓:
import createErrorOverlayMiddleware from 'react-dev-utils/errorOverlayMiddleware'
{
devServer: {
before(app) {
app.use(createErrorOverlayMiddleware())
}
}
}
複製代碼
此外須要保證你的命令行自己就能夠經過 code
命令打開 VSCode 編輯器,若是沒有配置這個,能夠參考如下步驟:
一、首先打開 VSCode。
二、使用 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>
)
}
複製代碼
在大項目的開發和維護過程當中,擁有這樣一個調試神器真的特別重要,再好的記憶力也無法應對日益膨脹的組件數量…… 接入了這個插件後,指哪一個組件跳哪一個組件,大大節省了咱們的時間。
在解讀這個插件的源碼過程當中也能看出來,想要作一些對項目總體提效的事情,常常須要咱們全面的瞭解運行時、構建時、Node 端的不少知識,學無止境。
本文首發於公衆號「前端從進階到入院」,點擊關注領取萬字高級前端進階路線,前端算法零基礎精選題解,我也會常常分享一些在工做、生活裏遇到的有趣的事情,和你們交朋友聊聊天。