根据DOM快速定位到源代码

1,963 阅读7分钟

需求开发之殇

日常需求开发时,大家是否也碰到过这样的问题,随着项目仓库越来越臃肿,每次在开发需求时,要花好多时间来定位功能点所在的代码文件以及代码位置,影响整体的开发效率。

Q:大家平常是如何定位到需求的功能点所在的源代码?

比如:根据项目结构查找模块?根据关键词全局检索?...

我们来看看部分长的比较帅的同学是怎么来定位代码的:

吴x祖:先找路由再看看是哪个组件

胡x歌:搜页面上的文案

彭x晏:无他、但手熟尔

古x乐:先骂骂咧咧,然后再问

金毛狮王:我一般在群里吼

以上可能是大家工作中常用的一些手段,总体来说更多还是依赖人肉去检索,那有什么办法可以提高这块的效率呢?

你别说,还真有,今天就给大家分享一款工具以及实现原理:react-dev-inspector,支持webpack、vite、umi等脚手架接入使用,其实现思路是通过页面上的DOM节点定位代码位置,与我们需求开发时,先根据PRD找到页面功能位置,然后寻找代码的习惯比较一致,即 UI -> Code,简称U2C(开个玩笑)。

举个🌰

也别光吹,实操一下有没有效果、好不好用。

实操演示

inspect.gif

操作步骤:

  1. 通过快捷键 ctrl⌃ + shift⇧ + commmand⌘ + c 激活选择器;
  2. 选择要定位代码位置的DOM节点,然后点击;
  3. 点击后自动打开编辑器并定位到代码所在的文件,甚至将光标定位到代码所在的行列;

如何实现这样一款工具

我们先来分析一下需求以及可行的方案。

一、谁知道源代码的位置?

显然在浏览器运行时是不知道的,联想一下sourcemap,它是在代码构建时生成的,因此能定位到源代码的位置,那我们自然也可以在这个环节做一些事情,把源代码的位置告诉浏览器的DOM。

适合做这件事的非babel莫属,可以遍历ast找到jsx节点,为其注入源代码位置信息(所在的文件路径、代码所在行列)。

二、谁能在浏览器打开编辑器?

有一些特殊的协议,比如:lark://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=xxx,它可以打开飞书客户端的群聊。

那我们编辑器的客户端有没有类似的协议呢?可能有,但上述的方式体验不是很好,浏览器有弹窗拦截提示,而且能力的丰富程度受限于浏览器和客户端的交互协议。

还有一种做法,NodeJS能力更加强大,只要你想的到,基本上它都能做到。那浏览器如何与NodeJS通信,很自然可以想到http协议,发起请求给devServer,由devServer实现相关能力。

基于以上两点能力,接下来只要我们在浏览器端实现快捷键选择DOM并发起打开编辑器的请求,整个流程就通了。

有了以上思路,不管是在webpack、vite等打包工具,根据所处环境做适配就行,整体思路是可复用的,下面我们以webpack为例说明下实现原理。

实现原理

Babel插件

思路: 通过AST找到每个JSX节点,AST中含有该代码片段的源文件信息,操作AST节点数据,给每个节点注入静态props信息,在渲染时可呈现在DOM节点上。

核心代码

// 工具函数
const { jsxAttribute, jsxIdentifier, stringLiteral} = require('@babel/types/lib/builders/generated');

module.exports = (babel, options) => {
  return {
    name: 'babel-plugin-myjsx',
    visitor: {
      // 匹配jsx节点进行处理
      JSXOpeningElement: {
        enter(path, state) {
          const filename = state?.file?.opts?.filename; // 文件路径
          const line = path.node.loc?.start?.line; // 代码所在行
          const column = path.node.loc?.start?.column; // 代码所在列
          // const name = path.node.name; // 组件名称
          
          // 给当前jsx节点添加props
          path.node.attributes.unshift(
            jsxAttribute(
              jsxIdentifier('data-inspector-line'),
              stringLiteral(line.toString()),
            ),
            jsxAttribute(
              jsxIdentifier('data-inspector-column'),
              stringLiteral(column.toString()),
            ),
            jsxAttribute(
              jsxIdentifier('data-inspector-filename'),
              stringLiteral(filename),
            )
          );
        }
      }
    },
  };
};

编译结果

"use strict";

var _interopRequireDefault = require("/xxx/react-app/node_modules/babel-preset-react-app/node_modules/@babel/runtime/helpers/interopRequireDefault");

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = void 0;

var _react = _interopRequireDefault(require("react"));

