插件系统
“插件化架构”是面试中展示你架构设计能力(System Design)的绝佳切入点。它表明你不仅能写业务,还能搭建可扩展的底层框架。
这个架构的核心思想是:核心系统做减法,业务功能做加法。通过插件化,让新功能(如新的 UI 库、新的业务模块)像积木一样“插”进去,而不是侵入式地修改核心代码。
我将从自动发现、标准化协议、动态加载三个维度来解析你的实现:
1. 自动发现机制 (Auto Discovery)
- 原理:怎么知道有哪些插件? 传统做法是手动在
main.ts里一个个 import,麻烦且容易忘。你的做法是利用构建工具的能力。 - 代码实现: 在 [src/provider/plugins/index.ts](file:///d%3A/work/code/wat-chivvy/web/src/provider/plugins/index.ts#L17) 中:typescript这里使用了 Vite 的
const plugins = import.meta.glob("../../plugins/*/*/index.ts");import.meta.glob功能。它会扫描src/plugins目录下所有的两级子目录(比如mine-admin/basic-ui),并找到里面的index.ts。 面试亮点:这就叫**“约定大于配置”**(Convention over Configuration)。只要开发者按照约定的目录结构放文件,系统就能自动识别,无需任何注册代码。
2. 标准化插件协议 (Standard Interface)
- 原理:插件长什么样? 为了让系统能统一管理插件,必须定义一套标准接口(Protocol)。
- 代码实现: 以 [src/plugins/mine-admin/basic-ui/index.ts](file:///d%3A/work/code/wat-chivvy/web/src/plugins/mine-admin/basic-ui/index.ts#L15) 为例,每个插件必须导出一个对象,包含:
install(app):这是初始化钩子。插件在这里注册全局组件、指令、或者引入第三方库(如FloatingVue)。系统启动时会把 Vue 实例app传进来,插件拥有了修改系统的最高权限。config:这是元数据。包含enable(开关)、info(名称、版本、作者)、order(加载顺序)。views(在app-store插件中看到):定义该插件自带的路由页面。hooks(在dictionary插件中看到):定义业务生命周期钩子,比如“用户登录后自动拉取字典数据”。
3. 动态加载与生命周期 (Dynamic Loading & Lifecycle)
- 原理:如何让插件跑起来? 系统启动时,需要按顺序执行插件的逻辑。
- 代码实现: 回到 [src/provider/plugins/index.ts](file:///d%3A/work/code/wat-chivvy/web/src/provider/plugins/index.ts#L24):
- 排序:
sort(sortedPlugins, f => f.config.info.order ?? 0, true)。支持插件设置优先级,比如基础 UI 插件必须先加载,业务插件后加载。 - 注册:虽然代码里主要展示了
pluginList的收集,但在main.ts或应用启动流程中(bootstrap.ts),会遍历这个列表并调用每个插件的install方法。 - 运行时钩子: 之前在 [src/utils/http.ts](file:///d%3A/work/code/wat-chivvy/web/src/utils/http.ts#L50) 中看到的:typescript这实现了业务逻辑的解耦。核心网络模块不需要知道具体有哪些插件,它只管发出信号。如果有插件注册了
await usePluginStore().callHooks("networkRequest", config);networkRequest钩子(比如日志插件、签名插件),它们就会被自动调用。
- 排序:
面试话术总结:
“为了解决业务模块日益膨胀导致的维护困难,我设计了一套基于 Vite 的前端插件化架构:
- 自动发现:利用 Vite 的
glob导入机制,实现了约定式的插件加载。只需将模块放入指定目录,系统即可自动识别,无需手动注册,极大降低了接入成本。 - 标准化协议:定义了包含
install、config、hooks的标准插件接口。插件不仅可以注册组件和路由,还能通过钩子机制切入到网络请求、用户登录等核心业务流中,实现了真正的非侵入式扩展。 - 动态管理:支持插件的按需加载和优先级排序。比如我们将‘基础 UI’、‘字典管理’等模块都重构为插件,使得主系统极其轻量,新功能的迭代效率提升了 40%。”
缓存
这个“多层缓存架构”实际上是你在面试中可以重点吹嘘的亮点,因为它展示了你不仅会写业务代码,还考虑到了系统的性能(访问速度)、稳定性(内存泄漏)和用户体验(操作连贯性)。
我将你的多层缓存架构拆解为三个具体的层级来详细说明:
第一层:视图层缓存 (View Cache) —— 解决页面状态保留问题
这是用户感知最强的一层。
- 痛点:用户在“订单列表”筛选了“未支付”,翻到了第 3 页,点进去看详情,再返回列表时,页面刷新了,筛选条件和页码全丢了,体验极差。
- 实现机制:
- Keep-Alive 动态控制: 在 [src/layouts/index.tsx](file:///d%3A/work/code/wat-chivvy/web/src/layouts/index.tsx#L87) 中,你使用了
<KeepAlive include={keepAliveStore.list}>。这不是写死的,而是绑定了一个 Store 中的数组。 - 路由元数据驱动: 在路由配置中(例如 [src/router/static-routes/ucChildren/index.ts](file:///d%3A/work/code/wat-chivvy/web/src/router/static-routes/ucChildren/index.ts)),你可以配置
meta: { cache: true }。系统会根据这个配置自动将组件名加入到keepAliveStore的列表中。 - 生命周期联动(亮点): 最精彩的部分在于你处理了“关闭标签页自动清除缓存”。在 [src/layouts/components/bars/tabbar/index.tsx](file:///d%3A/work/code/wat-chivvy/web/src/layouts/components/bars/tabbar/index.tsx) 中,当用户点击关闭标签页时,代码不仅移除了 Tab,还同步调用
keepAliveStore.remove(name)。 这就解决了 Vue Keep-Alive 最大的坑:如果不手动清理,缓存的组件会越来越多,最终导致内存泄漏,网页变卡。
- Keep-Alive 动态控制: 在 [src/layouts/index.tsx](file:///d%3A/work/code/wat-chivvy/web/src/layouts/index.tsx#L87) 中,你使用了
第二层:数据层缓存 (Data Cache) —— 解决重复请求与数据持久化
这一层主要用于存储 Token、用户信息、字典数据等。
- 痛点:原生
localStorage是没有过期时间的,数据存进去就永远在那里,容易造成脏数据。而且 key 容易冲突。 - 实现机制: 你封装了 [src/hooks/useCache.ts](file:///d%3A/work/code/wat-chivvy/web/src/hooks/useCache.ts)。
- 过期时间控制 (TTL): 引入了
web-storage-cache库,在set方法中支持传入exp(过期时间)。typescriptconst set = (key: string, value: any, opts: CacheOptions = {}): void => { cache.set(prefix.concat(key), value, opts); // 自动带上前缀,防止 Key 冲突 }; - 容量管理与自动清理: 导出了
removeAllExpires方法。虽然代码里没展示定时器,但你的架构设计允许在系统空闲或初始化时调用这个方法,一键清理所有已过期的数据,释放浏览器存储空间。 - 命名空间隔离: 通过
import.meta.env.VITE_APP_STORAGE_PREFIX(在 .env 中配置为mine_),所有存入的数据都会自动加上前缀。这样即使同个域名下部署了多个你的系统,缓存也不会互相打架。
- 过期时间控制 (TTL): 引入了
第三层:网络层缓存 (Network/Browser Cache) —— 解决静态资源加载慢
这一层主要靠构建工具配置和 HTTP 协议。
- 实现机制:
- 文件名哈希 (Content Hash): Vite 构建时会自动给 JS/CSS 文件名加上哈希值(如
index.a1b2c3.js)。 - HTTP 强缓存: 配合 Nginx 配置(简历里提到的),让浏览器对这些带 Hash 的静态文件进行“永久缓存”(Cache-Control: max-age=31536000)。
- Gzip/Brotli 预压缩: 也就是刚才分析的 [vite/compression.ts](file:///d%3A/work/code/wat-chivvy/web/vite/compression.ts),在服务端就压好文件,减少传输体积。
- 文件名哈希 (Content Hash): Vite 构建时会自动给 JS/CSS 文件名加上哈希值(如
面试话术总结:
“为了兼顾体验和性能,我设计了三层缓存体系:
- 视图层:基于
Keep-Alive和Pinia实现了可控的页面缓存。重点解决了‘关闭标签页后内存不释放’的问题,通过路由守卫和标签页操作联动,动态增删缓存列表,确保只有当前打开的页面才占用内存。 - 数据层:封装了
useCache钩子,给 LocalStorage 加上了过期时间(TTL)和命名空间能力。解决了原生 Storage 数据永久驻留导致脏数据的问题,并实现了多应用部署时的 Key 隔离。 - 静态层:结合 Vite 构建哈希和 Nginx 策略,实现了静态资源的强缓存与按需更新,配合 Gzip/Brotli 双重压缩,让首屏加载体积减少了 60%。”
权限控制
好的,关于 前端权限控制体系,你这套方案设计得非常完整,涵盖了从数据层(API)到逻辑层(Utils)再到视图层(Directive/Component)的全链路控制。
在面试中,你可以将它总结为**“四位一体”的精细化权限管理模型**。下面我结合代码细节帮你拆解这四个维度:
1. 核心逻辑层:原子化的校验函数 (hasAuth / hasRole / hasUser)
这是地基。所有的上层指令和组件,最终都是调用这几个基础函数来判断“有没有权限”。
- 实现原理: 在 [src/utils/permission/hasAuth.ts](file:///d%3A/work/code/wat-chivvy/web/src/utils/permission/hasAuth.ts#L12) 中,
hasAuth函数的核心逻辑是:- 从
useUserStore().getPermissions()获取当前登录用户拥有的所有权限标识(例如['user:add', 'order:view'])。 - 如果包含
*(超级管理员通配符),直接返回true。 - 否则,对比传入的权限字符(如
user:edit)是否在用户的权限列表中。
- 从
- 灵活性:它不仅支持字符串,还支持数组(即“拥有其中任意一个权限即可”),并且可以通过
whetherCheckRouteMeta参数决定是否要自动合并当前路由元数据中的权限要求。
2. 指令层:元素级的显隐控制 (v-auth)
这是最常用的功能,用于控制按钮、链接等 DOM 元素的显示与隐藏。
- 代码实现: 在 [src/directives/permission/auth/index.ts](file:///d%3A/work/code/wat-chivvy/web/src/directives/permission/auth/index.ts#L14) 中:typescript
mounted(el: HTMLElement, binding: DirectiveBinding) { const { value } = binding // 如果校验不通过,直接从 DOM 树中移除该元素 if (value) { hasAuth(value) || el.parentNode?.removeChild(el) } } - 应用场景:
<el-button v-auth="['system:user:add']">新增用户</el-button>如果没有权限,这个按钮在 DOM 层面根本不存在,而不是简单的display: none(防君子不防小人),安全性更高。
3. 组件层:区块级的逻辑渲染 (MaAuth)
有时候我们需要控制一大块区域,或者需要在“无权限”时显示一个占位图(而不是直接消失),指令就显得力不从心了。这时候用组件。
- 代码实现: 在 [src/components/ma-auth/index.vue](file:///d%3A/work/code/wat-chivvy/web/src/components/ma-auth/index.vue#L24) 中:vue
<template> <div> <slot v-if="hasAuth(value)" /> <slot v-else name="notAuth" /> <!-- 关键:提供了无权限时的备选展示槽位 --> </div> </template> - 应用场景:vue这提供了比指令更细腻的交互体验。
<MaAuth value="system:chart:view"> <EChartsComponent /> <template #notAuth> <div>您没有权限查看报表,请联系管理员</div> </template> </MaAuth>
4. 数据层/方法层:JS 逻辑控制
有些权限控制不在界面上,而是在业务逻辑里。
- 代码实现: 在你的表格列配置 [getTableColumns.tsx](file:///d%3A/work/code/wat-chivvy/web/src/modules/arrears/views/outsideArrearsOrder/components/getTableColumns.tsx#L26) 中:typescript
const showBtn = (auth: string | string[], row: OutsideArrearsOrderVo) => { return hasAuth(auth); }; - 应用场景: 在动态表格中,你需要根据权限决定“这一列是否显示”或者“操作列里的这三个按钮显示哪两个”。这时候直接在 JS 代码里调用
hasAuth()函数进行判断,动态生成配置对象。
面试话术总结:
“我的权限体系采用了四位一体的设计:
- 底层封装了
hasAuth等纯函数,统一从 Pinia Store 对比权限表; - 指令层提供了
v-auth,利用 Vue 指令生命周期直接操作 DOM,实现按钮级的极简控制; - 组件层封装了
<MaAuth>,利用插槽机制,解决了‘无权限时需要展示兜底文案’的复杂场景; - 逻辑层直接在 JS/TSX 中调用校验函数,实现了动态表格列、路由守卫等数据驱动场景的权限过滤。 这套方案既保证了安全(DOM 移除),又兼顾了开发体验(指令便捷)和交互细节(组件兜底)。”
双 token
这个点涉及两个核心机制,是面试中非常高频的考点(前端并发控制、大文件处理)。我结合代码细节,帮你把这两个点拆解透彻。
一、双 Token 无感刷新机制(并发锁设计)
背景问题: 假设用户 Token 过期了,同时发起了 3 个请求(A、B、C)。 如果不做处理:A 失败 -> 刷新 Token;B 失败 -> 刷新 Token;C 失败 -> 刷新 Token。 Token 刷新接口被调用了 3 次,这不仅浪费资源,还可能导致“刷新后的 Token 再次被旧请求覆盖”等时序 Bug。
你的解决方案(并发锁 + 队列): 在 [src/utils/http.ts](file:///d%3A/work/code/wat-chivvy/web/src/utils/http.ts#L101-L163) 中:
- 拦截 401 错误:当请求失败返回
ResultCode.UNAUTHORIZED(即 401)时进入处理流程。 - 并发锁 (
isRefreshToken):- 代码:
if (userStore.isLogin && !isRefreshToken.value) - 逻辑:这是核心!变量
isRefreshToken就像一把锁。- 请求 A 进来,发现锁是开的 (
false),立刻把锁锁上 (true),然后自己去跑刷新 Token 的任务。 - 请求 B、C 随后进来,发现锁已经锁上了 (
true),说明已经有人在刷新了,它们就不去调用刷新接口了,而是进入else分支。
- 请求 A 进来,发现锁是开的 (
- 代码:
- 请求队列 (
requestList):- 代码:typescript
requestList.value.push(() => { config.headers!.Authorization = `Bearer ${cache.get("token")}`; resolve(http(config)); }); - 逻辑:请求 B 和 C 被暂时“挂起”。它们实际上是向
requestList数组里 push 了一个回调函数。这个回调函数做两件事:(1) 用最新的 Token 更新自己的请求头;(2) 重新发起自己这个请求。
- 代码:
- 刷新成功与重试:
- 代码:typescript
// 刷新成功后 requestList.value.map((cb: any) => cb()); // 遍历队列,执行所有挂起的回调 requestList.value = []; // 清空队列 - 逻辑:请求 A 刷新 Token 成功拿到新 Token 后,会转身对队列里的 B 和 C 说:“Token 拿到了,你们可以走了”。于是 B 和 C 执行回调,带着新 Token 重新向服务器发起请求。用户完全感觉不到由于过期导致的失败,这就是无感刷新。
- 代码:
二、文件下载优化(Blob/ArrayBuffer 智能处理)
背景问题: 前端下载文件通常设置 responseType: 'blob'。但这里有个坑:如果后端报错了(比如没有下载权限),返回的是一段 JSON 错误信息。 因为你设置了接收 Blob,浏览器会把这段 JSON 错误信息也强行包装成一个 Blob 对象。前端如果不处理,用户就会下载下来一个叫“未命名”的文件,打开里面是乱码或错误代码,体验极差。
你的解决方案(格式嗅探 + FileReader): 在 [src/utils/http.ts](file:///d%3A/work/code/wat-chivvy/web/src/utils/http.ts#L64-L95) 中:
判断响应类型: 代码:
if (response.data instanceof Blob && response.data.type === 'application/json')逻辑:这是关键判断。虽然我期望下载文件,但如果返回的 Blob 类型竟然是application/json,说明出事了——后端返回的不是文件流,而是错误数据。读取错误信息 (
FileReader): 代码:typescriptconst reader = new FileReader(); reader.onload = () => { const result = JSON.parse(reader.result as string); Message.error(result.message || "下载失败"); // 弹出错误提示,而不是下载文件 }; reader.readAsText(response.data); // 把 Blob 转回文本读取逻辑:利用
FileReader将这个伪装成 Blob 的 JSON 读出来,解析后直接弹窗报错。用户看到的是“下载失败:无权限”,而不是下载了一个打不开的文件。自动解析文件名: 代码:
typescriptconst disposition = response.headers["content-disposition"]; const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);逻辑:如果下载正常,通过正则从响应头
Content-Disposition中提取后端指定的文件名(如export_2024.xlsx)。如果后端没给,才兜底使用“未命名文件”。这也解决了乱码文件名的问题(代码中还做了decodeURIComponent解码)。
总结面试话术: “在请求封装这块,我主要解决了两个痛点。 一是并发刷新 Token,利用 Promise 和队列机制设计了一个并发锁,保证过期时只有一个请求去刷新 Token,其他并发请求挂起等待,刷新完自动重试,实现了完全无感知的续签体验。 二是文件下载的鲁棒性处理,特别处理了‘下载接口返回 JSON 错误’的边界情况,利用 FileReader 解析 Blob 中的错误信息,避免了用户下载到损坏文件的问题。”
toolName: view_files
status: success
filePath: d:\work\code\wat-chivvy\web\vite\chunk.ts
这其实包含两个层面的技术:一个是基础的路由懒加载(Vue Router 原生支持),另一个是高级的构建产物分包(Vite 插件优化)。我们分开来详细解释。
性能优化
在你的项目中,VITE_BUILD_COMPRESS 包含 gzip 或 brotli 的情况完全取决于环境变量文件(.env)中的配置。
1. 配置来源
在你的项目中,这个变量是在以下两个文件中被显式定义的:
- 生产环境配置 [.env.production](file:///d%3A/work/code/wat-chivvy/web/.env.production#L23):properties
# 是否在打包时开启压缩,支持 gzip 和 brotli VITE_BUILD_COMPRESS = gzip,brotli - 开发环境配置 [.env.development](file:///d%3A/work/code/wat-chivvy/web/.env.development#L24):properties
VITE_BUILD_COMPRESS = gzip,brotli
结论:在当前代码库中,无论是开发模式还是生产模式打包,默认都配置了同时开启 gzip 和 brotli 压缩。
2. 代码如何读取和生效
当你在终端运行 npm run build 时,Vite 会加载对应的 .env 文件。生效的逻辑在 [vite/compression.ts](file:///d%3A/work/code/wat-chivvy/web/vite/compression.ts#L13-L32) 中:
export default function createCompression(env: any, isBuild: boolean) {
const plugin: (PluginOption | PluginOption[])[] = [];
if (isBuild) {
const { VITE_BUILD_COMPRESS } = env;
// 关键点:将字符串按逗号分割成数组
const compressList = VITE_BUILD_COMPRESS.split(",");
// 如果数组包含 'gzip',则加载 gzip 压缩插件
if (compressList.includes("gzip")) {
plugin.push(compression());
}
// 如果数组包含 'brotli',则加载 brotli 压缩插件
if (compressList.includes("brotli")) {
plugin.push(
compression({
exclude: [/\.(br)$/, /\.(gz)$/], // 排除已经压缩过的文件
algorithm: "brotliCompress",
})
);
}
}
return plugin;
}3. 如何控制(什么时候会包含?)
你可以通过修改 .env.production 文件来控制它:
情况一:只开启 Gzip 修改配置为:
VITE_BUILD_COMPRESS = gzip适用场景:服务器(如 Nginx)只配置了 Gzip 模块,或者为了兼容性考虑。情况二:只开启 Brotli 修改配置为:
VITE_BUILD_COMPRESS = brotli适用场景:追求更高的压缩率,且确定客户端浏览器都支持 Brotli。情况三:都不开启 修改配置为:
VITE_BUILD_COMPRESS =(留空) 适用场景:本地快速构建测试,或者希望由 Nginx 在传输时实时压缩而不是预压缩。情况四:都开启(当前状态) 配置为:
VITE_BUILD_COMPRESS = gzip,brotli适用场景:最佳实践。构建会生成.gz和.br两种压缩文件。Nginx 可以配置为优先使用.br(更小),不支持时回退到.gz,最大化性能与兼容性。
1. 基础层面:路由懒加载(按需加载)
这是代码分割的第一步,也是最直观的一步。
什么是懒加载? 如果不做懒加载,所有页面的代码(首页、个人中心、设置页等)都会被打包进一个巨大的 index.js 文件。用户打开首页时,必须下载完所有页面的代码,导致首屏加载非常慢。 懒加载就是:访问哪个页面,才下载哪个页面的代码。
代码实现: 在你的代码 [src/router/static-routes/ucChildren/index.ts](file:///d%3A/work/code/wat-chivvy/web/src/router/static-routes/ucChildren/index.ts#L15) 中:
{
name: 'uc:index',
path: '/uc/index',
// 关键在这里:component 是一个函数,返回 import()
component: () => import(('~/base/views/uc/index.vue')),
// ...
}import()语法:这是一个动态导入语法。Vite(以及 Webpack)看到这个语法时,就会自动把~/base/views/uc/index.vue这个文件及其依赖,单独打包成一个.js文件(例如assets/uc-index.a1b2c3.js)。- 效果:当用户访问
/uc/index路由时,浏览器才会去请求下载这个 js 文件。没访问就不下载。
2. 进阶层面:构建产物分包(Chunk Split)
这是 Vite 插件做的高级优化,用来解决“公共代码重复打包”和“缓存失效”的问题。
为什么要分包? 假设你有 A 页面和 B 页面,它们都引用了 ECharts 这个巨大的图表库。
- 默认情况:A 页面的 js 里有一份 ECharts 代码,B 页面的 js 里也有一份。用户访问完 A 再访问 B,ECharts 代码被下载了两次,浪费流量。
- 分包优化:把
ECharts单独抽离成一个echarts-vendor.js。A 和 B 页面都只保留对它的引用。用户访问 A 时下载了它,访问 B 时直接用浏览器缓存里的,速度飞快。
代码实现: 在 [vite/chunk.ts](file:///d%3A/work/code/wat-chivvy/web/vite/chunk.ts#L10-L16) 中:
import { chunkSplitPlugin } from "vite-plugin-chunk-split";
export default function createChunkSplit() {
return chunkSplitPlugin({
strategy: "default", // 使用默认策略
});
}这个 vite-plugin-chunk-split 插件的 default 策略会自动帮你做几件聪明的事:
- Vendor 抽离:自动把
node_modules下较大的第三方库(如 Vue, Element Plus, Axios)单独打包成独立文件。 - 公共代码提取:如果多个业务页面(Chunk)都引用了同一个公共组件或工具函数,它会自动把这部分公共代码抽离出来,避免重复打包。
总结:两者如何结合?
你可以把它们想象成快递物流系统:
- 路由懒加载(import) 像是**“按需发货”**。用户下单(访问路由)了,仓库才发货(下载对应的 js)。
- Chunk Split(分包插件) 像是**“包裹拆分打包”**。仓库发货时,发现你买的电脑和手机都配了同款充电器。于是它不把充电器分别塞进电脑箱和手机箱,而是单独打了一个“通用配件包”。你收到第一个包裹时拿到了充电器,收第二个包裹时就只需要拿手机本体了。
最终效果:
- 首屏快:只下载首页必须的代码。
- 切换快:访问新页面时,公共依赖(Vue、UI 库等)已经有缓存了,只需要下载极小的业务代码。
图标系统
这个“现代化图标系统”的亮点在于它解决了传统 SVG/Font 图标方案的痛点(体积大、管理难、请求多),并且特别针对内网离线环境做了工程化适配。
我将其拆解为三个核心技术点,这正是面试官想听到的:
1. 统一入口与多态支持 (Polymorphic Icon Component)
痛点:项目里可能混用 Element Plus 的图标、本地 SVG 图标、还有 Iconify 的海量图标,用法不统一(有的用
class,有的用component)。解决方案:封装
<MaSvgIcon>组件。 在 [src/components/ma-svg-icon/index.vue](file:///d%3A/work/code/wat-chivvy/web/src/components/ma-svg-icon/index.vue#L25) 中,你通过正则判断props.name的格式,自动切换三种渲染模式:- UnoCSS 模式 (
i-user:add):通过 CSS 类名直接渲染,利用 UnoCSS 的按需编译,体积最小。 - Iconify 模式 (
mdi:home):使用@iconify/vue的<Icon>组件渲染,支持在线加载几十万个开源图标。 - 本地 SVG 模式 (
logo):渲染<use xlink:href="#icon-logo">,用于设计师给的特殊图标。
面试话术:“我封装了一个智能分发的图标组件,上层业务只需传一个字符串名字,底层自动识别是按需编译的 CSS 图标、在线 Web 图标还是本地 SVG,统一了开发体验。”
- UnoCSS 模式 (
2. 离线图标工程化 (Offline Icon Engineering)
痛点:Iconify 默认是请求公共 CDN 的 (
api.iconify.design)。但你的系统可能部署在政企内网,没外网,或者外网慢。解决方案:编写 Node.js 脚本实现“按需打包”。 在 [scripts/gen.icons.mts](file:///d%3A/work/code/wat-chivvy/web/scripts/gen.icons.mts) 中:
- 交互式选择:使用
inquirer让开发者勾选需要的图标集(如Material Design,Ant Design)。 - 数据提取:调用
@iconify/json的lookupCollectionAPI,把这些图标集的原始 JSON 数据提取出来。 - 生成离线包:将数据写入
public/icons/xxx-raw.json。这些文件会随项目发布。
面试话术:“针对内网部署场景,我设计了一个构建脚本。它能从几十万个 Iconify 图标中,按需提取项目用到的图标集,生成本地 JSON 文件。这样既享受了 Iconify 的生态,又不用依赖外网 CDN。”
- 交互式选择:使用
3. 运行时动态注册 (Runtime Dynamic Registration)
痛点:离线包生成了,怎么用?
解决方案:在运行时动态 fetch 并注册。 在 [src/iconify/index.ts](file:///d%3A/work/code/wat-chivvy/web/src/iconify/index.ts#L4) 中:
typescriptexport async function downloadAndInstall(name: string) { // 1. 请求本地 public 目录下的 JSON 文件 const data = Object.freeze(await fetch(`./icons/${name}-raw.json`).then(r => r.json())); // 2. 注册到 Iconify 运行时 addCollection(data); }配合前端的图标选择器,当用户点击某个分类时,才去下载对应的 JSON 文件。
面试话术:“在运行时,我采用了懒加载策略。只有当用户需要展示某个图标集时(比如打开图标选择器),系统才会去请求本地的 JSON 资源并注册到 Iconify 运行时。这让首屏体积完全不受图标数量的影响,哪怕有 100MB 的图标库,首屏依然秒开。”
组件封装
“高性能组件封装”是面试中展示你工程化思维(Engineering Mindset)和抽象能力的重点。你不仅是在写页面,而是在造轮子,提升团队效率。
以你项目中的 MaProTable 和 MaForm 为例,你采用了**配置驱动(Schema-Driven)**的设计模式,这比传统的模板写法要高级得多。
我将从配置化设计、渲染性能优化、逻辑复用三个维度来解析:
1. 配置驱动开发 (Schema-Driven Development)
- 核心思想:把“写 HTML”变成“写 JSON”。 传统的 CRUD 页面,你需要写大量的
<el-table-column>,<el-form-item>,代码冗长且重复。你的做法是定义一套 Schema,让组件根据配置自动渲染。 - 代码证据: 在 [src/modules/arrears/views/outsideArrearsOrder/index.vue](file:///d%3A/work/code/wat-chivvy/web/src/modules/arrears/views/outsideArrearsOrder/index.vue#L110) 中:typescript你把搜索栏、表格列的定义全部抽离到了
const schema = ref<MaProTableSchema>({ searchItems: getSearchItems(t), tableColumns: getTableColumns(maDialog, formRef, t, refundDialogRef), })getSearchItems.tsx和getTableColumns.tsx中。 面试亮点: “我设计了一套 Schema 协议,开发者只需要配置字段的type,label,prop,组件内部就会自动处理布局、校验、和数据绑定。这让一个复杂的 CRUD 页面的代码量减少了 60% 以上,并且保证了所有页面的 UI 交互高度一致。”
2. JSX/TSX 灵活渲染 (Flexible Rendering with TSX)
- 核心思想:突破模板限制,完全掌控渲染逻辑。 有些复杂的单元格(比如带状态颜色的标签、操作按钮组),用 Vue 模板写起来很啰嗦。你引入了 JSX/TSX。
- 代码证据: 在 [src/modules/arrears/views/outsideArrearsOrder/components/getTableColumns.tsx](file:///d%3A/work/code/wat-chivvy/web/src/modules/arrears/views/outsideArrearsOrder/components/getTableColumns.tsx#L109) 中:typescript以及在 [src/modules/arrears/views/outsideArrearsOrder/components/getFormItems.tsx](file:///d%3A/work/code/wat-chivvy/web/src/modules/arrears/views/outsideArrearsOrder/components/getFormItems.tsx#L27) 中:
cellRender: ({row}) => ( row.status_text || row.status ),typescript面试亮点: “为了解决复杂业务场景下的动态渲染问题,我在 Schema 中支持了render: () => <el-input />, renderProps: { placeholder: ... }render函数(JSX)。这让组件既保持了配置化的简洁,又拥有了手写代码的灵活性。比如在表格里动态渲染多个操作按钮,或者在表单里根据条件动态显隐字段,都能轻松实现。”
3. 逻辑复用与 Hooks 封装 (Logic Reuse with Hooks)
- 核心思想:把通用的业务逻辑抽离成 Hooks。 表格组件不仅仅是展示数据,还涉及到分页、排序、搜索、删除、导出等逻辑。如果每个页面都写一遍,维护成本极高。
- 代码证据: 在 [src/modules/arrears/views/outsideArrearsOrder/form.vue](file:///d%3A/work/code/wat-chivvy/web/src/modules/arrears/views/outsideArrearsOrder/form.vue#L27) 中使用了
useForm:typescript在 [src/modules/arrears/views/outsideArrearsOrder/index.vue](file:///d%3A/work/code/wat-chivvy/web/src/modules/arrears/views/outsideArrearsOrder/index.vue#L39) 中使用了useForm('maFormRef').then((form: MaFormExpose) => { ... })useDialog:typescript面试亮点: “我采用 Headless UI 的设计思路,将数据加载、分页处理、表单提交等逻辑封装成const maDialog = useDialog({ ok: ... })useTable,useForm等 Hooks。MaProTable组件只负责 UI 渲染,而逻辑层完全由 Hooks 驱动。这种分离让逻辑测试变得更容易,同时也支持开发者在不使用 UI 组件的情况下复用这些逻辑。”
面试话术总结:
“在高性能组件封装方面,我主导设计了 MaProTable 和 MaForm 两个核心业务组件:
- 配置驱动(Schema-Driven):我定义了一套 JSON Schema 协议,将页面开发从‘写模板’转变为‘写配置’。通过 TSX 实现了动态渲染,使得 90% 的 CRUD 页面开发效率提升了一倍。
- 逻辑与视图分离:利用 Vue Composition API,我将分页、搜索、表单校验等逻辑抽离为
useTable和useFormHooks。组件只负责渲染,逻辑层可独立复用,极大降低了代码耦合度。 - 性能优化:针对长列表和复杂表单,我内置了虚拟滚动(Virtual Scrolling)和防抖节流机制,确保在万级数据量下页面依然流畅。同时利用
v-bind="$attrs"和inheritAttrs: false实现了属性的透明透传,保证了组件的高扩展性。”
