vite-plugin-vue-comp-name-route自动化构造引入vue路由插件
本文介绍了一个Vite插件vite-plugin-vue-comp-name-route,它旨在解决路由组件强制使用index命名的问题。作者认为现有规范过度强调形式而忽视实用性,导致开发体验差(满屏index文件)、调试困难。该插件通过分析组件文件(优先检查script标签的isRoute属性),自动构建路由配置,生成虚拟模块供项目引入。技术实现包括:1) 基于fast-glob匹配文件;2)
项目地址: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连接的时候,会去更新,也就是说,刷新本地页面的时候,就会更新,有更好的做法欢迎指出
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐
所有评论(0)