我点击页面元素,为什么VSCode乖乖打开了组件

开发 开发工具
在大型项目开发中,经常会遇到这样一个场景,QA 丢给你一个出问题的链接,但是你完全不知道这个页面 & 组件对应的文件位置。

[[355277]]

前言

在大型项目开发中,经常会遇到这样一个场景,QA 丢给你一个出问题的链接,但是你完全不知道这个页面 & 组件对应的文件位置。

这时候如果可以点击页面上的组件,在 VSCode 中自动跳转到对应文件,并定位到对应行号岂不美哉?

react-dev-inspector[1] 就是应此需求而生。

使用非常简单方便,看完这张动图你就秒懂: 

可以在 预览网站[2] 体验一下。

使用方式

这个插件功能很强大,代码也写得很漂亮,唯一的缺点就是文档不是很完善,我阅读了源码总结了成功接入这个插件需要的几个步骤,缺一不可。

简单来说就是三步:

构建时:

  • 需要加一个 webpack loader 去遍历编译前的的 AST 节点,在 DOM 节点上加上文件路径、名称等相关的信息 。
  • 需要用 DefinePlugin 注入一下项目运行时的根路径,后续要用来拼接文件路径,打开 VSCode 相应的文件。

运行时:需要在 React 组件的最外层包裹 Inspector 组件,用于在浏览器端监听快捷键,弹出 debug 的遮罩层,在点击遮罩层的时候,利用 fetch 向本机服务发送一个打开 VSCode 的请求。

本地服务:需要启动 react-dev-utils 里的一个中间件,监听一个特定的路径,在本机服务端执行打开 VSCode 的指令。

下面简单分析一下这几步到底做了什么。

原理简化

构建时

首先如果在浏览器端想知道这个组件属于哪个文件,那么不可避免的要在构建时就去遍历代码文件,根据代码的结构解析生成 AST,然后在每个组件的 DOM 元素上挂上当前组件的对应文件位置和行号,所以在开发环境最终生成的 DOM 元素是这样的: 

  1. <div 
  2.   data-inspector-line="11" 
  3.   data-inspector-column="4" 
  4.   data-inspector-relative-path="src/components/Slogan/Slogan.tsx" 
  5.   class="css-1f15bld-Description e1vquvfb0" 
  6.   <p 
  7.     data-inspector-line="44" 
  8.     data-inspector-column="10" 
  9.     data-inspector-relative-path="src/layouts/index.tsx" 
  10.   > 
  11.     Inspect react components and click will jump to local IDE to view component 
  12.     code. 
  13.   </p> 
  14. </div> 

这样就可以在输入快捷键的时候,开启 debug 模式,让 DOM 在 hover 的时候增加一个遮罩层并展示组件对应的信息:

这一步通过 webpack loader 拿到未编译的 JSX 源码,再配合 AST 的处理就可以完成。

运行时

