我點擊頁面元素,為什么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 模式之后,鼠標(biāo) 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<Node>) {
- 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é)點類型做處理,把文件相關(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 }
- }
獲取組件名稱
在運行時鼠標(biāo) 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é)無止境。
參考資料
[1]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
本文轉(zhuǎn)載自微信公眾號「前端從進階到入院」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系前端從進階到入院公眾號。