init
This commit is contained in:
18
vite/plugins/auto-import.ts
Normal file
18
vite/plugins/auto-import.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import AutoImport from 'unplugin-auto-import/vite';
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default () => {
|
||||
return AutoImport({
|
||||
// 自动导入 Vue 相关函数
|
||||
imports: ['vue', 'vue-router', '@vueuse/core', 'pinia'],
|
||||
resolvers: [
|
||||
// 自动导入 Element Plus 相关函数ElMessage, ElMessageBox... (带样式)
|
||||
ElementPlusResolver({
|
||||
importStyle: false
|
||||
})
|
||||
],
|
||||
vueTemplate: true, // 是否在 vue 模板中自动导入
|
||||
dts: resolve(import.meta.dirname, '../../src/types/auto-imports.d.ts')
|
||||
});
|
||||
};
|
||||
117
vite/plugins/check-transition.ts
Normal file
117
vite/plugins/check-transition.ts
Normal file
@@ -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> 包裹,每个页面即为 transition 的子节点。
|
||||
* Vue 的 <transition> 只能管理单个子节点,因此页面模板出现多个根节点会导致过渡失效。
|
||||
* 该插件在编译 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 页面外层已包裹 <transition>,模板必须只有单个根节点,请用一个容器元素(如 div)包裹,并删除多余的注释/空标签。`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
};
|
||||
15
vite/plugins/components.ts
Normal file
15
vite/plugins/components.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default () => {
|
||||
return Components({
|
||||
resolvers: [
|
||||
// 自动导入 Element Plus 组件
|
||||
ElementPlusResolver({
|
||||
importStyle: false
|
||||
})
|
||||
],
|
||||
dts: resolve(import.meta.dirname, '../../src/types/components.d.ts')
|
||||
});
|
||||
};
|
||||
112
vite/plugins/compression.ts
Normal file
112
vite/plugins/compression.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import type { Plugin, ResolvedConfig } from 'vite';
|
||||
|
||||
const gzip = promisify(zlib.gzip);
|
||||
const brotliCompress = promisify(zlib.brotliCompress);
|
||||
const compressibleFileRE = /\.(js|mjs|json|css|html)$/i;
|
||||
const defaultThreshold = 1025;
|
||||
|
||||
type CompressionKind = 'gzip' | 'brotli';
|
||||
|
||||
const compressionHandlers: Record<CompressionKind, { ext: string; compress: (content: Buffer) => Promise<Buffer> }> = {
|
||||
gzip: {
|
||||
ext: '.gz',
|
||||
compress: content => gzip(content, { level: zlib.constants.Z_BEST_COMPRESSION })
|
||||
},
|
||||
brotli: {
|
||||
ext: '.br',
|
||||
compress: content =>
|
||||
brotliCompress(content, {
|
||||
params: {
|
||||
[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY,
|
||||
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
async function collectFiles(rootDir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(rootDir, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return collectFiles(fullPath);
|
||||
}
|
||||
return compressibleFileRE.test(entry.name) ? [fullPath] : [];
|
||||
})
|
||||
);
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
function createCompressionPlugin(kind: CompressionKind): Plugin {
|
||||
const handler = compressionHandlers[kind];
|
||||
let config: ResolvedConfig | undefined;
|
||||
|
||||
return {
|
||||
name: `local:compression:${kind}`,
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
configResolved(resolvedConfig) {
|
||||
config = resolvedConfig;
|
||||
},
|
||||
async closeBundle() {
|
||||
const outputDir = path.resolve(process.cwd(), config?.build.outDir ?? 'dist');
|
||||
const files = await collectFiles(outputDir);
|
||||
const compressedEntries: Array<{ file: string; originalKb: string; compressedKb: string }> = [];
|
||||
|
||||
await Promise.all(
|
||||
files.map(async filePath => {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.size < defaultThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath);
|
||||
const compressed = await handler.compress(content);
|
||||
const outputFile = `${filePath}${handler.ext}`;
|
||||
|
||||
await fs.writeFile(outputFile, compressed);
|
||||
compressedEntries.push({
|
||||
file: path.relative(outputDir, outputFile).replaceAll('\\', '/'),
|
||||
originalKb: (stat.size / 1024).toFixed(2),
|
||||
compressedKb: (compressed.byteLength / 1024).toFixed(2)
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (!compressedEntries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
compressedEntries.sort((a, b) => a.file.localeCompare(b.file));
|
||||
config?.logger.info(`\n[compression:${kind}] generated ${compressedEntries.length} files`);
|
||||
for (const entry of compressedEntries) {
|
||||
config?.logger.info(
|
||||
`${path.basename(outputDir)}/${entry.file} ${entry.originalKb}kb -> ${entry.compressedKb}kb`
|
||||
);
|
||||
}
|
||||
config?.logger.info('');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default (env: Record<string, string>) => {
|
||||
const { VITE_BUILD_COMPRESS } = env;
|
||||
const plugins: Plugin[] = [];
|
||||
if (!VITE_BUILD_COMPRESS) {
|
||||
return plugins;
|
||||
}
|
||||
|
||||
const compressionList = VITE_BUILD_COMPRESS.split(',').map(item => item.trim()) as CompressionKind[];
|
||||
if (compressionList.includes('gzip')) {
|
||||
plugins.push(createCompressionPlugin('gzip'));
|
||||
}
|
||||
if (compressionList.includes('brotli')) {
|
||||
plugins.push(createCompressionPlugin('brotli'));
|
||||
}
|
||||
return plugins;
|
||||
};
|
||||
22
vite/plugins/index.ts
Normal file
22
vite/plugins/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
import createUnoCss from './unocss.js';
|
||||
import createAutoImport from './auto-import.js';
|
||||
import createComponents from './components.js';
|
||||
import createSvgIconsPlugin from './svg-icon.js';
|
||||
import createCompression from './compression.js';
|
||||
import createSetupExtend from './setup-extend.js';
|
||||
import { viteCheckTransitionPlugin } from './check-transition.js';
|
||||
|
||||
export default (viteEnv: any, isBuild = false): [] => {
|
||||
const vitePlugins: any = [];
|
||||
vitePlugins.push(vue());
|
||||
vitePlugins.push(createUnoCss());
|
||||
vitePlugins.push(createAutoImport());
|
||||
vitePlugins.push(createComponents());
|
||||
vitePlugins.push(createCompression(viteEnv));
|
||||
vitePlugins.push(createSvgIconsPlugin());
|
||||
vitePlugins.push(createSetupExtend());
|
||||
vitePlugins.push(viteCheckTransitionPlugin())
|
||||
return vitePlugins;
|
||||
};
|
||||
5
vite/plugins/setup-extend.ts
Normal file
5
vite/plugins/setup-extend.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import setupExtend from 'unplugin-vue-setup-extend-plus/vite';
|
||||
|
||||
export default () => {
|
||||
return setupExtend({});
|
||||
};
|
||||
11
vite/plugins/svg-icon.ts
Normal file
11
vite/plugins/svg-icon.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons-ng';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default () => {
|
||||
return createSvgIconsPlugin({
|
||||
// 指定需要缓存的图标文件夹
|
||||
iconDirs: [resolve(import.meta.dirname, '../../src/assets/icons/svg')],
|
||||
// 指定symbolId格式
|
||||
symbolId: 'icon-[dir]-[name]'
|
||||
});
|
||||
};
|
||||
7
vite/plugins/unocss.ts
Normal file
7
vite/plugins/unocss.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import UnoCss from 'unocss/vite';
|
||||
|
||||
export default () => {
|
||||
return UnoCss({
|
||||
hmrTopLevelAwait: false // unocss默认是true,低版本浏览器是不支持的,启动后会报错
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user