🎉我點了頁面上的元素,VSCode 乖乖打開了對應的組件?原理揭祕。

前言

在大型項目開發中,常常會遇到這樣一個場景,QA 丟給你一個出問題的連接,可是你徹底不知道這個頁面 & 組件對應的文件位置。css

這時候若是能夠點擊頁面上的組件,在 VSCode 中自動跳轉到對應文件,並定位到對應行號豈不美哉?html

react-dev-inspector 就是應此需求而生。前端

使用很是簡單方便,看完這張動圖你就秒懂:vue

preview

能夠在 預覽網站 體驗一下。node

使用方式

簡單來講就是三步:react

  1. 構建時
    • 須要加一個 webpack loader 去遍歷編譯前的的 AST 節點,在 DOM 節點上加上文件路徑、名稱等相關的信息 。
    • 須要用 DefinePlugin 注入一下項目運行時的根路徑,後續要用來拼接文件路徑,打開 VSCode 相應的文件。
  2. 運行時:須要在 React 組件的最外層包裹 Inspector 組件,用於在瀏覽器端監聽快捷鍵,彈出 debug 的遮罩層,在點擊遮罩層的時候,利用 fetch 向本機服務發送一個打開 VSCode 的請求。
  3. 本地服務:須要啓動 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 的時候增長一個遮罩層並展現組件對應的信息: imagegithub

這一步經過 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 這個屬性,直到找到第一個符合指望的節點。

這裏遞歸查找 fiberreturn,就相似於在 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 組件的狀況下就對應那個類,取上面的的 displayNamename 屬性便可:

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
}
複製代碼

image

服務端跳轉 VSCode 原理

雖然簡單來講,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',
  ...
}
複製代碼

而後在 macOSLinux 下,經過執行 ps x 命令去列出進程名,經過進程名再去映射對應的打開編輯器的指令。好比你的進程裏有 /Applications/Visual Studio Code.app/Contents/MacOS/Electron,那說明你用的是 VSCode,就獲取了 code 這個指令。

以後調用 child_process 模塊去執行命令便可:

child_process.spawn("code", pathInfo, { stdio: "inherit" });
複製代碼

launchEditor 源碼地址

詳細接入教程

構建時只須要對 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 端的不少知識,學無止境。

相關閱讀

Web 現代應用程序架構下的性能優化,漸進式的極致藝術。

我在工做中寫React,學到了什麼?

我在工做中寫React,學到了什麼?性能優化篇

深刻揭祕前端路由本質,手寫 mini-router

React-Redux 100行代碼簡易版探究原理

感謝關注

本文首發於公衆號「前端從進階到入院」,點擊關注領取萬字高級前端進階路線,前端算法零基礎精選題解,我也會常常分享一些在工做、生活裏遇到的有趣的事情,和你們交朋友聊聊天。

相關文章
相關標籤/搜索