likes
comments
collection
share

react-devtools点击组件,打开源码的探索与实现

作者站长头像
站长
· 阅读数 53

为什么要改造react-devtools?这一切都要源于一次痛苦的看代码过程, 刚来字节的时候,要熟悉接触公司内部的业务代码, 上手熟悉就得先找到组件的文件位置,但项目中组件的层层嵌套让我难以定位,以至于页面上随便一个按钮,找到它的位置就要花费一段时间了。 react-devtools点击组件,打开源码的探索与实现 那么,我们能不能自动化这个找的过程呢?回答是:可以,不过这是马后炮了,笔者在这中途的调研过程中,经历了很多曲折。 描述一下这个需求,便是: 在页面上点击组件,vscode打开组件文件位置。

开始研究react-devtool

猜测可能react-devtool可能已经有跟踪组件文件位置的功能,因此一开始,我去把react-devtool上的所有按钮都点了一遍,最终在view source功能按钮上发现了可能的解决问题的可能 react-devtools点击组件,打开源码的探索与实现 点击之后chrome的开发者工具跳转到了源代码标签,并标识了按钮渲染函数的位置 react-devtools点击组件,打开源码的探索与实现 好家伙,这和笔者想做的跟踪文件位置已经很接近了,不仅如此,注意下方“第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标签所在的位置。 react-devtools点击组件,打开源码的探索与实现 那,如果inspect的入参不是dom而是一个函数呢?

function func(){}
inspect(func)

这时候神奇的事情来了,控制台跳转到了函数a的定义位置, react-devtools点击组件,打开源码的探索与实现 现在我们再来说说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为什么要写这一套发布订阅机制来绕圈。 www.cnblogs.com/liuxianan/p… 好,那么现在,我们可以基本总结,react devtools已经探明拥有的能力 1.获取组件的渲染函数 2.跳转到渲染函数的所在位置 但是 3.可以获取到文件位置路径和其所在的行和列 在viewElementSourceFunction探究的过程中可以发现,无此能力,很遗憾,我们只能另想办法了

弯路:[[FunctionLocation]]

我想,既然react devtools能获取到渲染函数,那么我是不是能利用 [[FunctionLocation]]来获取路径位置? react-devtools点击组件,打开源码的探索与实现 但是最终答案是【暂时不行】,至少在浏览器层不行,出自我在stack overflow的搜索# Access function location programmatically

不过,在这个走弯路的过程中,我又了解到一件事,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构建的过程中我们可以利用webpack提供的中间件Middleware,在后台起一个本地服务,接收浏览器传递过来的[行号,列号,位置],然后进行本地操作打开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();
    }
  };
};

接入后台服务

上面讲完了原理,我们就来说说怎么配置

  1. 安装被我魔改之后的react devtools插件(暂未发布,代码后文会提到)
  2. webpack配置
npm i -D react-dev-inspector
const { DefinePlugin } = require('webpack');
const  createErrorOverlayMiddleware = require("react-dev-utils/errorOverlayMiddleware")
const {resolve} = require('path');

{
  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),
    }),
  ],
  devServer: {
    onBeforeSetupMiddleware(devServer){
        devServer.app.use(createErrorOverlayMiddleware())
    }
  }
}

改造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]);

最终效果:

参考资料

# 我点了页面上的元素,VSCode 乖乖打开了对应的组件?原理揭秘 #【干货】Chrome插件(扩展)开发全攻略 # Chrome Extension API Reference # Access function location programmatically

转载自:https://juejin.cn/post/7086114119786430501
评论
请登录