Intro
在我开发的项目中,用户可以创建多种类型的卡片,卡片标题以及卡片内容都是用户可配置的。而不同类型的卡片外观基本一样。这导致的问题是,随着项目代码体积越来越庞大,查找源代码的成本也越来越高。
相信这个问题很多前端开发同学都会遇到。想象一下,用户反馈了一个紧急线上问题,你用 VSCode 打开代码仓库,由于出现问题的组件模块恰好不是你开发的,要如何快速找到该组件所对应的源文件呢?
以前我是这么做的:通过一些相对固定的文本,或者是查看 DOM 元素,找到关键信息(element、class、attributes、id 等等),再在代码中全局搜索。说实话,这样的方式体验不太友好,往往搜出来代码有十几处,为了明确代码位置,通常还需要加上 debugger/console
来确认,浪费了不少时间。
于是我想是否可以借助 dev-server 的能力来提高源代码定位效率呢 —— 实现点击页面元素,自动打开 VSCode 对应的源代码,正好最近在接触 vite,可以实践 vite 插件的开发。
彼时我还在为这个 idea 感到兴奋,下一秒 Google 发现早已经有相关的 npm 库了 react-dev-inspector。
如上面的动图所示,按快捷键打开开关之后,点击页面元素,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 端的代码开始研究。
1.Node 中间件
查看 inspectorServer
方法代码,这里使用 vite 提供的 configureServer 钩子定义了两个中间件,queryParserMiddleware
和 launchEditorMiddleware
。
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 实现的)。
获取当前鼠标位置的 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
的信息,恰好就是该节点所在的文件绝对路径,第几行,第几列。
_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 看板,可以看到点击页面元素时,确实是发起了一次请求。
到这里,背后实现的逻辑就很清晰了,再回顾一次完整链路。
- 本地开发环境下,用户按下热键,打开
Inspector
模式; - 使用
elementFromPoint
获取用户鼠标正在 hover 的顶层 DOM 节点; - 遍历 DOM 节点上的属性,对应的 Fiber 节点;
fiberNode._debugSourc
存储了由 Babel 插件注入的__source
信息,拼接 HTTP 请求,发送至 Node 端;- Node 中间件拦截请求,判断是否为 launchEditor 请求;
- launchEditor 尝试找到正在允许项目的编辑器;
- 调用子进程通知编辑器打开指定文件的第 N 行,第 M 列;
- END
总结
这篇文章分析了 react-dev-inspector
这个小而美的包的实现原理,了解完其背后的实现逻辑,就我个人而言也学习到了很多新的知识 + 工具,或许下次有新的 idea 就可以用上了。
篇幅所限,只梳理了 vite 插件和主干的实现,其他构建工具和兼容性的代码留给读者自行去挖掘。言而总之,这个包确确实实可以提高开发效率,如果你觉得还不错,不妨在项目中引入/去给个 Star ⭐️⭐️⭐️ 传送门