点击页面 DOM 元素,VSCode 自动打开源代码 —— react-dev-inspector 源码解析

Intro

在我开发的项目中,用户可以创建多种类型的卡片,卡片标题以及卡片内容都是用户可配置的。而不同类型的卡片外观基本一样。这导致的问题是,随着项目代码体积越来越庞大,查找源代码的成本也越来越高。

相信这个问题很多前端开发同学都会遇到。想象一下,用户反馈了一个紧急线上问题,你用 VSCode 打开代码仓库,由于出现问题的组件模块恰好不是你开发的,要如何快速找到该组件所对应的源文件呢?

以前我是这么做的:通过一些相对固定的文本,或者是查看 DOM 元素,找到关键信息(element、class、attributes、id 等等),再在代码中全局搜索。说实话,这样的方式体验不太友好,往往搜出来代码有十几处,为了明确代码位置,通常还需要加上 debugger/console 来确认,浪费了不少时间

于是我想是否可以借助 dev-server 的能力来提高源代码定位效率呢 —— 实现点击页面元素,自动打开 VSCode 对应的源代码,正好最近在接触 vite,可以实践 vite 插件的开发。

彼时我还在为这个 idea 感到兴奋,下一秒 Google 发现早已经有相关的 npm 库了 react-dev-inspector

inspect.gif

如上面的动图所示,按快捷键打开开关之后,点击页面元素,VSCode 将自动打开对应代码的位置。既然已经有人实现了,没必要重复造轮子,接下来的内容,将通过深入 react-dev-inspector 源码,剖析这个工具的实现原理(结合 vite 插件)。

深入 react-dev-inspector

0.如何使用?

在 vite 项目中使用 react-dev-inspector 首先需要安装 npm 包。

npm i react-dev-inspector -D
复制代码

其次是在 vite.config.js 文件中引入 inspectorServer 插件。

import { inspectorServer } from 'react-dev-inspector/plugins/vite'


export default defineConfig({
 //...
 plugins: [
   react(),
   inspectorServer()
 ],
 //...
})
复制代码

接着还需要使用包导出的 Inspector 组件包裹整个 App,由于正式环境不需要这个功能,通过 process.env.NODE_ENV 判断,如果是正式环境就渲染为 Fragment 组件。

import { Inspector } from 'react-dev-inspector'

const InspectorWrapper =
 process.env.NODE_ENV === 'development' ? Inspector : React.Fragment

<InspectorWrapper
 keys={['shift', 'command', 'i']}
 disableLaunchEditor={false}
>
 <App />
</InspectorWrapper>
复制代码

完成之后就在页面上按快捷键打开开关,点击页面元素编辑器将会自动打开源代码了。整个包的代码不多,主要分为前端 React 组件和 Node 中间件两部分,由于 Node 中间件代码相对简单,接下来先从 Node 端的代码开始研究。

扫描二维码关注公众号,回复: 13770722 查看本文章

1.Node 中间件

查看 inspectorServer 方法代码,这里使用 vite 提供的 configureServer 钩子定义了两个中间件,queryParserMiddlewarelaunchEditorMiddleware

queryParserMiddleware 和 launchEditorMiddleware

export const inspectorServer = (): Plugin => ({
 name: 'inspector-server-plugin',
 configureServer(server) {
    server.middlewares.use(queryParserMiddleware)
    server.middlewares.use(launchEditorMiddleware)
 },
})
复制代码

queryParserMiddleware 中间件只是将请求 url 上的参数转成对象,挂载到 req.query 上,比较简单,这里不展开。而 launchEditorMiddleware 呢,去掉兼容代码之后看到,其实也只是使用了 react-dev-utils 提供的 errorOverlayMiddleware 中间件。

import createReactLaunchEditorMiddleware from 'react-dev-utils/errorOverlayMiddleware'
import launchEditorEndpoint from 'react-dev-utils/launchEditorEndpoint'

const reactLaunchEditorMiddleware: RequestHandler = createReactLaunchEditorMiddleware()

export const launchEditorMiddleware: RequestHandler = (req, res, next) => {
 // 判断是否为特殊标记的请求
 if (req.url.startsWith(launchEditorEndpoint)) {
     reactLaunchEditorMiddleware(req, res, next)
 } else {
     next()
 }
}
复制代码

等等, react-dev-utils 是什么?

react-dev-utils

react-dev-utils 是专门为 Create React App 服务的工具库,使用 Create React App 创建的 React 项目默认就集成了,不需要额外安装。这个库提供了很多开发环境便利的工具,比如 openBrowser 会尝试复用 Chrome 已有的 tab,choosePort 会尝试找一个可以监听的端口等等。

上面提到的 errorOverlayMiddleware 的作用是在判断到特殊请求时(launchEditorEndpoint 开头的 url),去调用 launchEditor 方法,顾名思义,这个方法是真正打开编辑器的调用方,里面做了许多不同操作系统下,常见的编辑器兼容工作,尝试找到并打开编辑器。

