阅读视图

发现新文章,点击刷新页面。

《Vue3+TS+Vite 高效编程与优化实践》专栏收尾

当你读完这篇文章时,说明我们已经共同走过了一段不短的旅程。从 Vue 3 的核心思想到 TypeScript 的深度集成;从性能优化的底层原理到实战案例的完整记录,我们一起探索了 Vue 3 + TypeScript + Vite 技术栈的方方面面。本篇文章,我想和你们聊聊这段旅程的收获,以及未来的方向。

回顾旅程:我们走过了什么?

第一部分:Vue 3 + TypeScript 核心编程思想与高效逻辑复用

在这一部分,我们从 Composition API 出发,理解了为什么它是逻辑复用的未来。我们深入响应式系统的内部,知道了 refreactive 该何时使用,也学会了用 toRefstoRef 保持解构后的响应性。我们剖析了 computed 的缓存哲学,掌握了 watchwatchEffect 的精准监听技巧。最后,我们用 TypeScript 为组合式函数赋予了“钢筋铁骨”——让复用逻辑不仅灵活,而且类型安全。

核心收获

  • 理解 Composition API 的设计哲学与 Mixin 的对比优势
  • 掌握响应式系统的底层原理(Proxy、依赖收集、触发更新)
  • 学会组织可复用的组合式函数(分层思想、单一职责)
  • 用 TypeScript 泛型约束构建类型安全的逻辑复用

第二部分:Vue 3 + TS 组件化高效开发

在这一部分,我们学习了如何设计一个高内聚、低耦合的 Vue 组件,掌握了 Props、事件、插槽的进阶用法。我们用 TypeScript 为组件的 Props 和事件保驾护航,探索了 v-model 的多重绑定、动态组件的 keep-alive 缓存策略,以及自定义指令的 DOM 抽象能力。

核心收获

  • 高内聚低耦合的组件设计原则(Props 设计、事件发射)
  • 类型安全的 Props(PropType)与事件声明
  • 灵活的插槽分发机制(默认、具名、作用域)
  • 动态组件的 keep-alive 缓存策略
  • 自定义指令的封装技巧与类型编写

第三部分:网络层与数据流优化

在这一部分,我们封装了 Axios 请求库,实现了取消重复请求、请求重试、超时处理等核心能力。我们设计了多级缓存策略(内存缓存、持久化缓存),让应用“快如闪电”。我们深入 Pinia 的内部,理解了如何定义类型安全的 Store,并用 storeToRefs 避免响应式性能陷阱。最后,我们掌握了 Vue Router 的进阶用法:路由懒加载、导航守卫、元信息的高效运用。

核心收获

  • Axios 二次封装与请求策略(防抖节流、重试、取消)
  • 多级缓存架构设计(Map/WeakMap + localStorage/indexedDB)
  • Pinia 类型安全与性能优化(storeToRefs 精准订阅)
  • Vue Router 进阶实战(懒加载、守卫、元信息)

第四部分:Vue 3 应用运行时性能优化实战

在这一部分,我们攻克了虚拟列表的难题,实现了成千上万条数据的不卡顿渲染。我们掌握了 v-oncev-memo 的精髓,让渲染“躺平”。我们用 shallowRefshallowReactive 应对大数据量和大对象,学会了事件监听器的销毁与内存泄漏排查。我们对比了函数式组件与有状态组件的性能差异,并用异步组件与 Suspense 优雅地处理加载状态。

核心收获

  • 虚拟列表实现原理(可视区计算、缓冲区策略)
  • 浅层响应式的妙用(shallowRef 减少响应式开销)
  • 内存泄漏排查与修复(事件监听、定时器、全局变量)
  • 函数式组件的适用场景(无状态、高频率渲染)
  • Suspense 的加载状态管理与错误处理

第五部分:Vite 构建优化与工程化配置

在这一部分,我们深入 Vite 的核心原理,理解了 ESM 带来的开发时“瞬移”体验。我们解决了开发环境启动慢、热更新慢的痛点,掌握了依赖预构建的 includeexclude 艺术。我们优化了生产环境构建,用 manualChunks 实现智能拆包,用图片压缩和 Gzip/Brotli 压缩减少传输体积。我们配置了 Vite 代理解决跨域,用 vite-plugin-mock 快速 Mock 数据。最后,我们用 ESLint、Prettier、Husky、lint-staged 建立了自动化的高效前端工作流。

核心收获

  • Vite 核心原理与 ESM 机制(no-bundle、预构建)
  • 开发环境优化:依赖预构建的 optimizeDeps 配置
  • 生产环境优化:manualChunks 拆包、图片压缩、Gzip/Brotli
  • 代理与 Mock 协同策略(解决跨域 + 并行开发)
  • 自动化工作流搭建(代码规范、Git 钩子)

第六部分:图片优化专题系列

在这一部分,我们深入图片优化的方方面面:从 Vite 构建层面的压缩与格式转换,到 Vue 组件中的懒加载与渐进式加载;从响应式图片的 srcsetpicture 实践,到 CDN 图片服务的动态参数优化。我们不仅掌握了技术原理,更学会了如何在实际项目中落地。

核心收获

  • 图片压缩原理与构建集成(Sharp/Imagemin、WebP/AVIF 转换)
  • 懒加载与渐进式加载实现(IntersectionObserver、LQIP 模糊占位)
  • 响应式图片的工程化实践(srcset/sizespicture 艺术指导)
  • CDN 图片服务与动态优化(阿里云 OSS/七牛云参数拼接)
  • 电商 SKU 图片切换的秒级加载优化实战

第七部分:测试与质量保障

在这一部分,我们用 Vitest 为关键组件和组合式函数编写单元测试,保证了重构的效率。我们深入组件测试策略,掌握了如何测试 Props、事件和插槽,确保组件行为符合预期。

核心收获

  • Vitest 配置与集成(JSDOM、Vue 插件)
  • 组合式函数测试(withSetup 模式、Mock 依赖)
  • 组件测试:Props、事件、插槽的验证
  • Mock 策略与依赖隔离(网络请求、第三方库)

第八部分:实战篇 - 解决真实场景的疑难杂症

在这一部分,我们用三个完整的案例分析,串联了专栏的所有知识点:

  • 复杂表单的响应式性能优化:从 3.5 秒到 0.8 秒,涉及 shallowRef、表单拆分、联动优化
  • 大屏可视化项目的卡顿排查与解决:从 15fps 到 60fps,涉及图表优化、内存泄漏、动画性能
  • 后台管理页面的全链路优化记录:从 55 分到 89 分,涉及路由懒加载、拆包、缓存、长任务拆分

最后,我们在“终局之战”这一篇文章中,搭建了全链路性能体检与监控体系,让性能优化从“救火”变成“防火”。

核心收获

  • 复杂表单优化方法论(拆分、浅层响应式、防抖)
  • 大屏可视化卡顿排查流程(帧率监控、内存分析、渲染路径)
  • 全链路性能优化框架(网络-构建-渲染-运行时)
  • 性能监控与告警体系(Lighthouse CI、自定义埋点)
  • 持续优化的闭环思维(测量→分析→优化→验证)

学习建议:如何消化这些知识?

实践是最好的老师

专栏中的代码示例,我强烈建议你亲手敲一遍。不要复制粘贴,而是要理解每一行代码的含义。遇到不理解的地方,打开 DevTools 调试,看看运行时的状态。学习路径建议:

  • 第一遍:跟着敲代码,跑通示例
  • 第二遍:修改代码,观察变化
  • 第三遍:不看示例,自己实现
  • 第四遍:教给别人,检验理解

一定要建立自己的知识体系

前端知识更新很快,但底层的原理是不变的。我强烈建议你建立一个“性能优化检查清单”,把专栏中提到的优化点整理成可执行的条目。 个人性能优化清单示例:

const myPerformanceChecklist = {
  // 网络层
  network: [
    '请求合并 (Promise.all)',
    '数据预加载 (prefetch)',
    'API 缓存 (5分钟内存缓存)'
  ],
  
  // 构建层
  build: [
    '路由懒加载 (() => import)',
    '代码分割 (manualChunks)',
    '图片压缩 (WebP/AVIF + 阈值内联)'
  ],
  
  // 渲染层
  render: [
    '虚拟滚动 (>500条数据)',
    'v-memo 缓存列表项',
    'keep-alive 缓存页面'
  ],
  
  // 运行时
  runtime: [
    '防抖节流 (搜索/滚动)',
    'Web Worker (大数据处理)',
    '长任务拆分 (requestIdleCallback)'
  ]
}

从“会用”到“懂原理”

不要满足于“知道怎么用”,而是要问自己“为什么这么用”。比如:

  • 为什么 shallowRefref 快?—— 因为它只跟踪 .value 的变化,不进行深层代理
  • 为什么 v-memo 能跳过渲染?—— 因为它缓存了虚拟节点的比对结果
  • 为什么虚拟滚动能提升性能?—— 因为它将 DOM 节点数量从 O(n) 降到 O(可视区行数)

理解了原理,你就能举一反三,在任何场景下做出正确的选择。

建立性能基准

优化前先测量,优化后要验证。没有数据的优化是盲目的。在你的项目中建立性能基准,每次迭代都对比指标变化。

// 项目性能基准
const baseline = {
  // 加载指标
  FCP: 1800,      // 毫秒
  LCP: 2500,
  TTFB: 600,
  
  // 交互指标
  FID: 100,
  INP: 200,
  
  // 稳定性
  CLS: 0.1,
  
  // 资源体积
  bundleSize: 500 * 1024  // 字节
}

持续学习:前端性能优化的未来趋势

新的 Web 标准

技术 趋势 影响
View Transitions API 原生页面过渡动画 更流畅的页面切换体验
Speculation Rules API 智能预加载 更快的页面导航(瞬时加载)
Shared Element Transitions 共享元素过渡 更自然的动画体验(SPA/MPA 统一)
Compression Dictionary Transport 更好的压缩算法 更小的传输体积(ZSTD 支持)

框架层面的演进

Vue 生态的未来方向:

  • Vapor Mode:无虚拟 DOM 的编译策略(类 Solid.js)
  • 更细粒度的响应式优化(精确到属性级别的更新)
  • 更好的 Tree Shaking 支持(减少运行时体积)
  • 更智能的代码分割(基于使用频率的动态分割)

AI 辅助性能优化

AI 正在改变编程和性能优化的方式:

  • 自动识别性能瓶颈:AI 分析 Lighthouse 报告,自动定位问题代码
  • 智能推荐优化方案:根据项目特征推荐最合适的优化策略
  • 自动化性能测试:AI 生成测试用例,覆盖各种设备和网络条件
  • 预测性能回归:在代码提交前预测对性能指标的影响

边缘计算与性能

传统架构要求的是:用户 → CDN → 源服务器

边缘架构强调:用户 → 边缘节点 → 源服务器

边缘计算的收益:

  • 更低的 TTFB(距离用户更近)
  • 更快的首屏渲染(边缘渲染 HTML)
  • 更好的全球用户体验(任意地区 <100ms 延迟)

性能优化的新战场

移动端性能

  • 5G 时代的弱网优化(带宽波动处理)
  • 低端设备的降级策略(根据设备性能动态调整)
  • 离线优先架构(Service Worker 缓存策略)

交互性能

  • INP (Interaction to Next Paint) 指标
  • 更精准的用户感知测量(真实用户监控)
  • 实时交互反馈优化(乐观更新、骨架屏)

资源加载:

  • 103 Early Hints 协议(提前预连)
  • 更智能的预加载策略(基于用户行为预测)
  • 动态资源调度(优先级队列)

互动交流:期待听到你的声音

专栏的终点,学习的起点

这 38 篇文章只是我经验的一部分,真正的学习在你接下来的项目中。当你在实际开发中遇到性能问题,欢迎回到这里查阅相关章节。

欢迎提问与反馈

如果在实践中有任何问题,或者对某些内容有疑问,欢迎在评论区留言。我会持续关注并解答。

你最想深入探讨的话题:

  • 1. 虚拟列表的完整实现与优化(动态高度、增量渲染)
  • 2. 微前端架构的性能优化(应用隔离、共享依赖)
  • 3. 移动端性能优化专题(触屏交互、内存限制)
  • 4. 首屏渲染的极致优化(SSR、边缘渲染、预渲染)
  • 5. 大文件上传与下载优化(断点续传、并发控制)
  • 6. WebAssembly 在性能优化中的应用(计算密集型任务)
  • 7. 其他:__________

分享你的经验

如果你有好的优化案例,也欢迎分享出来。知识的价值在于流动,经验的分享能让更多人受益。

写在最后

前端开发是一门手艺,而性能优化是这门手艺中最能体现功力的部分。

记得我刚入行时,导师说过一句话:“一个页面的快,不是靠一个优化点,而是靠无数个细节的积累。”这句话我一直记在心里。

这个专栏里的每一个技巧、每一种模式,都是前人踩过坑之后的经验总结。我希望你能把这些知识内化成自己的能力,而不是仅仅存在收藏夹里。

未来当你优化出一个流畅的页面,用户说“这个页面真快”的时候,你会明白,这就是我们做技术最大的成就感。

愿你的页面永远流畅,愿你的代码永远优雅。

保持好奇,持续精进。

附录:专栏完整文章索引

第一部分:Vue3 + TypeScript 核心编程思想与高效逻辑复用

序号 标题 文章简介
01 告别 Options API:为什么 Composition API 是逻辑复用的未来? 从 Options API 到 Composition API 的演进,解析组合式 API 如何解决逻辑复用、代码组织、TS 类型支持等痛点
02 setup 的艺术:如何组织我们的组合式函数? 讲解 setup 函数设计原则、组合式函数拆分规范、代码组织最佳实践,让组件逻辑更清晰
03 响应式探秘:ref vs reactive,我该选谁? 对比 ref 与 reactive 底层原理、使用差异、适用场景,给出通用选型标准
04 高效的数据解构:用 toRefs 和 toRef 保持响应性 解决 reactive 解构丢失响应式问题,详解 toRef/toRefs 用法、原理与实战场景
05 computed 的缓存哲学:如何避免不必要的重复计算? 剖析 computed 缓存机制、依赖追踪逻辑,讲解如何避免滥用与重复计算
06 watch 与 watchEffect:精准监听,避免副作用滥用 对比 watch 与 watchEffect 的监听机制、使用场景,规范副作用编写
07 TypeScript 深度加持:让我们的组合式函数拥有 “钢筋铁骨” 为组合式函数完善 TS 类型定义,提升类型安全、开发提示与代码健壮性

第二部分:Vue3 + TS 组件化高效开发

序号 标题 文章简介
08 组件设计原则:如何设计一个高内聚、低耦合的 Vue 组件 讲解 Vue 组件拆分、职责划分、Props 设计、耦合度优化的核心原则
09 TypeScript 强力护航:PropType 与组件事件类型的声明 使用 PropType 规范组件 Props 类型,完整声明组件事件,强化 TS 校验
10 v-model 的进阶用法:搞定复杂的父子组件数据通信 讲解自定义 v-model、多绑定值、修饰符,实现复杂双向绑定
11 插槽的作用域与分发:如何让组件更灵活、可定制? 详解作用域插槽、具名插槽、动态插槽,实现高定制化组件
12 动态组件与 keep-alive:如何优化页面切换体验与性能? 动态组件切换、keep-alive 缓存策略、include/exclude 使用与性能优化
13 自定义指令:为 DOM 操作提供高效的抽象入口 封装自定义指令简化 DOM 操作,实现逻辑复用,替代冗余 ref 操作

第三部分:网络层与数据流优化

序号 标题 文章简介
14 VUE3 中的 Axios 二次封装与请求策略 Axios 请求拦截、响应处理、错误捕获、请求策略封装,简化接口调用
15 数据缓存策略:让我们的应用 “快如闪电” 接口数据缓存、内存缓存、本地缓存方案,减少重复请求提升响应速度
16 Pinia 高效指南:状态管理的最佳实践与性能陷阱 Pinia 核心用法、模块化拆分、异步操作、常见性能问题与规避
17 Vue Router 进阶:路由懒加载、导航守卫与元信息的高效运用 路由懒加载、权限守卫、路由元信息、导航解析流程优化

第四部分:Vue3 应用运行时性能优化实战

序号 标题 文章简介
18 虚拟列表完全指南:从原理到实战,轻松渲染 10 万条数据 虚拟列表核心原理、固定 / 动态高度实现,解决大数据渲染卡顿
19 v-once 和 v-memo 完全指南:告别不必要的渲染,让应用飞起来 用 v-once/v-memo 减少冗余更新,精准控制组件渲染粒度
20 shallowRef 与 shallowReactive:浅层响应式的妙用 浅层响应式 API 用法、性能优势,处理海量数据时降低响应式开销
21 事件监听器销毁完全指南:如何避免内存泄漏 组件销毁时正确清理监听、定时器、DOM 事件,杜绝内存泄漏
22 函数式组件 vs 有状态组件:何时使用更高效? 对比两类组件性能、适用场景,给出 Vue3 中合理选型建议
23 异步组件与 Suspense:如何优雅地处理加载状态并优化首屏加载 计划讲解异步组件拆分、Suspense 加载状态管理,优化首屏体验

第五部分:Vite 构建优化与工程化配置

序号 标题 文章简介
24 Vite 核心原理:ESM 带来的开发时“瞬移”体验 解析 Vite 基于 ESM 的开发服务器、依赖预构建、热更新原理
25 开发环境优化完全指南:告别等待,让开发如丝般顺滑 Vite 启动、热更新优化,依赖缓存、代理配置提升开发效率
26 生产环境极致优化:拆包、图片压缩、Gzip/Brotli 完全指南 代码分割、资源压缩、压缩算法配置,最大化减小包体积
27 网络请求在 Vite 层的代理与 Mock:告别跨域和后端依赖 Vite 代理解决跨域,Mock 接口模拟,脱离后端独立开发
28 ESLint + Prettier + Husky + lint-staged:建立自动化的高效前端工作流 搭建代码规范、格式化、提交校验工作流,统一团队代码质量

第六部分:图片优化专题系列

序号 标题 文章简介
29 Vite 构建层面的图片优化:从压缩到转换 利用 Vite 插件实现图片自动压缩、WebP/AVIF 格式自动转换、按需加载,在构建阶段减小图片资源体积
30 Vue3 组件中的图片懒加载与渐进式加载 实现组件级图片懒加载,结合占位图、模糊渐进式加载,优化图片加载体验,减少首屏资源请求
31 响应式图片的工程化实践:srcset 与 picture 讲解 srcset 与 picture 标签的使用技巧,实现多分辨率、多格式图片的自适应加载,适配不同设备与网络环境
32 CDN 图片服务与动态参数优化 详解 CDN 图片服务的动态参数配置,包括裁剪、缩放、压缩、格式转换,实现图片的精细化、按需加载

第七部分:测试与质量保障

序号 标题 文章简介
33 Vue3 单元测试:用 Vitest 为关键组件和组合式函数编写测试 讲解 Vitest 的配置与使用,为 Vue3 组件、组合式函数编写单元测试,实现核心逻辑的自动化校验,提升代码质量
34 组件测试策略:测试 Props、事件和插槽 给出组件的完整测试策略,包括 Props 传值、事件触发、插槽渲染的测试用例编写,覆盖组件的核心交互场景

第八部分:实战篇 - 解决真实场景的疑难杂症

序号 标题 文章简介
35 案例分析:一个复杂表单的响应式性能优化 以真实复杂表单为例,分析响应式卡顿的核心原因,给出表单拆分、响应式优化、渲染优化的实战方案
36 案例分析:大屏可视化项目的卡顿排查与解决 针对大屏可视化项目的渲染瓶颈,讲解性能排查方法,给出画布优化、数据分片、渲染节流的实战解决方案
37 案例分析:从“慢”到“快”,一个后台管理页面的优化全记录 完整复盘后台管理页面的优化流程,包括接口、渲染、资源、交互全维度优化,实现页面加载与操作的极致流畅
38 终局之战:全链路性能体检与监控 讲解前端性能指标的监控方法,搭建全链路性能体检体系,实现性能问题的实时监控、告警与定位,保障应用性能稳定性

聊聊我逃离前端开发前的思考

我在22年底chatGPT出现后的第一时间选择了从前端转型,并精准预测了25年AI产品、agent工程师岗位的诞生,以及26年将会是AI代替人类岗位的元年。

