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 中。

其他问题

相关推荐
布瑞泽的童话19 分钟前
无需切换平台?TuneFree如何搜罗所有你爱的音乐
前端·vue.js·后端·开源
白鹭凡31 分钟前
react 甘特图之旅
前端·react.js·甘特图
2401_8628867836 分钟前
蓝禾,汤臣倍健,三七互娱,得物,顺丰,快手,游卡,oppo,康冠科技,途游游戏,埃科光电25秋招内推
前端·c++·python·算法·游戏
书中自有妍如玉43 分钟前
layui时间选择器选择周 日月季度年
前端·javascript·layui
Riesenzahn44 分钟前
canvas生成图片有没有跨域问题?如果有如何解决?
前端·javascript
f8979070701 小时前
layui 可以使点击图片放大
前端·javascript·layui
忘不了情1 小时前
左键选择v-html绑定的文本内容,松开鼠标后出现复制弹窗
前端·javascript·html
世界尽头与你1 小时前
HTML常见语法设计
前端·html
写bug如流水1 小时前
【Git】Git Commit Angular规范详解
前端·git·angular.js