記一次react-devtools探索過程


為什么要探究react-devtools?這一切都要源於一次痛苦的看代碼過程,
剛來字節的時候,要熟悉接觸公司內部的業務代碼,
上手熟悉就得先找到組件的文件位置,但項目中組件的層層嵌套讓我難以定位,以至於頁面上隨便一個按鈕,找到它的位置就要花費一段時間了。
復雜的組件嵌套
那么,我們能不能自動化這個找的過程呢?回答是:可以,不過這是馬后炮了,筆者在這中途的調研過程中,經歷了很多曲折。
描述一下這個需求,便是:
在頁面上點擊組件,vscode打開組件文件位置。

開始研究react-devtool

猜測可能react-devtool可能已經有跟蹤組件文件位置的功能,因此一開始,我去把react-devtool上的所有按鈕都點了一遍,最終在view source功能按鈕上發現了可能的解決問題的可能
view source
點擊之后chrome的開發者工具跳轉到了源代碼標簽,並標識了按鈕渲染函數的位置

好家伙,這和筆者想做的跟蹤文件位置已經很接近了,不僅如此,注意下方“第442行,第23列 (從xxx.js)”映射到源代碼,說明這個功能可能還有獲取行,列,文件位置的能力
綜上,view source功能的能力有:

  1. 獲取組件的渲染函數
  2. 跳轉到渲染函數的所在位置
  3. 可以獲取到文件位置路徑和其所在的行和列(后面證實,無此能力)
    接下來就讓我們來驗證這三個能力是如何實現的。
    因此筆者馬不停蹄的在github上搜索react devtools 的開源代碼

react-devtool 源碼

  const viewElementSourceFunction = id => {
          const rendererID = store.getRendererIDForElement(id);
          if (rendererID != null) {
            // Ask the renderer interface to determine the component function,
            // and store it as a global variable on the window
            bridge.send('viewElementSource', {id, rendererID});

            setTimeout(() => {
              // Ask Chrome to display the location of the component function,
              // or a render method if it is a Class (ideally Class instance, not type)
              // assuming the renderer found one.
              chrome.devtools.inspectedWindow.eval(`
                if (window.$type != null) {
                  if (
                    window.$type &&
                    window.$type.prototype &&
                    window.$type.prototype.isReactComponent
                  ) {
                    // inspect Component.render, not constructor
                    inspect(window.$type.prototype.render);
                  } else {
                    // inspect Functional Component
                    inspect(window.$type);
                  }
                }
              `);
            }, 100);
          }
        };

關鍵點在於chrome.devtools.inspectedWindow.eval這個api,其中執行了 inspect(window.$type.prototype.render)
window.$type.prototype.render便是組件的渲染函數,具體是怎么被注冊到全局window 上的,后面會提到。
現在我們先來具體講講inspect這個瀏覽器api

inspect

很簡單,我們做個實驗就懂了,首先在控制台輸入這行代碼

let p=document.querySelector('p');
inspect(p);

執行了之后,我們會驚奇的發現,它從控制台跳轉到了元素,並標識了p標簽所在的位置。

那,如果inspect的入參不是dom而是一個函數呢?

function func(){}
inspect(func)

這時候神奇的事情來了,控制台跳轉到了函數a的定義位置,
func位置
現在我們再來說說window.$type到底是什么東西:

window.$type

現在我們再來說說window.$type到底是什么東西,
window.&type的獲取如下

bridge.send('viewElementSource', {id, rendererID});

它是react devtools內部實現的一個發布訂閱機制,這里的意思是,觸發 viewElementSouce任務,並且帶上id和renderID的載荷(用於查找組件渲染函數)
而viewElementSource的職能便是根據(id和renderID)找到組件對應的fiber.type並賦 予給window.$type,而fiber.type.prototype.render就是組件的渲染函數,這樣就可 以為后面inspect所用了。
具體函數邏輯如下:

 function prepareViewElementSource(id) {
    const fiber = idToArbitraryFiberMap.get(id);

    if (fiber == null) {
      console.warn(`Could not find Fiber with id "${id}"`);
      return;
    }

    const {
      elementType,
      tag,
      type
    } = fiber;
    console.log('@fiber', fiber);

    switch (tag) {
      case ClassComponent:
      case IncompleteClassComponent:
      case IndeterminateComponent:
      case FunctionComponent:
        global.$type = type;
        break;

      case ForwardRef:
        global.$type = type.render;
        break;

      case MemoComponent:
      case SimpleMemoComponent:
        global.$type = elementType != null && elementType.type != null ? elementType.type : type;
        break;

      default:
        global.$type = null;
        break;
    }
  }

