diff --git a/vite/plugins/check-transition.ts b/vite/plugins/check-transition.ts new file mode 100644 index 00000000..5e54af89 --- /dev/null +++ b/vite/plugins/check-transition.ts @@ -0,0 +1,117 @@ +import type { PluginOption } from 'vite'; + +import { normalizePath } from 'vite'; +import { parse } from 'vue/compiler-sfc'; + +/** 模板 AST 元素节点类型 */ +const NODE_TYPE_ELEMENT = 1; +/** 模板 AST 注释节点类型 */ +const NODE_TYPE_COMMENT = 3; +/** 模板 AST 指令属性类型 */ +const NODE_TYPE_DIRECTIVE = 7; + +/** + * 判断节点是否为元素节点 + */ +function isElement(node: any): boolean { + return node?.type === NODE_TYPE_ELEMENT; +} + +/** + * 判断节点是否为注释节点 + * @description 注释节点同样是 transition 的一个子节点,会导致多节点 + */ +function isComment(node: any): boolean { + return node?.type === NODE_TYPE_COMMENT; +} + +/** + * 节点用于报错展示的标签文本 + */ +function nodeLabel(node: any): string { + return isComment(node) ? '' : `<${node.tag}>`; +} + +/** + * 判断元素是否带有指定指令(如 v-if / v-else-if / v-else) + */ +function hasDirective(node: any, name: string): boolean { + return (node.props || []).some( + (prop: any) => prop.type === NODE_TYPE_DIRECTIVE && prop.name === name, + ); +} + +/** + * 判断多个元素子节点是否构成合法的 v-if / v-else-if / v-else 分支链 + * @description 分支链任意时刻只渲染一个节点,对外层 transition 是合法的 + */ +function isConditionalChain(elements: any[]): boolean { + // 首个节点必须是 v-if,后续节点必须都是 v-else-if 或 v-else + if (!hasDirective(elements[0], 'if')) { + return false; + } + return elements + .slice(1) + .every((el) => hasDirective(el, 'else-if') || hasDirective(el, 'else')); +} + +/** + * 检测页面模板根节点是否存在多标签的 vite 插件 + * @description 外层路由/页面切换已用 包裹,每个页面即为 transition 的子节点。 + * Vue 的 只能管理单个子节点,因此页面模板出现多个根节点会导致过渡失效。 + * 该插件在编译 src/views 下的页面时检测此类写法并直接报错。 + */ +export const viteCheckTransitionPlugin = (): PluginOption => { + // 仅检测 views 目录下的页面 + const targetDir = normalizePath('/src/views/'); + + return { + enforce: 'pre', + name: 'vite:check-transition', + transform(code, id) { + const normalizedId = normalizePath(id); + + // 跳过非 views 下的文件、非 .vue 文件以及带 query 的子请求 + if ( + normalizedId.includes('?') || + !normalizedId.endsWith('.vue') || + !normalizedId.includes(targetDir) + ) { + return null; + } + + const { descriptor, errors: parseErrors } = parse(code, { + filename: id, + }); + + // 解析失败交由 vue 插件处理,这里不重复报错 + if (parseErrors.length > 0 || !descriptor.template?.ast) { + return null; + } + + // 统计根级元素节点与注释节点,二者都是 transition 的子节点,忽略空白文本 + const rootNodes = (descriptor.template.ast.children || []).filter( + (child: any) => isElement(child) || isComment(child), + ); + + // 仅元素节点参与 v-if 分支链判断(注释不属于分支链) + const rootElements = rootNodes.filter((node: any) => isElement(node)); + const isChain = + rootNodes.length === rootElements.length && + isConditionalChain(rootElements); + + if (rootNodes.length > 1 && !isChain) { + const relativePath = normalizedId.slice( + normalizedId.indexOf('/src/') + 1, + ); + const tags = rootNodes.map((node: any) => nodeLabel(node)).join(' '); + + throw new Error( + `[check-transition] ${relativePath} 存在多个根节点: ${tags}\n 页面外层已包裹 ,模板必须只有单个根节点,请用一个容器元素(如 div)包裹,并删除多余的注释/空标签。`, + ); + } + + return null; + }, + }; +}; diff --git a/vite/plugins/index.ts b/vite/plugins/index.ts index fdc90d12..79235357 100644 --- a/vite/plugins/index.ts +++ b/vite/plugins/index.ts @@ -6,6 +6,7 @@ import createComponents from './components'; import createSvgIconsPlugin from './svg-icon'; import createCompression from './compression'; import createSetupExtend from './setup-extend'; +import { viteCheckTransitionPlugin } from "./check-transition"; export default (viteEnv: any, isBuild = false): [] => { const vitePlugins: any = []; @@ -16,5 +17,6 @@ export default (viteEnv: any, isBuild = false): [] => { vitePlugins.push(createCompression(viteEnv)); vitePlugins.push(createSvgIconsPlugin()); vitePlugins.push(createSetupExtend()); + vitePlugins.push(viteCheckTransitionPlugin()) return vitePlugins; };