function App() {
  return /*#__PURE__*/_react.default.createElement("div", {
    "data-inspector-line": "5",
    "data-inspector-column": "4",
    "data-inspector-filename": "/xxx/react-app/src/App.tsx",
    className: "App"
  }, /*#__PURE__*/_react.default.createElement("p", {
    "data-inspector-line": "6",
    "data-inspector-column": "6",
    "data-inspector-filename": "/xxx/react-app/src/App.tsx"
  }, "Edit and save to reload."), /*#__PURE__*/_react.default.createElement(C1, {
    "data-inspector-line": "7",
    "data-inspector-column": "6",
    "data-inspector-filename": "/xxx/react-app/src/App.tsx"
  }), /*#__PURE__*/_react.default.createElement(C2, {
    "data-inspector-line": "8",
    "data-inspector-column": "6",
    "data-inspector-filename": "/xxx/react-app/src/App.tsx"
  }));
}

function C1() {
  return /*#__PURE__*/_react.default.createElement("div", {
    "data-inspector-line": "15",
    "data-inspector-column": "4",
    "data-inspector-filename": "/xxx/react-app/src/App.tsx"
  }, /*#__PURE__*/_react.default.createElement("h1", {
    "data-inspector-line": "16",
    "data-inspector-column": "6",
    "data-inspector-filename": "/xxx/react-app/src/App.tsx"
  }, "C1"));
}

function C2() {
  return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, /*#__PURE__*/_react.default.createElement("h2", {
    "data-inspector-line": "24",
    "data-inspector-column": "6",
    "data-inspector-filename": "/xxx/react-app/src/App.tsx"
  }, "C2"));
}

var _default = App;
exports.default = _default;

可以看到,babel插件为每个jsx节点都注入了props信息,在浏览器中渲染时作为dom节点的data属性,那么当我们选择dom节点后获取该dom上的data信息,即可知道源代码的位置。

源文件代码

import React from 'react';

function App() {
  return (
    <div className="App">
      <p>Edit and save to reload.</p>
      <C1 />
      <C2 />
    </div>
  );
}

function C1() {
  return (
    <div>
      <h1>C1</h1>
    </div>
  );
}

function C2() {
  return (
    <>
      <h2>C2</h2>
    </>
  );
}

export default App;

完整代码:github.com/zthxxx/reac…

Webpack插件

这部分主要是给devServer提供http接口,以便浏览器唤起编辑器,不需要在编译时做任何的工作。如何提供http接口这块没啥好讲的,主要讲一下如何通过node打开编辑器,下面以VSCode编辑器为例。

许多编辑器都有配套的命令行工具,VSCode也不例外(部分编辑器可能不会默认安装命令行,需要手动安装),关于命令行的使用我们可以在官网的文档中找到:code.visualstudio.com/docs/editor…

我们来看一下命令行的参数:

红框内的这段描述我们可以知道,通过该命令可以打开具体的文件并且定位到行列,在node中我们可以很轻松地执行上述命令,示例如下:

const { spawnSync } = require('child_process');

// 打开当前路径下package.json文件并将光标定位到3行5列
spawnSync('code', ['--goto', './package.json:3:5']);

可能有同学会问,我不用VSCode,其他编辑器咋处理呢?别急,已经有人替大家考虑到了,他将所有主流的编辑器,在不同的平台(Mac、Windows、Linux)的兼容都处理好了,详细代码:github.com/facebook/cr…(这里有个比较有趣的点是,它会根据你当前开启的进程猜测你在用哪个编辑器,然后用这个编辑器打开,感兴趣的同学可以看看)。

还有部分追(zuan)求(niu)极(jiao)致(jian)的同学可能会问,我就是想在浏览器中直接打开编辑器,能够处理吗?这个我也替大家找到了,直接在浏览器URL中输入vscode``://file/[fullPath]:[line]:[column]即可打开。

Webpack插件的完整代码:github.com/zthxxx/reac…

浏览器快捷键组件

通过babel的处理,现在DOM节点上已经有了源代码位置的信息了,这块的实现和普通React组件是一样的,具体步骤:

  1. 在React根节点注册组件,绑定快捷键;
  2. 激活快捷键后,监听鼠标hover事件,定位当前选中的DOM;
  3. 渲染Overlay节点,点击后发起请求打开编辑器;

具体代码就不展开来讲了,具体可以看这里:

完整代码:github.com/zthxxx/reac…

对构建效率有多大影响

有的同学可能会担心上述的过程会导致我们编译变慢,实际上不用担心,上述的过程并没有任何比较重的任务,唯一影响编译速度的可能就是babel插件,但这个插件我们只是修改了js对象的一些信息,耗时几乎可以忽略不计,而webpack插件并不会影响编译,它是在开发者与浏览器交互后发起的请求才发挥作用,编译阶段没有任何干扰。

抛砖引玉

可以看到上述的实现过程,就单一的点来说并没有太大的难度,对该领域的知识稍微有一些了解即可完成,换做在座的各位相信也都能够很好地完成。其最有价值的点是创意变现,首先有了一个不错的idea,然后不断探索去实现了这个idea,我们在工作中可能也会有各种各样的想法,放手去“肝”吧!