项目地址:https://gitee.com/li-hanming/vite-plugin-vue-comp-name-route

这个插件的开发动机呢,主要是有人想强推一些所谓的规范,而我,天生反骨,并且这些所谓的规范,其实很多都并没有什么必要,在我看来,代码的可维护性、稳定性等等要远远比那些所谓的规范,重要的多。这次主要是被规定,所有的路由组件都要用index命名,然后动态导入构建路由。虽然这也是业界流传下来的一些金科玉律,但是,流传下来的,就一定是好的,正确的,实用的吗?不过是被贴上伟光正的标签而已。再好比less、scss写嵌套css的时候都喜欢用&+后缀进行编写,但在实际开发中,会发现这种写法你想要理解代码、排查问题,是要比写完整的class困难得多。所以vite-plugin-vue-comp-name-route就这样顺理成章地诞生了。

直接先上源码,下面再解释

import { readFileSync } from "node:fs";
// import { parse } from "@babel/parser";
import { createFilter } from "vite";
import path from "node:path";
import fastGlob from "fast-glob";
import { parse as parseVue } from "@vue/compiler-sfc"; // 引入Vue SFC解析器
import { ComponentFilterConfig } from "./type.ts";

// 构建时组件分析函数
function analyzeComponentAtBuildTime(
  componentPath: string,
  projectRoot: any,
  checkKey: string = "isRoute"
) {
  try {
    const fullPath = path.resolve(projectRoot, componentPath.replace("./", ""));
    const vueFileContent = readFileSync(fullPath, "utf-8");

    // 第一步:使用Vue编译器解析SFC文件
    const sfcDescriptor = parseVue(vueFileContent, {
      source: vueFileContent,
      sourceMap: false, // 构建时分析不需要sourcemap
    }).descriptor;

    // 检查是否存在<script>块
    if (!sfcDescriptor.script && !sfcDescriptor.scriptSetup) {
      console.warn(`组件 ${componentPath} 没有找到 <script> 块`);
      return null;
    }

    // 获取<script>块的内容(优先考虑setup语法糖)
    const scriptContent =
      sfcDescriptor.scriptSetup?.content || sfcDescriptor.script?.content;

    if (!scriptContent) {
      console.warn(`组件 ${componentPath} 的 <script> 块内容为空`);
      return null;
    }

    // 第二步:使用Babel解析<script>块中的JS/JSX代码
    // const ast = parse(scriptContent, {
    //   sourceType: "module",
    //   plugins: [
    //     "jsx", // 启用JSX支持
    //     // 'typescript'     // 如果项目使用TypeScript也支持
    //   ],
    // });

    // 遍历AST寻找componentConfig导出
    // ast.program.body.forEach((node) => {
    //   if (node.type === 'VariableDeclaration') {
    //     node.declarations.forEach((decl) => {
    //       if (decl.id.name === 'componentConfig' && decl.init) {
    //         // 提取配置对象的值
    //         if (decl.init.type === 'ObjectExpression') {
    //           componentConfig = {}
    //           decl.init.properties.forEach((prop) => {
    //             if (prop.key && prop.key.name) {
    //               // 简化处理:只提取基本值
    //               if (prop.value.type === 'StringLiteral') {
    //                 componentConfig[prop.key.name] = prop.value.value
    //               } else if (prop.value.type === 'BooleanLiteral') {
    //                 componentConfig[prop.key.name] = prop.value.value
    //               } else if (prop.value.type === 'NumericLiteral') {
    //                 componentConfig[prop.key.name] = prop.value.value
    //               }
    //               // 可以在这里添加更多类型的处理
    //             }
    //           })
    //         }
    //       }
    //     })
    //   }

    //   // 额外检查ExportNamedDeclaration(具名导出)
    //   if (
    //     node.type === 'ExportNamedDeclaration' &&
    //     node.declaration?.type === 'VariableDeclaration'
    //   ) {
    //     node.declaration.declarations.forEach((decl) => {
    //       if (decl.id.name === 'componentConfig' && decl.init) {
    //         if (!componentConfig) componentConfig = {}
    //         decl.init.properties.forEach((prop) => {
    //           if (prop.key && prop.key.name) {
    //             // 简化处理:只提取基本值
    //             if (prop.value.type === 'StringLiteral') {
    //               componentConfig[prop.key.name] = prop.value.value
    //             } else if (prop.value.type === 'BooleanLiteral') {
    //               componentConfig[prop.key.name] = prop.value.value
    //             } else if (prop.value.type === 'NumericLiteral') {
    //               componentConfig[prop.key.name] = prop.value.value
    //             }
    //             // 可以在这里添加更多类型的处理
    //           }
    //         })
    //       }
    //     })
    //   }
    // })

    // 新的核心逻辑:检查isRoute属性
    let isRoute = false;
    let scriptLang: string | true = "";

    // 优先检查 <script setup> 块
    if (sfcDescriptor.scriptSetup) {
      // 获取<script setup>标签上的所有属性
      const setupAttrs = sfcDescriptor.scriptSetup.attrs;
      isRoute = setupAttrs.hasOwnProperty("isRoute"); // 检查是否存在isRoute属性
      scriptLang = setupAttrs.lang || "js"; // 获取语言类型
    }

    // 如果没有在setup脚本中找到,检查普通<script>块
    if (!isRoute && sfcDescriptor.script) {
      const scriptAttrs = sfcDescriptor.script.attrs;
      isRoute = scriptAttrs.hasOwnProperty("isRoute");
      scriptLang = scriptAttrs.lang || "js";
    }

    return { [checkKey]: isRoute };
  } catch (error: any) {
    console.warn(`分析组件 ${componentPath} 失败:`, error.message);
    return null;
  }
}