為什么react devtools要這樣繞一大圈來實現這段邏輯?主要和瀏覽器插件消息不互通有關,但畢竟這篇文章不是細講瀏覽器插件的,所以不細說
不過,有興趣的小伙伴可以看看這位同學的瀏覽器插件教程,第七小節的通信部分能解釋react devtools為什么要寫這一套發布訂閱機制來繞圈。
https://www.cnblogs.com/liuxianan/p/chrome-plugin-develop.html
好,那么現在,我們可以基本總結,react devtools已經探明擁有的能力
1.獲取組件的渲染函數
2.跳轉到渲染函數的所在位置
但是
3.可以獲取到文件位置路徑和其所在的行和列
在viewElementSourceFunction探究的過程中可以發現,無此能力,很遺憾,我們只能另想辦法了

彎路:[[FunctionLocation]]

我想,既然react devtools能獲取到渲染函數,那么我是不是能利用 [[FunctionLocation]]來獲取路徑位置?

但是最終答案是【暫時不行】,至少在瀏覽器層不行,
在這個走彎路的過程中,我又了解到一件事,nodejs底層可以獲取函數的[[FunctionLocation]],原理是nodejs底層cpp層可以直接獲取到函數位置信息,
而nodejs官方也有相應工具提供
通過inspector這個內置工具

global.a = () => { /* test function */ };

const s = new (require('inspector').Session)();
s.connect();

let objectId;
s.post('Runtime.evaluate', { expression: 'a' }, (err, { result }) => {
  objectId = result.objectId;
});
s.post('Runtime.getProperties', { objectId }, (err, { internalProperties }) => {
  console.log(internalProperties);
});

但是瀏覽器層無論從瀏覽器還是查閱了瀏覽器插件api,似乎都沒有辦法獲取到 [[FunctionLocation]],
這條路宣布告吹

正路:webpack編譯時直接在dom上插入位置信息

這些靈感取自於我點了頁面上的元素,VSCode 乖乖打開了對應的組件?原理揭秘
沒錯,筆者研究到這一步才發現原來已經有已經實現這個功能的插件[React Dev inspector]了....,
但React Dev inspector也有自己的缺陷,它需要在組件最外圍侵入式的包裹一層

<InspectorWrapper>
 <App />
</InspectorWrapper>

因此后文中筆者結合React Dev inspector對react devtools進行改造
那么,為什么React Dev inspector可以獲取到位置信息呢?
實際上是利用了webpack loader 去遍歷編譯前的的 AST 節點,在 DOM 節點上加上文件路徑、名稱等相關的信息 。
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
}

在遍歷的過程中對 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 }
}

這樣在組件編譯后我們就能得到三個信息,行號,列號,位置,
在webpack構建的過程中利用中間件,我們再起一個本地服務,接收瀏覽器傳遞過來的[行號,列號,位置],然后進行本地操作打開vscode對應路徑文件,
即可實現我們的需求
中間件errorOverlayMiddleware.js源碼:

// 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();
    }
  };
};

改造react devtools

最后筆者在react devtools插入了如下這段代碼,會在點擊open source in editor按鈕的時候發送fetch請求通知后台打開源碼文件。

 const openInEditor = useCallback(() => {
    if ( inspectedElement !== null) {
      const path =inspectedElement.props['data-inspector-relative-path'];
      const line =inspectedElement.props['data-inspector-line'];
      const column =inspectedElement.props['data-inspector-column'];
      chrome.devtools.inspectedWindow.eval(`
      fetch('/__open-stack-frame-in-editor/relative?fileName=${path}&lineNumber=${line}&colNumber=${column}')
      `);
    }
  }, [inspectedElement]);

最終效果:

react-devtools-updated

參考資料

# 我點了頁面上的元素,VSCode 乖乖打開了對應的組件?原理揭秘
#【干貨】Chrome插件(擴展)開發全攻略
# Chrome Extension API Reference
# Access function location programmatically


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM