webpack/vite 提效插件——点击页面元素,定位到对应代码位置(支持react/vue,附实现原理)

背景及相关信息

在页面开发调试时,首先需要找到组件对应的源代码位置。对于一些大型项目,文件数量多、文件层级深、代码行数多,查找一个页面上组件对应的源代码位置,往往需要花费大量时间。

之前网上看到一篇 react 项目的点击页面元素打开 vscode 对应源代码位置的文章,大受启发,所以开发了 code-inspector-plugin,支持场景更加广泛,支持 vite/webpack 中使用,且支持 vue3/vue2/react 等框架。开发环境只需要按住组合键,点击页面元素,就能自动跳转 vscode 对应的源代码位置。

欢迎大家安装体验,有帮助的希望动个小手帮忙点个 star。

效果预览:

实现解析

对于一个技术项目,我们基于最终要实现的功能目标,将功能进行拆解,逐一演进实现最终的功能,code-inspector-plugin 也是同理。

code-inspector-plugin 最终要实现的目标是,按住组合键时,点击页面上的 DOM 元素,能自动打开 vscode 并定位到元素对应的源代码位置。那么基于这个目标,我们可以先将功能拆解为两步,然后去完善:

点击页面元素 -> vscode 定位到对应代码

点击元素细节实现

code-inspector-plugin 插件不能影响到用户正常的页面开发,所以点击页面元素唤醒 vscode 的功能仅在代码定位模式开启时才触发,插件内部设定了两种开启代码定位模式方式:

  1. 按住组合键时(Mac 系统默认组合键是 Option + Shift;Window 的默认组合键是 Alt + Shift)
  2. 当插件配置了 showSwitch: true 时,会在页面上展示一个模式开关,打开开关可以开启代码定位模式

同时,当代码定位模式开启时,鼠标在页面上移动时会在对应 dom 上展示一个类似 chrome 调试的遮罩层,以帮助用户知道当前定位的是哪个元素。

这部分功能的实现上难度不大,就是基础的 html+js+css,为了保证 js 逻辑和 css 样式不会影响到宿主页面,我采用了 web component 组件的方式来封装了这部分逻辑(基于 lit 实现的 web component),并通过 webpack/vite 插件,在 development 环境下将 web component 组件注入到页面中。具体的实现细节将不多讲了,源码位于 packages/core/src/client/index.ts 文件中。

如何打开 vscode

如何打开 vscode 并定位到具体的代码位置

点击页面元素后,我们打开 vscode 并定位到对应的代码。浏览器本身并不具备打开 vscode 的能力,所以要 vscode 应用需要在操作系统级别执行,打开 vscode 应用有两种方式:

  1. 在终端通过 vscode 应用路径直接打开应用
  2. 通过安装 vscode 提供的命令行工具,在终端通过 code 指令唤醒,launching-from-the-command-line

这里我们采用了第二种方式,通过 node 的 spwan 或者 exec 启动一个子进程,执行 code -g 文件路径:行:列 就能打开 vscode 并定位到对应的文件路径、行、列位置。

源码位于packages/core/src/server/launch-editor.ts文件中的 launchEditor 中,会自动识别当前系统使用的 IDE 并打开 IDE 定位到源码:

ts 复制代码
function launchEditor(
  fileName: string,
  lineNumber: unknown,
  colNumber: unknown,
  _editor?: Editor
) {
  // others code....

  let [editor, ...args] = guessEditor(_editor);

  // others code....

  _childProcess = child_process.spawn(editor, args, { stdio: 'inherit' });
}

点击元素时如何通知 node 打开

解决了如何打开 vscode 并定位到具体的代码位置后,还有一个问题,点击了页面上元素后,如何通知 node 去 vscode 呢?

由于点击元素是在浏览器,打开 vscode 是在 node 后台进程,所以我们交互的方式只有发送 http 请求通知。我们在项目开发启动时,通过 webpack/vite 插件启动一个 node HttpServer,当点击元素后发送一个 http 请求到 HttpServer,HttpServer 接收到请求后就知道要打开 vscode 了。

该部分源码位于 packages/core/src/server/index.ts:

ts 复制代码
export function startServer(
  callback: (port: number) => any,
  rootPath: string,
  editor?: Editor
) {
  if (started) {
    callback(recordPort);
    return;
  }
  started = true;
  const server = http.createServer((req: any, res: any) => {
    // 收到请求唤醒vscode
    const params = new URLSearchParams(req.url.slice(1));
    const file = path.join(rootPath, params.get('file') as string);
    const line = Number(params.get('line'));
    const column = Number(params.get('column'));
    res.writeHead(200, {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': '*',
      'Access-Control-Allow-Headers': '*',
      'Access-Control-Allow-Private-Network': 'true',
    });
    res.end('ok');
    launchEditor(file, line, column, editor);
  });

  // 寻找可用接口
  portFinder.getPort({ port: recordPort }, (err: Error, port: number) => {
    if (err) {
      throw err;
    }
    server.listen(port, () => {
      recordPort = port;
      callback(port);
    });
  });
}

如何获取元素对应的源代码位置

那么如何获取到点击元素对应的源代码位置呢?源代码位置对应了三个信息:源代码文件路径、代码所在行、代码所在列,这部分信息显然在代码编译阶段可能通过 babel 等代码编译工具获取到,这里对于 vue 文件主要通过 @vue/compiler-dom@vue/babel-plugin-jsx 处理,对于 react 通过 babel 相关插件进行处理。