function routePathLoadFn(path: string) {
  return path;
  return path.replace("./src", "..");
}

function extractViewPathRegex(fullPath: string) {
  // 正则表达式解释:
  // \.\/src\/views - 匹配固定的前缀 ./src/views
  // (.*?)           - 非贪婪匹配任意字符,这是我们要提取的部分
  // \/[^\/]+\.vue$  - 匹配最后一个/后面的文件名(不含/字符)和.vue后缀
  const regex = /\.\/src\/views(.*?)\/[^\/]+\.vue$/;
  const match = fullPath.match(regex);

  return match ? match[1] : ""; // 返回第一个捕获组的内容
}

async function updateVirtualModule(server: any) {
  const virtualModuleId = "virtual:filtered-route-components";
  const resolvedId = `\0${virtualModuleId}`;

  // 2.1 获取 Vite 的模块图
  const moduleGraph = server.moduleGraph;

  // console.log("moduleGraph", moduleGraph.getModuleById);
  // 根据虚拟模块的内部ID获取模块实例
  const module = moduleGraph.getModuleById(resolvedId);

  if (module) {
    // 2.2 清除该模块的缓存,确保下次加载的是新内容
    // console.log("module.clientImports", moduleGraph);
    moduleGraph.invalidateModule(module);

    // let now = Array.from(module._clientModule.importers || []).map(
    //   (val: any) => ({
    //     type: "js-update",
    //     path: val.id,
    //     acceptedPath: val.file,
    //     timestamp: Date.now(),
    //   })
    // );

    // console.log("now", now);

    // 2.3 触发该模块的热更新,通知浏览器重新请求模块
    // server.ws.send({
    //   type: "update",
    //   updates: [
    //     {
    //       type: "js-update",
    //       path: resolvedId,
    //       acceptedPath: resolvedId,
    //       timestamp: Date.now(),
    //     },
    //     {
    //       type: "js-update",
    //       path: "/src/main.js",
    //       acceptedPath: "/src/main.js",
    //       timestamp: Date.now(),
    //     },
    //     ...now,
    //   ],
    // });

    // console.log(`虚拟模块 ${virtualModuleId} 已手动更新`);
  } else {
    // console.warn(`未找到虚拟模块: ${virtualModuleId}`);
  }
}