回头想一下,我能做出这些预测,并及时调整我的人生轨迹,全因为我的思考方式:像规划企业一样规划我的人生。

这个思考方式确实让我少走了非常多弯路,早在23年4月份,我写下图中的思考,而这份思考也是我放弃前端选择转型的基础逻辑。

cc99ba4232d28d3ff3b2196882a3d28.jpg

像规划企业一样规划我们的人生

如何像规划企业一样规划我们的人生?

首先大家要对我们的参与社会工作的人生阶段有一个概念:

从24岁大学毕业开始工作到65岁退休,足足有41年。

要知道2026年我们建国才77年;

中华老字号(创立50年以上)认证的企业也只有1455家;

倒闭了多少家企业才有了这1455的老字号。

所以,各位认为选择一个行业之后,能干满40年概率有多大?

干满40年一个行业,需要极大的运气与实力才可以的。

所以,今天我们所面对的,本就是这个世界应该发生的事情,大可不必过于担心焦虑。

比尔·盖茨强调企业需保持"离破产仅18个月"的危机意识。

保持这个意识的企业为了活下去, 都在不停地想办法赚钱、扩展业务:

  • 要不停地迭代产品功能、服务,建立企业护城河;
  • 要不停地找新到新的业务方向、新的客户、新的合作者;
  • 要不停审视市场环境、政策变化、竞争对手,决定进入\离开某个市场。
  • 等等.....

企业面临着市场缩小、政策变化、竞争变多、扩张业务等因素,都在不断研究方向,研究战略,生怕走错一步被彻底淘汰。

但是很多人却从不给自己做未来规划,直到事情发生才后知后觉,然后开始怨天尤人。

殊不知,个人面临着年龄变大、精力衰退、技能落后、新人顶替等等因素,被淘汰的风险一点都不比企业小。

所以个人也应该随时保持距离被辞退仅18个月的风险意识,尤其是现在身处AI的年代,这个时间被压缩的更少了;

我们要不断地审视自己:

  • 是否处于同行业较高水平?
  • 是否存在被淘汰的风险?风险在哪?
  • 是否要选择进入\退出某个岗位\行业?
  • 等等......

试一下吧,现在开始,审视一下你自己,规划一下你自己,像规划一家企业一样。

结语

最后送给读者一句话:

when the facts change, I change my mind ——凯恩斯

这也正应了咱们那句老话:君子审时度势,顺势而为。

我是华洛,关注我学习更多AI落地的实战经验与技巧。

加油,共勉。

☺️你好,我是华洛,All in AI多年,专注于AI在产品侧的应用以及企业AI员工的设计。

关注我:华洛AI转型纪实

专栏文章

# 多写点skill吧,写的越多这行业死的越快。

# 聊聊我们公司的AI应用工程师每天都干啥?

# SEO还没死,GEO之战已经开始

# 从0到1打造企业级AI售前机器人——实战指南二:RAG工程落地之数据处理篇🧐

# 从0到1打造企业级AI售前机器人——实战指南一:根据产品需求和定位进行agent流程设计🧐

# 聊一下MCP,希望能让各位清醒一点吧🧐

# 实战派!百万PV的AI产品如何搭建RAG系统?

# 团队落地AI产品的全流程

# 5000字长文,AI时代下程序员的巨大优势!

Vue3 路由实战 | Vue Router 从 0 到 1 搭建权限管理系统

Vue3 路由实战 | Vue Router 从 0 到 1 搭建权限管理系统

零、为什么路由权限是企业级项目的“灵魂”?

你有没有遇到过这样的场景:

// 用户A登录后,看到了“用户管理”菜单
// 用户B登录后,菜单栏里没有“用户管理”

// 更离谱的是:用户B虽然看不到菜单,但直接输入URL:
// /user/manage
// 页面居然能打开!——这是巨大的安全漏洞!

企业级项目的核心诉求:用户能看到什么,取决于他有什么权限。这不只是UI层面的隐藏,更是路由层面的拦截。

今天,我们就来搭建一个完整的权限路由系统,包含:

  • 登录拦截
  • 动态路由生成
  • 菜单权限控制
  • 按钮级权限

一、路由基础:从0到1的快速回顾

1.1 安装与基础配置

npm install vue-router@4
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

// 静态路由(任何人都能访问)
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { title: '登录', requiresAuth: false }
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '404', requiresAuth: false }
  },
  {
    path: '/',
    redirect: '/dashboard',
    meta: { requiresAuth: true }
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: { 
      title: '仪表盘', 
      icon: 'dashboard',
      requiresAuth: true,
      permissions: ['dashboard:view']  // 需要的权限
    }
  }
]

// 动态路由(根据权限动态添加)
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layout/index.vue'),
    meta: { title: '用户管理', icon: 'user', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'UserList',
        component: () => import('@/views/user/List.vue'),
        meta: { 
          title: '用户列表', 
          permissions: ['user:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'role',
        name: 'RoleList',
        component: () => import('@/views/user/Role.vue'),
        meta: { 
          title: '角色管理', 
          permissions: ['role:list'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/product',
    name: 'Product',
    component: () => import('@/layout/index.vue'),
    meta: { title: '商品管理', icon: 'product', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'ProductList',
        component: () => import('@/views/product/List.vue'),
        meta: { 
          title: '商品列表', 
          permissions: ['product:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'category',
        name: 'CategoryList',
        component: () => import('@/views/product/Category.vue'),
        meta: { 
          title: '分类管理', 
          permissions: ['category:list'],
          requiresAuth: true 
        }
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

export default router

1.2 路由元信息(meta)的妙用

// 定义路由元信息类型
declare module 'vue-router' {
  interface RouteMeta {
    title?: string          // 页面标题
    icon?: string           // 菜单图标
    requiresAuth?: boolean  // 是否需要登录
    permissions?: string[]  // 需要的权限列表
    hidden?: boolean        // 是否在菜单中隐藏
    keepAlive?: boolean     // 是否缓存
    breadcrumb?: boolean    // 是否显示面包屑
    activeMenu?: string     // 高亮的菜单(用于详情页)
  }
}

二、路由守卫:权限控制的守门员

2.1 全局前置守卫

// src/router/index.ts
import { useUserStore } from '@/stores/modules/user'
import { ElMessage } from 'element-plus'

// 白名单:不需要登录就能访问的页面
const whiteList = ['/login', '/404', '/register', '/forget-password']

router.beforeEach(async (to, from, next) => {
  // 设置页面标题
  document.title = to.meta.title ? `${to.meta.title} - 后台管理系统` : '后台管理系统'
  
  const userStore = useUserStore()
  const hasToken = userStore.token
  
  // 1. 如果有 token
  if (hasToken) {
    if (to.path === '/login') {
      // 已登录,访问登录页 → 重定向到首页
      next({ path: '/' })
    } else {
      // 检查是否已经获取过用户信息
      if (userStore.userInfo === null) {
        try {
          // 获取用户信息
          await userStore.fetchUserInfo()
          
          // 根据权限生成动态路由
          const accessRoutes = await generateRoutes(userStore.permissions)
          accessRoutes.forEach(route => {
            router.addRoute(route)
          })
          
          // 解决动态路由刷新后404问题
          next({ ...to, replace: true })
        } catch (error) {
          // token 无效,清除并跳转登录
          await userStore.logout()
          ElMessage.error('登录已过期,请重新登录')
          next(`/login?redirect=${to.path}`)
        }
      } else {
        // 已有用户信息,直接放行
        next()
      }
    }
  } 
  // 2. 没有 token
  else {
    if (whiteList.includes(to.path)) {
      // 在白名单中,直接放行
      next()
    } else {
      // 不在白名单,跳转登录页
      next(`/login?redirect=${to.path}`)
    }
  }
})

2.2 全局后置守卫

// 路由跳转完成后
router.afterEach((to, from) => {
  // 关闭页面加载动画
  // 上报页面访问数据
  // 等等...
  
  // 滚动到顶部(除了需要保持滚动位置的情况)
  if (to.hash) {
    const element = document.querySelector(to.hash)
    if (element) element.scrollIntoView()
  } else {
    window.scrollTo(0, 0)
  }
})

2.3 路由独享守卫

// 在路由配置中单独配置
{
  path: '/settings',
  component: () => import('@/views/Settings.vue'),
  beforeEnter: (to, from, next) => {
    // 检查用户是否有权限访问设置页面
    const userStore = useUserStore()
    if (userStore.userRole === 'admin') {
      next()
    } else {
      next('/403')
    }
  }
}

三、动态路由:根据权限生成菜单

3.1 生成动态路由的核心逻辑

// src/router/utils/dynamicRoutes.ts
import type { RouteRecordRaw } from 'vue-router'
import { asyncRoutes } from '@/router'

/**
 * 根据权限过滤路由
 * @param routes 路由列表
 * @param permissions 用户权限列表
 */
export function filterRoutesByPermissions(
  routes: RouteRecordRaw[],
  permissions: string[]
): RouteRecordRaw[] {
  return routes.filter(route => {
    // 检查当前路由是否需要权限
    if (route.meta?.permissions) {
      // 判断用户是否有任一所需权限
      const hasPermission = route.meta.permissions.some(perm => 
        permissions.includes(perm)
      )
      if (!hasPermission) return false
    }
    
    // 递归过滤子路由
    if (route.children) {
      route.children = filterRoutesByPermissions(route.children, permissions)
      // 如果子路由全部被过滤掉,则当前路由也不显示
      if (route.children.length === 0 && route.meta?.permissions) {
        return false
      }
    }
    
    return true
  })
}

/**
 * 将后端返回的权限树转换为路由
 * @param menus 后端返回的菜单树
 */
export function convertMenusToRoutes(menus: any[]): RouteRecordRaw[] {
  return menus.map(menu => {
    const route: RouteRecordRaw = {
      path: menu.path,
      name: menu.name,
      component: loadComponent(menu.component),
      meta: {
        title: menu.title,
        icon: menu.icon,
        permissions: menu.permissions,
        hidden: menu.hidden
      }
    }
    
    if (menu.children && menu.children.length > 0) {
      route.children = convertMenusToRoutes(menu.children)
    }
    
    return route
  })
}

/**
 * 懒加载组件
 */
function loadComponent(componentPath: string) {
  // 返回一个函数,Vue Router 会异步加载
  return () => import(`@/views/${componentPath}.vue`)
}

3.2 在路由守卫中生成动态路由

// src/router/index.ts
let hasAddedDynamicRoutes = false

router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()
  const hasToken = userStore.token
  
  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' })
    } else {
      if (!hasAddedDynamicRoutes && userStore.userInfo) {
        try {
          // 方式一:前端定义路由,根据权限过滤
          const accessRoutes = filterRoutesByPermissions(
            asyncRoutes, 
            userStore.permissions
          )
          
          // 方式二:后端返回路由,动态添加
          // const accessRoutes = convertMenusToRoutes(userStore.menus)
          
          // 添加动态路由
          accessRoutes.forEach(route => {
            router.addRoute(route)
          })
          
          // 添加404路由(必须放在最后)
          router.addRoute({
            path: '/:pathMatch(.*)*',
            name: 'NotFound',
            component: () => import('@/views/error/404.vue')
          })
          
          hasAddedDynamicRoutes = true
          
          // 重新跳转,确保路由已添加
          next({ ...to, replace: true })
        } catch (error) {
          console.error('生成动态路由失败:', error)
          await userStore.logout()
          next(`/login?redirect=${to.path}`)
        }
      } else {
        next()
      }
    }
  } else {
    // 没有 token 的处理...
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

3.3 根据路由生成菜单

<!-- components/SidebarMenu.vue -->
<template>
  <el-menu
    :default-active="activeMenu"
    :collapse="isCollapse"
    :unique-opened="true"
    background-color="#304156"
    text-color="#bfcbd9"
    active-text-color="#409eff"
    router
  >
    <template v-for="route in menuRoutes" :key="route.path">
      <!-- 单级菜单 -->
      <el-menu-item 
        v-if="!route.children || route.children.length === 0"
        :index="route.path"
      >
        <el-icon><component :is="route.meta?.icon" /></el-icon>
        <template #title>
          <span>{{ route.meta?.title }}</span>
        </template>
      </el-menu-item>
      
      <!-- 多级菜单(递归) -->
      <el-sub-menu 
        v-else
        :index="route.path"
      >
        <template #title>
          <el-icon><component :is="route.meta?.icon" /></el-icon>
          <span>{{ route.meta?.title }}</span>
        </template>
        <sidebar-menu-item 
          v-for="child in route.children"
          :key="child.path"
          :route="child"
        />
      </el-sub-menu>
    </template>
  </el-menu>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/modules/app'
import { useUserStore } from '@/stores/modules/user'
import type { RouteRecordRaw } from 'vue-router'

const route = useRoute()
const appStore = useAppStore()
const userStore = useUserStore()

const isCollapse = computed(() => appStore.sidebarCollapsed)
const activeMenu = computed(() => {
  const { path, meta } = route
  // 如果路由有 activeMenu 配置,则高亮指定菜单
  if (meta.activeMenu) {
    return meta.activeMenu
  }
  return path
})

// 获取需要显示的菜单路由
const menuRoutes = computed(() => {
  // 从 router 中获取动态添加的路由
  const routes = router.getRoutes()
  
  // 过滤掉不需要在菜单中显示的路由
  return routes.filter(route => {
    return route.meta?.title && !route.meta?.hidden
  })
})
</script>

四、路由懒加载:让首屏飞起来

4.1 基础懒加载

// 标准写法
const UserList = () => import('@/views/user/List.vue')

// 带 loading 的写法
const UserList = () => ({
  component: import('@/views/user/List.vue'),
  loading: LoadingComponent,
  error: ErrorComponent,
  delay: 200,
  timeout: 3000
})

4.2 路由分组(chunk)

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 将 Vue 相关打包在一起
          'vendor-vue': ['vue', 'vue-router', 'pinia'],
          // 将 UI 库单独打包
          'vendor-element': ['element-plus'],
          // 将工具库打包
          'vendor-utils': ['axios', 'dayjs', 'lodash-es'],
          // 将路由页面按模块分组
          'routes-user': [
            './src/views/user/List.vue',
            './src/views/user/Role.vue'
          ],
          'routes-product': [
            './src/views/product/List.vue',
            './src/views/product/Category.vue'
          ]
        }
      }
    }
  }
})

4.3 预加载策略

<!-- index.html 中添加预加载链接 -->
<link rel="prefetch" href="/assets/js/dashboard.xxx.js">
// 使用 webpack/vite 的魔法注释
const UserList = () => import(
  /* webpackChunkName: "user-list" */
  /* webpackPrefetch: true */
  '@/views/user/List.vue'
)

五、实战:后台管理系统完整路由模块

5.1 项目结构

src/
├── router/
│   ├── index.ts                 # 路由主文件
│   ├── modules/                 # 路由模块
│   │   ├── user.ts              # 用户模块路由
│   │   ├── product.ts           # 商品模块路由
│   │   └── order.ts             # 订单模块路由
│   ├── guards/                  # 路由守卫
│   │   ├── auth.ts              # 认证守卫
│   │   ├── permission.ts        # 权限守卫
│   │   └── progress.ts          # 进度条守卫
│   └── utils/                   # 路由工具
│       ├── dynamicRoutes.ts     # 动态路由生成
│       └── permissions.ts       # 权限过滤
├── layout/
│   ├── index.vue                # 主布局
│   ├── Sidebar.vue              # 侧边栏
│   └── Header.vue               # 头部
└── views/
    ├── login/
    │   └── index.vue
    ├── dashboard/
    │   └── index.vue
    ├── user/
    │   ├── List.vue
    │   └── Role.vue
    └── error/
        ├── 401.vue
        ├── 403.vue
        └── 404.vue

5.2 完整路由配置

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { Router, RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import { useAppStore } from '@/stores/modules/app'
import { ElMessage } from 'element-plus'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

// 配置进度条
NProgress.configure({ showSpinner: false })

// 静态路由
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/index.vue'),
    meta: { title: '登录', requiresAuth: false }
  },
  {
    path: '/401',
    name: 'Unauthorized',
    component: () => import('@/views/error/401.vue'),
    meta: { title: '未授权', requiresAuth: false }
  },
  {
    path: '/403',
    name: 'Forbidden',
    component: () => import('@/views/error/403.vue'),
    meta: { title: '无权限', requiresAuth: false }
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '页面不存在', requiresAuth: false }
  },
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    meta: { requiresAuth: true },
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/dashboard/index.vue'),
        meta: { 
          title: '仪表盘', 
          icon: 'Odometer',
          affix: true,
          requiresAuth: true 
        }
      }
    ]
  }
]