只看 mac 下的 VSCode 编辑器,调用 launchEditor 最终会通过 child_process.spawn 唤起另起一个子进程去执行命令。

child_process.spawn(editor, args, { stdio: 'inherit' });
复制代码

editor, args 分别打印出来,其实就是调用 code -g --goto <file:line[:character]> 命令,传递了具体文件代码中的行列信息。

// editor
code

// args
[
 '-g', '/Users/xxx/projects/ad/src/components/situation/index.tsx:156:19'
]
复制代码

可以看到 Node 端的代码很简单,就是拦截请求,把请求参数(searchParams)传递给 VSCode,请求参数包括具体要打开的文件,第几行,第几列。这些信息是从哪里来的才是关键,且看前端组件部分。

2. 前端组件部分

使用快捷键切换开关 (hotkeys),打开开关之后,鼠标移动至 DOM 元素上,可以看到该 DOM 元素的盒子模型、html 标签以及所属组件名(这部分也是 react-dev-inspector 实现的)。

截屏2022-03-31 上午10.16.27.png

获取当前鼠标位置的 DOM 元素,可以使用 DocumentOrShadowRoot.elementFromPoint() API,该方法返回给定坐标点下最上层的 element 元素。如果指定的坐标点在文档的可视范围外,或者两个坐标都是负数,那么结果返回 null。(如果需要返回特定坐标下的多个元素,可以用 elementsFromPoint)

var element = document.elementFromPoint(x, y);
复制代码

获取到 DOM 元素之后,遍历 DOM 节点对象,找到以 __reactInternalInstance$react <= v16.13.1) 或者 __reactFiber$ 开头的 key,这里就存着 DOM 节点对应在 React 内部的 Fiber 节点的引用。

export const getElementFiber = (element: FiberHTMLElement): Fiber | undefined => {
 const fiberKey = Object.keys(element).find(key => (
   // 兼容 react <= v16.13.1
   key.startsWith('__reactInternalInstance$')
       || key.startsWith('__reactFiber$')
 ))

 return element[fiberKey] as Fiber
}
复制代码

在 React 源码中相关的代码在这里 precacheFiberNode

拿到 Fiber 节点,就可以读取到 _debugSource 的信息,恰好就是该节点所在的文件绝对路径,第几行,第几列。

截屏2022-03-31 上午11.25.56.png

_debugSource 又是哪里来的?原来我们在使用 @babel/preset-react 时就默认使用了 @babel/plugin-transform-react-jsx-source 插件,这个插件会在开发环境解析 JSX 时注入 __source 的信息。

// input
<sometag />

// output
<sometag __source={ { fileName: 'this/file.js', lineNumber: 10, columnNumber: 1 } } />
复制代码

__source 是特殊的 props,创建 ReactElement 时会将其从 props 剔除,挂载到 Fiber 节点上的 _debugSource。同样作为保留的 props 还有 key, ref, __self

拿到这些信息后,接下来只需要发起一个 HTTP 请求,到 Node dev-server 即可。

 const launchParams = {
  fileName,
  lineNumber,
  colNumber
 }
 const apiRoute = launchEditorEndpoint

 fetch(`${apiRoute}?${queryString.stringify(launchParams)}`)
复制代码

launchEditorEndpoint 再一次出现,在 Node 端的 launchEditorMiddleware 也是通过 url.startsWith(launchEditorEndpoint) 来判断是否要打开编辑器,它其实只是个在 react-dev-utils 中定义的普通字符串。

module.exports = '/__open-stack-frame-in-editor';
复制代码

打开 Chrome Dev Tools 查看 Networks 看板,可以看到点击页面元素时,确实是发起了一次请求。

切片.png

到这里,背后实现的逻辑就很清晰了,再回顾一次完整链路。

截屏2022-03-31 下午12.02.18.png

  1. 本地开发环境下,用户按下热键,打开 Inspector 模式;
  2. 使用 elementFromPoint 获取用户鼠标正在 hover 的顶层 DOM 节点;
  3. 遍历 DOM 节点上的属性,对应的 Fiber 节点;
  4. fiberNode._debugSourc 存储了由 Babel 插件注入的 __source 信息,拼接 HTTP 请求,发送至 Node 端;
  5. Node 中间件拦截请求,判断是否为 launchEditor 请求;
  6. launchEditor 尝试找到正在允许项目的编辑器;
  7. 调用子进程通知编辑器打开指定文件的第 N 行,第 M 列;
  8. END

总结

这篇文章分析了 react-dev-inspector 这个小而美的包的实现原理,了解完其背后的实现逻辑,就我个人而言也学习到了很多新的知识 + 工具,或许下次有新的 idea 就可以用上了。

篇幅所限,只梳理了 vite 插件和主干的实现,其他构建工具和兼容性的代码留给读者自行去挖掘。言而总之,这个包确确实实可以提高开发效率,如果你觉得还不错,不妨在项目中引入/去给个 Star ⭐️⭐️⭐️ 传送门

猜你喜欢

转载自juejin.im/post/7083323002116866085