export default function componentFilterPlugin(options: ComponentFilterConfig) {
  const {
    include = "./src/views/**/*.vue",
    exclude = ["**/components/**", "**/component/**", "**/*component*/**"],
    filterFn = (config: any) => {
      // 构建时筛选条件
      return config && config.isRoute;
    },
    loadCompPathFn = null,
    customExtViewPathRegexFn = null,
    showConsole = false,
    checkKey = "isRoute",
    useCache = true,
  } = options;

  const filter = createFilter(include, exclude);
  let resolvedComponents: Array<any> = [];

  async function load() {
    const componentFiles = await fastGlob(include, {
      cwd: process.cwd(), // 相对于项目根目录
      absolute: false, // 返回绝对路径
      onlyFiles: true, // 只匹配文件,排除目录
      ignore: exclude || [], // 处理排除模式
    });

    showConsole && console.log("componentFiles", componentFiles);

    resolvedComponents = componentFiles
      .map((file: any) => {
        const config = analyzeComponentAtBuildTime(
          file,
          process.cwd(),
          checkKey
        );
        showConsole &&
          console.log("1111", file, path.basename(file, ".vue"), config);
        return {
          compPath: file,
          config,
          name: path.basename(file, ".vue"),
        };
      })
      .filter((item) => filterFn(item.config));

    showConsole &&
      console.log(
        `构建时组件分析完成,找到 ${resolvedComponents.length} 个符合条件的组件`
      );
  }

  return {
    name: "vite-plugin-component-filter",
    enforce: "pre",

    // 在构建开始时分析组件
    async buildStart() {
      load();
    },

    // closeBundle() {
    //   console.log('构建已完成,可以在这里执行清理或通知逻辑。')
    //   // 例如,可以调用一个脚本或生成报告
    // },

    // 生成虚拟模块,提供筛选结果
    resolveId(id: string) {
      if (id === "virtual:filtered-route-components") {
        showConsole && console.log("resolveId");
        return "\0virtual:filtered-route-components";
      }
    },

    configureServer(server: any) {
      server.ws.on("connection", async () => {
        showConsole && console.log("connection");
        await load();
        updateVirtualModule(server);
      });
      //   server.httpServer?.once('listening', async () => {
      //     console.log('listening')
      //     await load()
      //     updateVirtualModule(server)
      //   })
      showConsole && console.log("服务器已启动,正在初始化虚拟模块...");
    },

    load(id: string) {
      if (id === "\0virtual:filtered-route-components") {
        showConsole && console.log("resolvedComponents", resolvedComponents);
        return `
          // 构建时筛选的组件列表
          export const filteredComponents = ${JSON.stringify(
            resolvedComponents,
            null,
            2
          )};

          export const routeComponents = [${resolvedComponents
            .map(
              (comp) => `{
            name:'${comp.name}',
            path:'${
              customExtViewPathRegexFn
                ? customExtViewPathRegexFn(comp.compPath)
                : extractViewPathRegex(comp.compPath)
            }',
            component:()=>import('${
              loadCompPathFn
                ? loadCompPathFn(comp.compPath)
                : routePathLoadFn(comp.compPath)
            }')
        }`
            )
            .join(",")}]
            ;
        `;
      }
    },
  };
}

原理其实就是构造一个虚拟模块文件出来,里面的内容是写好的路由配置数据,使用也非常的简单,直接引入然后放到创建路由的地方就行了

import { routeComponents } from 'virtual:filtered-route-components'

只需要在你想要其成为路由的组件的script标签上面,加上isRoute的参数就行

<script setup isRoute>

流程:

1.根据输入的include和exclude地址查找到对应的文件

2.解析文件里的内容,根据filterFn进行文件筛选

3.将筛选出来的文件构造数据

4.将数据写入虚拟模块里面

QA

Q:为什么用index命名文件不好?

A:像这样满屏都是index文件,本地开发体感很差,你可能会说,不是还是后面的文件夹名吗,但人都是会有一种先入为主的观念,当你心烦意乱的时候,看到的就真的是满屏index了,个人体会。

还有就是打包出来的文件,除非进行处理,不然也是满屏的index,想要去排查断点调试,非常的麻烦

Q:为什么要把参数放到script标签里?

A:这是目前想到的最优雅的做法,一开始是打算直接在script里定义一个变量,但这样太突兀了,甚至一些变态点的eslint规则还会爆红,非常瓦达,有更好的做法欢迎指出

Q:为什么要把更新时机要放在server.ws.on("connection"里?

A:一开始考虑过放热更,but太麻烦,业务系统也可能要加点东西,这是我不希望的担心一些大型项目,放热更的话,会影响本地开发体验,所以就放在了建立ws连接的时候,会去更新,也就是说,刷新本地页面的时候,就会更新,有更好的做法欢迎指出

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