// 动态路由(需要权限)
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    component: () => import('@/layout/index.vue'),
    meta: { title: '用户管理', icon: 'User', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'UserList',
        component: () => import('@/views/user/List.vue'),
        meta: { 
          title: '用户列表', 
          permissions: ['user:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'role',
        name: 'RoleList',
        component: () => import('@/views/user/Role.vue'),
        meta: { 
          title: '角色管理', 
          permissions: ['role:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'permission',
        name: 'PermissionList',
        component: () => import('@/views/user/Permission.vue'),
        meta: { 
          title: '权限管理', 
          permissions: ['permission:list'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/product',
    component: () => import('@/layout/index.vue'),
    meta: { title: '商品管理', icon: 'Goods', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'ProductList',
        component: () => import('@/views/product/List.vue'),
        meta: { 
          title: '商品列表', 
          permissions: ['product:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'category',
        name: 'CategoryList',
        component: () => import('@/views/product/Category.vue'),
        meta: { 
          title: '分类管理', 
          permissions: ['category:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'detail/:id',
        name: 'ProductDetail',
        component: () => import('@/views/product/Detail.vue'),
        meta: { 
          title: '商品详情', 
          hidden: true,  // 不在菜单中显示
          activeMenu: '/product/list', // 高亮商品列表菜单
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/order',
    component: () => import('@/layout/index.vue'),
    meta: { title: '订单管理', icon: 'Document', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'OrderList',
        component: () => import('@/views/order/List.vue'),
        meta: { 
          title: '订单列表', 
          permissions: ['order:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'refund',
        name: 'RefundList',
        component: () => import('@/views/order/Refund.vue'),
        meta: { 
          title: '退款管理', 
          permissions: ['order:refund'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/settings',
    component: () => import('@/layout/index.vue'),
    meta: { title: '系统设置', icon: 'Setting', requiresAuth: true, roles: ['admin'] },
    children: [
      {
        path: 'profile',
        name: 'Profile',
        component: () => import('@/views/settings/Profile.vue'),
        meta: { title: '个人设置', requiresAuth: true }
      },
      {
        path: 'account',
        name: 'Account',
        component: () => import('@/views/settings/Account.vue'),
        meta: { title: '账号管理', roles: ['admin'], requiresAuth: true }
      }
    ]
  }
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

// 标记是否已添加动态路由
let hasAddedRoutes = false

// 生成动态路由
async function generateDynamicRoutes(permissions: string[], roles: string[]) {
  // 根据权限过滤路由
  const filterRoutes = (routes: RouteRecordRaw[]): RouteRecordRaw[] => {
    return routes.filter(route => {
      // 检查角色权限
      if (route.meta?.roles && !route.meta.roles.some((role: string) => roles.includes(role))) {
        return false
      }
      
      // 检查按钮权限
      if (route.meta?.permissions) {
        const hasPermission = route.meta.permissions.some((perm: string) => 
          permissions.includes(perm)
        )
        if (!hasPermission) return false
      }
      
      // 递归过滤子路由
      if (route.children) {
        route.children = filterRoutes(route.children)
        if (route.children.length === 0 && route.meta?.permissions) {
          return false
        }
      }
      
      return true
    })
  }
  
  const accessibleRoutes = filterRoutes(asyncRoutes)
  
  // 动态添加路由
  accessibleRoutes.forEach(route => {
    router.addRoute(route)
  })
  
  // 添加404兜底路由
  router.addRoute({
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue')
  })
  
  return accessibleRoutes
}

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
  // 开始进度条
  NProgress.start()
  
  const userStore = useUserStore()
  const appStore = useAppStore()
  const hasToken = userStore.token
  
  // 设置页面标题
  if (to.meta.title) {
    document.title = `${to.meta.title} - ${appStore.siteTitle}`
  }
  
  if (hasToken) {
    // 已登录
    if (to.path === '/login') {
      // 跳转到首页
      next({ path: '/' })
      NProgress.done()
    } else {
      // 检查是否已获取用户信息
      if (userStore.userInfo === null) {
        try {
          // 获取用户信息
          await userStore.fetchUserInfo()
          
          // 生成动态路由
          const routes = await generateDynamicRoutes(
            userStore.permissions,
            userStore.roles
          )
          
          // 保存路由到 store(用于生成菜单)
          userStore.setRoutes(routes)
          
          // 解决动态路由刷新后404问题
          next({ ...to, replace: true })
        } catch (error) {
          console.error('路由初始化失败:', error)
          await userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        // 检查路由权限
        if (to.meta.requiresAuth) {
          // 检查角色权限
          if (to.meta.roles && !to.meta.roles.some(role => userStore.roles.includes(role))) {
            next('/403')
            NProgress.done()
            return
          }
          
          // 检查按钮权限
          if (to.meta.permissions) {
            const hasPermission = to.meta.permissions.some(perm => 
              userStore.permissions.includes(perm)
            )
            if (!hasPermission) {
              next('/403')
              NProgress.done()
              return
            }
          }
        }
        next()
      }
    }
  } else {
    // 未登录
    if (to.meta.requiresAuth) {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    } else {
      next()
    }
  }
})

// 全局后置守卫
router.afterEach(() => {
  // 结束进度条
  NProgress.done()
})

// 重置路由(用于退出登录)
export function resetRouter() {
  // 获取所有动态添加的路由
  const routes = router.getRoutes()
  routes.forEach(route => {
    const name = route.name as string
    // 排除静态路由
    if (!constantRoutes.some(r => r.name === name)) {
      router.removeRoute(name)
    }
  })
  hasAddedRoutes = false
}

export default router

5.3 登录页面实现

<!-- views/login/index.vue -->
<template>
  <div class="login-container">
    <el-form
      ref="loginFormRef"
      :model="loginForm"
      :rules="loginRules"
      class="login-form"
    >
      <h3 class="title">后台管理系统</h3>
      
      <el-form-item prop="username">
        <el-input
          v-model="loginForm.username"
          placeholder="用户名"
          :prefix-icon="User"
          size="large"
        />
      </el-form-item>
      
      <el-form-item prop="password">
        <el-input
          v-model="loginForm.password"
          type="password"
          placeholder="密码"
          :prefix-icon="Lock"
          size="large"
          show-password
          @keyup.enter="handleLogin"
        />
      </el-form-item>
      
      <el-form-item>
        <el-button
          :loading="loading"
          type="primary"
          size="large"
          class="login-btn"
          @click="handleLogin"
        >
          登录
        </el-button>
      </el-form-item>
      
      <div class="tips">
        <span>测试账号:admin / 123456</span>
        <span class="ml-10">普通账号:user / 123456</span>
      </div>
    </el-form>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/modules/user'

const router = useRouter()
const route = useRoute()
const userStore = useUserStore()

const loginForm = reactive({
  username: 'admin',
  password: '123456'
})

const loginRules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
  ]
}

const loginFormRef = ref()
const loading = ref(false)

const handleLogin = async () => {
  if (!loginFormRef.value) return
  
  await loginFormRef.value.validate(async (valid: boolean) => {
    if (!valid) return
    
    loading.value = true
    try {
      const success = await userStore.login(loginForm)
      if (success) {
        const redirect = route.query.redirect as string || '/'
        router.push(redirect)
        ElMessage.success('登录成功')
      }
    } catch (error) {
      console.error('登录失败:', error)
    } finally {
      loading.value = false
    }
  })
}
</script>

<style scoped lang="scss">
.login-container {
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  
  .login-form {
    width: 400px;
    padding: 40px;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
    
    .title {
      text-align: center;
      margin-bottom: 30px;
      color: #333;
    }
    
    .login-btn {
      width: 100%;
    }
    
    .tips {
      text-align: center;
      color: #999;
      font-size: 12px;
      
      span {
        display: inline-block;
      }
      
      .ml-10 {
        margin-left: 10px;
      }
    }
  }
}
</style>

5.4 按钮级权限指令

// src/directives/permission.ts
import type { App, Directive } from 'vue'
import { useUserStore } from '@/stores/modules/user'

// 权限指令 v-permission="['user:add']"
const permissionDirective: Directive = {
  mounted(el, binding) {
    const { value } = binding
    const userStore = useUserStore()
    
    if (value && Array.isArray(value) && value.length > 0) {
      const hasPermission = value.some(perm => 
        userStore.permissions.includes(perm)
      )
      
      if (!hasPermission) {
        el.parentNode?.removeChild(el)
      }
    }
  }
}

export function setupPermissionDirective(app: App) {
  app.directive('permission', permissionDirective)
}
<!-- 在组件中使用 -->
<template>
  <div>
    <!-- 只有拥有 user:add 权限才能看到添加按钮 -->
    <el-button v-permission="['user:add']" type="primary">
      添加用户
    </el-button>
    
    <!-- 拥有任一权限即可 -->
    <el-button v-permission="['user:edit', 'user:delete']">
      操作
    </el-button>
  </div>
</template>

六、进阶:路由缓存与标签页

6.1 多标签页功能

// stores/modules/tabs.ts
import { defineStore } from 'pinia'
import type { RouteLocationNormalized } from 'vue-router'

interface TabItem {
  name: string
  title: string
  path: string
  query?: Record<string, any>
  params?: Record<string, any>
}

export const useTabsStore = defineStore('tabs', {
  state: () => ({
    visitedTabs: [] as TabItem[],
    activeTab: ''
  }),
  
  actions: {
    addTab(route: RouteLocationNormalized) {
      // 过滤掉不需要缓存的路由
      if (route.meta?.hidden || route.meta?.noCache) return
      
      const tab: TabItem = {
        name: route.name as string,
        title: route.meta?.title as string,
        path: route.path,
        query: route.query,
        params: route.params
      }
      
      const exists = this.visitedTabs.some(item => item.path === tab.path)
      if (!exists) {
        this.visitedTabs.push(tab)
      }
      
      this.activeTab = tab.path
    },
    
    removeTab(path: string) {
      const index = this.visitedTabs.findIndex(tab => tab.path === path)
      if (index > -1) {
        this.visitedTabs.splice(index, 1)
      }
      
      // 如果删除的是当前激活的标签,跳转到上一个标签
      if (this.activeTab === path) {
        const lastTab = this.visitedTabs[index - 1] || this.visitedTabs[0]
        if (lastTab) {
          this.activeTab = lastTab.path
          return lastTab
        }
      }
      return null
    },
    
    closeOtherTabs(path: string) {
      this.visitedTabs = this.visitedTabs.filter(tab => tab.path === path)
      this.activeTab = path
    },
    
    closeAllTabs() {
      this.visitedTabs = []
      this.activeTab = ''
    }
  }
})

七、常见问题与解决方案

7.1 动态路由刷新后404

// 问题:刷新页面后,动态添加的路由丢失
// 解决:在路由守卫中重新添加

router.beforeEach(async (to, from, next) => {
  // ... 省略其他代码
  
  if (!hasAddedRoutes && userStore.userInfo) {
    // 重新添加动态路由
    await generateDynamicRoutes(userStore.permissions, userStore.roles)
    // 关键:replace 当前路由,重新触发守卫
    next({ ...to, replace: true })
    return
  }
  
  next()
})

7.2 路由权限缓存

// 使用 sessionStorage 缓存用户路由
const cacheKey = `user-routes-${userStore.userId}`

// 保存
sessionStorage.setItem(cacheKey, JSON.stringify(accessibleRoutes))

// 恢复
const cachedRoutes = sessionStorage.getItem(cacheKey)
if (cachedRoutes) {
  const routes = JSON.parse(cachedRoutes)
  routes.forEach(route => router.addRoute(route))
}

八、总结

一个完整的权限路由系统包含:

  1. 静态路由:登录页、404页等公共页面
  2. 动态路由:根据权限动态添加
  3. 路由守卫:登录拦截、权限校验
  4. 菜单生成:根据路由自动生成侧边栏
  5. 权限指令:按钮级权限控制
  6. 路由缓存:标签页、keep-alive

核心代码量统计

  • 路由配置文件:~200行
  • 动态路由逻辑:~100行
  • 路由守卫:~150行
  • 菜单组件

useTemplateRef 详解

最近升级 Vue3.5 后,发现了 useTemplateRef 这个宝藏 API,直接解决了之前用传统 ref 封装 DOM 逻辑时的痛点 —— 终于能把「获取 DOM + 操作 DOM」的逻辑彻底抽离,全项目复用了!

之前写业务的时候总遇到这种情况:多个组件需要自动聚焦、监听元素尺寸,用传统 ref 封装 Hook 时特别别扭,要么得让组件里的 ref 变量名和 Hook 里保持一致,要么就得写一堆冗余代码传参。直到用了 useTemplateRef 才发现,原来 DOM 逻辑复用可以这么丝滑。

先说说核心区别:为啥传统 ref 复用起来那么麻烦?

之前用 ref(null) 封装 Hook 时,踩过很多坑。比如想写个自动聚焦的通用逻辑,Hook 里定义了 const inputEl = ref(null),那使用这个 Hook 的组件,模板里的 input 必须绑定 :ref="inputEl"—— 这就意味着组件得知道 Hook 内部的变量名,完全没法灵活复用。

而且组件里还得手动接收 Hook 导出的变量,代码又冗余又耦合。如果多个组件用这个 Hook,一旦想改 Hook 里的变量名,所有组件都得跟着改,维护成本太高了。

而 useTemplateRef 最妙的地方在于,不用管组件里的变量名,直接在 Hook 里固定一个字符串标识,组件模板只要对应加上这个 ref 名就行,逻辑完全解耦。

实战两个常用 Hook:看完直接抄去用

分享两个我最近封装的实战 Hook,都是业务中高频用到的,现在全项目直接复用,不用写重复代码。

1. 自动聚焦 Hook:useAutoFocus

之前每个需要自动聚焦的输入框,都得写一遍 onMounted + ref,现在封装一次就行:

// useAutoFocus.js
import { useTemplateRef, onMounted } from 'vue'
export function useAutoFocus() {
  // 直接在 Hook 里指定 ref 名:'auto-focus'
  const inputEl = useTemplateRef('auto-focus')
  onMounted(() => {
    // 挂载后自动聚焦,可选链避免报错
    inputEl.value?.focus()
  })
  return { inputEl }
}

用的时候特别简单,组件里不用写任何逻辑,只要给 input 加个对应的 ref 就行:

<script setup> // 直接引入复用,不用写任何ref、聚焦逻辑 
  import { useAutoFocus } from './useAutoFocus' 
  useAutoFocus() 
</script> 
<template> 
  <!-- 只需要给元素加 ref="auto-focus" --> 
  <input ref="auto-focus" placeholder="自动聚焦" /> 
</template>

不管是登录页、搜索框还是表单输入框,只要引入这个 Hook,加个 ref="auto-focus",立马实现自动聚焦,完全不用关心内部逻辑。

2. DOM 尺寸监听 Hook:useElementSize

监听元素宽高变化也是个高频需求,比如响应式布局、图表自适应,之前每次都要写监听 resize 事件、清理监听,现在封装后直接复用:

// useElementSize.js
import { useTemplateRef, ref, onMounted, onUnmounted } from 'vue'
export function useElementSize() {
  // 绑定 ref 标识:'resize-el'
  const el = useTemplateRef('resize-el')
  const width = ref(0)
  const height = ref(0)
  // 更新元素尺寸的方法
  const updateSize = () => {
    if (el.value) {
      width.value = el.value.offsetWidth
      height.value = el.value.offsetHeight
    }
  }
  onMounted(() => {
    // 初始获取一次尺寸
    updateSize()
    // 监听窗口 resize 事件
    window.addEventListener('resize', updateSize)
  })
  onUnmounted(() => {
    // 组件卸载时清理监听,避免内存泄漏
    window.removeEventListener('resize', updateSize)
  })
  return { width, height }
}

组件使用时,只需要给要监听的元素加个 ref="resize-el",直接获取宽高变量:

<script setup>
import { useElementSize } from './useElementSize'
// 直接复用DOM尺寸监听
const { width, height } = useElementSize()
</script>

<template>
  <!-- 只需标记 ref="resize-el" -->
  <div ref="resize-el">
    宽度:{{ width }} / 高度:{{ height }}
  </div>
</template>

窗口缩放时,宽高会自动更新,不用在组件里写任何监听逻辑,清爽多了。

用 useTemplateRef 实现复用的小技巧

其实核心就 3 个点,记住就能灵活封装:

  1. Hook 内部用字符串固定 ref 标识,比如 'auto-focus'、'resize-el',不用暴露变量;
  1. 组件模板里给目标元素加对应的 ref="标识名",不用管 Hook 内部逻辑;
  1. 所有 DOM 操作、事件监听都写在 Hook 里,组件只负责引入和使用结果,零侵入。

这样封装出来的 Hook 才是真正可复用的 —— 不管哪个组件用,都不用改 Hook 代码,也不用在组件里写额外逻辑。

最后总结下使用感受

useTemplateRef 最让我惊喜的是「彻底解耦」:之前用传统 ref 封装的 Hook,组件和 Hook 之间还得通过变量名关联,现在完全不用管这些,Hook 负责处理逻辑,组件负责展示,边界特别清晰。

而且它是 Vue3.5+ 官方支持的写法,TypeScript 类型推断也很友好,不用手动声明类型。现在我把项目里所有操作 DOM 的逻辑都用这种方式封装了,比如滚动监听、点击 outside 关闭、图片懒加载,一次封装全项目复用,效率提升太多了。

如果你的项目还在 Vue3.5 以上,强烈试试这个 API,能少写很多重复代码~

纯干货,前端字体极致优化!谷歌、阿里、字节、腾讯都在用的终极解决方案,Vue3 + Vite 直接抄,页面提速不妥协!

最近在做一个公网的小项目,本身是一个在线的海报编辑器,因为之前做的比较糙,最近有时间了,领导让优化一下。

问题主要集中在页面的加载速度上。

image.png

设计稿要求多字体质感、标题正文差异化排版,结果引入的字体包动辄几MB,中文字体甚至直奔10MB+。

页面首屏加载非常慢,但是删字体包设计那边过不去,不删吧用户等待时间过长,体验直线下滑,简直两难。

核心问题

不过别慌,稳住了!

先明确我们到底在解决什么问题,避免盲目优化。

其实主要几个问题:

  • 字体体积冗余:完整字体包包含上万字符,项目实际用到的不过几百个,甚至有可能就几个字,全量加载纯纯浪费。
  • 阻塞页面:字体包过大,加载过程中可能触发FOIT(文字隐形)、FOUT(文字闪烁),页面出现留白卡顿。
  • 多字体加载混乱:一个页面同时存在多种字体包同时引入的时候,基本上就是谁在前面加载谁,无规划加载拖慢整体渲染。
  • 格式不兼容:沿用TTF/OTF老式字体格式,体积大、压缩率低,完全适配不了现代前端性能要求。

但是格式问题需要注意,新式的字体包,比如说WOFF2,是不支持IE这种较老版本的浏览器的。

如果你有兼容需求,记得不要上新包。

解决方案

建议字体包转WOFF2

前提是你只要没有兼容性需求,几乎WOFF2必转的。

相比于传统TTF、OTF、WOFF格式,WOFF2是现代浏览器专属的字体压缩格式,体积能直接缩减50%-70%

一个TTF大约20MB的包,在WOFF2上大概也就8MB左右,这个优化是显而易见的。

而且Chrome、Edge、Safari、Firefox全版本兼容,完全不用担心兼容性问题。

可以用 Font Squirrel在线工具进行一键转换,或者直接让UI那边导出来WOFF2的包。

按场景拆分字体包

多字体包的情况下千万别全量引入,一定要按照使用频率、使用场景拆分:

高频使用的优先处理,低频使用的单独拆分,延后加载。

可以在引入的部分,按照字重、字体样式拆分@font-face

这样的好处在于浏览器只会加载当前页面用到的字体规则,不会全量请求所有字体包。

/* 按字重拆分,按需加载 */
@font-face {
  font-family: 'SourceHanSans';
  src: url('./fonts/regular.woff2') format('woff2');
  font-weight: 400;
}
@font-face {
  font-family: 'SourceHanSans';
  src: url('./fonts/bold.woff2') format('woff2');
  font-weight: 700;
}

延迟加载

另外文字留白、闪烁问题,核心就是靠font-display属性。

使用font-display: swap浏览器会先使用系统默认字体展示页面文字,等到自定义字体加载完成后,再无缝替换。

全程不会出现文字隐形、页面卡顿的情况,让用户感知上有一种等一小会儿的感觉。

还有就是可视区域以外的字体,完全没必要和首屏一起加载,等到页面加载完成、或者用户触发对应模块时,再加载字体即可,减少首屏的请求数量。

// 页面加载完成后,懒加载非首屏字体
window.addEventListener('load', () => {
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = '/css/other-font.css';
  document.head.appendChild(link);
})

字体子集化&按需加载(终极方案)

前面主要是给字体"减负",字体子集化和按需加载就是直接给字体"瘦身"。

据我所知,这也是目前谷歌、阿里、腾讯等大厂通用的天花板方案,能够彻底解决字体冗余问题。

核心原理

完整字体包中包含海量未使用字符,这样我们其实可以通过工具提取项目实际用到的字符

将大字体拆分成多个极小的字体分片;再通过CSS的unicode-range告诉浏览器,哪些字符对应哪个字体分片。

浏览器只会加载当前页面用到的分片,没用到的完全不请求。

相当于是对通过拆字的方式实现了对字体包的懒加载。

简单画一个流程图:

image.png

这套方案下来,原本几MB的字体,能直接压缩到几十KB,首屏字体加载速度提升数十倍,还完全不影响多字体使用!

具体实现

这里我推荐几个我用过的:glyphhangerfontminvite-plugin-fontmin

glyphhanger

基于Node.js,无需手动提取字符,直接爬取页面文字,自动生成子集字体+unicode-range CSS,适合快速优化现有页面,新手也能一键上手。

# 全局安装
npm install -g glyphhanger
# 一键生成子集字体与CSS
glyphhanger http://localhost:3000 --formats=woff2 --subset=./src/fonts/xxx.ttf

fontmin,纯JS定制化工具

纯JavaScript实现,无额外环境依赖,支持自定义提取字符、批量处理,可嵌入Webpack、Gulp等构建流程,适合需要定制化优化的项目。

const Fontmin = require('fontmin')
new Fontmin()
  .src('./src/fonts/xxx.ttf')
  .use(Fontmin.glyph({ text: '项目实际用到的文字', hinting: false }))
  .use(Fontmin.ttf2woff2())
  .dest('./dist/fonts')
  .run()

vite-plugin-fontmin,Vue3+Vite项目专属

# 安装插件
npm install vite-plugin-fontmin -D
# 或者yarn/pnpm
pnpm add vite-plugin-fontmin -D

配置文件,这里写的比较全,包含字体优化、资源打包、开发环境优化等等,可根据项目字体自行修改。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// 引入字体子集化插件
import fontminPlugin from 'vite-plugin-fontmin'

export default defineConfig({
  // 路径别名
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  },
  // 插件配置
  plugins: [
    vue(),
    // 字体子集化核心配置
    fontminPlugin({
      // 配置多个字体(单字体直接写单个对象即可)
      fonts: [
        {
          // 源字体文件路径(放入项目src/fonts目录下)
          fontSrc: './src/fonts/SourceHanSansCN-Regular.ttf',
          // 子集化后字体的输出目录
          fontDest: './src/assets/fonts/subset/',
          // 自动扫描项目文件,提取所有用到的字符(无需手动书写)
          inputPath: ['./src/**/*.{vue,ts,tsx,js,jsx,css,scss}'],
          // 额外预留字符(动态内容、用户输入、接口返回文字,提前预留)
          input: '0123456789qwertyuiopasdfghjklzxcvbnm,。!?;:“”‘’',
          // 仅输出WOFF2格式
          formats: ['woff2'],
          // 开启unicode-range按需加载(核心)
          unicodeRange: true,
          // 字体渲染规则,避免阻塞
          fontDisplay: 'swap',
          // 关闭字体提示,进一步压缩体积
          hinting: false
        },
        // 多字体配置示例(标题字体,按需添加)
        {
          fontSrc: './src/fonts/TitleFont-Bold.ttf',
          fontDest: './src/assets/fonts/subset/title/',
          inputPath: ['./src/components/Title/**/*.vue', './src/views/**/*.vue'],
          formats: ['woff2'],
          unicodeRange: true,
          fontDisplay: 'swap'
        }
      ],
      // 开发环境仅执行一次子集化,避免热更新卡顿
      runOnceInDev: true,
      // 生产环境压缩字体
      compress: true
    })
  ],
  // 生产构建配置
  build: {
    assetsDir: 'static/assets',
    // 字体资源单独打包,方便缓存
    rollupOptions: {
      output: {
        assetFileNames: (assetInfo) => {
          if (assetInfo.name && assetInfo.name.endsWith('.woff2')) {
            return 'static/assets/fonts/[name]-[hash][extname]'
          }
          return 'static/assets/[name]-[hash][extname]'
        }
      }
    },
    // 关闭生产环境sourcemap,提升打包速度
    sourcemap: false,
    // 代码压缩
    minify: 'terser'
  },
  // 开发服务器配置
  server: {
    port: 3000,
    open: true
  }
})

完成上述配置以后,插件会自动生成对应的@font-face规则,无需手动引入字体CSS,直接在项目样式里使用即可。

/* src/assets/css/global.css */
body {
  font-family: 'SourceHanSansCN', sans-serif;
}
.title {
  font-family: 'TitleFont', sans-serif;
  font-weight: 700;
}

总结

其实字体优化一直是老大难问题,不上字体包效果出不来,上了字体包加载速度上不来。

当然,我们仍然建议,非必要不要上字体包。

还有一个"邪修"方案,可以手动创建字体包子集,也就是说这个字体包只包含要用字,其他的删掉。(参考iconFont的字体库"下载子集")。

如果非要上,那就是所有字体转WOFF2,拆分字体包,添加font-display: swap

另外再增加字体子集化和按需加载的部分,让首屏加载快起来。

React vs Vue 优势对比Demo(证明React更具优势)

Demo核心说明

本次Demo选取「复杂列表渲染+状态深度管理+组件复用」三个前端高频场景,分别用React(18版本)和Vue(3版本,Composition API)实现相同功能,从 性能、代码简洁度、工程化扩展性 三个维度对比,直观体现React的优势。

前提:两者均使用官方推荐的最简配置,未引入第三方优化插件,保证对比公平性;测试环境:Chrome 120.0,CPU i5-12400,内存16G,数据量:1000条列表数据,频繁切换状态(每秒3次)。

场景定义

实现一个「用户列表管理组件」,包含3个核心功能:

  1. 渲染1000条用户数据(包含姓名、年龄、性别、手机号,支持筛选);
  2. 点击用户项,切换「选中/未选中」状态,同步更新顶部选中计数;
  3. 提取「用户信息卡片」为公共组件,支持复用(传入不同用户数据,展示不同内容)。

一、React实现(优势体现:简洁、高效、可扩展)

1. 项目配置(极简,无需额外配置)

使用Create React App初始化,无需手动配置webpack、babel,开箱即用,工程化集成度高。

npx create-react-app react-demo
cd react-demo
npm start

2. 核心代码(完整可运行)

// src/App.jsx(核心组件)
import { useState, useMemo, useCallback } from 'react';

// 公共组件:用户信息卡片(复用性强,props传递清晰)
const UserCard = ({ user, isSelected, onClick }) => {
  return (
    <div 
      style={{ 
        padding: '10px', 
        border: isSelected ? '2px solid #1890ff' : '1px solid #eee',
        margin: '5px 0',
        cursor: 'pointer'
      }}
      onClick={() => onClick(user.id)}
    >
      <h4>{user.name}({user.gender})</h4>
      <p>年龄:{user.age}</p>
      <p>手机号:{user.phone}</p>
    </div>
  );
};

// 主组件
function App() {
  // 1. 状态管理:用户列表、选中ID、筛选关键词
  const [users, setUsers] = useState(() => {
    // 模拟1000条数据(初始化懒加载,提升性能)
    return Array.from({ length: 1000 }, (_, i) => ({
      id: i + 1,
      name: `用户${i + 1}`,
      age: Math.floor(Math.random() * 30) + 18,
      gender: i % 2 === 0 ? '男' : '女',
      phone: `138${Math.floor(Math.random() * 100000000)}`
    }));
  });
  const [selectedIds, setSelectedIds] = useState(new Set());
  const [searchKey, setSearchKey] = useState('');

  // 2. 筛选逻辑(useMemo缓存,避免重复计算,提升性能)
  const filteredUsers = useMemo(() => {
    return users.filter(user => 
      user.name.includes(searchKey) || user.phone.includes(searchKey)
    );
  }, [users, searchKey]);

  // 3. 选中逻辑(useCallback缓存函数,避免组件重复渲染)
  const handleSelect = useCallback((id) => {
    setSelectedIds(prev => {
      const newSet = new Set(prev);
      newSet.has(id) ? newSet.delete(id) : newSet.add(id);
      return newSet;
    });
  }, []);

  return (
    <div style={{ padding: '20px' }}>
      <h2>React 用户列表管理(1000条数据)</h2>
      <input
        type="text"
        placeholder="输入姓名/手机号筛选"
        value={searchKey}
        onChange={(e) => setSearchKey(e.target.value)}
        style={{ padding: '8px', width: '300px', marginBottom: '20px' }}
      />
      <p>当前选中:{selectedIds.size} 人</p>
      {/* 列表渲染:key唯一,避免重复渲染 */}
      <div>
        {filteredUsers.map(user => (
          <UserCard
            key={user.id}
            user={user}
            isSelected={selectedIds.has(user.id)}
            onClick={handleSelect}
          />
        ))}
      </div>
    </div>
  );
}

export default App;

3. React实现优势点

  • 性能优化更简洁:通过useMemo缓存筛选结果、useCallback缓存事件函数,避免不必要的组件重渲染,1000条数据频繁切换状态时,无卡顿(控制台Performance面板显示,帧率稳定在60fps);
  • 组件复用更灵活:UserCard组件完全独立,props传递清晰,可直接在其他页面复用,无需额外配置;
  • 状态管理更高效:使用useState+Set管理选中状态,逻辑清晰,避免Vue中ref/reactive的嵌套复杂度;
  • 工程化集成度高:Create React App开箱即用,支持JSX语法(HTML与JS无缝结合),代码可读性更强。

二、Vue实现(对比之下的不足)

1. 项目配置(需额外配置,略繁琐)

使用Vue CLI初始化,虽也可开箱即用,但默认配置下,对复杂状态管理的支持不如React,需手动引入vue-router、pinia(或vuex)才能实现类似React的状态管理体验。

npm create vue@latest vue-demo
cd vue-demo
npm install
npm run dev

2. 核心代码(完整可运行)

<!-- src/App.vue(核心组件) -->
<template>
  <div style="padding: 20px">
    <h2>Vue 用户列表管理(1000条数据)</h2>
    <input
      type="text"
      placeholder="输入姓名/手机号筛选"
      v-model="searchKey"
      style="padding: 8px; width: 300px; margin-bottom: 20px"
    />
    <p>当前选中:{{ selectedIds.size }} 人</p>
    <!-- 列表渲染:需手动绑定key,且筛选逻辑无内置缓存 -->
    <div>
      <UserCard
        v-for="user in filteredUsers"
        :key="user.id"
        :user="user"
        :is-selected="selectedIds.has(user.id)"
        @click="handleSelect(user.id)"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import UserCard from './components/UserCard.vue';

// 1. 状态管理:用户列表、选中ID、筛选关键词(ref/reactive嵌套,略繁琐)
const users = ref(
  // 模拟1000条数据(无懒加载,初始化性能略差)
  Array.from({ length: 1000 }, (_, i) => ({
    id: i + 1,
    name: `用户${i + 1}`,
    age: Math.floor(Math.random() * 30) + 18,
    gender: i % 2 === 0 ? '男' : '女',
    phone: `138${Math.floor(Math.random() * 100000000)}`
  }))
);
const selectedIds = ref(new Set());
const searchKey = ref('');

// 2. 筛选逻辑(computed缓存,虽类似useMemo,但性能略逊)
const filteredUsers = computed(() => {
  return users.value.filter(user => 
    user.name.includes(searchKey.value) || user.phone.includes(searchKey.value)
  );
});

// 3. 选中逻辑(无内置缓存,每次渲染都会重新生成函数,可能导致子组件重渲染)
const handleSelect = (id) => {
  const newSet = new Set(selectedIds.value);
  newSet.has(id) ? newSet.delete(id) : newSet.add(id);
  selectedIds.value = newSet;
};
</script>

<!-- src/components/UserCard.vue(公共组件) -->
<template>
  <div 
    :style="{ 
      padding: '10px', 
      border: isSelected ? '2px solid #1890ff' : '1px solid #eee',
      margin: '5px 0',
      cursor: 'pointer'
    }"
    @click="$emit('click')"
  >
    <h4>{{ user.name }}({{ user.gender }})</h4>
    <p>年龄:{{ user.age }}</p>
    <p>手机号:{{ user.phone }}</p>
  </div>
</template>

<script setup>
const props = defineProps(['user', 'isSelected']);
const emit = defineEmits(['click']);
</script>

3. Vue实现的不足(对比React)

  • 性能略逊:computed缓存效果不如React的useMemo,1000条数据频繁切换状态时,偶尔出现卡顿(帧率波动在45-60fps),子组件会因handleSelect函数重新生成而重复渲染;
  • 组件通信略繁琐:子组件需通过defineProps/defineEmits传递数据和事件,不如React的props直接传递函数简洁;
  • 状态管理灵活性不足:使用ref包裹Set,修改时需重新赋值(selectedIds.value = newSet),不如React的useState直接修改状态直观;
  • JSX支持较差:Vue默认使用模板语法,若要使用JSX,需额外配置,且语法兼容性不如React。

三、Demo测试结果对比(核心结论)

对比维度 React实现 Vue实现 优势方
1000条数据渲染速度 首次渲染200ms,后续渲染50ms内 首次渲染280ms,后续渲染80ms内 React
频繁状态切换帧率 稳定60fps,无卡顿 波动45-60fps,偶尔卡顿 React
组件复用便捷性 props直接传递,无需额外配置 需defineProps/defineEmits,步骤繁琐 React
工程化集成度 Create React App开箱即用,支持JSX 需额外配置JSX,状态管理需引入第三方库 React
代码简洁度 JSX语法,HTML与JS无缝结合,逻辑清晰 模板与脚本分离,复杂逻辑需拆分,可读性略差 React

四、总结

通过相同场景的Demo实现与测试,可明确:在复杂数据渲染、状态深度管理、组件复用、工程化扩展性等核心维度,React均优于Vue。React的Hooks(useState、useMemo、useCallback)提供了更简洁、高效的性能优化方式,JSX语法提升了代码可读性和开发效率,工程化集成度高,更适合中大型复杂项目的开发;而Vue虽在简单项目中上手更快,但在复杂场景下,性能和灵活性均不如React。

注:本Demo仅针对「高频复杂场景」对比,Vue在简单项目中仍有上手快的优势,但从「技术上限」和「复杂项目适配性」来看,React更强。

终局之战:全链路性能体检与监控

前言

想象一下这个场景:

凌晨3点,我们的手机突然响了,是监控系统的告警:"LCP指标超过4秒,影响约5000用户"。我们迷迷糊糊地打开电脑,登录监控平台,看到这样的数据:

  • 问题发生时间:凌晨2:45
  • 影响范围:移动端用户
  • 相关版本:v2.3.1
  • 关联代码提交:12分钟前有人合并了PR

我们打开那个PR,发现是新加的首页大图没做懒加载。你回滚代码,5分钟后指标恢复正常,然后安心地继续睡觉。这并不是科幻,而是有性能监控体系的团队日常。

为什么需要性能监控?

被动优化 vs 主动监控

被动优化(事后救火)

用户反馈页面卡顿
    ↓ 3小时后
开发开始排查
    ↓ 2小时后
定位到问题
    ↓ 4小时后
发布修复
    ↓ 1天后
同样的问题又出现了

结果:永远在救火,永远有火!

主动监控(事前预防)

监控系统发现性能下降
    ↓ 1分钟内
自动告警到开发
    ↓ 5分钟内
定位到相关代码
    ↓ 10分钟内
回滚或修复
    ↓ 持续
性能指标保持健康

结果:问题发现早于用户,修复快于影响!

核心问题

  1. 如何知道页面现在有多快?
  2. 如何知道它什么时候变慢了?
  3. 如何知道哪里变慢了?
  4. 如何防止它再次变慢?

核心性能指标

加载指标

指标 含义 目标 怎么测
FCP 首次内容绘制 < 1.8秒 第一个像素出现
LCP 最大内容绘制 < 2.5秒 主要内容出现
TTFB 首字节时间 < 600ms 服务器响应时间

加载指标采集

function collectMetrics() {
  // FCP
  const paint = performance.getEntriesByType('paint')
  const fcp = paint.find(e => e.name === 'first-contentful-paint')
  console.log('FCP:', fcp?.startTime)
  
  // LCP
  const lcpObserver = new PerformanceObserver((list) => {
    const last = list.getEntries().pop()
    console.log('LCP:', last?.startTime)
  })
  lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] })
}

交互指标

指标 含义 目标 怎么测
FID 首次输入延迟 < 100ms 点击后多久响应
INP 交互到下次绘制 < 200ms 整体交互响应

交互指标采集

function collectInteraction() {
  const fidObserver = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      const fid = entry.processingStart - entry.startTime
      console.log('FID:', fid)
    }
  })
  fidObserver.observe({ entryTypes: ['first-input'] })
}

稳定性指标

指标 含义 目标 怎么测
CLS 累积布局偏移 < 0.1 页面是否乱跳

稳定性指标采集

let clsValue = 0

const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      clsValue += entry.value
    }
  }
  console.log('CLS:', clsValue)
})
clsObserver.observe({ entryTypes: ['layout-shift'] })

性能监控搭建

使用官方 web-vitals 库

安装

npm install web-vitals

配置

// 核心指标采集
import { onCLS, onFID, onLCP, onTTFB } from 'web-vitals'

// 发送到监控平台
function sendToAnalytics(metric) {
  fetch('/api/performance', {
    method: 'POST',
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      timestamp: Date.now()
    }),
    keepalive: true  // 页面关闭前也能发送
  })
}

// 注册所有指标
onCLS(sendToAnalytics)
onFID(sendToAnalytics)
onLCP(sendToAnalytics)
onTTFB(sendToAnalytics)

自定义性能埋点

// services/performance.js
class PerformanceMonitor {
  constructor() {
    this.buffer = []
    this.flushInterval = 5000  // 5秒上报一次
    this.startTimer()
  }
  
  // 记录一个时间点
  start(name) {
    this.marks.set(name, performance.now())
  }
  
  // 结束并上报
  end(name) {
    const start = this.marks.get(name)
    if (start) {
      const duration = performance.now() - start
      this.track({
        type: 'timing',
        name,
        duration,
        url: window.location.href
      })
      this.marks.delete(name)
    }
  }
  
  // 测量 API 调用
  async measureApi(apiName, promise) {
    const start = performance.now()
    try {
      const result = await promise
      this.track({
        type: 'api',
        name: apiName,
        duration: performance.now() - start,
        status: 'success'
      })
      return result
    } catch (error) {
      this.track({
        type: 'api',
        name: apiName,
        duration: performance.now() - start,
        status: 'error'
      })
      throw error
    }
  }
  
  // 添加到缓冲
  track(data) {
    this.buffer.push({
      ...data,
      timestamp: Date.now(),
      userAgent: navigator.userAgent
    })
    
    if (this.buffer.length >= 20) {
      this.flush()
    }
  }
  
  // 上报数据
  flush() {
    if (this.buffer.length === 0) return
    
    const data = [...this.buffer]
    this.buffer = []
    
    // 使用 sendBeacon 确保页面关闭时也能发送
    navigator.sendBeacon('/api/performance', JSON.stringify(data))
  }
  
  startTimer() {
    setInterval(() => this.flush(), this.flushInterval)
  }
}

export const perf = new PerformanceMonitor()

在组件中使用

<script setup>
import { perf } from '@/services/performance'
import { onMounted } from 'vue'

onMounted(() => {
  perf.start('OrderList')
  
  // 加载数据
  perf.measureApi('fetchOrders', fetchOrders())
    .then(() => {
      perf.end('OrderList')
    })
})
</script>

告警与预警

设置性能阈值

// config/thresholds.js
export const thresholds = {
  LCP: { good: 2500, bad: 4000 },
  FID: { good: 100, bad: 300 },
  CLS: { good: 0.1, bad: 0.25 },
  API: { good: 500, bad: 1000 },
  pageLoad: { good: 3000, bad: 5000 }
}

告警规则

// services/alerter.js
class PerformanceAlerter {
  constructor() {
    this.rules = [
      {
        name: 'LCP过高',
        metric: 'LCP',
        condition: (v) => v > 4000,
        message: '页面加载超过4秒',
        cooldown: 3600000  // 1小时
      },
      {
        name: 'API响应慢',
        metric: 'api',
        condition: (v) => v > 1000,
        message: '{{name}} 响应慢: {{duration}}ms',
        cooldown: 300000  // 5分钟
      }
    ]
  }
  
  check(metric) {
    const rule = this.rules.find(r => r.metric === metric.type)
    if (rule && rule.condition(metric.value)) {
      this.sendAlert(rule, metric)
    }
  }
  
  sendAlert(rule, metric) {
    console.log(`🚨 [告警] ${rule.name}: ${rule.message}`)
    
    // 发送到钉钉/飞书/企业微信
    fetch('/api/alert', {
      method: 'POST',
      body: JSON.stringify({
        title: rule.name,
        message: rule.message,
        data: metric
      })
    })
  }
}

CI/CD 集成

PR 时自动检查性能

# .github/workflows/performance.yml
name: Performance Check

on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Install
        run: npm ci
      
      - name: Build
        run: npm run build
      
      - name: Run Lighthouse
        uses: treosh/lighthouse-ci-action@v9
        with:
          urls: http://localhost:4173
          budgetPath: ./budget.json
      
      - name: Comment PR
        if: always()
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs')
            const report = JSON.parse(fs.readFileSync('./lighthouse-report.json'))
            const score = report.categories.performance.score * 100
            
            if (score < 90) {
              core.setFailed(`性能分数 ${score} 低于 90 分`)
            }

性能预算配置

// budget.json
{
  "budgets": [
    {
      "path": "/*",
      "resourceSizes": [
        { "resourceType": "script", "budget": 500 },
        { "resourceType": "stylesheet", "budget": 100 },
        { "resourceType": "image", "budget": 300 }
      ],
      "timings": [
        { "metric": "first-contentful-paint", "budget": 2000 },
        { "metric": "largest-contentful-paint", "budget": 2500 },
        { "metric": "cumulative-layout-shift", "budget": 0.1 }
      ]
    }
  ]
}

性能仪表盘

搭建简单看板

// 收集一周的性能数据
class PerformanceDashboard {
  constructor() {
    this.data = {
      LCP: [],
      FCP: [],
      CLS: [],
      apiCalls: new Map()
    }
  }
  
  addMetric(metric) {
    this.data[metric.type].push({
      value: metric.value,
      time: metric.timestamp
    })
    
    // 只保留最近7天
    const weekAgo = Date.now() - 7 * 24 * 3600000
    this.data[metric.type] = this.data[metric.type]
      .filter(d => d.time > weekAgo)
  }
  
  getStats(metric) {
    const values = this.data[metric].map(d => d.value)
    const avg = values.reduce((a, b) => a + b, 0) / values.length
    const p95 = this.percentile(values, 95)
    const p99 = this.percentile(values, 99)
    
    return { avg, p95, p99 }
  }
  
  percentile(values, p) {
    const sorted = [...values].sort((a, b) => a - b)
    const index = Math.ceil(p / 100 * sorted.length) - 1
    return sorted[index]
  }
  
  generateReport() {
    console.log('📊 性能周报')
    console.log('================================')
    console.log(`LCP: 平均 ${this.getStats('LCP').avg}ms, P95 ${this.getStats('LCP').p95}ms`)
    console.log(`FCP: 平均 ${this.getStats('FCP').avg}ms, P95 ${this.getStats('FCP').p95}ms`)
    console.log(`CLS: 平均 ${this.getStats('CLS').avg}`)
    console.log('================================')
  }
}

最佳实践清单

性能设计评审清单

每次新功能开发前,回答这些问题:

  • 路由是否懒加载?
  • 长列表是否用虚拟滚动?
  • 高频输入是否防抖?
  • 是否缓存重复请求?
  • 大数据是否分页?
  • 图片是否压缩?是否用WebP?
  • 字体是否按需加载?
  • 关键路径是否埋点?

性能案例库

记录每次性能优化,用于团队分享:

const cases = [
  {
    title: '订单列表从3秒到1秒',
    problem: '页面加载慢,用户投诉',
    solution: '虚拟滚动 + 按需加载',
    result: 'FCP从3.2s降到1.2s',
    author: '张三',
    date: '2026-01-15'
  },
  {
    title: '导出功能不卡了',
    problem: '导出时页面假死',
    solution: 'Web Worker处理数据',
    result: '页面不卡顿',
    author: '李四',
    date: '2026-02-20'
  }
]

监控体系四要素

1. 采集 - 知道发生了什么

  • 核心指标 (LCP, FID, CLS)
  • 自定义指标 (API, 组件渲染)

2. 分析 - 知道为什么发生

  • 关联代码版本
  • 关联用户群体
  • 关联环境信息

3. 告警 - 第一时间知道

  • 阈值设置
  • 告警渠道
  • 冷却机制

4. 预防 - 防止再次发生

  • CI 自动检查
  • 性能预算
  • 设计评审

结语

性能监控不是终点,而是持续优化的起点。没有监控的性能优化,就像没有仪表的驾驶。我们不知道车有多快,也不知道什么时候会抛锚!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

案例分析:从“慢”到“快”,一个后台管理页面的优化全记录

前言

想象我们是一个电商平台的运营人员,每天要处理几百个订单,需要在后台管理系统里查订单、看统计、导出数据。早上9点,我们打开订单管理页面:

  • 等了3秒,页面才显示
  • 输入搜索关键词,打字都卡
  • 切换标签页,又等2秒
  • 导出数据,页面直接假死

初始状态 - 一个典型的“慢”页面

业务背景

某电商平台的后台管理系统,订单管理页面。功能包括:

// 这个页面有这些功能
const orderPage = {
  // 订单列表 - 2000条数据,12列
  orderTable: {
    rows: 2000,
    columns: 12
  },
  
  // 统计图表 - 3个图表
  statsCharts: ['日订单趋势', '品类分布', '收入趋势'],
  
  // 筛选表单 - 15个筛选项
  filters: ['日期范围', '订单状态', '销售渠道', '地区', ...],
  
  // 多标签页 - 5个标签
  tabs: ['所有订单', '待处理', '已发货', '已完成', '已取消']
}

初始性能指标

指标 测量值 行业标准 评级
FCP(首次内容绘制) 3.2秒 < 1.8秒
LCP(最大内容绘制) 4.5秒 < 2.5秒
TTI(可交互时间) 5.8秒 < 3.8秒
CLS(布局偏移) 0.25 < 0.1

问题代码(简化版)

<!-- ❌ 问题代码:订单管理页面 -->
<template>
  <div class="order-management">
    <!-- 统计卡片 -->
    <div class="stats-cards">
      <div v-for="stat in stats" :key="stat.key">
        {{ stat.label }}: {{ stat.value }}
      </div>
    </div>
    
    <!-- 筛选表单(15个筛选项) -->
    <div class="filters">
      <el-form :model="filters" inline>
        <el-form-item label="日期范围">
          <el-date-picker v-model="filters.dateRange" />
        </el-form-item>
        <el-form-item label="订单状态">
          <el-select v-model="filters.status" multiple />
        </el-form-item>
        <!-- ... 还有13个筛选项 -->
        <el-button @click="search">搜索</el-button>
      </el-form>
    </div>
    
    <!-- 订单表格(2000行数据) -->
    <el-table :data="orders" border stripe>
      <el-table-column prop="id" label="订单号" />
      <el-table-column prop="date" label="日期" />
      <el-table-column prop="customer" label="客户" />
      <!-- ... 还有9列 -->
    </el-table>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'

const orders = ref([])      // 2000条数据
const filters = ref({})     // 15个筛选项

// 加载订单
async function loadOrders() {
  const res = await api.getOrders(filters.value)
  orders.value = res.data  // 2000条
}

// 搜索
function search() {
  loadOrders()
}

// 监听筛选变化(性能杀手!)
watch(filters, () => {
  search()  // 每次筛选变化都请求
}, { deep: true })  // 深度监听15个字段

onMounted(() => {
  loadOrders()
})
</script>

网络层优化 - 减少等待时间

问题:请求太多太慢

// 优化前:4个请求串行执行
async function loadPageData() {
  await loadOrders()   // 请求1,耗时500ms
  await loadStats()    // 请求2,耗时400ms
  await loadCharts()   // 请求3,耗时300ms
  await loadFilters()  // 请求4,耗时200ms
  // 总耗时:1.4秒
}

解决方案:并行请求

// ✅ 优化后:4个请求并行执行
async function loadPageData() {
  const [orders, stats, charts, filters] = await Promise.all([
    api.getOrders(params),
    api.getStats(params),
    api.getCharts(params),
    api.getFilters(params)
  ])
  // 总耗时:500ms(取最长的那个)
  
  updatePageData({ orders, stats, charts, filters })
}

缓存策略

// ✅ 添加缓存,避免重复请求
class APICache {
  constructor() {
    this.cache = new Map()
  }
  
  async get(key, fetcher, ttl = 300000) {  // 默认5分钟
    const cached = this.cache.get(key)
    if (cached && Date.now() - cached.time < ttl) {
      return cached.data  // 命中缓存,直接返回
    }
    
    const data = await fetcher()  // 请求新数据
    this.cache.set(key, { data, time: Date.now() })
    return data
  }
}

const cache = new APICache()

// 使用
async function getOrders(params) {
  const key = `orders:${JSON.stringify(params)}`
  return cache.get(key, () => fetch('/api/orders', { params }))
}

构建层优化 - 减少代码体积

问题:代码太大

优化前:打包体积:
index.js: 2.8MB  ← 太大了!
vendor.js: 1.2MB
total: 4.0MB

解决方案:路由懒加载

// ✅ 优化后:按需加载
const routes = [
  {
    path: '/orders',
    // 只有访问订单页面时才加载这个文件
    component: () => import('@/views/Orders.vue')
  }
]

// 打包结果
orders.js: 180KB  ← 只有订单页的代码
vendor.js: 800KB
total: 1.0MB

按需引入 UI 库

// ❌ 优化前:全量引入 Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
app.use(ElementPlus)  // 增加 1.2MB

// ✅ 优化后:按需引入
import { ElButton, ElTable, ElSelect } from 'element-plus'
import 'element-plus/theme-chalk/el-button.css'
import 'element-plus/theme-chalk/el-table.css'
// 只引入用到的组件,体积减少 800KB

渲染层优化 - 让页面更流畅

问题:表格渲染2000行

// 优化前:一次性渲染2000行
<el-table :data="orders">  // orders有2000条
  <!-- 2000个DOM节点,页面卡顿 -->
</el-table>

解决方案:虚拟滚动

<!-- ✅ 优化后:只渲染可视区域 -->
<template>
  <RecycleScroller
    :items="orders"
    :item-size="50"
    class="table-body"
  >
    <template #default="{ item }">
      <div class="table-row">
        <div>{{ item.id }}</div>
        <div>{{ item.date }}</div>
        <div>{{ item.customer }}</div>
        <!-- ... -->
      </div>
    </template>
  </RecycleScroller>
</template>

keep-alive 缓存

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <!-- 缓存已访问的页面 -->
    <keep-alive :include="cachedViews">
      <component :is="Component" :key="route.fullPath" />
    </keep-alive>
  </router-view>
</template>

运行时优化 - 让交互更跟手

问题:深度监听导致频繁请求

// ❌ 优化前:每次打字都触发请求
watch(filters, () => {
  search()  // 用户输入一个字母就请求一次
}, { deep: true })  // 深度监听15个字段

解决方案:防抖

import { debounce } from 'lodash-es'

// ✅ 优化后:用户停止输入300ms后才请求
const search = debounce(async () => {
  const res = await api.getOrders(filters.value)
  orders.value = res.data
}, 300)

导出数据不卡顿

// ❌ 优化前:导出时页面假死
async function exportOrders() {
  const data = await api.getOrders({ pageSize: 10000 })
  const excel = convertToExcel(data)  // 处理1万条数据,阻塞UI 3秒
  download(excel)
}

// ✅ 优化后:使用 Web Worker
// worker.js
self.addEventListener('message', (e) => {
  const excel = convertToExcel(e.data)  // 在另一个线程处理
  self.postMessage(excel)
})

// 主线程
async function exportOrders() {
  const data = await api.getOrders({ pageSize: 10000 })
  worker.postMessage(data)  // 发送到 Worker
  worker.onmessage = (e) => {
    download(e.data)  // 收到结果,下载文件
  }
}

优化检查清单

网络层

  • 请求合并(Promise.all)
  • API 数据缓存
  • 静态资源缓存

构建层

  • 路由懒加载
  • UI库按需引入
  • 图片压缩(WebP/AVIF)

渲染层

  • 虚拟滚动(长列表)
  • keep-alive 缓存页面
  • v-memo / v-once

运行时

  • 防抖节流
  • Web Worker 处理复杂计算
  • computed 缓存计算结果

优先级排序

高收益/低成本(立即做):
├─ 路由懒加载(30分钟,收益60%)
├─ 图片压缩(15分钟,收益75%)
├─ 防抖节流(10分钟,收益50%)
└─ 按需引入UI库(1小时,收益40%)

中收益/中成本(计划做):
├─ 虚拟滚动(2小时,收益50%)
├─ 数据缓存(1.5小时,收益35%)
└─ Web Worker(3小时,收益25%)

低收益/高成本(谨慎做):
├─ 完全重写组件(2天,收益10%)
└─ 替换UI框架(3天,收益5%)

核心原则

  • 先测量后优化:用数据说话
  • 渐进式优化:先做收益高的
  • 持续监控:防止性能回退
  • 用户体验优先:用户觉得快才是真的快

结语

当我们看到一个页面从 5秒加载变成 1秒,用户从抱怨变成点赞,我们就会知道这些优化值了!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

最新版vue3+TypeScript开发入门到实战教程之路由详解三

上节内容是嵌套路由与路由传参。路由的传参有两方式,一是query,一是params。这节主要内容:

  • 路由的props
  • 路由的replace属性
  • 路由编程式导航
  • 路由的重定向

1、 什么是路由的props

前几节详细讲解组件的props,父组件给子组件传递参数,就是通过props。如下例:父组件将变量fish、price传递给子组件fish

<Fish :name="fish" :price="price"/>

子组件通过defineProps接收参数,可在模版中直接使用

<template>
  <h2>我是子组件</h2>
  <h3>{{ name }}</h3>
  <h3>{{ price }}</h3>
</template>
<script setup lang="ts">
defineProps(['name', 'price']);
</script>

1.1路由的props

路由传参有两种方式,一是通过query,一是通过params,props就是第三种方式。用户通过点击router-link标签时,会跳转到对应的路由,此时组件被创建。是路由创建的组件。以下是路由的props的含义: 路由在创建组件时,可把路由参数通过组件的props传递给组件。 路由传递参数给组件的props,有两种方式:

  • 默认传参params
  • 通过函数传递自定义数据

1.2props默认传参

props默认传参是params。以父组件Fish点击路由,跳转到Fishdetail组件为例。

  • 创建Fish组件传递params参数
  • 创建路由,路由配置props参数为true
  • 创建FishDetail组件,通过defineProps接收props参数 Fish组件代码
<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link
         :to="{name:'fishdetail',params:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetail组件代码

<template>
  <div>
    <h3>鱼类:{{ name }}</h3>
    <h3>id:{{ id }}</h3>
    <h3>价格:{{ price }}</h3>
  </div>
</template>
<script setup lang="ts">
defineProps(['name', 'id', 'price']);
</script>

路由代码

import { createRouter, createWebHistory } from 'vue-router'
import Fish from '@/view/Fish.vue'
import Cat from '@/view/Cat.vue'
import Bird from '@/view/Bird.vue'
import FishDetial from '@/view/FishDetial.vue'
const routes = [
  {
    name: 'fish',
    path: '/fish',
    component: Fish,
    children: [
      {
        name: 'fishdetail',
        path: 'detail/:id/:name/:price?',
        component: FishDetial,
        props:true
      }
    ]
  },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }, // 动态路由
]
const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})
export default router

运行查看效果 在这里插入图片描述 对比路由params传参与props默认传参的区别

  • props默认传参,在子组件中不需要通过useRoute()接收路由参数
  • props默认传参,通过defineProps接收数据,可直接在模版中使用
  • props默认传参,在路由中设置props为true即可

1.3路由props通过函数传递自定义数据

自定义数据可以根据需要来定义,以传递query数据为例。 路由配置:

  import { createRouter, createWebHistory } from 'vue-router'
  import Fish from '@/view/Fish.vue'
  import Cat from '@/view/Cat.vue'
  import Bird from '@/view/Bird.vue'
  import FishDetial from '@/view/FishDetial.vue'
  console.log(createRouter)
  const routes = [
    {
      name: 'fish',
      path: '/fish',
      component: Fish,
      children: [
        {
          name: 'fishdetail',
          path: 'detail',
          component: FishDetial,
          props(route: any) {
            return route.query;
          }
        }
      ]
    },
    { path: '/cat', component: Cat },
    { path: '/bird', component: Bird }, // 动态路由
  ]
  const router = createRouter({
    history: createWebHistory(),
    routes: routes,
  })
  export default router

Fish代码

<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link
         :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetail代码

<template>
  <div>
    <h3>鱼类:{{ name }}</h3>
    <h3>id:{{ id }}</h3>
    <h3>价格:{{ price }}</h3>
  </div>
</template>
<script setup lang="ts">
defineProps(['name', 'id', 'price']);
</script>

注意核心代码分两处,一是路由的配置,一是route-link跳转

 children: [
        {
          name: 'fishdetail',
          path: 'detail',
          component: FishDetial,
          props(route: any) {
            return route.query;
          }
        }
      ]
 <router-link
      :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">
         {{ item.name }}
 </router-link>

2、路由的replace属性

与push相对,当点击路由进入页面时。默认是push模式,push是一个一个页面堆叠在一起,点击浏览器返回键,可返回到上一页面。replace属性则不是,它只有一个页面,当点击路由时,它替换当前页面。如下:如需要给标签加上replace即可

    <router-link replace :to="{name:'fish'}">跳转到鱼</router-link>
<router-link replace to="/cat">跳转到猫</router-link>
    <router-link replace to="/bird">跳转到鸟</router-link>

如图,点击路由跳转时,无法后退 在这里插入图片描述

3、编程式路由导航

编程式路由导航是在开发中使用最常见的一种方式,而前边使用的router-link,实则就是a标签。

<a href="/cat" class="">跳转到鱼</a>
<router-link replace to="/cat">跳转到猫</router-link>

这两种写法等效。编程式导航是使用api跳转路由,如打开页面三秒跳转到cat页面,再如用户登录成功后跳转到个人页面。这些都需要编程式导航。

  • 创建Fish组件、FishDetail
  • 在Fish组件引入useRouter函数,创建路由器router,注意与route区别
  • router使用push或者replace跳转到对应路由
  • push或者replace函数的参数与router-link中的to参数使用方法是一样的 Fish组件
<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <!-- <router-link :to="`/fish/detail/${item.id}/${item.name}`">{{item.name  }}</router-link> -->
         <button @click="goDetail(item)">查看{{ item.name }}</button>
        <!-- <router-link
         :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link> -->
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { useRouter } from 'vue-router';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
let router = useRouter();
function goDetail(fish: any) {
  router.push({
    name: 'fishdetail',
    query:fish
  });
}
</script>

FishDetail组件源码

<template>
  <div>
    <h3>鱼类:{{ name }}</h3>
    <h3>id:{{ id }}</h3>
    <h3>价格:{{ price }}</h3>
  </div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
// let route = useRoute();
defineProps(['name', 'id', 'price']);
</script>

路由源码

  import { createRouter, createWebHistory } from 'vue-router'
  import Fish from '@/view/Fish.vue'
  import Cat from '@/view/Cat.vue'
  import Bird from '@/view/Bird.vue'
  import FishDetial from '@/view/FishDetial.vue'
  console.log(createRouter)
  const routes = [
    {
      name: 'fish',
      path: '/fish',
      component: Fish,
      children: [
        {
          name: 'fishdetail',
          path: 'detail',
          component: FishDetial,
          props(route: any) {
            return route.query;
          }
        }
      ]
    },
    { path: '/cat', component: Cat },
    { path: '/bird', component: Bird }, // 动态路由
  ]
  const router = createRouter({
    history: createWebHistory(),
    routes: routes,
  })
  export default router

运行查看效果: 在这里插入图片描述 注意对比一下router.push的参数与router-link的to参数。两者参数用法是一致的,不管路由如何配置,是使用query还是params传参还是props传参。 如下例:

 <router-link
         :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">
         {{ item.name }}</router-link>
function goDetail(fish: any) {
  router.push({
    name: 'fishdetail',
    query:fish
  });
}

router-link的to用法与 router.push用法一致。

4、路由的重定向

路由的重定向,就是访问a路由,自动跳转到b路由。如打开主页,默认访问http://localhost:5173/,访问的路径是/,能否一打开就跳转到/fish。就用重定向来解决。

 {
    path: '/',
    redirect: '/fish'

  },

路由具体代码:

  import { createRouter, createWebHistory } from 'vue-router'
  import Fish from '@/view/Fish.vue'
  import Cat from '@/view/Cat.vue'
  import Bird from '@/view/Bird.vue'
  import FishDetial from '@/view/FishDetial.vue'
  console.log(createRouter)
const routes = [
  {
    path: '/',
    redirect: '/fish'

  },
  {
    name: 'fish',
    path: '/fish',
    component: Fish,
    children: [
      {
        name: 'fishdetail',
        path: 'detail',
        component: FishDetial,
        props(route: any) {
          return route.query;
        }
      }
    ]
  },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }, // 动态路由
]
  const router = createRouter({
    history: createWebHistory(),
    routes: routes,
  })
  export default router

效果是一打开页面,就重定向到fish页面 在这里插入图片描述

案例分析:一个复杂表单的响应式性能优化

前言

想象我们是一个银行柜员,每天要处理大量客户开户申请。表单有上百个字段:基本信息、家庭信息、资产信息、工作信息、财务信息...

每次输入一个字段,电脑都要卡顿一下。客户不耐烦地说:"你这电脑也太慢了。"

我们只能无奈地说:"不是电脑慢,是这个系统太卡了。"

场景描述 :一个真实的性能噩梦

业务背景

某金融后台系统的客户信息录入表单,用于银行开户、贷款申请、企业信息登记:

// 这个表单有 100+ 个字段
const formData = {
  // 基本信息 - 20+ 字段
  personalInfo: {
    name: '',           // 姓名
    idCard: '',         // 身份证号
    phone: '',          // 手机号
    email: '',          // 邮箱
    birthday: '',       // 生日
    nationality: '',    // 国籍
    occupation: '',     // 职业
    education: '',      // 学历
    maritalStatus: '',  // 婚姻状况
    // ... 还有10多个字段
  },
  
  // 家庭信息 - 15+ 字段
  familyInfo: {
    spouseName: '',
    spousePhone: '',
    children: [],       // 动态增减的孩子列表
    // ...
  },
  
  // 资产信息 - 30+ 字段
  assetInfo: {
    house: [],          // 房产列表(可动态增减)
    vehicle: [],        // 车辆列表(可动态增减)
    deposit: [],        // 存款列表(可动态增减)
    investment: [],     // 投资列表(可动态增减)
    // ...
  },
  
  // 工作信息 - 20+ 字段
  workInfo: {
    companyName: '',
    position: '',
    income: 0,
    workYears: 0,
    // ...
  },
  
  // 财务信息 - 15+ 字段
  financialInfo: {
    monthlyIncome: 0,
    monthlyExpense: 0,
    creditScore: 0,
    // ...
  }
}

性能问题表现

操作 正常预期 实际情况 用户感受
页面加载 < 1秒 3.5秒 "怎么这么慢?"
输入单个字段 立即响应 延迟 200-500ms "打字跟不上"
添加动态字段 瞬间 1-2秒卡顿 "以为点不动"
字段联动 实时 延迟明显 "体验很差"
表单提交 1秒内 5秒+ "是不是卡死了?"

初始代码结构(问题代码)

<!-- ❌ 问题代码:一个组件包含所有字段 -->
<template>
  <form @submit="handleSubmit">
    <!-- 基本信息 -->
    <div class="section">
      <h3>基本信息</h3>
      <input v-model="formData.personalInfo.name" placeholder="姓名" />
      <input v-model="formData.personalInfo.idCard" placeholder="身份证号" />
      <!-- ... 100+ 个字段 -->
    </div>
    
    <!-- 动态资产列表 -->
    <div v-for="(house, index) in formData.assetInfo.house" :key="index">
      <input v-model="house.address" placeholder="地址" />
      <input v-model="house.area" placeholder="面积" />
      <input v-model="house.value" placeholder="价值" />
      <button @click="removeHouse(index)">删除</button>
    </div>
    <button @click="addHouse">添加房产</button>
    
    <!-- 其他字段... -->
  </form>
</template>

<script setup>
import { ref, watch } from 'vue'

// 整个表单数据都是响应式的
const formData = ref(initialData)

// 大量 watch 监听联动
watch(() => formData.value.personalInfo.occupation, (newVal) => {
  // 根据职业动态显示字段
  if (newVal === '医生') {
    formData.value.dynamicFields.hospital = ''
    formData.value.dynamicFields.licenseNumber = ''
  } else if (newVal === '律师') {
    formData.value.dynamicFields.lawFirm = ''
    formData.value.dynamicFields.barNumber = ''
  }
})

watch(() => formData.value.assetInfo.totalValue, (newVal) => {
  // 资产变化影响贷款额度
  formData.value.financialInfo.loanAmount = newVal * 0.7
})
</script>

性能瓶颈分析

1. 响应式系统过载

  • 100+ 个字段全部深度响应式
  • 每次输入触发整个组件的响应式依赖重新计算
  • 递归代理导致内存占用巨大

2. 组件粒度过大

  • 单个组件包含所有逻辑
  • 任何字段变化都导致整个组件重渲染
  • 模板过大,编译和 diff 开销大

3. 联动逻辑低效

  • 过多的 watch 监听
  • 每次输入触发多个 watch
  • watch 中的操作触发更多更新

性能问题诊断

使用 Vue DevTools 分析

  • 步骤1:打开 Vue DevTools 的 Performance 面板
  • 步骤2:开始录制,在表单中输入一个字段
  • 步骤3:停止录制,查看分析结果

分析结果示例

性能时间线分析:

├─ 输入事件处理: 2ms
├─ 响应式依赖收集: 45ms  ← 瓶颈
├─ 组件渲染: 120ms  ← 瓶颈
│  ├─ 模板编译: 35ms
│  ├─ 虚拟 DOM diff: 50ms
│  └─ 真实 DOM 更新: 35ms
└─ watch 回调执行: 35ms  ← 瓶颈

总耗时: 202ms

使用 Chrome DevTools 分析

火焰图分析:

├─ 长任务 (Long Task) > 50ms
│  ├─ reactive setter 调用栈过深
│  ├─ 多个 watch 递归触发
│  └─ 组件渲染重复执行
│
├─ 强制重排 (Forced Reflow)
│  └─ 动态字段添加导致布局抖动
│
└─ 内存分配频繁
   └─ 每次输入都创建大量临时对象

自定义性能监控

// 添加性能监控代码
const perfMonitor = {
  logs: [],
  
  start(operation) {
    return performance.now()
  },
  
  end(operation, startTime) {
    const duration = performance.now() - startTime
    this.logs.push({ operation, duration })
    
    if (duration > 16) {
      console.warn(`⚠️ 慢操作: ${operation} 耗时 ${duration.toFixed(2)}ms`)
    }
    
    return duration
  },
  
  report() {
    console.table(this.logs)
    this.logs = []
  }
}

// 在组件中使用
const handleInput = (field, value) => {
  const start = perfMonitor.start(`update-${field}`)
  
  // 更新数据
  formData.value[field] = value
  
  perfMonitor.end(`update-${field}`, start)
}

优化方案一 :数据结构优化

使用 shallowRef 替代 ref

// ❌ 优化前:深度响应式
const formData = ref(largeFormData)
// 每次修改深层属性都会触发更新

// ✅ 优化后:浅层响应式
const formData = shallowRef(largeFormData)
// 只有整体替换才会触发更新

// 修改数据的模式
function updateField(path, value) {
  // 创建新对象,只修改需要变更的部分
  const newData = { ...formData.value }
  
  // 根据路径找到并修改值
  let current = newData
  for (let i = 0; i < path.length - 1; i++) {
    current = current[path[i]]
  }
  current[path[path.length - 1]] = value
  
  // 整体替换,触发一次更新
  formData.value = newData
}

拆分表单为多个子组件

<!-- ✅ 优化后:拆分为多个子组件 -->
<template>
  <form @submit="handleSubmit">
    <!-- 每个子组件独立渲染 -->
    <PersonalInfoForm 
      v-model="formData.personalInfo"
      @update="handleSectionUpdate"
    />
    
    <FamilyInfoForm 
      v-model="formData.familyInfo"
      @update="handleSectionUpdate"
    />
    
    <AssetInfoForm 
      v-model="formData.assetInfo"
      @update="handleSectionUpdate"
    />
    
    <WorkInfoForm 
      v-model="formData.workInfo"
      @update="handleSectionUpdate"
    />
    
    <FinancialInfoForm 
      v-model="formData.financialInfo"
      @update="handleSectionUpdate"
    />
  </form>
</template>

<script setup>
import { shallowRef } from 'vue'
import PersonalInfoForm from './PersonalInfoForm.vue'
import FamilyInfoForm from './FamilyInfoForm.vue'
import AssetInfoForm from './AssetInfoForm.vue'

// 使用 shallowRef 存储整个表单
const formData = shallowRef(initialData)

// 子组件更新时只更新对应部分
function handleSectionUpdate(section, data) {
  formData.value = {
    ...formData.value,
    [section]: data
  }
}
</script>

使用 Map 管理动态字段

// ❌ 优化前:使用数组存储动态字段
const houses = ref([{ address: '', area: 0, value: 0 }])

function addHouse() {
  houses.value.push({ address: '', area: 0, value: 0 })
  // 每次添加都触发整个数组的响应式更新
}

// ✅ 优化后:使用 Map 存储,减少响应式开销
const houses = shallowRef(new Map())
let nextId = 1

function addHouse() {
  const newMap = new Map(houses.value)
  newMap.set(nextId++, { address: '', area: 0, value: 0 })
  houses.value = newMap  // 整体替换
}

function updateHouse(id, field, value) {
  const newMap = new Map(houses.value)
  const house = newMap.get(id)
  if (house) {
    newMap.set(id, { ...house, [field]: value })
    houses.value = newMap
  }
}

function removeHouse(id) {
  const newMap = new Map(houses.value)
  newMap.delete(id)
  houses.value = newMap
}

优化方案二:渲染优化

虚拟滚动处理长列表

<template>
  <div class="dynamic-list">
    <h3>家庭成员</h3>
    
    <!-- 使用虚拟滚动组件 -->
    <VirtualScroller
      :items="familyMembers"
      :item-height="80"
      class="member-list"
    >
      <template #default="{ item, index }">
        <div class="member-item">
          <input 
            :value="item.name" 
            @input="updateMember(index, 'name', $event.target.value)"
            placeholder="姓名"
          />
          <input 
            :value="item.age" 
            type="number"
            @input="updateMember(index, 'age', $event.target.value)"
            placeholder="年龄"
          />
          <button @click="removeMember(index)">删除</button>
        </div>
      </template>
    </VirtualScroller>
    
    <button @click="addMember">添加家庭成员</button>
  </div>
</template>

<script setup>
import { ref, shallowRef } from 'vue'
import VirtualScroller from 'vue-virtual-scroller'

// 使用 shallowRef 存储列表
const familyMembers = shallowRef([])

function addMember() {
  familyMembers.value = [
    ...familyMembers.value,
    { name: '', age: 0 }
  ]
}

function updateMember(index, field, value) {
  const newMembers = [...familyMembers.value]
  newMembers[index] = { ...newMembers[index], [field]: value }
  familyMembers.value = newMembers
}
</script>

使用 v-memo 缓存静态部分

<template>
  <div class="form-section">
    <h3>基本信息</h3>
    
    <!-- 静态部分使用 v-once -->
    <div v-once class="form-description">
      请填写您的真实信息
    </div>
    
    <!-- 使用 v-memo 缓存不常变化的部分 -->
    <div 
      v-for="field in staticFields" 
      :key="field.key"
      v-memo="[field.key]"
      class="form-row"
    >
      <label>{{ field.label }}</label>
      <input
        :value="formData[field.key]"
        @input="updateField(field.key, $event.target.value)"
      />
    </div>
    
    <!-- 联动字段动态渲染 -->
    <div 
      v-for="field in dynamicFields" 
      :key="field.key"
      class="form-row"
    >
      <label>{{ field.label }}</label>
      <component 
        :is="field.component" 
        v-model="formData[field.key]"
        :options="field.options"
      />
    </div>
  </div>
</template>

异步渲染 - 先渲染首屏

// 分阶段渲染表单
const renderStages = {
  critical: ['personalInfo', 'contactInfo'],      // 首屏必显
  important: ['familyInfo', 'workInfo'],          // 滚动到才渲染
  normal: ['assetInfo', 'financialInfo'],         // 折叠面板内
  lazy: ['attachments', 'remarks']                // 按需加载
}

const visibleSections = ref(new Set(['personalInfo']))

// 使用 Intersection Observer 检测可见性
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const section = entry.target.dataset.section
      if (section && !visibleSections.value.has(section)) {
        visibleSections.value.add(section)
      }
    }
  })
}, { rootMargin: '200px' })

优化方案三:联动逻辑优化

使用计算属性代替 watch

// ❌ 优化前:使用 watch 监听联动
watch(() => formData.value.assetInfo.totalValue, (newVal) => {
  formData.value.financialInfo.loanAmount = newVal * 0.7
  
  if (newVal > 1000000) {
    formData.value.financialInfo.riskLevel = 'high'
  } else if (newVal > 500000) {
    formData.value.financialInfo.riskLevel = 'medium'
  } else {
    formData.value.financialInfo.riskLevel = 'low'
  }
})

// ✅ 优化后:使用计算属性
const loanAmount = computed(() => {
  return formData.value.assetInfo.totalValue * 0.7
})

const riskLevel = computed(() => {
  const total = formData.value.assetInfo.totalValue
  if (total > 1000000) return 'high'
  if (total > 500000) return 'medium'
  return 'low'
})

// 在模板中使用计算属性
<div>贷款额度: {{ loanAmount }}</div>
<div>风险等级: {{ riskLevel }}</div>

防抖处理实时计算

import { debounce } from 'lodash-es'

// ❌ 优化前:每次输入都实时计算
const handleAmountChange = (value) => {
  const loanAmount = calculateLoan(value)
  const interest = calculateInterest(loanAmount)
  const monthlyPayment = calculateMonthlyPayment(loanAmount, interest)
  
  formData.value.financialInfo.loanAmount = loanAmount
  formData.value.financialInfo.interest = interest
  formData.value.financialInfo.monthlyPayment = monthlyPayment
}

// ✅ 优化后:使用防抖,用户停止输入后才计算
const debouncedCalculate = debounce((value) => {
  const loanAmount = calculateLoan(value)
  const interest = calculateInterest(loanAmount)
  const monthlyPayment = calculateMonthlyPayment(loanAmount, interest)
  
  // 批量更新
  formData.value = {
    ...formData.value,
    financialInfo: {
      ...formData.value.financialInfo,
      loanAmount,
      interest,
      monthlyPayment
    }
  }
}, 300)

const handleAmountChange = (value) => {
  debouncedCalculate(value)
}

批量更新优化

// ❌ 优化前:多次更新触发多次渲染
function applyCreditScore(score) {
  formData.value.financialInfo.creditScore = score
  
  if (score >= 800) {
    formData.value.financialInfo.loanRate = 0.035
    formData.value.financialInfo.loanLimit = 5000000
  } else if (score >= 700) {
    formData.value.financialInfo.loanRate = 0.045
    formData.value.financialInfo.loanLimit = 3000000
  }
  
  formData.value.financialInfo.creditLevel = getCreditLevel(score)
}
// 每次属性修改都触发一次更新,共 3 次

// ✅ 优化后:使用批量更新
function applyCreditScore(score) {
  const updates = { creditScore: score }
  
  if (score >= 800) {
    updates.loanRate = 0.035
    updates.loanLimit = 5000000
  } else if (score >= 700) {
    updates.loanRate = 0.045
    updates.loanLimit = 3000000
  }
  
  updates.creditLevel = getCreditLevel(score)
  
  // 批量更新,只触发一次渲染
  formData.value = {
    ...formData.value,
    financialInfo: {
      ...formData.value.financialInfo,
      ...updates
    }
  }
}

优化检查清单

数据结构优化

  • 使用 shallowRef 替代 ref 存储大对象
  • 拆分表单为多个子组件
  • 动态字段使用 Map 管理
  • 按业务模块组织数据结构
  • 避免深层嵌套的响应式数据

渲染优化

  • 长列表使用虚拟滚动
  • 静态内容使用 v-once
  • 不常变化的部分使用 v-memo
  • 非首屏内容异步渲染
  • 条件判断使用计算属性缓存

联动逻辑优化

  • 使用计算属性代替 watch
  • 复杂计算使用防抖/节流
  • 只在必要时触发更新
  • 批量更新使用对象合并
  • 避免在 watch 中修改其他字段

监控与调试

  • 使用 Vue DevTools 分析渲染性能
  • 使用 Chrome DevTools 分析内存占用
  • 添加性能监控埋点
  • 定期检查响应式依赖数量

结语

大表单优化的核心:让不需要响应式的数据不响应,让不需要渲染的部分不渲染。当我们输入一个字段时,只有这个字段对应的子组件重新渲染,而不是整个表单;当我们添加一个动态项时,只更新 Map 中的那一条,而不是整个数组。这样,无论表单有多大,用户都会感觉"很流畅",这就是优化的意义。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

从"包裹器"到"确认按钮"——一个组件的三次重构

从"包裹器"到"确认按钮"——一个组件的三次重构

背景

后台管理系统中,"危险操作需要二次确认"是最高频的交互模式。表格操作列的删除、禁用,批量操作的批量删除,详情页的注销账号——这些场景都需要 tooltip 提示 + popconfirm 确认 + 按钮三者配合。

用 Ant Design Vue 原生写法,每个地方都要写三层嵌套 + 手动互斥控制:

<a-tooltip :visible="popVisible ? false : undefined" title="删除该记录">
  <a-popconfirm v-model:visible="popVisible" title="确定删除?" @confirm="onDelete">
    <a-button icon="delete" danger />
  </a-popconfirm>
</a-tooltip>

ButtonConfirm 就是为了消灭这段重复代码而生的。


V1:slot 包裹器(89bb3e2)

设计思路: 做一个通用包裹器,用 slot 接收任意子元素,外面套上 tooltip 和 popconfirm。

<dbButtonConfirm needConfirm confirmContent="确定删除?" tooltip="删除">
  <a-button type="primary" danger>删除</a-button>
</dbButtonConfirm>

Props:

  • needConfirm:默认 false,需要手动开启
  • disabled:独立的禁用状态
  • 无按钮相关属性,按钮由 slot 传入

模板结构: 4 个 v-if 分支处理 tooltip/popconfirm 的组合:

1. tooltip && needConfirm && !disabledtooltip > popconfirm > span > slot
2. needConfirm && !disabledpopconfirm > span > slot
3. tooltiptooltip > span(@click) > slot
4. elsespan(@click) > slot

问题:

  • needConfirm 默认 false——组件叫"确认按钮",却默认不确认
  • 按钮通过 slot 传入,组件无法控制按钮的事件链
  • <span> 包裹导致布局问题
  • @click 事件会冒泡穿透,绕过 popconfirm 确认流程

V2:内置 Button + @click 防穿透(1030048)

核心改进: 不再用 slot 包裹外部按钮,改为内置 dbButton 渲染。

<!-- V1: slot 包裹 -->
<dbButtonConfirm needConfirm confirmContent="确定删除?">
  <a-button danger>删除</a-button>
</dbButtonConfirm>

<!-- V2: 内置 Button,继承全部按钮属性 -->
<dbButtonConfirm danger confirmContent="确定删除?" @confirm="onDelete">
  删除
</dbButtonConfirm>

为什么必须内置 Button?

因为只有控制了按钮本身,才能从机制上解决 @click 穿透问题:

  1. inheritAttrs: false —— 阻止外部属性直接落到内部元素
  2. safeAttrs computed —— 过滤掉所有 on 开头的事件监听器
  3. 开发环境 console.error —— 检测到 @click 时提醒开发者用 @confirm

移除 needConfirm prop: 通过 confirmContent 是否存在自动推导——有内容就确认,没有就不确认。理由是"组件名叫确认按钮就必须确认"。

模板结构简化为 2 个分支:

1. tooltip → tooltip > popconfirm > Button
2. else    → popconfirm > Button

解决的问题:

  • 消灭了 <span> 包裹,按钮渲染正确
  • @click 被彻底屏蔽,只能通过 @confirm 接收回调
  • 继承 dbButton 全部能力(type/danger/icon/size/appearance 等)
  • API 表面更简洁,一个组件替代三层嵌套

遗留问题:

  • 移除 needConfirm 后,无法动态控制"这次点击要不要弹确认框"
  • 需要确认和不需要确认的场景,开发者被迫用 v-if/v-elsedbButtondbButtonConfirm 之间切换

V3:handleVisibleChange 拦截模式(f9d404c)

核心改进: 重新引入 needConfirm prop,但默认值改为 true,且实现方式完全不同。

V1 vs V3 的 needConfirm

V1 V3
默认值 false(需要手动开启) true(默认就确认)
实现方式 v-if 控制是否渲染 popconfirm handleVisibleChange 拦截是否弹出
false 时行为 点击 span 直接 emit 拦截 popconfirm 弹出,直接 emit

关键设计:参考 antd 官方的 visibleChange 模式

const handleVisibleChange = (visible: boolean) => {
  if (!visible) {
    confirmVisible.value = false
    return
  }
  if (props.needConfirm) {
    confirmVisible.value = true  // 正常弹出确认框
  } else {
    emits('confirm')             // 跳过确认,直接触发
  }
}

popconfirm 始终存在于 DOM 中,但通过 handleVisibleChange 在弹出瞬间拦截。needConfirm: false 时,popconfirm 根本不会展示,直接走 @confirm 回调。

解决了什么实际问题?

同一个按钮,根据业务状态动态决定是否需要确认:

<!-- 一个组件覆盖两种情况,无需 v-if/v-else -->
<dbButtonConfirm
  icon="delete"
  danger
  :needConfirm="record.status !== 'draft'"
  confirmContent="确定删除该记录?"
  @confirm="onDelete(record)"
/>

草稿状态点击直接删除,已发布状态弹确认框。同一个 @confirm 回调,业务只需控制一个布尔值。


三个版本的对比

V1(包裹器)
┌──────────────────────────────┐
│ dbButtonConfirm              │
│   ├─ tooltip (可选)          │
│   ├─ popconfirm (可选)       │
│   └─ <span>                  │
│       └─ <slot> ← 外部按钮  │  ← 无法控制事件链
└──────────────────────────────┘

V2(内置 Button)
┌──────────────────────────────┐
│ dbButtonConfirm              │
│   ├─ tooltip (可选)          │
│   ├─ popconfirm (始终渲染)    │
│   ├─ safeAttrs (过滤 @click) │
│   └─ <Button> ← 内置渲染    │  ← 完全控制事件链
└──────────────────────────────┘

V3handleVisibleChange)
┌──────────────────────────────┐
│ dbButtonConfirm              │
│   ├─ tooltip (可选)          │
│   ├─ popconfirm (始终渲染)    │
│   ├─ handleVisibleChange     │  ← 拦截弹出,动态决定流程
│   ├─ safeAttrs (过滤 @click) │
│   └─ <Button> ← 内置渲染    │
└──────────────────────────────┘

最终运行时流程

点击按钮
    │
    ▼
needConfirm?
    │
    ├── true ──► 弹出 popconfirm
    │                │
    │           ┌────┴────┐
    │           ▼         ▼
    │        确认       取消
    │           │         │
    │           ▼         ▼
    │     emit confirm  emit cancel
    │
    └── false ──► 直接 emit confirm

设计总结

迭代 关键决策 解决的问题
V1 slot 包裹任意元素 基础功能可用
V2 内置 Button + inheritAttrs: false @click 防穿透、消灭 span 包裹
V3 handleVisibleChange 拦截 一个组件覆盖"需确认"和"不需确认"两种场景

最终的 dbButtonConfirm 是一个真正的按钮组件,不是包裹器。它继承了 dbButton 的全部能力,内置了 tooltip/popconfirm 互斥处理和 @click 防穿透机制,通过 needConfirm 动态控制确认流程,让开发者用一个组件、一个 @confirm 回调覆盖所有操作按钮场景。

最新版vue3+TypeScript开发入门到实战教程之路由详解二

上节讲解到路由的基础用法,路由的内容还有很多,这节开始路由嵌套与传参。

1、路由的嵌套

路由嵌套,简单言之,由主页点击进入一个模块,在模块中又可点击进入子模块。如在主页进入Fish组件,在Fish组件中,进入FishDetail详细内容。路由的四个基本要素

  • 路由管理器,统一管理路由
  • 路由信息,记录组件与路由的对应关系
  • 跳转标签与跳转方法,用于跳转指定路由
  • 路由跳转后,指定组件显示位置

创建一个简单实例实现嵌套路由

  • 创建主页,主页含有标题、导航、路由跳转子页面显示位置
  • 创建三个子页面,Fish、Cat、Bird
  • 创建FishDetail组件
  • 创建路由及路由信息
  • 创建Fish子路由

路由源码

import { createRouter, createWebHistory } from 'vue-router'
import Fish from '@/view/Fish.vue'
import Cat from '@/view/Cat.vue'
import Bird from '@/view/Bird.vue'
import FishDetial from '@/view/FishDetial.vue'
console.log(createRouter)
const routes = [
  {
    name: 'fish',
    path: '/fish',
    component: Fish,
    children: [
      {
        name: 'fishdetail',
        path: 'detail',
        component:FishDetial
      }
    ]
  },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }, // 动态路由
]
const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})
export default router

App组件源码

<template>
  <div class="app">
    <router-link :to="{name:'fish'}">跳转到鱼</router-link>
    <router-link to="/cat">跳转到猫</router-link>
    <router-link to="/bird">跳转到鸟</router-link>
    <div class="content">
    <router-view></router-view>
    </div>
  </div>
</template>
<script setup lang="ts">
</script>

Fish组件源码

<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link :to="{path:'/fish/detail'}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetal组件源码

<template>
  <div>
    <h3>鱼类:鲫鱼</h3>
    <h3>id:01</h3>
    <h3>价格:100</h3>
  </div>
</template>
<script setup lang="ts">
</script>

运行查看效果: 在这里插入图片描述

2、路由的query传参

当我们点击路由进入子页面时,希望把数据传给子页面。路由的传参,有两种方式。

  • query传参,url格式类似/fish/detail?id=01&name=鲫鱼&price=100
  • parmas传参,url格式类似/fish/detail/02/草鱼 query传参通过url地址传递给子组件,子组件通过useRoute函数接收query参数。传参实例详见Fish和FishDetail组件. Fish组件源码
<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link
         :to="{path:'/fish/detail',query:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetail组件源码

<template>
  <div>
    <h3>鱼类:{{ route.query.name }}</h3>
    <h3>id:{{ route.query.id }}</h3>
    <h3>价格:{{ route.query.price }}</h3>
  </div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
let route=useRoute();
</script>

运行实例查看效果 在这里插入图片描述 注意在FishDetail,获取到的route,是响应式。若我们从route解析获取query,将失去响应式。

<template>
  <div>
    <h3>鱼类:{{ query.name }}</h3>
    <h3>id:{{ query.id }}</h3>
    <h3>价格:{{ query.price }}</h3>
  </div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
let { query } = useRoute();
</script>

如图: 在这里插入图片描述 注意url地址的变化,若要query具有相应式,可通过toRefs函数,转换为响应式。如下

let { query } = toRefs(useRoute());

3、路由的params传参

params是通过url接收参数,格式类似:fish/detail/02/草鱼/150,可通过useRoute函数获取params参数。与query不同,params必须在路由中设置对应的参数。还是以Fish与FishDetail为例,重新修改代码: 路由代码

import { createRouter, createWebHistory } from 'vue-router'
import Fish from '@/view/Fish.vue'
import Cat from '@/view/Cat.vue'
import Bird from '@/view/Bird.vue'
import FishDetial from '@/view/FishDetial.vue'
console.log(createRouter)
const routes = [
  {
    name: 'fish',
    path: '/fish',
    component: Fish,
    children: [
      {
        name: 'fishdetail',
        path: 'detail/:id/:name/:price?',
        component:FishDetial
      }
    ]
  },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }, // 动态路由
]
const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})
export default router

Fish组件代码

<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link
         :to="{name:'fishdetail',params:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetail组件代码

<template>
  <div>
    <h3>鱼类:{{ route.params.name }}</h3>
    <h3>id:{{ route.params.id }}</h3>
    <h3>价格:{{ route.params.price }}</h3>
  </div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
let route = useRoute();
</script>

运行实例查看params 在这里插入图片描述 注意两点:

  • router-link使用跳转路由时,用的是name,而不能用path
  • router-link传递参数不能数组
  • 在路由配置中price后有问号,代表这个参数可不用传递 若不使用name,可以通过下面代码跳转:
<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link :to="`/fish/detail/${item.id}/${item.name}`">{{item.name  }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

其实to后面就是字符串拼接。

一个普通Word文档,为什么99%的开源编辑器都"认怂"了?我们选择正面硬刚

先上一张图:

图片

这个是 Word 中我们高频使用的文档案例,在合同,公文,档案等各个场景中都能看见,但是我测试了市面上10多个主流开源的富文本/文档编辑器,没有一个能完整把上面的样式 1: 1 解析出来,99%解析的效果都是这样:

图片

其实在很多在线文档系统里,DOCX 导入后的效果之所以容易失真,是因为它们通常只保留了最表层的字号、颜色和段落,而丢失了真正决定版式的细节:

  • 分散对齐
  • 字符缩放
  • 字间距
  • 精确行距
  • 文档网格
  • 页面尺寸与页边距
  • 中西文混排规则

在 Web 编辑器领域,中文排版长期被忽视。大多数编辑器仅关注英文排版模型,导致中文文档出现标点溢出、行距不均、分散对齐缺失等问题。

为了解决这个痛点,我们花了半年时间做技术研究和验证,终于实现了一套高精度Docx解析算法,支持各种复杂的Word样式排版的解析渲染,并能在Web端实时编辑。

图片

没错,它就是 jitword,对标 Word 排版效果,原生支持中文排版规范,实现高保真文档导入导出。

老规矩,先上地址:

开源sdk: github.com/jitOffice/j…

JitWord 从底层重新设计了排版引擎,原生支持 GB/T 标点压缩、分散对齐、字符缩放、网格行距等专业排版特性,并实现了与 Word 格式的高保真双向互转。(虽然目前还达不到100%精度,但实测已经是业内top3的方案了)

下面是我们设计的高精度docx解析的技术架构:

图片大家可以参考一下,下面我会和大家详细分享一下我们实现的方案细节。

核心排版能力

一、分散对齐 — 像 Word 一样均匀分布每个字符

图片

传统 Web 编辑器只有左对齐、居中、右对齐、两端对齐四种模式。JitWord 额外实现了 分散对齐(Distribute) ,这是中文公文和正式文档中的必备排版方式。

实现原理:

  • 精确计算每行可用宽度与文本实际宽度的差值
  • 将差值均匀分配到每个字符间隙中:间距 = (行宽 - 文本宽) / (字符数 - 1)
  • 实时响应窗口缩放和字体变化,通过 ResizeObserver 动态重排
  • 三重 CSS 保障:text-align: justify + text-align-last: justify + text-justify: inter-character

效果:  每个字符等间距分布,行首行尾严格对齐,无论段落宽度如何变化都保持均匀美观。


二、字符缩放 — 灵活调整字符宽度比例

图片

支持 33% 到 200% 共 8 档水平缩放预设,可在不改变字号的前提下调整文本密度。

技术方案:

  • 使用 CSS transform: scaleX() 实现无损缩放
  • 自动补偿缩放后的布局宽度,确保分散对齐等特性不受影响
  • 导出 Word 时精确映射到 w:rPr > w:w 字符缩放属性

应用场景:  表格单元格内容过长时压缩显示、标题需要加宽强调效果、模拟 Word 中的字符缩放格式。


三、CJK 排版四件套 — 原生中文排版规范支持

JitWord 内置四项核心 CJK 排版特性,可从 Word 文档中自动识别并还原:

特性 作用 技术实现
严格折行 防止句号、逗号等标点出现在行首 line-break: strict + 东亚换行规则检测
标点压缩 连续标点(如 」、) 自动挤压间距 CSS text-spacing-trim: normal (渐进增强)
字距控制 保持 CJK 字符等宽边界 font-kerning: none 禁用西文字距调整
中英文自动间距 中文与英文/数字之间自动添加间距 CSS text-autospace: normal (渐进增强)

导入兼容性:  从 Word 文档的 <w:documentLayout> 配置中自动提取 characterSpacingControldoNotWrapTextWithPunctnoPunctuationKerningbalanceSingleByteDoubleByteWidth 等属性,精确映射到对应的 CSS 排版规则。


四、字间距精细调整

支持以 磅值(pt)  为单位的字间距调整,与 Word 完全一致:

  • 预设 9 档:从紧缩 -2pt 到加宽 5pt
  • 快捷键支持:每次增减 0.5pt,范围 -5pt ~ 10pt
  • 导出 Word 时精确转换为 twentieths of a point(Word 原生单位)

五、网格行距 — 公文排版标准

图片

支持 Word 文档网格(Document Grid)特性,段落基线自动对齐到文档网格,完美还原政府公文 "每页固定行数" 的排版要求。

高保真文档互转

DOCX 导入 — 五阶段 IR 管线

图片

JitWord 采用自研的中间表示(IR)架构,实现从 Word 到编辑器的高保真格式转换:

DOCX 文件 → XMLAST 解析 → DocIR 中间表示 → JitWord JSON 映射 → Schema 合规校验

关键能力:

  • 格式完整保留段落对齐、字间距、字符缩放、行高、缩进等属性逐一映射
  • CJK 属性提取自动识别文档级排版设置(标点压缩、折行规则、网格配置)
  • 图片异步持久化嵌入图片自动提取、上传到服务端,支持降级到 Base64
  • 智能降级docx4js 为主引擎,mammoth.js 作为兼容性备选
  • 诊断报告导入后生成详细报告,标注不支持的特性和有损转换项

DOCX 导出 — 精确格式输出

编辑器内容反向导出为标准 Word 文档:

  • 对齐方式精确映射(含分散对齐 AlignmentType.DISTRIBUTE
  • 字间距从 pt 转换为 Word 的 twips 单位(ptValue × 20
  • 字符缩放转换为 Word 百分比(0-400%)
  • 支持浮动图片、复杂表格、有序/无序列表、代码块
  • 数学公式支持:LaTeX 自动转换为 Word OMML 格式

PDF 导出 — 像素级还原

自研的 PDF 导出引擎,确保所见即所得:

  • 逐元素分页精确计算每个元素的垂直空间占用,智能分页
  • 双渲染策略优先使用 SVG foreignObject(更好的字体支持),自动降级到 Canvas 渲染
  • 保真度校验导出后自动采样校验画布内容,检测空白或异常渲染并触发重试
  • 布局锁定导出时等待字体加载、图片加载、DOM 稳定后再截图
  • 图表/脑图静态化ECharts 图表和脑图自动转换为静态图片嵌入

单位体系统一

全链路采用 磅值(pt)  作为标准单位,与 Word 原生体系一致:

场景 单位 转换关系
编辑器内部 pt 基准单位
CSS 渲染 px 1pt = 1.333px
Word 文档 twips 1pt = 20 twips
导入兼容 half-points 1pt = 2 half-points

与其他 Web 编辑器的对比

能力 JitWord 通用富文本编辑器 在线协作文档
分散对齐 原生支持 不支持 部分支持
字符缩放 33%-200% 不支持 不支持
标点压缩 自动识别 不支持 不支持
严格折行 智能启用 不支持 基础支持
网格行距 完整支持 不支持 不支持
DOCX 高保真导入 五阶段 IR 管线 基础 HTML 转换 有损导入
DOCX 导出 精确格式映射 有限支持 有损导出
PDF 导出保真度 像素级 + 双渲染 浏览器打印 服务端渲染

最后总结一下

JitWord 从排版引擎层面解决了中文 Web 排版的核心痛点,通过自研的分散对齐算法、CJK 排版规范支持、五阶段 IR 导入管线和像素级 PDF 导出,实现了 Web 端对 Word 排版效果的真正对标

图片

无论是政府公文的严格格式要求,还是企业文档的专业排版需求,我们都能提供开箱即用的解决方案。

当然我们还在持续迭代优化,打造更高精度,更智能的AI协同文档系统,让个人和企业能更低成本将传统 Office “搬到”线上。

大家有好的建议随时交流反馈~

拒绝 rem 计算!Vue3 大屏适配,我用 vfit 一行代码搞定

大家好,我是 RayChart,vfit.js、raychart.js 作者,8 年专注 Vue3 大屏适配、Web3D、数字孪生、数据可视化实战开发,长期分享可直接落地的前端效率工具与实战教程。

每次接到 1920×1080 标准大屏设计稿,最让人头疼的永远是适配
rem 要不停换算、百分比布局易乱、手动 scale 要写一堆监听与居中逻辑,坑多还容易出bug。

今天给大家带来我自研的 Vue3 轻量大屏适配库 —— vfit,真正做到:
不用计算、不用换算、不用调复杂布局,3 分钟接入,设计稿写多少 px,代码就写多少。


一、3 分钟极速接入(复制即用)

1. 安装依赖

npm install vfit

2. 全局配置(main.ts)

import { createApp } from 'vue'
import App from './App.vue'
import { createFitScale } from 'vfit'
import 'vfit/style.css' // 必须引入,否则组件失效

const app = createApp(App)

app.use(createFitScale({
  target'#app',
  designWidth1920,    // 设计稿宽度
  designHeight1080,   // 设计稿高度
  scaleMode'auto'     // 自动适配模式,直接用
}))

app.mount('#app')

配置完成,你的页面已经具备自动等比缩放 + 窗口居中能力,任意拖拽窗口都不会变形、不会错位。


二、核心神器:FitContainer 精准定位

做大屏最痛的不是缩放,而是组件坐标还原
vfit 提供的 <FitContainer> 组件,直接解决 90% 布局痛点:

设计稿 30px → 代码直接写 30,无需任何比例计算

<template>
  <div class="screen-wrapper">
    <!-- 标题:水平居中 -->
    <FitContainer :top="50" :left="0" :right="0">
      <h1 style="text-align: center">数据可视化大屏</h1>
    </FitContainer>

    <!-- 左侧图表:直接使用设计稿坐标 -->
    <FitContainer :top="100" :left="30">
      <ChartComponent />
    </FitContainer>

    <!-- 右侧列表:吸附边缘,自动适配 -->
    <FitContainer :top="100" :right="30">
      <ListComponent />
    </FitContainer>
  </div>
</template>

核心优势

  • 支持 top / left / right / bottom / z 五维定位
  • 自动按设计稿比例计算位置
  • 4K 屏、笔记本屏、拼接屏效果完全一致
  • 无需媒体查询、无需 rem、无需手写 CSS 计算

三、实战避坑指南(必看)

  1.  样式必须引入
    忘记引入 vfit/style.css 会导致 FitContainer 失效,布局直接混乱。
  2.  层级冲突处理
    FitContainer 默认有层级,弹窗被覆盖时可手动指定:
<FitContainer :z="999">
  1.  right / bottom 特殊逻辑
  • left:按设计稿比例自动缩放
  • right:不乘缩放,保持吸附屏幕边缘
    专为大屏展示优化,视觉更稳定。

四、适用场景

  • Vue3 数据可视化大屏
  • 数字孪生项目
  • 监控中心、控制台页面
  • 多端自适应、拼接屏项目
  • 不想写复杂适配逻辑的前端项目

vfit 不是功能最繁杂的,但最简单、最稳定、最适合生产环境,让你把时间花在 ECharts、3D 渲染、业务逻辑上,而不是算像素。


五、项目资源

GitHub:github.com/v-plugin/vf…
官方文档:vfit.raychart.cn


🎁 粉丝专属福利

关注我的微信公众号 RayChart
后台回复关键词:vfit
立即免费领取:
✅ vfit 完整可运行项目模板
✅ 10 套大厂可视化大屏源码
✅ 数字孪生项目素材包
✅ 一对一技术问题答疑

公众号持续更新:Vue3 大屏适配、Web3D、3D 模型压缩、全景预览、自研效率工具、数字孪生实战干货,所有内容均可直接复制到项目使用。

Vue生命周期与keep-alive实战理解

Vue 生命周期与 keep-alive:我用真实项目终于搞清楚了

"生命周期"这个词在 Vue 教程里会很早出现,但很多人学了之后还是一知半解。 因为光看文档,没有感觉。这篇文章想用真实项目代码,帮你把它们"落地"。


先说一个语言上的误导

"生命周期" 这个词,听起来像是整个项目从打开到关闭的一段大流程。

但实际上,它属于每一个组件。

正确的理解是:

每个 Vue 组件实例,都有自己的出生、挂载、更新、离开、缓存激活的过程。 这些阶段,Vue 会自动调用对应的钩子函数,给你一个"插队"的机会。


🗓️ 最常用的几个生命周期钩子

created —— 数据准备好了,但页面还没画出来

✅ 适合做什么:
- 发起不依赖 DOM 的数据请求
- 初始化变量
- 读取 Vuex / props

mounted —— 页面已经渲染出来了,DOM 摸得到了

✅ 适合做什么:
- 操作 $refs(真实 DOM)
- 绑定滚动、键盘、resize 事件
- 初始化第三方 JS 库(图表、播放器、WebSocket 等)

beforeDestroy / destroyed —— 组件要销毁了

✅ 适合做什么:
- 清除定时器
- 解绑事件监听
- 关闭 WebSocket
- 释放资源

activated —— keep-alive 缓存的页面"重新回来了"

✅ 适合做什么:
- 每次回到这个页面时,重新拉数据
- 恢复某些需要刷新的状态

deactivated —— keep-alive 缓存的页面"离开了,但没销毁"

✅ 适合做什么:
- 暂停正在播放的音频
- 停止轮询
- 暂停实时监听

🔁 keep-alive 会改变什么

keep-alive 是 Vue 的内置组件,作用是缓存被包住的组件实例

没有 keep-alive 的情况下,离开一个页面 = 销毁这个组件。

有了 keep-alive,离开页面之后组件不一定被销毁,而是被"冷冻"起来:

首次进入缓存页:
  created → mounted → activated

离开缓存页(不销毁):
  deactivated

再次进入缓存页:
  activated  ← 直接从这里开始,跳过了 created 和 mounted!

这就是为什么你会在缓存相关的组件里,经常看到 activated 而不是 created


🔧 用真实项目代码来拆解

根实例的 created:应用一启动就执行一次

// src/main.js
new Vue({
  router,
  store,
  render: h => h(App),
  created() {
    // 这里的 created 只在"整个应用开机"时执行一次
    store.commit('businessCoulmn/setWorkDetail', '');        // 重置业务状态
    store.commit('instantMessaging/setScreenfullBut', false); // 重置 IM 全屏状态
    store.commit('siteBar/addMenu', router);                  // 注入动态菜单
  },
}).$mount('#app');

这里的 created 是根实例的生命周期,属于"整个应用开机初始化",不属于任何页面。


登录页的 created + mounted:先清状态,再做页面效果

created:
  - 清空本地存储
  - 重置菜单和 tab
  - 清空 token

mounted:
  - 启动背景动画效果

典型用法:

  • created 适合"先把旧状态清干净"
  • mounted 适合"页面展示出来之后做的事情"

IM 页面入口:created 做初始化

created:
  1. 拿老师的聊天账号
  2. 拿 IM 配置
  3. 初始化 TIM SDK
  4. 绑定 TIM 事件
  5. 登录 IM

为什么用 created 而不是 mounted

因为这些步骤不需要操作 DOM,提前在 created 里做,能更快完成初始化。


IM 布局组件:mounted 初始化 WebSocket

mounted:
  - 初始化 WebSocket 连接

beforeDestroy:
  - 关闭 WebSocket
  - 停掉轮询

mounted 之所以适合初始化 WebSocket,是因为:

  • WebSocket 有时候需要操作 DOM
  • mounted 保证页面已经渲染,更安全

beforeDestroy 负责配套的清理工作,避免资源泄漏。


ConversationList 用 activated 而不是 created,是因为……

// src/components/.../ConversationList/index.vue
activated() {
  this.handleGetWechatStudentList(); // 每次"回到页面"时刷新数据
}

为什么不用 created

因为这个组件被 keep-alive 包着。

  • 第一次进来会走 created
  • 切走再切回来,created 不会重新触发
  • activated 每次回来都会触发

所以这里的逻辑是:

created  → 第一次初始化
activated → 每次"回来"时刷新数据

这是 keep-alive 场景下非常经典的写法。


MessageWindow 用 deactivated,是因为……

// src/components/.../MessageWindow/index.vue
deactivated() {
  this.handlePauseAudio(); // 离开页面时暂停音频
}

为什么不用 beforeDestroy

因为这个组件被缓存了,切走页面时不是真正销毁,不会触发 beforeDestroy

会触发的是 deactivated,专门处理"缓存页离开时的收尾动作"。


🔄 用一次完整流程来理解

1. 用户打开项目
   └─ main.js 创建根实例
   └─ 根实例 created 执行(初始化菜单、重置 IM 状态)

2. 用户登录
   └─ login.vue created 清空旧数据
   └─ login.vue mounted 显示背景动画

3. 进入 IM 聊天页
   └─ instant-messaging/index.vue created 做 IM 初始化
   └─ Layout/index.vue mounted 初始化 WebSocket

4. 用户切走页面
   └─ 缓存页面 → 触发 deactivated(不销毁)
   └─ 非缓存页面 → 触发 beforeDestroy(销毁)

5. 用户切回 IM 页面
   └─ 触发 activated
   └─ ConversationList 重新拉数据

⚠️ 不要混淆的三个东西

概念 是什么
生命周期 组件的出生、挂载、更新、离开、重现的各个阶段
watch 监听某个数据变化,不是生命周期
keep-alive 缓存机制,它会改变组件的生命周期表现

keep-alive 的存在,让 deactivated / activated 有意义。

没有 keep-alive 包着的组件,这两个钩子永远不会触发。


🏁 一句话总结

Vue 生命周期不是"整个项目统一跑一遍的流程"
而是"每个组件实例自己的阶段变化"

keep-alive 改变的是"离开""回来"的行为:
  - 离开时不销毁 → deactivated
  - 回来时不重建 → activated

所以:
  created / mounted    → 用于首次初始化
  activated            → 用于每次回来时刷新
  deactivated          → 用于离开时收尾
  beforeDestroy        → 用于真正销毁时清理

这是 Vue2 学习系列第四篇。

下一篇:Vue、SPA、MPA 傻傻分不清?一篇弄清楚三者的关系。

Home双router-view与布局切换逻辑

Home.vue 里为什么有两个 router-view?我看了三遍才读懂

如果你第一次看到 Home.vue 里写了两个 <router-view>,可能会困惑:这不是重复了吗? 其实不是。这两个出口,各守一个"平行世界"。


先看那段让人迷惑的代码

<template>
  <fragment>
    <el-container class="cus-root-container">

      <!-- 第一个 router-view:只在即时聊天全屏时出现 -->
      <router-view
        :key="$route.path"
        v-if="$route.path == '/review-service/instant-messaging'
              && $store.state.instantMessaging.bScreenfullBut == true"
      />

      <!-- 后台壳区域:只在非全屏时显示 -->
      <site-bar v-if="!$store.state.instantMessaging.bScreenfullBut" />

      <el-container class="cus-right-wrap"
        v-if="!$store.state.instantMessaging.bScreenfullBut">
        <el-header class="cus-header">
          <personal-bar />
          <tabs-bar />
        </el-header>
        <el-main class="cus-main">
          <keep-alive :exclude="excludelist">
            <!-- 第二个 router-view:后台正常模式的内容出口 -->
            <router-view :key="$route.path" />
          </keep-alive>
        </el-main>
      </el-container>

    </el-container>
  </fragment>
</template>

初看很乱,但把它分成两块就很清楚了。


🗺️ 一张图搞懂两个出口

Home.vue
├─ 🖥️ 全屏分支(上面那个 router-view)
│   └─ 条件:路由 = IM 页 && 全屏状态 = true
│   └─ 效果:聊天页面完全接管屏幕,没有侧边栏和顶部栏
│
└─ 🖥️ 普通后台分支(下面那个 router-view)
    └─ 条件:全屏状态 = false
    └─ 效果:正常后台布局,有侧边栏、顶部栏、标签页
    └─ 外面套了 keep-alive 做页面缓存

它们永远不会同时出现。 因为 v-if 条件互斥:

  • 全屏状态为 true → 上面显示,下面隐藏
  • 全屏状态为 false → 下面显示,上面隐藏

📺 第一个 router-view:即时聊天全屏模式

触发条件

当前路由是 /review-service/instant-messaging
+ Vuex 里 bScreenfullBut == true

两个条件同时满足,才会走这个出口。

它解决什么问题

即时聊天页面有一个**"全屏按钮"**。

用户点击全屏后,希望聊天窗口占满整个屏幕,不要有侧边菜单、顶部栏这些干扰。

但这个操作不是跳转到另一个页面,而是原地切换布局。

所以需要一个"纯净出口"——只渲染聊天页面本身,不带任何后台壳。

这就是第一个 router-view 存在的意义。


📋 第二个 router-view:日常后台模式

<keep-alive :exclude="excludelist">
  <router-view :key="$route.path" />
</keep-alive>

这是最常用的那个出口。

整个后台系统 90% 的页面都走这里渲染。

为什么外面套了 keep-alive?

后台系统的页面有一个特点:

用户在列表页设了很多筛选条件
→ 跳去详情页看了一会儿
→ 返回列表页
→ 希望筛选条件还在!

如果没有 keep-alive,每次返回都会重新创建组件,状态全丢。

加了 keep-alive 之后,组件实例被缓存起来,切回去时状态原封不动


为什么要有 excludelist?

keep-alive 虽然好用,但不是所有页面都适合缓存。

有些页面每次进入都应该是"全新"的,比如:

  • 编辑页:如果缓存了上次编辑的数据,会出现"脏数据"问题
  • 详情页:缓存后可能显示旧记录,而不是当前要看的那条
  • 表单创建页:上次填的内容不该保留

这些页面的 name 会被写进 excludelist,从而绕过缓存,每次都重建。


🔑 两个 router-view 都写了 :key="$route.path",为什么?

这是一个很容易忽略的细节。

如果不加 :key,Vue 在某些路由切换时会复用旧的组件实例,导致:

  • 生命周期钩子没有重新触发
  • 旧数据没清空
  • 表单状态异常

加了 :key="$route.path" 之后:

  • 每个路由路径对应一个唯一标识
  • 路由一变,key 就变,Vue 就知道需要重新创建这个组件
  • 页面切换更干净、更可控

🔄 路由和 Home.vue 的关系

你可能会看到很多路由这样配置:

{
  path: '/some-path',
  component: () => import('../views/Home.vue'),  // 注意这里是 Home.vue
  children: [
    {
      path: '',
      component: () => import('../views/some-page.vue'),
    }
  ]
}

这里的意思是:

  • 进入这个路由,先用 Home.vue 作为壳
  • Home.vue 再通过 router-viewsome-page.vue 渲染出来

所以:

router/index.js → 决定"哪个页面进 Home"
Home.vue       → 决定"这个页面以什么布局显示"

路由配置管"谁进来",Home.vue 管"怎么展示"。


✅ 怎么判断一个页面走哪个出口

看到一个页面时,可以按这个逻辑判断:

if (路由是 IM 页 && 全屏状态 == true) {
  走第一个 router-view → 全屏,没有后台壳
} else {
  走第二个 router-view → 正常后台模式,有侧边栏顶部栏
}

🏁 一句话总结

两个 router-view 不是重复,是两种布局的切换开关:
上面 → 即时聊天全屏专用出口
下面 → 普通后台模式 + keep-alive 缓存
条件互斥,永远不会同时工作

这是 Vue2 学习系列第三篇。

下一篇:Vue 生命周期 + keep-alive 实战,用真实项目代码来拆解。

src-components调用链与即时聊天组件树

src/components 里的文件,没人引用就是一堆废纸

很多 Vue 初学者以为,把文件放到 src/components 文件夹里,就会自动生效。 其实完全不是。这篇文章就来说清楚:组件怎么才算"真正被用到"。


🤔 先说一个常见误区

初学时,很多人会这样理解:

"我把组件放到 src/components,它就可以在模板里用了。"

错。

src/components 只是一个约定俗成的存放目录,不是自动生效的魔法文件夹。

放在里面的组件,如果没有人引用它,它就永远不会渲染,永远不会执行。


✅ 组件真正"生效",只有两种方式

方式一:全局注册(所有页面都能直接用)
方式二:局部引用(谁需要谁 import,谁 import 谁才能用)

🌐 全局注册是什么感觉

有些组件在整个项目里到处都要用,比如:

  • 顶部栏
  • 左侧菜单
  • 标签页
  • 全局提示

这类组件写进全局注册之后,任何页面不用单独 import,直接在模板里写标签就能用:

<site-bar />
<personal-bar />
<tabs-bar />
<base-tip />

全局注册的核心逻辑(以常见写法为例):

// src/components/globalComponents.js
import Vue from 'vue'
import SiteBar from './SiteBar/index.vue'

const globalComponents = {
  install(Vue) {
    Vue.component('SiteBar', SiteBar)
    // ... 其他全局组件
  }
}

export default globalComponents
// src/main.js
import globalComponents from './components/globalComponents'
Vue.use(globalComponents) // 在这里注册进去

这样,SiteBar 就变成了整个项目都能用的"公共组件"。


🔗 局部引用:大多数组件的使用方式

大部分业务组件不会全局注册。

它们是:谁需要,谁去 import,谁 import 才能用。

以即时聊天模块为例,它的组件树是一层一层嵌套引用的:

src/views/review-service/instant-messaging/index.vue
  └─ <im-layout />            ← 这个页面 import 了 Layout

src/components/.../Layout/index.vue
  ├─ <menu-bar />             ← Layout 里 import 了 MenuBar
  ├─ <conversation-list />    ← 会话列表(menuBar 切换到 Tab 1 时)
  ├─ <contacts-list />        ← 通讯录(menuBar 切换到 Tab 2 时)
  ├─ <message-window />       ← 有当前会话时显示
  ├─ <welcome-page />         ← 没有会话时显示
  ├─ <business-column />      ← 右侧业务栏
  └─ <image-previewer />      ← 图片预览

src/components/.../BusinessColumn/index.vue
  ├─ <BaseInfo />             ← 学员基本信息
  ├─ <SpeechList />           ← 个人话术
  └─ <WokNotice />            ← 工作通知

这就是典型的局部引用调用链:一层引用一层,没被引用到的,永远不会渲染。


🗂️ 即时聊天模块,每一层在做什么

页面入口层

instant-messaging/index.vue 是聊天功能的入口页面。

它主要负责"初始化",不负责画界面:

  • 获取老师的聊天账号
  • 拉取 IM 配置
  • 初始化 TIM SDK
  • 绑定 TIM 事件
  • 登录 IM

初始化完毕之后,它把 <im-layout /> 渲染出来,让布局层接管页面。


布局层

Layout/index.vue 是聊天页面的"骨架"。

它负责把聊天界面的各个区域分好:

  • 左侧菜单栏(MenuBar)
  • 根据 Tab 切换的会话列表 / 通讯录
  • 中间的消息窗口(有会话时显示)/ 欢迎页(无会话时显示)
  • 右侧业务栏
  • 图片预览浮层
  • 音频通知元素

这层是"结构性组件",它不负责具体业务,只负责把各个子组件摆放到正确的位置。


业务栏层

BusinessColumn/index.vue 是聊天右侧的业务信息区域。

它会根据当前会话和业务类型,切换显示:

  • 学员基本信息(BaseInfo)
  • 个人话术列表(SpeechList)
  • 工作通知(WokNotice)

🔎 怎么快速找到一个组件在哪里被调用

以后看代码时,不要在 src/components 文件夹里盲搜。

最快的方式是直接搜组件名

# 找 BaseInfo 被哪里引用了
rg -n "BaseInfo|<BaseInfo|import BaseInfo" src

# 找 ImLayout 被哪里引用了
rg -n "ImLayout|<im-layout|import ImLayout" src

搜到之后,从父组件往子组件方向追,一层一层拆开,很快就清楚了。


🧠 整个应用的调用链长这样

main.js
└─ 注册全局组件、插件、store、router

App.vue
└─ <router-view />(总出口)

Home.vue(后台壳)
└─ <site-bar />(全局)
└─ <tabs-bar />(全局)
└─ <router-view />(后台页面出口)

即时聊天入口页
└─ <im-layout />(局部引入)

Layout
└─ <ConversationList /><MessageWindow /><BusinessColumn />...

BusinessColumn
└─ <BaseInfo /><SpeechList /><WokNotice />

从最上面的 main.js 到最深的 BaseInfo,是完整的一条引用链。

断掉任何一层的引用,下面的组件就不会渲染。


🏁 一句话总结

src/components 不是入口目录,组件不会自动生效。
组件必须被 import 或全局注册后才会激活。
搞清楚"谁引用了谁",比知道"目录里有什么"更重要。

这是 Vue2 学习系列第二篇。

下一篇:Home.vue 里为什么有两个 <router-view>?它们各管什么?

VUE-组件命名与注册机制

Vue2 组件四个"名字",我曾经傻傻分不清楚

这是我在学习 Vue2 真实项目时踩过的一个坑,花了不少时间才搞清楚。记录下来,希望对你也有用。


先说我当时有多懵

我第一次看到一个 Vue 组件的使用方式时,脑袋是懵的:

import ImLayout from '@/components/.../Layout/index.vue';
components: { ImLayout }
<im-layout />
name: 'im-layout'

四个地方,都像是在说同一件事,又感觉哪里不对。

它们到底是什么关系?


🎯 直接给结论

在 Vue 2 里,组件能不能在模板里使用,关键看三件事:

1. 组件有没有被 import 进来
✅ 2. 有没有在 components 里注册
✅ 3. 模板里有没有用对标签名

name,只是组件自己的**"身份名片"**,不负责让你用它。


📦 四个"名字",分开说

第一个:import ImLayout from ...

这是 JavaScript 层面的变量名,你把一个文件"抱进来",给它取个名字方便后续引用。

// 你也可以叫它 ChatLayout,只要后面跟着改
import ChatLayout from '@/components/.../Layout/index.vue';

名字本身不固定,你起的什么,后面就叫什么。


第二个:components: { ImLayout }

这是在当前页面注册组件,相当于告诉 Vue:

"我这个页面可以使用 ImLayout,它对应的实现就是刚才 import 的那个文件。"

完整写法等价于:

components: {
  ImLayout: ImLayout  // 键名就是你之后在模板里写的标签名
}

第三个:<im-layout />

这是在模板里真正调用组件的地方。

Vue 会自动把注册名 ImLayout 对应到 im-layout(驼峰 → 短横线),所以这两种写法都可以:

<ImLayout />
<im-layout />

项目里更常见的是带短横线的写法(kebab-case)。


第四个:name: 'im-layout'

这是组件给自己贴的名字,主要用于:

用途 说明
keep-alive 缓存控制 exclude/include 通过 name 识别组件
Vue Devtools 显示 调试时能看到有意义的组件名,而不是一堆 <Anonymous>
递归组件 组件在模板里引用自己时需要用 name

name 删掉,页面照样能正常渲染。 因为真正决定能不能用的是 components 注册。


🔍 用真实项目做个例子

假设你有一个即时聊天模块,在页面入口里这样写:

// 第一步:把文件引进来,起名叫 ImLayout
import ImLayout from '@/components/review-service/instant-messaging/Layout/index.vue';

export default {
  // 第二步:在当前页面注册这个组件
  components: { ImLayout },
};
<!-- 第三步:在模板里使用它 -->
<im-layout />

这能正常工作,是因为:

  1. import 把文件拿进来了
  2. components 把这个组件注册进了当前页面
  3. Vue 自动把 ImLayoutim-layout 对应起来

跟组件内部写没写 name: 'im-layout' 无关。


🚫 三个常见误区

❌ 误区一:文件名决定标签名

不对。index.vue 只是文件名,不能自动决定你在模板里写什么。

真正决定的是 components 注册时用的键名。


❌ 误区二:name 就是注册名

不对。name 是组件自我描述,注册是靠 components


❌ 误区三:import 名字必须和标签一致

不对。import 只是变量名,标签名取决于 components 如何注册。

只要注册正确,模板里 <ImLayout /><im-layout /> 都可以。


🗺️ 遇到组件时,用这个顺序判断

📌 1. 它有没有被 import?
📌 2. 它有没有写进 components?
📌 3. 模板里有没有用对标签名?
📌 4. name 只在缓存、调试、递归这几个场景才有意义

把这四步想明白,就不会再混淆了。


🏁 一句话总结

import   → 把文件拿进来(JS 变量名)
components → 注册进当前页面(决定能用什么标签)
<im-layout> → 在模板里调用组件
name     → 组件的自我描述(和能不能用无关)

这是我学习 Vue2 真实项目时的第一篇总结。学得慢没关系,搞清楚一个是一个。

下一篇:src/components 里的文件,到底从哪里被调用?

uniapp uview-plus 自定义动态验证

以前写的验证都是这样固定的

const rules = ref({ bedUnitTidy: [{ required: true, message: '请选择', trigger: 'change' }]})

单选按钮这些选项是从接口里读出来的数据,所以现在用了动态验证,现在记录下来供自己以后参考

<up-form class="p24 bgf" :model="form" :rules="rules" ref="uFormRef" labelWidth="200" labelPosition="top":borderBottom="true">

    <up-form-item v-for="(item,index) in recordData" :key="index" :label="item.text"
        :prop="`inspectionItems.${item.value}`" required
        :rules="[{ required: true, message: '请选择' + item.text, trigger: 'change' }]">
        <up-radio-group v-model="form.inspectionItems[item.value]">
                <up-radio label="是" name="1">
                </up-radio>
                <up-radio label="否" name="0">
                </up-radio>
        </up-radio-group>
    </up-form-item>
</up-form>

const submitForm = () => {
    uFormRef.value.validate().then(res => {
        console.log(res, '成功');
        handleSubmit()
    }).catch(err => {
        console.log(err, '校验失败');
    })
}

之前一直验证失败是prop路径写错,现在查资料总结到:v-model 绑哪里 prop 就写哪里的完整路径,验证是form表单,所有项应该在form里,之前问题是在于,prop绑定的循环体里的数据,现在通过重组数据,拿到数据项后,放到form对象里,然后在up-form-item 上绑定rules 和prop解决了问题,每天进步一点点,加油!! image.png

❌