通过编译阶段,可以获取到源代码中 dom 部分的 ast 结构,ast 结构中会包含对应的文件路径、行、列,然后将页面上的 dom 和源代码信息对应有两种方式:

  1. 给每个 dom 注入一个 unique id,然后将 unique id 和源代码信息在内存中维护一个映射
  2. 直接将源代码信息作为 dom attribute 注入到 dom 上,点击 dom 时获取 attribute 即可

插件中采用了第 2 种方式,实现上更方便些,性能也更高一些,只需要在编译阶段将 源代码文件路径:行:列 作为属性添加到 ast 中,后续的编译过程中 vue 和 react 就能够自动将信息一直带到页面的 dom 上:

点击 dom 时,从 dom 上取出源代码信息带到 http 请求的参数上,通知 node HttpServer 即可拿到 dom 的源代码信息了。

该部分源码位于 packages/core/src/server/content-enhance.ts。(webpack 可以在 webpack loader 中作为入口,vite 可以在插件的 transform hook 上做这部分工作)。

ts 复制代码
import MagicString from 'magic-string';
import { PathName } from '../shared/constant';
import type { TemplateChildNode, NodeTransform } from '@vue/compiler-dom';
import { parse, transform } from '@vue/compiler-dom';
import vueJsxPlugin from '@vue/babel-plugin-jsx';
import { parse as babelParse, traverse as babelTraverse } from '@babel/core';
import tsPlugin from '@babel/plugin-transform-typescript';
import importMetaPlugin from '@babel/plugin-syntax-import-meta';
import proposalDecorators from '@babel/plugin-proposal-decorators';

type FileType = 'vue' | 'jsx';

type EnhanceCodeParams = {
  code: string;
  filePath: string;
  fileType: FileType;
};

export function enhanceCode(params: EnhanceCodeParams) {
  const { code: content, filePath, fileType } = params;
  try {
    const s = new MagicString(content);
    // vue 部分内置元素添加 attrs 可能报错,不处理
    const escapeTags = [
      'style',
      'script',
      'template',
      'transition',
      'keepalive',
      'keep-alive',
      'component',
      'slot',
      'teleport',
      'transition-group',
      'transitiongroup',
      'suspense',
    ];

    if (fileType === 'vue') {
      // vue template 处理
      const ast = parse(content, {
        comments: true,
      });

      transform(ast, {
        nodeTransforms: [
          ((node: TemplateChildNode) => {
            if (
              !node.loc.source.includes(PathName) &&
              node.type === 1 &&
              escapeTags.indexOf(node.tag.toLowerCase()) === -1
            ) {
              // 向 dom 上添加一个带有 filepath/row/column 的属性
              const insertPosition =
                node.loc.start.offset + node.tag.length + 1;
              const { line, column } = node.loc.start;
              const addition = ` ${PathName}="${filePath}:${line}:${column}:${
                node.tag
              }"${node.props.length ? ' ' : ''}`;

              s.prependLeft(insertPosition, addition);
            }
          }) as NodeTransform,
        ],
      });
    } else if (fileType === 'jsx') {
      // jsx 处理
      const ast = babelParse(content, {
        babelrc: false,
        comments: true,
        configFile: false,
        plugins: [
          importMetaPlugin,
          [vueJsxPlugin, {}],
          [tsPlugin, { isTSX: true, allowExtensions: true }],
          [proposalDecorators, { legacy: true }],
        ],
      });

      babelTraverse(ast, {
        enter({ node }: any) {
          if (
            node.type === 'JSXElement' &&
            escapeTags.indexOf(
              (node?.openingElement?.name?.name || '').toLowerCase()
            ) === -1 &&
            node?.openingElement?.name?.name
          ) {
            if (
              node.openingElement.attributes.some(
                (attr: any) =>
                  attr.type !== 'JSXSpreadAttribute' &&
                  attr.name.name === PathName
              )
            ) {
              return;
            }

            // 向 dom 上添加一个带有 filepath/row/column 的属性
            const insertPosition =
              node.openingElement.end -
              (node.openingElement.selfClosing ? 2 : 1);
            const { line, column } = node.loc.start;
            const addition = ` ${PathName}="${filePath}:${line}:${column + 1}:${
              node.openingElement.name.name
            }"${node.openingElement.attributes.length ? ' ' : ''}`;

            s.prependLeft(insertPosition, addition);
          }
        },
      });
    } else {
      return content;
    }
    return s.toString();
  } catch (error) {
    return content;
  }
}

整体架构

lua 复制代码
📦packages
 ┣ 📂code-inspector-plugin --------------------------   入口包
 ┣ 📂core ----------------------------------------  核心代码处理
 ┣ 📂vite-plugin ---------------------------------  vite 插件
 ┗ 📂webpack-plugin ---------------------------   webpack 插件

项目采用了 monorepo 结构,为了降低用户的接入成本,通过 code-inspector-plugin 作为入口包,然后根据用户传入的 bundler 参数,判断使用 vite 还是 webpack 插件,vite 和 webpack 插件主要文件编译处理和 web component 代码注入的入口,核心逻辑都位于 core 中。

其他问题

相关推荐
崔庆才丨静觅1 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60612 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了2 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅2 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅2 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment3 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅3 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊3 小时前
jwt介绍
前端
爱敲代码的小鱼3 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax