别再用关键字搜了!手搓一个Vite插件,为页面上的标签打上标记

219 阅读9分钟

背景

相信大家都遇到过类似的场景,前端业务开发的过程中,产品提了个修改文案或者某个页面某个按钮的样式要做一下调整。首先应该定位到修改的代码位置,一般比较常规的方案就是复制页面上要修改部分的关键字,然后在Code中查找,不过这个方法有一个常见的问题,就是可能这个要修改的地方有很多,会搜索出很多拥有这个关键字的文件。但可能我们只需要修改其中的某一个或者一部分。因此,定位要修改的过程,会存在大量的人力成本。

最好的解决办法就是在页面上获取一个唯一的id,然后直接可以在Code中定位,由于文件绝对路径本身是唯一的,因此我们需要在要修改的地方就可以拿到这个位置所在文件的绝对路径,这样就不会存在重复的情况。

通用解决方案

其实,业界上对于该场景已经落地了相关的产品,比如Vite的code-inspector-plugin

npm install code-inspector-plugin -D

在vite.config.js中做如下配置:

// vite.config.js
import { defineConfig } from 'vite';
import { codeInspectorPlugin } from 'code-inspector-plugin';

export default defineConfig({
  plugins: [
    codeInspectorPlugin({
      bundler: 'vite',
    }),
  ],
});

使用说明:

  • 启动开发服务器
  • 按下 Alt + Shift (Mac 用户使用 Option + Shift)
  • 点击页面上任意元素,即可自动跳转到对应源码位置

摘自原文:www.cnblogs.com/mengqc1995/…

这是现有的解决方案,通过这个插件我们可以在开发时很轻松地定位到要修改的代码位置。

接下来,我打算手动实现一个类似的插件,这个插件要实现一个核心功能——干预Vite的编译,给组件的模板代码打标记。以达到开发者可以直接在控制台拿到这个标记去找到这个修改点的文件位置。

建议大家可以手动写一下这个逻辑,锻炼一下自己的js代码编写能力,过程中会涉及到一些字符串的处理,体会直接干预编译源码过程的魅力。

手动实现

Vite插件主函数

干预Vite编译,需要我们写一个自定义的Vite插件,Vite 插件是一种扩展 Vite 功能的方式。通过编写或使用插件,你可以在 Vite 的编译、打包、开发服务器等多个阶段中执行自定义逻辑,实现特定的功能。

OK,先创建一个js文件,我这里命名为ViteFileTagPlugin.js,在该文件内默认导出一个函数。

export default function ViteFileTagPlugin() {
  return {
    load(id) {},
  };
}

这里我们需要在返回的对象中写一个load方法,该方法是一个Vite插件的钩子,用于在加载模块时,提供自定义的加载逻辑。

在load方法中可以得到一个参数id,该参数是编译的目标文件路径,我们通过这个路径可以读取文件内的源代码字符串。这一步很重要,我们只有获得源码才能对其进行相关操作。不过,需要注意的是,不是所有的文件都需要进行Debug标记,通常我们仅需要对业务代码组件做标记,因此我们需要做判断,判断文件后缀是否符合我们预期要处理的文件类型。

export default function ViteFileTagPlugin() {
  return {
    load(id) {
      // 对于react项目,我们需要对.jsx和.tsx后缀的文件进行处理
      if (id.endsWith('.jsx') || id.endsWith('.tsx')) {
        try {
          // 读取文件内容
          const content = fs.readFileSync(id, 'utf-8');
          // 将文件内容和路径作为参数传递给标识函数
          return addComponentTag(content, id);
        } catch (error) {
          console.log('解析失败', error);
        }
      }
    },
  };
}

大概框架写好了,下面我们就来重点处理一下addComponentTag这个方法。

组件标识方法

首先在写这部分逻辑之前,可以思考几个问题:

  1. 如何找到标识的插入位置?
  2. 如果存在对应标识,该怎么处理?
  3. 如何尽可能多的覆盖不同的标识插入场景?

这会涉及到字符串的加工处理,归根到底,在该场景下,对源码的操作本质上就是字符串的操作。

首先,这个标识应该是一个标签属性,因为它可以体现在页面上,我们可以根据属性值找到对应的源码文件,那么属性值就应该是源码绝对路径。因此,标识形式应该是data-[name]=[value]这种形式。

确定函数参数,首先我们肯定需要源代码、对源代码上标签的属性标识、标记的位置以及标记名称。下面我们一一介绍这几个参数存在的意义。

我们可以先写出一个函数框架,首先需要区分如果要处理的源代码中包括对应的标识,那么我们应该略过这个文件。

function addComponentTag(code, path, mark = 'div', property = 'data-compath') {
    if (code.indexOf(property) !== -1) {
      console.log(`>>> ${path} 该文件已添加${property}属性`);
      return;
    }
    // 主逻辑...
}

如果没有匹配到property属性,那么开始查找标识的首个插入点,如果找到,则继续,否则直接返回源码,不做任何处理。找到后,我们需要获取一个范围,即插入位置为起始位置(起始索引),字符'>'为结束位置(截止索引),大家可以脑补一下为什么要这么做,这里我们可以简单做一个演示。

image.png

const firstMarkIndex = code.search(`<${mark}`);
if (firstMarkIndex !== -1) {
  // 处理后的源码
  return code;
}
// 未处理的源码
return code;

这里理解了,我们继续往下走,收集完起始索引和截止索引之后,将二者相加,能得到相对源码的插入标签后的实际内容的起始位置索引。这里不太好理解,我们就简单认为这里的索引是操作完插入动作后剩下源码的开始位置就行。

if (insertIndex !== -1) {
    const rawIndex = insertIndex + firstMarkIndex;
}

可能这里你会问,为什么我要获取这么多索引,因为我们后面会拿着这些索引去操作字符串,这些索引就是操作的位置标志。

下面,我们就进入到源码操作的核心模块,这里我们需要再定义一个新的方法,这个方法包括源码内容、文件路径、标记索引和标记名称四个参数。

function rawDeepSearch(content, filePath, tagIdx, property) {
  // 主逻辑...
  return content;
}

这里我们第一步,先对源码进行正则匹配,找到第一个匹配组,这个匹配组就是当前源码的插入匹配,若匹配到,就进行实际的标识插入操作。

const group = /<(\w+)[ ]+/.exec(content.slice(tagIdx));
if (group?.index !== -1 && group?.[0]?.length) {
    const dataCompath = `${property}='${filePath.slice(
      filePath.indexOf('src/'),
    )}'`;
}

这里我们拼出来一个key=value,key为传入的自定义属性名,值则是当前文件从src开始截取的绝对路径。 然后开始将dataCompath插入进源码。

content = `${content.slice(0, tagIdx + group.index)}${content.slice(
      tagIdx + group.index,
      tagIdx + group.index + group[0].length,
    )}${dataCompath} ${content.slice(tagIdx + group.index + group[0].length)}`;

这里本质上就是字符串的拼接,我们将字符串分割成插入标签前的段插入标签开始索引至插入点的段实际插入内容段插入之后的剩余代码段,然后将拼接完的源码字符串重新赋值,完成源码操作的替换。

然后,更新标记索引,将标记索引指向插入的标记字符串末尾,为下一次标识插入做准备。

tagIdx += group.index + group[0].length + dataCompath.length;

由于html标签是嵌套的,因此我们需要递归进行标记插入操作。

function rawDeepSearch(content, filePath, tagIdx, property) {
  const group = /<(\w+)[ ]+/.exec(content.slice(tagIdx));
  if (group?.index !== -1 && group?.[0]?.length) {
    // 插入逻辑
    return rawDeepSearch(content, filePath, tagIdx, property);
  }
  return content;
}

核心插入方法到这里差不多就实现完成了,然后我们回到刚才的addComponentTag方法,调用rawDeepSearch,返回deepResult,然后我们将完成插入的源码字符串和不参与插入逻辑的部分源码进行最后拼接,就可以得到最终属性标识插入后的完整源码,最后将结果返回。

const deepResult = rawDeepSearch(
  code.slice(rawIndex),
  path,
  0,
  property,
);
const result = `${code.slice(0, rawIndex)}${deepResult}`;
console.log(chalk.green(`>>> ${path} 完成标记写入`));
return result;

其中 code.slice(rawIndex)代表要参与插入逻辑的源代码段,code.slice(0, rawIndex)代表不需要参与插入逻辑的源代码段。

最后我们将addComponentTag放入主函数中调用并返回,得到的最终结果就是编译后的带自定义标识的源码字符串,我们通过自己的逻辑干预了Vite源码的编译,并将该编译结果传递给后续的插件钩子继续执行其他编译逻辑。 启动Vite,随便找个页面,控制台看下效果⬇️

截屏2025-08-05 15.06.20.png 可以看到,每个标签上都已经打上了data-compath的属性了,当然,这个属性名可以通过参数定义,我们直接在vite.config.js中进行定义。

import viteFileTagPlugin from './ViteFileTagPlugin';
export default defineConfig({
    plugins: [viteFileTagPlugin({
      mark: 'div',
      property: 'data-compath',
    })]
})
完整代码
/** ViteFileTagPlugin.js */
const fs = require('fs');
const chalk = require('chalk');

function addComponentTag(code, path, mark = 'div', property = 'data-compath') {
  try {
    if (code.indexOf(property) !== -1) {
      console.log(chalk.red(`>>> ${path} 该文件已添加${property}属性`));
      return;
    }
    const firstMarkIndex = code.search(`<${mark}`);
    if (firstMarkIndex !== -1) {
      const firstMark = code.slice(firstMarkIndex);
      const insertIndex = firstMark.indexOf('>');
      if (insertIndex !== -1) {
        const rawIndex = insertIndex + firstMarkIndex;
        const deepResult = rawDeepSearch(
          code.slice(rawIndex),
          path,
          0,
          property,
        );
        const result = `${code.slice(0, rawIndex)}${deepResult}`;
        console.log(chalk.green(`>>> ${path} 完成标记写入`));
        return result;
      }
      return code;
    }
    return code;
  } catch (error) {
    console.log(chalk.red('>>> 读取失败:', error));
  }
}