既然需要在浏览器端增加 hover 事件,添加遮罩框元素,那么肯定不可避免的要侵入运行时的代码,这里通过在整个应用的最外层包裹一个 Inspector 来尽可能的减少入侵。

  1. import React from 'react' 
  2. import { Inspector } from 'react-dev-inspector' 
  3.  
  4. const InspectorWrapper = process.env.NODE_ENV === 'development' 
  5.   ? Inspector 
  6.   : React.Fragment 
  7.  
  8. export const Layout = () => { 
  9.   // ... 
  10.  
  11.   return ( 
  12.     <InspectorWrapper 
  13.       keys={['control''shift''command''c']} // default keys 
  14.       ...  // Props see below 
  15.     > 
  16.      <Page /> 
  17.     </InspectorWrapper> 
  18.   ) 

这里也可以自定义你喜欢的快捷键,用来开启 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[3]

其实代码也很简单,就是监听了一个特殊的 URL:

  1. // launchEditorEndpoint.js 
  2. module.exports = "/__open-stack-frame-in-editor";
  1. // errorOverlayMiddleware.js 
  2. const launchEditor = require("./launchEditor"); 
  3. const launchEditorEndpoint = require("./launchEditorEndpoint"); 
  4.  
  5. module.exports = function createLaunchEditorMiddleware() { 
  6.   return function launchEditorMiddleware(req, res, next) { 
  7.     if (req.url.startsWith(launchEditorEndpoint)) { 
  8.       const lineNumber = parseInt(req.query.lineNumber, 10) || 1; 
  9.       const colNumber = parseInt(req.query.colNumber, 10) || 1; 
  10.       launchEditor(req.query.fileName, lineNumber, colNumber); 
  11.       res.end(); 
  12.     } else { 
  13.       next(); 
  14.     } 
  15.   }; 
  16. }; 

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 把启动所在路径写入到浏览器环境中:

  1. new DefinePlugin({ 
  2.   "process.env.PWD": JSON.stringfy(process.env.PWD), 
  3. }); 

至此,整套插件集成完毕,简化版的原理解析就结束了。

源码重点

看完上面的简化原理解析后,其实大家也差不多能写出一个类似的插件了,只是实现的细节可能不太相同。这里就不一一解析完整的源码了,来看一下源码中比较值得关注的一些细节。

如何在元素上埋点

在浏览器端能找到节点在 VSCode 里的对应的路径,关键就在于编译时的埋点,webpack loader 接受代码字符串,返回你处理过后的字符串,用作在元素上增加新属性再合适不过,我们只需要利用 babel 中的整套 AST 能力即可做到:

  1. export default function inspectorLoader( 
  2.   this: webpack.loader.LoaderContext, 
  3.   source: string 
  4. ) { 
  5.   const { rootContext: rootPath, resourcePath: filePath } = this; 
  6.  
  7.   const ast: Node = parse(source); 
  8.  
  9.   traverse(ast, { 
  10.     enter(path: NodePath<Node>) { 
  11.       if (path.type === "JSXOpeningElement") { 
  12.         doJSXOpeningElement(path.node as JSXOpeningElement, { relativePath }); 
  13.       } 
  14.     }, 
  15.   }); 
  16.  
  17.   const { code } = generate(ast); 
  18.  
  19.   return code 

这是简化后的代码,标准的 parse -> traverse -> generate 流程,在遍历的过程中对 JSXOpeningElement这种节点类型做处理,把文件相关的信息放到节点上即可:

  1. const doJSXOpeningElement: NodeHandler< 
  2.   JSXOpeningElement, 
  3.   { relativePath: string } 
  4. > = (node, option) => { 
  5.   const { stop } = doJSXPathName(node.name
  6.   if (stop) return { stop } 
  7.  
  8.   const { relativePath } = option 
  9.  
  10.   // 写入行号 
  11.   const lineAttr = jsxAttribute( 
  12.     jsxIdentifier('data-inspector-line'), 
  13.     stringLiteral(node.loc.start.line.toString()), 
  14.   ) 
  15.  
  16.   // 写入列号 
  17.   const columnAttr = jsxAttribute( 
  18.     jsxIdentifier('data-inspector-column'), 
  19.     stringLiteral(node.loc.start.column.toString()), 
  20.   ) 
  21.  
  22.   // 写入组件所在的相对路径 
  23.   const relativePathAttr = jsxAttribute( 
  24.     jsxIdentifier('data-inspector-relative-path'), 
  25.     stringLiteral(relativePath), 
  26.   ) 
  27.  
  28.   // 在元素上增加这几个属性 
  29.   node.attributes.push(lineAttr, columnAttr, relativePathAttr) 
  30.  
  31.   return { result: node } 

获取组件名称

在运行时鼠标 hover 在 DOM 节点上,这个时候拿到的只是 DOM 元素,如何获取组件的名称?其实 React 内部会在 DOM 上反向的挂上它所对应的 fiber node 的引用,这个引用在 DOM 元素上以 __reactInternalInstance 开头命名,可以这样拿到:

  1. /** 
  2.  * https://stackoverflow.com/questions/29321742/react-getting-a-component-from-a-dom-element-for-debugging 
  3.  */ 
  4. export const getElementFiber = (element: HTMLElement): Fiber | null => { 
  5.   const fiberKey = Object.keys(element).find( 
  6.     key => key.startsWith('__reactInternalInstance$'), 
  7.   ) 
  8.  
  9.   if (fiberKey) { 
  10.     return element[fiberKey] as Fiber 
  11.   } 
  12.  
  13.   return null 

由于拿到的 fiber可能对应一个普通的 DOM 元素比如 div ,而不是对应一个组件 fiber,我们肯定期望的是向上查找最近的组件节点后展示它的名字(这里使用的是 displayName 属性),由于 fiber 是链表结构,可以通过向上递归查找 return 这个属性,直到找到第一个符合期望的节点。

这里递归查找 fiber 的 return,就类似于在 DOM 节点中递归向上查找parentNode 属性,不停的向父节点递归查找。

  1. // 这里用正则屏蔽了一些组件名 这些正则匹配到的组价名不会被检测到 
  2. export const debugToolNameRegex = /^(.*?\.Provider|.*?\.Consumer|Anonymous|Trigger|Tooltip|_.*|[a-z].*)$/; 
  3.  
  4. export const getSuitableFiber = (baseFiber?: Fiber): Fiber | null => { 
  5.   let fiber = baseFiber; 
  6.  
  7.   while (fiber) { 
  8.     // while 循环向上递归查找 displayName 符合的组件 
  9.     const name = fiber.type?.displayName; 
  10.     if (name && !debugToolNameRegex.test(name)) { 
  11.       return fiber; 
  12.     } 
  13.     // 找不到的话 就继续找 return 节点 
  14.     fiber = fiber.return
  15.   } 
  16.  
  17.   return null
  18. }; 

fiber 上的属性 type 在函数式组件的情况下对应你书写的函数,在 class 组件的情况下就对应那个类,取上面的的 displayName 属性即可:

  1. export const getFiberName = (fiber?: Fiber): string | null => { 
  2.   return getSuitableFiber(fiber)?.type?.displayName; 
  3. }; 

这里有些美中不足的是,大部分我们手写的函数组件都不会人为的加上displayName,这是我认为源码可以优化的点。

服务端跳转 VSCode 原理

虽然简单来说,react-dev-utils 其实就是开了个接口,当你 fetch 的时候帮你执行code filepath 指令,但是它底层其实是很巧妙的实现了多种编辑器的兼容的。

如何“猜”出用户在用哪个编辑器?它其实实现定义好了一组进程名对应开启指令的映射表:

  1. const COMMON_EDITORS_OSX = { 
  2.   '/Applications/Atom.app/Contents/MacOS/Atom''atom'
  3.   '/Applications/Visual Studio Code.app/Contents/MacOS/Electron''code'
  4.   ... 

然后在 macOS 和 Linux 下,通过执行 ps x 命令去列出进程名,通过进程名再去映射对应的打开编辑器的指令。比如你的进程里有 /Applications/Visual Studio Code.app/Contents/MacOS/Electron,那说明你用的是 VSCode,就获取了 code 这个指令。

之后调用 child_process 模块去执行命令即可:

  1. child_process.spawn("code", pathInfo, { stdio: "inherit" }); 

launchEditor 源码地址[4]

详细接入教程构建时只需要对 webpack 配置做点改动,加入一个全局变量,引入一个 loader 即可。

  1. const { DefinePlugin } = require('webpack'); 
  2.  
  3.   module: { 
  4.     rules: [ 
  5.       { 
  6.         test: /\.(jsx|js)$/, 
  7.         use: [ 
  8.           { 
  9.             loader: 'babel-loader'
  10.             options: { 
  11.               presets: ['es2015''react'], 
  12.             }, 
  13.           }, 
  14.           // 注意这个 loader babel 编译之前执行 
  15.           { 
  16.             loader: 'react-dev-inspector/plugins/webpack/inspector-loader'
  17.             options: { exclude: [resolve(__dirname, '想要排除的目录')] }, 
  18.           }, 
  19.         ], 
  20.       } 
  21.     ], 
  22.   }, 
  23.   plugins: [ 
  24.     new DefinePlugin({ 
  25.       'process.env.PWD': JSON.stringify(process.env.PWD), 
  26.     }), 
  27.   ] 

如果你的项目是自己搭建而非 cra 搭建的,那么有可能你的项目中没有开启 errorOverlayMiddleware 中间件提供的服务,你可以在 webpack 的 devServer 中开启:

  1. import createErrorOverlayMiddleware from 'react-dev-utils/errorOverlayMiddleware' 
  2.  
  3.   devServer: { 
  4.     before(app) { 
  5.       app.use(createErrorOverlayMiddleware()) 
  6.     } 
  7.   } 

此外需要保证你的命令行本身就可以通过 code 命令打开 VSCode 编辑器,如果没有配置这个,可以参考以下步骤:

1、首先打开 VSCode。

2、使用 command + shift + p (注意 window 下使用 ctrl + shift + p) 然后搜索code,选择 install 'code' command in path。

最后,在 React 项目的最外层接入:

  1. import React from 'react' 
  2. import { Inspector } from 'react-dev-inspector' 
  3.  
  4. const InspectorWrapper = process.env.NODE_ENV === 'development' 
  5.   ? Inspector 
  6.   : React.Fragment 
  7.  
  8. export const Layout = () => { 
  9.   // ... 
  10.  
  11.   return ( 
  12.     <InspectorWrapper 
  13.       keys={['control''shift''command''c']} // default keys 
  14.       ...  // Props see below 
  15.     > 
  16.      <Page /> 
  17.     </InspectorWrapper> 
  18.   ) 

总结在大项目的开发和维护过程中,拥有这样一个调试神器真的特别重要,再好的记忆力也没法应对日益膨胀的组件数量…… 接入了这个插件后,指哪个组件跳哪个组件,大大节省了我们的时间。

在解读这个插件的源码过程中也能看出来,想要做一些对项目整体提效的事情,经常需要我们全面的了解运行时、构建时、Node 端的很多知识,学无止境。

参考资料

[1]react-dev-inspector:

https://github.com/zthxxx/react-dev-inspector[2]预览网站:

https://react-dev-inspector.zthxxx.me/[3]errorOverlayMiddleware:

https://github.com/facebook/create-react-app/blob/master/packages/react-dev-utils/errorOverlayMiddleware.js[4]launchEditor 源码地址:

https://github.com/facebook/create-react-app/blob/master/packages/react-dev-utils/launchEditor.js

本文转载自微信公众号「前端从进阶到入院」,可以通过以下二维码关注。转载本文请联系前端从进阶到入院公众号。

责任编辑:武晓燕 来源: 前端从进阶到入院
相关推荐

2020-06-03 15:14:35

Chrome进程架构

2022-02-17 20:51:00

vuevscode前端

2023-08-01 08:18:09

CSSUnset

2023-02-13 11:34:13

数字孪生工业4.0

2020-07-17 14:06:36

Scrum敏捷团队

2012-02-28 09:11:51

语言Lua

2012-04-04 22:07:12

Android

2022-11-08 08:53:56

插件IDE

2022-11-11 17:06:43

开发组件工具

2014-01-09 09:24:40

2013-10-22 15:18:19

2015-03-02 15:13:52

Apple Watch

2012-06-18 14:51:09

Python

2015-06-04 11:22:22

前端程序员

2019-09-17 15:30:13

Java编程语言

2012-11-14 20:55:07

容错服务器选型CIO

2014-01-17 14:39:18

12306 抢票

2014-09-22 10:06:07

2023-07-23 17:19:34

人工智能系统

2019-10-23 15:53:16

JavaScript可选链对象
点赞
收藏

51CTO技术栈公众号