function rawDeepSearch(content, filePath, tagIdx, property) {
  const group = /<(\w+)[ ]+/.exec(content.slice(tagIdx));
  if (group?.index !== -1 && group?.[0]?.length) {
    const dataCompath = `${property}='${filePath.slice(
      filePath.indexOf('src/'),
    )}'`;
    content = `${content.slice(0, tagIdx + group.index)}${content.slice(
      tagIdx + group.index,
      tagIdx + group.index + group[0].length,
    )}${dataCompath} ${content.slice(tagIdx + group.index + group[0].length)}`;
    tagIdx += group.index + group[0].length + dataCompath.length;
    return rawDeepSearch(content, filePath, tagIdx, property);
  }
  return content;
}

export default function ViteFileTagPlugin({ mark, property }) {
  return {
    load(id) {
      if (id.endsWith('.jsx') || id.endsWith('.tsx')) {
        try {
          const content = fs.readFileSync(id, 'utf-8');
          return addComponentTag(content, id, mark, property);
        } catch (error) {
          console.log(chalk.red('解析失败', error));
        }
      }
    },
  };
}

8.11补充

上述方法算法比较复杂,而且有些层面没有覆盖到。比如对于没有属性的标签,并没有打上标记,有些子节点的标签也没有加上标记,因此对于上述两种特殊情况,我们要对之前的算法进行改造。本人尝试了很多字符串片段解析方法,但是效果都不是很好,因为源代码边界条件太多,用常规字符串进行处理,难免会漏掉一些场景。

这里我写了尝试了其他两种方案:

1. AST解析

使用该方法需要安装@babel/parser、@babel/traverse、@babel/generator第三方库,本质上是使用babel提供解析AST的能力进行处理。

改造rawDeepSearch方法,将生成AST、处理AST、生成Code替换原来的字符串深度解析。

function rawDeepSearch(content, filePath, property) {
  // 解析代码并生成 AST
  const ast = babylon.parse(content, {
    plugins: ['jsx'],
  });

  // 遍历 AST,查找所有的 <div> 元素
  traverse(ast, {
    enter(path) {
      if (path.isJSXElement()) {
        // 如果是 <div> 元素,添加自定义属性
        const newAttribute = {
          type: 'JSXAttribute',
          name: {
            type: 'JSXIdentifier',
            name: property,
          },
          value: {
            type: 'StringLiteral',
            value: filePath.slice(filePath.indexOf('src/')),
          },
        };
        path.node.openingElement.attributes.push(newAttribute);
      }
    },
  });

  // 生成修改后的源代码
  const output = generate(ast).code;
  return output;
}

看似问题可以通过AST节点处理,不过在我多次测试后,仍然存在一些问题。由于在JSX处理模式下,源码需要严格按照JSX的格式,但我们对源码处理的时候,也包含了很多非JSX的部分,导致处理报错。

读取失败: SyntaxError: Adjacent JSX elements must be wrapped in an enclosing tag. Did you want a JSX fragment <>...</>? (5:8)

2. 正则替换

这也是我目前认为最适合本场景的方案,大家有更好的方案欢迎分享。该方案也是所有方案中最简单的一种方案,直接通过源代码的正则匹配替换,达到全局插入自定义属性的目的。

// Plugin中load钩子
load(id) {
  if (id.endsWith('.jsx') || id.endsWith('.tsx')) {
    try {
      const content = fs.readFileSync(id, 'utf-8');
      const targetStr = `<$1 ${property}="${id.slice(id.indexOf('src/'))}"`;
      const code = content
        .replace(/<(div|span|p|button)[ ]+/g, `${targetStr} `) // 有其他属性的标签元素
        .replace(/<(div|span|p|button)\n+/g, `${targetStr} `) // 有其他属性且属性和标签名换行的标签元素
        .replace(/<(div|span|p|button)>/g, `${targetStr}>`); // 没有其他属性的纯标签元素
      return code;
    } catch (error) {
      console.log(chalk.red('解析失败:', error));
    }
  }
},

这里我们读取出源码字符串后,通过replace方法,利用全局正则,匹配所有目标标签名,根据后续是否存在空格/换行符来校验当前标签是否存在其他属性,因为有其他属性的情况下,标签名后一定会跟着一个空格,有多个属性情况下有些格式化工具会将源码进行换行。因此,我们针对这几种情况分别做全局正则匹配,最后链式调用,得到最终处理后的目标代码。

这个方法是目前最精简的源码处理方式,依据Vite的load钩子,我们可以很方便地拿到对应源码的文件地址,然后根据readFileSync,就可以获得相应文件的源码内容,然后通过正则匹配替换,最后得到插入自定义属性后的源码,最终将code返回,让后续钩子继续处理其他逻辑。

通过实现以上Case,实现功能是次要的,重点是我们可以针对某场景可以想到多少种解决办法,以及处理该方法的程序思维,通过不断地训练,我们设计代码的思维能力自然会越来越好。