Vue 生态(全解)- Vue 3 架构师修炼宝典:精通 TypeScript 驱动的现代化前端工程体系
Vue 生态(全解)- Vue 3 架构师修炼宝典:精通 TypeScript 驱动的现代化前端工程体系
Prorise序章:启程之前 · 构筑前端架构师的思维蓝图
摘要: 这不是一篇教你如何“使用”Vue 的入门文章,而是一部旨在重塑你前端世界观的 工程化 宣言。在 2025 年这个前端技术大爆炸的时代,我们将共同探讨一个核心问题:如何超越“框架使用者”的身份,成为一名能驾驭复杂性、构建健壮系统的 前端架构师。本章将为你描绘一幅宏大的知识图景,从 Vue 3 的设计哲学到工业级的技术选型,为你接下来的蜕变之旅,奠定最坚实的思想基石。
0.1. 前端的十字路口:为什么 Vue 3 是通往未来的“高速公路”?
2025 年,我们站在一个技术的十字路口。无数的框架、库和范式在争夺我们的注意力,制造了前所未有的“选择噪音”。然而,拨开迷雾,我们看到 Vue 3 及其生态,已经不再是“小而美”的代名词,而是凭借其 卓越的设计、极致的性能和无与伦比的工程化能力,铺就了一条通往构建下一代大规模应用的“高速公路”。
性能的终极形态: Vue 3 的核心不再仅仅是响应式,而是对现代浏览器能力的极致挖掘。它的编译时优化 (Compiler-Informed Optimizations),意味着你的代码在诞生之初,就已经被赋予了最优的性能基因,这在处理复杂交互和海量数据时,将成为你最可靠的性能保障。
开发的沉浸体验: 借助
Vite
的原生 ESM 驱动,Vue 3 彻底终结了前端开发“保存-等待编译”的割裂感,将开发体验带入了“心流”状态。全新的<script setup>
范式,更是让我们得以用最纯粹、最直觉的方式组织逻辑,回归编程的乐趣本身。健壮性的基石——TypeScript: 如果说 Vue 3 是应用的骨架,那么 TypeScript 就是贯穿其中的、保证其健壮性的“神经网络”。在 Vue 3 中,TypeScript 不再是“外挂”,而是与生俱来的“母语”。它为我们带来了强大的类型约束与智能的代码提示,使得构建数十万行代码量的复杂应用,依然能做到条理清晰、稳如磐石。
0.2. 我们的学习哲学:从“工匠”到“架构师”的思维跃迁
本指南的唯一目标,是引领你完成一次思维上的跃迁。
我们构建的,是“系统”而非“零件”: 你将学到的,远不止如何编写一个组件。你将学会如何设计一个 可扩展、可维护、高内聚、低耦合 的组件体系;如何组织你的路由、状态和数据流,让它们在庞大的应用中和谐共存;如何建立一套自动化的质量保障体系,为你的代码质量提供绝对信心。
我们传授的,是“思想”而非“指令”: 我们会深入每一个技术选型背后的“为什么”。为什么 Pinia 的设计在大型应用中优于 Vuex?为什么 TanStack Query 代表了服务端状态管理的未来?理解这些决策背后的原理,将赋予你独立进行技术选型和架构设计的能力。
我们提供的,是“蓝图”而非“拐杖”: 我们为你精选的“黄金组合”,是一套经过千锤百炼的工业级工具集。掌握它,你将拥有一套足以应对任何复杂业务挑战的“武器库”。但这套工具集的目的,是让你站在巨人的肩膀上,去专注于业务逻辑的实现与创新,最终形成你自己的架构风格。
核心承诺:跟随本指南,你将建立起一套属于自己的、可复用的 前端工程化心智模型。这套模型,将是你未来职业生涯中最宝贵的资产。
0.3. 技术栈总览:架构师的“七种武器”
这是我们构建未来应用的“七种武器”,它们共同构成了一个无懈可击的现代化技术生态系统。
分类 | 技术选型 (2025 年稳定版) | 核心价值 (一句话概括) |
---|---|---|
核心框架 | Vue 3.5.x + TypeScript 5.8.x | (灵魂) 极致的开发体验与工业级的类型安全完美融合,是构建一切复杂应用的坚实地基。 |
构建工具 | Vite 6.2.x | (引擎) 将开发体验提升至毫秒级的“思想引擎”,其插件化架构更是提供了无限的扩展能力。 |
路由管理 | Vue Router 4.5.x | (中枢) 官方出品,身经百战的单页应用“交通枢纽”,掌控着应用的每一次视图跳转与权限校验。 |
状态管理 | Pinia 3.0.x | (心脏) 为你的应用提供可预测、类型安全的“中央供血系统”,让复杂的状态流动变得清晰可控。 |
服务端状态 | TanStack Query for Vue v5 | (外脑) 接管繁琐的异步数据管理,让你的应用拥有“智能记忆”,自动处理缓存、同步与更新。 |
UI 与样式 | Naive UI 2.41.x + UnoCSS | (战袍) 设计精良、类型完备的组件库与拥有终极灵活性的原子化 CSS 引擎,让你的应用兼具风度与效率。 |
质量保证 | Vitest + Cypress | (坚盾) 从代码的最小单元到用户的完整操作流程,构建起一套无死角的自动化“质量保证体系”。 |
0.4. 我们的征途:通往精通的三部曲
我们为你规划了一条从核心到外延、从理论到实践的清晰路径,分为三个相辅相成的卷册。
mastery-path
卷一:铸造核心
在这一卷,我们将心无旁骛,专注于 Vue 3 与 TypeScript 的深度融合。我们将像铸剑师一样,反复捶打每一个核心概念——响应式、组件化,直到它们成为你技术直觉的一部分。
卷二:构建巨构
我们将视野放大,开始学习如何将路由、状态管理等强大的生态库协同起来,将之前锻造的“核心零件”组装成一座结构宏伟、功能完备的“应用大厦”。
卷三:臻于至善
这是从“可用”到“卓越”的最后一步。我们将为“大厦”进行精装修,学习自动化测试、性能优化,并探索 SSR 等更广阔的架构领域,让你的作品成为真正的杰作。
0.5. 必备知识与环境准备
你需要具备的:
- 扎实的
JavaScript (ES6+)
功底,特别是对Promise
、async/await
、模块化等现代特性有深入理解。 - 熟练的
HTML / CSS
布局与样式能力。 Node.js
与pnpm
包管理器的基本使用经验。Git
版本控制的基本操作与协作流程。
- 扎实的
你 不 需要强制具备的:
- 任何 Vue 2 的历史包袱。 我们将直接从未来出发,建立最现代、最正确的 Vue 3 心智模型,但是我们也会有 vue2 教程,对于 vue3 来讲,vue2 会更加基础的讲解许多核心的 api
- 预先成为 TypeScript 专家。 你只需要简单的学习我们的 TypeScript 章节,我将会带着你们解决所有人,包括我,第一次在框架上使用 TypeScript 的知识盲区
蓝图已经绘就,远征即将开始。如果你渴望的,不仅仅是找到一份工作,而是成为一名真正受人尊敬的、能够创造卓越数字产品的 前端工匠与架构师,那么,请深吸一口气,让我们一起,翻开第一章。
第一章:专业基石 · 构筑自动化与规范化的开发环境
摘要: 卓越的应用始于一个无可挑剔的基石。在本章中,我们将摒弃“能跑就行”的临时主义,以“架构师”的视角,从零开始,亲手构筑一个工业级的 Vue 3 开发环境。我们将深入探讨为何 pnpm
是现代项目的首选,如何深度配置 Vite
和 tsconfig.json
,并最终建立一套由 ESLint
、Prettier
协同工作的、完全自动化的代码质量保障体系。学完本章,你将拥有一个坚如磐石、高度规范化的项目起点,为后续的高效开发扫清一切障碍。
在本章中,我们将循序渐进地完成以下核心任务,为我们的项目打下坚实的地基:
- 首先,我们将选择并使用最高效的包管理器
pnpm
来初始化我们的Vite + TypeScript
项目。 - 接着,我们将深入
Vite
的心脏,进行核心配置,以支持更优雅的开发。 - 然后,我们将为项目引入代码的“纪律委员”——
ESLint
和Prettier
,并让它们协同工作。 - 之后,我们将解构
tsconfig.json
文件,理解 TypeScript 编译器的核心指令。 - 最后,我们将确立一套清晰、可扩展的“功能驱动”项目结构,为未来的代码组织铺平道路。
1.1. 包管理器选型:为何 pnpm
是现代大型项目的首选
在开始之前,我们首先要选择一个包管理器。虽然 npm
和 yarn
同样流行,但在 2025 年,对于追求极致性能和磁盘空间效率的专业团队而言,`pnpm` 已成为不二之选。
极致的磁盘空间效率pnpm
采用内容寻址存储,相同版本的依赖在磁盘上仅存一份副本,多项目共享,大幅节省硬盘空间。
闪电般的安装速度
本地缓存命中时,pnpm
通过硬链接而非复制文件完成安装,速度显著快于传统拷贝。
严格的依赖管理
非扁平化 node_modules
杜绝“幽灵依赖”,只能访问 package.json
中显式声明的包,项目更健壮。
如果尚未安装 pnpm
,可用 npm
全局安装:
1 | npm install -g pnpm |
1.2. 初始化项目:从一个最纯净的 Vite
模板开始
在过去,搭建项目可能需要手动配置 Webpack
等复杂的工具。幸运的是,我们现在有了 Vite
——一个快如闪电的现代前端构建工具。它能让我们在几秒钟内启动一个项目。
我们的策略: 从最简化的模板开始。为什么?因为这就像学习解剖学,从一个干净的骨架开始,我们能更清晰地看到后续添加的每一个“器官”(如路由、状态管理、UI 库)是如何与主体结合,并理解其存在的价值。
现在,打开你的终端,导航到希望创建项目的目录,然后运行:
1 | pnpm create vite |
Vite
会像一位友好的向导,引导你完成几个选择:
1 | ◇ Project name: |
按照提示,进入项目目录,安装依赖,并启动它:
1 | cd prorise-vue3-guide |
浏览器会自动打开 http://localhost:5173/
。看到那个旋转的 Vue Logo 了吗?恭喜你,你的项目地基已成功铺设!现在,让我们深入内部,看看 Vite
为我们准备了什么。
1.3. 项目初探:解剖 Vue 应用的“生命周期”
在我们添加任何新东西之前,最重要的一步是理解现有的一切是如何协同工作的。让我们来追踪一下,从你打开浏览器到看到页面,这背后发生了怎样的“故事”。
这是我们项目的初始结构:
1 | # prorise-vue3-guide/ |
故事的开始:index.html
这不仅仅是一个普通的 HTML 文件,它是我们整个单页应用(SPA)的“舞台”。
1 |
|
当浏览器加载这个文件时,它会发现一个 id
为 app
的 div
和一个指向 /src/main.ts
的 script
标签。这正是我们故事的下一站。
第一站:src/main.ts
- 应用的启动引擎
这个 ts
文件是整个 Vue 应用的入口(Entry Point)。它的职责非常专一和重要:
1 | // 从 'vue' 包中导入 createApp 函数 |
你可以把 main.ts
想象成汽车的点火钥匙。它启动了 App
这个“引擎”,并把它“安装”在 index.html
这个“车身”上。
第二站:src/App.vue
- 万物之根的“根组件”
App.vue
是所有组件的“祖先”。未来我们所有的页面、弹窗、按钮,最终都会被嵌套在它内部。
1 | <script setup lang="ts"> |
这个文件完美展示了 Vue 单文件组件(SFC)的魅力:
<script setup>
: 存放组件的逻辑和数据(JavaScript/TypeScript)。<template>
: 存放组件的结构(HTML)。<style>
: 存放组件的样式(CSS)。
它导入并使用了 HelloWorld.vue
组件,这构成了我们看到的欢迎页面。现在,我们对项目的初始状态和运行流程有了清晰的认知。是时候为这个“毛坯房”进行“精装修”了。
1.4. CSS 工程化基石:集成 SCSS
虽然 Vite 模板自带了 CSS 支持,但在任何一个真实项目中,直接编写原生 CSS 会很快遇到瓶颈:没有变量、无法嵌套、代码难以复用。为了解决这些问题,我们需要引入 CSS 预处理器,而 SCSS
(Sassy CSS) 是当前社区中最成熟、最受欢迎的选择。它为我们提供了变量、嵌套、混入 (Mixin) 等强大的编程能力,是 CSS 工程化的第一步。
1.4.1. 安装 SASS 编译器
Vite 对 SCSS 的支持是开箱即用的,我们唯一需要做的就是安装 SASS 的编译器。
1 | pnpm add -D sass |
是的,就这么简单!Vite 在检测到你的项目中有 sass
这个开发依赖后,会自动用它来处理 .scss
或 .sass
文件。
1.4.2. 创建并引入全局样式文件
一个好的实践是建立一个全局的 SCSS 文件,用来存放项目的主题色、通用间距、字体定义等变量,以及一些全局的样式重置 (CSS Reset)。
创建文件: 在
src/
目录下创建一个新的assets
文件夹,并在其中新建styles/main.scss
。1
2
3
4
5src/
├── assets/
│ └── styles/
│ └── main.scss
└── ...编写 SCSS: 打开
src/assets/styles/main.scss
,让我们来体验一下 SCSS 的魔力。文件路径:
src/assets/styles/main.scss
(新建)1
2
3
4
5
6
7
8
9
10
11
12.card {
background: #fff;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
h1 {
font-size: 2em;
margin-bottom: 1.5rem;
color: #2c3e50; // 这里暂时硬编码
}
}全局引入: 最后,我们需要在应用的入口
main.ts
中引入这个文件,让它对整个项目生效。文件路径:
src/main.ts
(修改)1
2
3
4
5
6
7import { createApp } from 'vue'
import App from './App.vue'
// 将之前的 CSS 引入替换为我们的主 SCSS 文件
import './assets/styles/main.scss'
createApp(App).mount('#app')
1.4.3. 在 Vue 组件中使用 SCSS
现在,我们可以在单个 Vue 组件中编写带作用域的 SCSS 样式了。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
重启你的开发服务器 (pnpm run dev
),你会看到一个样式更精致的卡片。我们已经成功地将 SCSS 集成到了工作流中,为编写可维护、可扩展的样式代码打下了坚实的基础。
1.5. tsconfig.json
与路径别名配置
随着项目变大,组件层级变深,你很快会写出这样的代码:import MyButton from '../../../components/MyButton.vue'
这种相对路径就像一场噩梦:层级难数,文件一移动路径就失效。路径别名 就是我们的解药。我们希望用 @/
来直接代表 src/
目录,代码将变成:import MyButton from '@/components/MyButton.vue'
这需要两步配置:一步告诉 TypeScript 这个别名的存在(为了类型检查和代码提示),另一步告诉 Vite(为了在运行时能正确找到文件)。
1.5.1. 第一步:告知 TypeScript
tsconfig.json
是 TS 编译器的配置文件。Vite 生成的初始文件是分离的,我们修改根目录的 tsconfig.json
来定义别名。
文件路径: tsconfig.json (修改)
1 | { |
现在,当你在 VSCode 中输入 @/
时,TS 已经能给你正确的路径提示了。但如果你现在运行项目,会报错,因为 Vite 还不知道这个别名。
1.5.2. 第二步:告知 Vite
接下来,我们去修改 Vite 的“控制面板” vite.config.ts
。
首先,Vite 的配置需要 Node.js 的一些内置模块来处理路径,所以我们先安装 Node.js 的类型定义,让 TS 不会报错:
1 | pnpm add -D @types/node |
然后,修改 vite.config.ts
:
文件路径: vite.config.ts (修改)
1 | // (新增) 引入 node:url 模块中的两个辅助函数 |
代码解读:
import.meta.url
: 这是一个 ES 模块特性,它会返回当前文件(vite.config.ts
)的 URL 路径。new URL('./src', import.meta.url)
: 基于当前文件路径,创建一个指向./src
目录的新 URL。fileURLToPath(...)
: 将这个 URL 对象转换为平台相关的绝对文件路径(例如D:\web\prorise-vue3-guide\src
)。
这样,Vite 就知道,所有以 @/
开头的导入请求,都应该去 src
目录下查找。
验证一下!
重启你的 Vite 开发服务器 (pnpm run dev
),然后修改 src/main.ts
:
1 | // src/main.ts |
1.6. 自动化代码纪律:铸造团队协作的统一语言
重要信息: 如果你是初学者,我不建议你继续阅读后续的 1.6 ~ 1.7 章节,因为这和打包部署有关,没有到打包部署的阶段直接阅读这部分的笔记会导致不清晰,所以我再次事先提醒
至此,我们的项目已经拥有了坚实的结构基础。但一个真正的工业级项目,不仅要能构建,更要 易于维护、便于协作。想象一下,在一个团队中,张三习惯用双引号,李四坚持用单引号;王五提交的代码忘记移除调试信息,导致生产环境出现意外的日志。这些微小的不一致性累积起来,会极大地侵蚀代码库的可读性和项目的稳定性。
我们需要一套自动化的“纪律系统”,它不依赖于任何人的记忆或自觉,而是在代码层面就建立起一套统一的、不可逾越的规范。本节,我们将从零开始,分三步构建这套体系:
- 制定规则: 通过
ESLint
和Prettier
定义代码的质量与风格标准。 - 编辑器集成: 配置
VS Code
,实现保存文件时自动应用这些规则,提升开发体验。 - 流程强制: 使用
Husky
和lint-staged
在代码提交前强制执行检查,打造最终的质量防线。
1.6.1. ESLint 的现代化配置:解构 eslint.config.js
ESLint 的配置方式已经迎来了一场革命,告别了传统的 .eslintrc
文件,全面转向了名为 eslint.config.js
的现代化配置文件。这种新范式基于标准的 JavaScript 模块,赋予了我们前所未有的灵活性和清晰度。它不再是一个简单的 JSON 或 YAML 文件,而是一个 配置对象的数组,我们可以像编写普通代码一样,用导入、函数、逻辑判断来动态地构建我们的规则集。
第一步:安装核心依赖
在开始配置之前,我们首先需要将所有必要的“专家”请入我们的项目。
1 | pnpm add -D eslint @eslint/js typescript-eslint eslint-plugin-vue eslint-config-prettier globals |
这行命令为我们安装了:
eslint
&@eslint/js
: ESLint 本体及其官方核心规则集。typescript-eslint
: 用于理解和校验 TypeScript 语法的“翻译官”和规则集,是 TS 项目的必备。eslint-plugin-vue
: 专为.vue
单文件组件量身打造的规则插件。eslint-config-prettier
: 用于关闭所有与 Prettier 冲突的 ESLint 规则的“调解员”。globals
: 帮助我们预设浏览器、Node.js 等环境的全局变量,避免undefined variable
错误。
第二步:创建并解构 eslint.config.js
现在,我们在项目根目录创建 eslint.config.js
。我们将逐块分析这个配置文件的构成,让你不仅知其然,更知其所以然。
文件路径: eslint.config.js
(新建)
1 | // 1. 导入所有需要的插件和配置模块 |
1.6.2. 开发体验的飞跃:与 VS Code 深度集成
理想的开发流程是,开发者只需专注于业务逻辑,规范的执行应是无感的、自动的。我们将通过配置 VS Code 来实现 保存即修复 的“心流”体验。
准备工作: 如果你发现跟随本文进行配置没有实现预期效果,请尝试 重启 VS Code。通常,修改 ESLint/Prettier 配置或 settings.json
后都需要重启来使其生效。
第一步:安装 VS Code 插件
在 VS Code 的扩展市场(Ctrl+Shift+X
或 Cmd+Shift+X
)中,搜索并安装两个核心插件:
- ESLint: 由 Microsoft 出品,它会读取我们的
eslint.config.js
,在编辑器中实时标记出不符合规范的代码。 - Prettier - Code formatter: 最流行的 Prettier 插件,负责执行代码格式化。
第二步:配置 settings.json
这是实现自动化的关键。按 Ctrl+Shift+P
(或 Cmd+Shift+P
),输入 settings
,选择 “Preferences: Open User Settings (JSON)”,打开你的 settings.json
文件,并添加或修改以下配置:
1 | { |
现在,魔法发生了!
当你编写代码并保存时,VS Code 会执行一个协同动作:
- ESLint 首先介入:根据
eslint.config.js
,自动修复所有可修复的语法和质量问题。 - Prettier 紧随其后:对 ESLint 修复后的代码,进行最终的、彻底的风格格式化。
这个流程完美地解决了 ESLint 和 Prettier 的协同问题,让你的代码在保存的瞬间就达到了团队规范的最高标准。
1.7. 终极质量关卡:Husky 与自动化提交检查
IDE 级别的配置能极大地提升个人开发效率,但它依赖于开发者的自觉和环境统一。一个健壮的工程体系,必须在代码流入版本库的最后一道关卡——git commit
——设立“哨兵”。无论开发者的本地环境如何,这个“哨兵”都会强制执行所有代码检查,确保入库的每一行代码都纯净无瑕。
我们将通过一个无缝的工作流,集成 Husky
(v9+) 与 lint-staged
,并最终引入 commitlint
,来构建这道自动化的质量防线。
第一步:安装与初始化
我们首先将所有需要的工具一次性安装到项目中。
1 | pnpm add -D husky lint-staged @commitlint/cli @commitlint/config-conventional |
安装完成后,我们通过 husky init
命令来初始化 Husky。
1 | npx husky init |
这个命令会自动在你的 package.json
中添加一个 prepare
脚本,并创建一个 .husky/
目录以及一个示例的 pre-commit
钩子文件。prepare
脚本能确保团队其他成员在 pnpm install
后,Husky 也会被自动激活。
第二步:配置 pre-commit
钩子与 lint-staged
现在,我们来配置 pre-commit
钩子,让它在每次提交前,通过 lint-staged
自动检查并修复暂存区的代码。
首先,在 package.json
文件中添加 lint-staged
的配置,精确地告诉它对哪些文件执行什么命令。
文件路径: package.json
(添加)
1 | { |
接着,编辑由 husky init
生成的 .husky/pre-commit
文件,将其内容修改为执行 lint-staged
。
文件路径: .husky/pre-commit
(修改)
1 | npx lint-staged |
至此,代码的提交前自动检查与修复流程已经配置完毕。
第三步:配置 commit-msg
钩子与 commitlint
最后,我们来规范提交信息。一个清晰的提交历史对于项目维护和版本发布至关重要。
首先,在项目根目录创建 commitlint.config.js
文件,定义我们团队需要遵循的提交类型规范。
文件路径: commitlint.config.js
(新建)
1 | // commitlint.config.js |
然后,我们需要手动创建 commit-msg
钩子文件。在 .husky/
目录下创建一个名为 commit-msg
的文件,并填入以下内容。
文件路径: .husky/commit-msg
(新建)
1 | npx --no -- commitlint --edit ${1} |
这个脚本会在 git commit
时被触发,调用 commitlint
来校验用户输入的提交信息。
全自动化的质量防线已构建完成!
现在,当任何团队成员尝试提交代码时:
pre-commit
钩子触发,lint-staged
会自动对暂存区的文件运行 ESLint 修复和 Prettier 格式化。如果存在无法自动修复的错误,提交将被 中止。- 通过代码检查后,用户输入的 commit message 会被
commit-msg
钩子拦截。 commitlint
会检查该信息是否符合commitlint.config.js
中定义的规范。如果不符合,提交同样会被 中止,并给出友好的提示。
这套体系,为我们的项目建立了一道坚不可摧的自动化质量防线。至此,第一章关于环境搭建与规范化的内容,已经达到了工业级的完备程度,并完全适配了最新的工具链版本。
1.8. 架构师的蓝图:构建可维护、可扩展应用的指导原则
注意: 本章节的目的是讲解 Vue 项目的最佳实践,如果你是一个新手,可以略过此章节,在后续的章节对于 Vue3 有了一定了解之后可以回来观看此章节,该章节会涉及到我们后续讲解的所有知识,对于他进行一个结构化的组件划分,以及每一个知识点的最佳实践
在我们开始编写第一行业务代码之前,最后一步,也是至关重要的一步,是为我们的“应用城市”绘制一份清晰的 架构蓝图。一个项目的成败,往往在最初的结构设计和原则确立时就已注定。一个清晰、可预测的结构,能让开发者在项目的任何阶段都迅速定位,极大地提升开发效率和项目的可维护性。
本节将阐述我们贯穿整个指南的核心开发哲学与最佳实践,它将成为我们构建高质量应用的“宪法”。
1.8.1. 结构即思想:功能内聚的目录组织
传统的按文件类型(components
, views
, store
)组织项目的方式在小型项目中尚可,但在大型应用中会导致“功能分散”的维护噩梦——修改一个功能,你可能需要在多个文件夹之间来回跳转。
我们将拥抱一种更现代、更具扩展性的 “功能内聚”(Feature-based) 结构。虽然我们仍会保留顶层的分类目录,但在业务复杂区域,我们会优先考虑按功能模块来组织代码。
1 | # prorise-vue3-guide/ |
核心思想: 将实现同一个业务功能所需的所有代码(组件、状态、逻辑、类型)都放在一个文件夹内。这使得功能本身变得 高内聚、低耦合,易于理解、维护、甚至是迁移。
我们一般都会在新的项目用指令快捷搭建,适用于 Windows 的 PowerShell 命令如下:
1.8.2 一键生成项目结构 PowerShell 脚本
这个脚本将为您完成以下自动化任务:
创建所有必需的目录,包括嵌套的
features
目录。处理 Vite 默认文件:
- 将
src/App.vue
移动到src/views/App.vue
。 - 将
src/components/HelloWorld.vue
移动到src/components/common/HelloWorld.vue
。
- 将
自动修复代码引用:
- 更新
src/main.ts
中对App.vue
的引用路径。 - 更新新的
src/views/App.vue
中对HelloWorld.vue
的引用路径。
- 更新
提供清晰的执行反馈。
如何使用
创建脚本文件: 在你刚创建的 Vite 项目的 根目录 (与
package.json
同级),创建一个新文件,命名为init-structure.ps1
。复制粘贴代码: 将下面的完整脚本代码复制并粘贴到
init-structure.ps1
文件中。运行脚本:
在项目根目录打开 PowerShell 终端(在 VS Code 中,可以通过 Terminal
> New Terminal
,并确保是 PowerShell)。
由于安全策略,PowerShell 默认可能不允许执行脚本。你需要先为当前会话 临时 放宽执行策略。运行以下命令:
1 | Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force |
现在,执行我们的脚本:
1 | .\init-structure.ps1 |
1.8.3. 组件设计的艺术:单一职责与清晰契约
组件是应用的基石。我们将遵循以下原则来设计组件:
- 小而美: 每个组件只做一件事,并把它做好(单一职责原则)。一个臃肿的“万能组件”是维护的灾难。
- 清晰的内外之别:
- 输入 (
Props
): 组件的数据来源应尽可能通过props
传入。我们将在 TypeScript 的加持下,为props
建立严格的类型契约。 - 输出 (
Emits
): 组件不应直接修改外部状态。当内部状态变化需要通知外部时,必须通过emits
发出事件。这保证了 单向数据流 的可预测性。
- 输入 (
- 内容分发 (
Slots
): 当一个组件需要承载不确定的、复杂的 UI 结构时,不要试图用无数的props
去控制它。slots
(插槽)是实现 UI 组合与控制反转的优雅之道。
1.8.4. 逻辑复用的范式:拥抱组合式函数 (Composables)
在 Vue 3 的世界里,组合式函数 (Composables) 是逻辑复用的黄金标准。我们将彻底摒弃 Mixins,因为它存在来源不明、命名冲突等问题。
我们的原则是:任何跨组件的、有状态的逻辑,都应该被抽离成一个 Composable。一个优秀的 Composable 应该是:
- 一个纯粹的函数: 输入(参数)和输出(返回一个包含
ref
、computed
、方法的对象)都极其明确。 - 自管理: 它应该自己处理好内部的生命周期和副作用(如
onMounted
中添加事件监听,onUnmounted
中自动清理)。 - 可测试: 脱离了组件的上下文,一个 Composable 可以像普通工具函数一样被轻松地进行单元测试。
1.8.5. 状态管理的策略:局部与全局的权衡
并非所有的状态都需要被放入全局的 Pinia Store 中。滥用全局状态会导致应用变得难以理解和调试。
- 组件内部状态: 默认情况下,状态应首先归属于最直接需要它的组件。使用
ref
和reactive
足以应对大多数场景。 - 跨层级状态 (
provide/inject
): 当一个状态需要在一个组件子树中共享(例如,一个复杂表单的根组件向其所有子孙组件提供校验状态),provide/inject
是比“属性透传”更优雅的解决方案。 - 全局应用级状态 (
Pinia
): 只有当一个状态需要被应用中多个 无直接父子关系 的组件共享时(例如,用户信息、主题设置、购物车),才应该将其提升到 Pinia Store 中。
1.8.6. 性能优化的意识:贯穿开发始终
性能不是项目上线前的“灵丹妙药”,而是一种需要贯穿于整个开发过程的 意识和习惯。
- 响应式数据的精细化: 理解
ref
vsreactive
,shallowRef
vsref
的区别,为不同的数据结构选择最高效的响应式方案。 - 渲染的智慧: 善用
v-memo
和v-once
来缓存那些不常变化的模板片段。在v-for
循环中,永远提供一个稳定且唯一的key
。 - 按需加载: 路由组件、大型第三方库、非首屏的重量级组件,都应使用 动态导入 (
import()
) 来实现懒加载,这是优化应用初始加载速度最有效的手段。
1.8.7. 质量与安全的底线:自动化与最佳实践
- 静态检查先行: 我们在
1.6
和1.7
节建立的ESLint
+Prettier
+Husky
自动化流程,是我们代码质量的第一道,也是最重要的一道防线。 - TypeScript 全覆盖: 我们将坚持为所有变量、函数参数、返回值、Props、Emits 提供明确的类型。TypeScript 不是负担,而是我们对抗复杂性、提升代码健壮性的最强盟友。
- 测试驱动: 我们会为核心的 Composable 和 Store 编写单元测试 (
Vitest
),并为关键的用户流程编写端到端测试 (Cypress
)。测试是信心的来源。 - 安全意识: 时刻警惕 XSS 攻击(永远不要使用
v-html
渲染未经处理的用户内容)、CSRF 等常见 Web 漏洞。
这份蓝图,是我们从“代码实现者”迈向“系统构建者”的思维转变。它所倡导的原则——功能内聚、职责单一、契约清晰、按需加载、质量先行——将是我们接下来每一章实践的指导思想。
有了这块坚如磐-石的地基和这份清晰的蓝图,我们终于可以满怀信心地,开始铸造 Vue 应用的核心——响应式系统。
第二章:响应式系统 · 构建坚实的数据基石
摘要: 欢迎来到 Vue 3 的引擎室。本章,我们将解构 Vue 的核心——响应式系统,它是一切动态交互的基石。我们将超越“数据变,视图也变”的表面现象,深入探讨其背后的设计哲学与工程实践。我们将从响应式系统的底层原理出发,理解其演进;接着从响应式数据的“原子” ref
开始,建立统一的数据处理范式;探索 reactive
代理模式的应用场景;最后,我们将锻造处理衍生状态与副作用的“瑞士军刀”—— computed
与 watch
。本章将以 TypeScript 作为我们的“蓝图语言”,让你从一开始就以构建健壮、可预测应用的角度,去思考和掌控数据。
2.1. 响应式原理:从 Object.defineProperty
到 Proxy
的革命
在深入 API 之前,理解其“为何如此”至关重要。Vue 的响应式系统并非凭空而来,而是经历了一次从 Vue 2 到 Vue 3 的重大架构升级。
2.1.1. Vue 2 的基石:Object.defineProperty
Vue 2 的响应式系统依赖于 ES5 的 Object.defineProperty
。它通过遍历一个对象的所有属性,并为每个属性设置 getter
和 setter
来实现数据劫持。
getter
: 当你读取属性时触发,Vue 在此时进行 依赖收集 (Track)。setter
: 当你修改属性时触发,Vue 在此时 触发更新 (Trigger)。
然而,这种方式存在一些天然的、无法绕过的局限性:
- 无法侦测新增属性: 对于一个已经响应化的对象,后续动态添加的新属性不会被
defineProperty
劫持,因此不是响应式的。Vue 2 必须通过Vue.set
(或this.$set
) 这一特殊 API 来解决。 - 无法侦测数组索引和长度的变化: 直接通过索引修改数组项 (
arr[0] = ...
) 或修改数组长度 (arr.length = 0
) 无法被setter
捕获。Vue 2 不得不重写数组的push
,pop
,shift
等方法来“曲线救国”。
2.1.2. Vue 3 的引擎:ES6 Proxy
为了从根本上解决这些问题,Vue 3 拥抱了 ES6 的 Proxy
。Proxy
是一种元编程能力,它允许我们创建一个对象的“代理”,从而可以拦截并自定义该对象上的各种操作。
特性对比 | Object.defineProperty (Vue 2) | Proxy (Vue 3) |
---|---|---|
拦截目标 | 对象的 属性 | 整个对象 |
拦截能力 | 只能拦截 get 和 set 等少数操作 | 可拦截多达 13 种操作 (如 get , set , has , deleteProperty ) |
新增属性 | 无法 自动侦测,需 Vue.set | 可以,代理是针对整个对象的 |
数组操作 | 无法 侦测索引赋值,需重写数组方法 | 可以,get 和 set 拦截器能处理索引访问 |
性能 | 初始化时需 深度递归 遍历所有属性 | 惰性初始化,仅在访问属性时才进行代理 |
Proxy
的引入,让 Vue 3 的响应式系统变得更加 完整、强大且高效,开发者不再需要记忆那些特例和边界情况。
2.2. ref
:响应式数据的“原子”与首选范式
在 Vue 3 中,我们有两种创建响应式状态的方式。但在构建大型、可维护的应用时,我们必须建立一套统一、可预测的范式。因此,我们提出本指南的第一个核心架构原则:“万物皆 ref (Ref-First)”。
这意味着,无论我们处理的是原始值还是对象,我们都 优先使用 ref 来包裹它。reactive 则作为处理特定场景(如大型、非替换性的聚合状态)的补充。我们将在后续内容中揭示为何这一原则能从根本上提升代码的健壮性和一致性。
在深入理论之前,我们先通过一个实验来感受“非响应式”世界的痛点。请修改 src/App.vue
,尝试用原生 JavaScript 变量实现一个计数器。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
你会发现,无论你如何点击按钮,控制台里 count
的值确实在增加,但页面上的 “Count is: 0” 却 纹丝不动。
2.2.1. 核心痛点:为何需要“信号”——从 JavaScript 变量到响应式状态
这就是问题的根源。对于 let count = 0
,JavaScript 引擎对 count
的修改是“静默”的。Vue 的渲染系统对此一无所知,因为它没有收到任何需要更新视图的“信号”。
我们需要一种机制,将一个普通变量包装成一个“响应式信源”。当这个信源的值发生变化时,它能主动向 Vue 的调度系统发出通知:“我已变更,请更新所有订阅我的部分!” 这个信源,就是 Vue 提供的 ref
。
2.2.2. 创建响应式引用:ref
的基本用法
现在,我们用 ref
来赋予数据“生命”。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
点击按钮,页面与控制台同步更新。魔法发生了。
2.2.3. 封装与意图:.value
背后的设计哲学
为什么必须通过 .value
?这并非繁琐,而是一种精妙的设计。const count = ref(0)
创建的 count
并非数字 0
本身,而是一个包含了值的 容器对象(Ref
对象)。这个容器是实现响应性的关键。
- 读取值 (
count.value
): 当你访问.value
时,Vue 底层的getter
被触发。它会记录下来:“哦,当前这个渲染上下文 依赖 于count
这个信源。” (此为 依赖收集/Track)。 - 修改值 (
count.value++
): 当你修改.value
时,Vue 底层的setter
被触发。它检测到这个动作,并说:“count
信源已更新,我要 通知 所有依赖它的地方进行更新。” (此为 触发更新/Trigger)。
.value
的存在,使得对值的 读写操作 都成为可被拦截的“钩子”。它让每一次数据访问和修改都变得 意图明确,并且为 Vue 提供了执行其响应式魔法的必要入口。
2.2.4. (TS 实战) 类型即契约:用 Ref<T>
为数据“立规矩”
在专业开发中,我们必须为数据建立严格的契约。TypeScript 的泛型是实现这一点的利器,在 TypeScript 的世界里,你将频繁遇到像 <T>
这样的尖括号语法,它被称为 泛型。你可以把它想象成一个“类型的变量”或“类型占位符”。当我们写 Ref<number>
时,就是在告诉 TypeScript:“我创建的这个 ref 是一个专门用来装 number 类型数据的容器。” 这个 <T>
赋予了我们创造灵活且类型安全的“模具”的能力,是 TypeScript 中最强大的特性之一。在本指南中,我们将通过一个个实战场景,让你逐步领会它的妙用。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
使用 Ref<T>
泛型进行类型注解,是 Vue + TypeScript 项目的 黄金标准。它将 TypeScript 的静态类型检查能力与 Vue 的响应式系统无缝结合,让你的代码在运行前就具备极高的健壮性。
2.3. reactive
:对象整体响应式的“代理”模式
当处理复杂的、拥有多个属性的 JavaScript 对象时(例如表单状态),为每个属性都创建一个 ref
会显得冗长。为此,Vue 提供了 reactive
,它采用了一种不同的策略。
2.3.1. 创建响应式代理:reactive
的基本用法
reactive
接收一个对象或数组,并返回其基于 Proxy
的 深度响应式 版本。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
与 ref
的核心差异与设计权衡:
- 访问方式:
reactive
返回的是代理对象本身,操作直观,无需.value
。 - 适用类型:
reactive
只能 接收对象或数组作为参数。reactive(0)
是无效的。 - 赋值替换: 你不能直接替换整个
reactive
对象,否则会使其与原始的响应式连接断开。例如state = { ... }
是错误的,而ref
可以通过count.value = ...
整体替换。
2.3.2. (TS 实战) 定义数据蓝图:interface
与 type
的力量
对于 reactive
对象,使用 interface
或 type
来定义其结构,是保障代码质量的基石。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
通过 interface
,我们为 user
对象建立了一个严格的“契约”。这为我们带来了三大好处:精准的智能提示、编译时的类型检查,以及 代码即文档 的可读性。
2.3.3. 响应性的“黑洞”:解构 reactive
的陷阱与原理
reactive
的魔法完全依赖于那个“代理”对象。一个常见的、致命的错误是使用 ES6 解构赋值。
1 | import { reactive } from 'vue'; |
核心原理:解构赋值 let { count } = state
实质上是 let count = state.count
。这个操作直接获取了代理对象内部的 原始值,从而绕过了代理的 get
和 set
拦截器,导致得到的变量与响应式系统 完全断开连接。
那么,如果我们需要将响应式对象的属性作为独立的、可传递的单元,同时保持其响应性,该怎么办?
2.4. toRefs
& toRef
:保持响应性连接的桥梁
toRefs
是解决上述问题的标准工具。它能够将一个 reactive
对象的所有属性,转换为一个普通对象,但该对象的每个属性都是一个指向原始对象相应属性的 ref
。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
toRefs
是编写可组合函数 (Composables) 时的关键工具,它确保了从函数中返回的响应式状态在被外部解构使用时,依然能保持与源状态的连接。toRef
则是它的单数形式,用于为单个属性创建 ref
。
2.5. 衍生与响应:computed
与 watch
的应用场景
除了直接定义和修改状态,我们还需要更高级的工具来处理两种核心场景:
- 衍生状态:一个状态的值由其他状态计算而来 (computed)
- 副作用:当状态变化时,需要执行一些额外的逻辑,如 API 请求、DOM 操作等(watch)
2.5.1. computed
:智能的衍生状态与缓存之美
computed
用于创建一个计算属性 ref
。其核心价值在于它的 声明性 和 缓存机制。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
在模板中多次访问 fullName
,控制台的 “Computing…” 只会打印一次。这是因为 computed
会缓存其结果,只有当其依赖项(firstName
或 lastName
)发生变化时,缓存才会失效并重新计算。这是一种重要的性能优化手段,也让模板代码更具可读性。
2.5.2. watch
与 watchEffect
:洞察数据变化的“哨兵”
当需要在数据变化时执行具有副作用的操作时,侦听器是我们的不二之选。
watch
:精确制导的侦听器
watch
是一个 精确制导 的侦听器,你需要明确指定侦听的目标、选项和回调。
特点:
- 懒执行:默认情况下,回调只在侦听源变化后执行。
- 目标明确:必须显式指定要侦听的一个或多个数据源。
- 访问新旧值:回调函数能接收到变化前后的值,便于进行比较或逻辑判断。
- 更强的控制力:通过
deep
(深度侦听) 和immediate
(立即执行) 选项进行精细控制。
1 | <script setup lang="ts"> |
watchEffect
:自动追踪的侦听器
watchEffect
是一个 自动追踪 的侦听器,它会响应其“势力范围”内任何依赖的变化。
特点:
- 立即执行:创建时会立即执行一次回调,以进行依赖收集。
- 自动追踪:无需指定侦听源,它会自动将回调函数中用到的所有响应式数据作为依赖。
- 更简洁:适用于不关心旧值,只关心“当依赖变化时重新运行这段逻辑”的场景。
1 | <script setup lang="ts"> |
2.6. 响应式工具与性能优化
除了核心 API,Vue 还提供了一些工具来应对特定场景,尤其是在性能优化方面。
2.6.1. readonly
:创建不可变的响应式状态
readonly
接收一个响应式对象(由 ref
或 reactive
创建)并返回一个只读的代理。任何对其的修改尝试都会在开发环境下产生警告。
1 | import { reactive, readonly } from 'vue'; |
核心用途:在父组件中,当你需要向子组件传递一个响应式状态,但又不希望子组件意外修改它时,readonly
是实现单向数据流的绝佳工具。
2.6.2. shallowRef
& shallowReactive
:性能优化的利器
默认情况下,ref
和 reactive
都是“深度”响应的。如果你有一个层级很深、数据量庞大的对象,对其所有属性进行深度代理可能会带来不必要的性能开销,这个用的比较少,简单了解一下即可
shallowRef
: 只对.value
的 赋值 操作是响应式的。它不会自动将.value
的内容转换为深度响应式对象。shallowReactive
: 只对对象的 第一层属性 是响应式的。嵌套对象内部的属性变化不会被追踪。
1 | import { shallowReactive, shallowRef, triggerRef } from 'vue'; |
核心用途:当处理大型、不可变的数据结构(如从后端获取的复杂列表),或者需要手动控制更新时机以进行极致性能优化时,应考虑使用 shallow
API。
2.7. 本章核心速查总结
分类 | 关键项 | 核心描述与设计考量 |
---|---|---|
响应式基础 | ref | 包裹任何值的 响应式容器。通过 .value 访问。设计考量: 提供了统一、可预测的 API,无论是原始值还是对象,行为一致,且不会有解构陷阱。首选范式。 |
响应式基础 | reactive | 仅用于对象或数组的 深度响应式代理。直接访问属性。设计考量: 提供了更自然的对复杂对象的操作语法,但在重分配和解构时有心智负担。 |
响应式工具 | toRefs | 将 reactive 对象转换为 属性为 ref 的普通对象。设计考量: 作为 reactive 解构问题的标准解决方案,是组合式函数模式的关键一环。 |
衍生状态 | computed | 基于其他响应式源的 声明式、带缓存 的衍生 ref 。设计考量: 提升性能,使模板逻辑更清晰,代码更具可读性。 |
副作用 | watch | 显式 侦听一个或多个源,在其变化时执行回调,可访问 新旧值。设计考量: 提供精确的控制,适用于需要了解变化细节的复杂副作用场景。 |
副作用 | watchEffect | 自动 追踪回调内的依赖,立即 执行一次,依赖变化时重新执行。设计考量: 提供更简洁的 API,适用于无需关心变化细节的“同步”型副作用。 |
优化/只读 | readonly | 创建一个 只读 的响应式代理,防止意外修改。设计考量: 强制单向数据流,增强代码健壮性。 |
优化/只读 | shallowRef /shallowReactive | 创建 浅层 响应式对象,仅顶层变化被追踪。设计考量: 性能优化,避免对大型或不可变数据进行不必要的深度代理。 |
2.8. 高频面试题与架构师思维
请解释一下 Vue 3 中 ref 和 reactive 的区别,以及在你的项目中,你是如何进行技术选型的?
好的。ref 和 reactive 是 Vue 3 构建响应式数据的两种核心机制。它们的主要区别在于:1. 数据类型: ref 设计上可以处理包括原始值(string, number)在内的任何类型,它通过一个 .value 属性来包装值。而 reactive 只能用于对象或数组。2. API 行为: ref 返回的是一个 Ref 对象,修改和访问总是通过 .value,这使得它的行为非常统一和可预测。reactive 返回的是一个 Proxy 代理,可以直接操作其属性,语法上更像普通对象,但直接对变量重新赋值会使其失去响应性。
在技术选型上,我的团队遵循 “首选 ref” (ref-first) 的原则。无论是定义组件的内部状态,还是在组合式函数中,我们都倾向于使用 ref 来声明所有响应式数据,即使是对象,例如 const user = ref({ name: ‘Prorise’ })。这么做的核心原因有三点:第一,一致性:所有响应式变量都通过 .value 访问,减少了心智切换的成本。第二,健壮性:ref 不存在解构丢失响应性的问题,这使得它在函数参数传递和返回值时更安全。第三,重构友好:一个 ref 可以轻易地从原始值重构为一个对象,而不需要改变其声明方式。只有当我们处理一个非常大的、聚合型的状态对象(比如一个复杂的表单 formState),并且明确知道这个对象不会被整个替换,只会在内部修改其属性时,才会考虑使用 reactive 以获得更简洁的属性访问语法。
非常清晰的思路。那 watch 和 watchEffect 呢?它们的核心区别和适用场景是什么?
watch 和 watchEffect 都是处理副作用的工具,但它们的设计意图 (Intent) 不同。核心区别在于三点:1. 依赖追踪: watch 需要显式声明要侦听的数据源,而 watchEffect 自动追踪其回调函数中访问到的所有响应式依赖。2. 执行时机: watchEffect 在创建时会立即执行一次,而 watch 默认是懒执行的,只有在数据源首次变化时才执行。3. 回调参数: watch 的回调可以访问到新值和旧值,而 watchEffect 的回调不接收任何参数。
基于这些区别,我的使用场景划分如下:使用 watch 的场景:当我需要精确控制副作用的触发源时;当我的逻辑依赖于新旧值的对比时(例如,侦听 ID 变化并判断是否需要重新获取数据);或者当我需要执行一个开销较大的异步操作,并且不希望它在初始时就执行。使用 watchEffect 的场景:当我想建立一种“同步关系”时,即“只要这段代码依赖的任何东西变了,就重新运行它”。它非常适合那些不关心变化过程,只关心最终结果的逻辑,比如将多个响应式数据同步到 localStorage,或者根据多个筛选条件自动发起请求。简单来说,watch 关乎 “因果”,而 watchEffect 关乎 “同步”。
第三章:组件化工程 · 铸造类型安全、高内聚的 UI 单元
摘要: 如果说响应式系统是 Vue 的“灵魂”,那么组件化就是 Vue 的“躯体”。本章,我们将从“工匠”晋升为“建筑师”,学习如何设计和构建出 高复用、低耦合、API 友好 的 UI 单元。我们将深入 <script setup>
这一现代 Vue 组件的最终形态,并以 TypeScript 作为我们的“结构图纸”,为 Props
、Emits
、Slots
等所有内外接口建立严格的类型契约。学完本章,你将有能力构建出在大型项目中依然能保持清晰、可维护的复杂组件系统。
3.1. <script setup>
:组件语法的最终形态
在深入组件设计的细节之前,我们必须先掌握最先进的“建造工具”——<script setup>
。它并非简单的语法糖,而是一场关乎开发效率、代码可读性和性能的革命。
3.1.1. 核心痛点:Options API 的“逻辑孤岛”
想象一下,我们在用 Vue 2 的 Options API 构建一个带有搜索功能的列表组件。
data
中需要定义searchQuery
和items
。methods
中需要一个fetchItems
方法。computed
中可能有一个filteredItems
。watch
中需要监听searchQuery
的变化来重新获取数据。mounted
中需要进行初次数据加载。
一个“搜索功能”,它的逻辑被迫分散在五个不同的“孤岛”上。当组件变得复杂时,维护和理解这段代码将成为一场噩梦。这就是 逻辑分散 的问题。
3.1.2. 解决方案:<script setup>
的“逻辑内聚”
<script setup>
允许我们将同一个功能的代码组织在一起,就像在写一个普通的 JavaScript/TypeScript 模块。
文件路径: src/components/FeatureList.vue
(新增)
1 | <script setup lang="ts"> |
<script setup>
的架构优势:
- 逻辑内聚:所有与“搜索”相关的状态、方法和副作用都集中在一起,代码的可读性和可维护性呈指数级提升。
- 更少的样板代码:告别
export default { setup() { ... } }
的冗长结构。 - 编译时优化:Vue 编译器可以更智能地分析
<script setup>
中的代码,生成更高效的渲染函数,运行时性能更佳。 - 告别
this
:在<script setup>
的世界里,没有this
的心智负担,所有逻辑都更接近纯粹的 JavaScript 函数式编程。
3.2. 组件的“公共 API”:Props
与 Emits
一个设计良好的组件就像一个黑盒,它通过 Props
(输入)和 Emits
(输出)与外界清晰地交互,而将其内部实现细节完全封装。
3.2.1. defineProps
:父给子传数据
Props
是父组件向子组件传递数据的通道。在架构层面,我们必须遵循 单向数据流 原则:子组件永远不应直接修改父组件传递的 Prop。这保证了数据流的可追溯性,让应用状态变得可预测。
defineProps<T>
与 withDefaults
我们将创建一个更复杂的 CustomInput
组件,它需要接收 modelValue
、label
和一个可选的 placeholder
。
文件路径: src/components/CustomInput.vue
(新增)
1 | <script setup lang="ts"> |
3.2.2. 组件通信:使用 defineEmits
从子组件向父组件传递信息
当子组件内部发生变化(例如,用户输入),而父组件需要知道这个变化时,子组件不应直接修改 Prop
(因为这是单向数据流所禁止的)。正确的做法是:子组件通过 emit
(发出)一个自定义事件,并附带上需要传递的数据,父组件则通过 @
符号来监听这个事件并接收数据。
这就像子组件在对父组件“喊话”:“嘿,我的值变了,新值是这个!”
第一步:在子组件中定义和触发事件
我们继续使用 CustomInput
组件。这次,我们先定义一个通用的 input-change
事件来传递最新的输入值。
文件路径: src/components/CustomInput.vue
1 | <script setup lang="ts"> |
第二步:在父组件中监听事件并更新数据
现在,父组件需要“收听”子组件发出的 input-change
事件,并在事件发生时执行一个方法来更新自己的数据。
文件路径: src/App.vue
1 | <script setup lang="ts"> |
现在,你已经掌握了父子组件通信最核心、最通用的模式:父组件通过 Props 向下传递数据,子组件通过 Emits 向上发送通知。 这种模式适用于任何场景。
3.2.3. 双向绑定:v-model
的优雅语法糖
你可能已经注意到了,上面“传递 value
Prop”和“监听 input-change
事件来更新 value
”的组合非常常见。为了简化这种模式,Vue 提供了一个强大的语法糖:v-model
。
v-model
本质上就是下面这行代码的简写:
:modelValue="someRef" @update:modelValue="newValue => someRef = newValue"
要让我们的组件支持 v-model
,我们只需要遵循一个简单的约定:
- Prop:接收的 Prop 名称必须是
modelValue
。 - Emit:触发的事件名称必须是
update:modelValue
。
让我们把 CustomInput
组件改造成支持 v-model
的标准形式。
文件路径: src/components/CustomInput.vue
(改回 v-model
版本)
1 | <script setup lang="ts"> |
文件路径: src/App.vue
(使用 v-model
的版本)
1 | <script setup lang="ts"> |
总结:
v-model
不是魔法,它是:prop
+@event
模式的一个约定和语法糖。- 理解底层的事件监听机制 (
@input-change
) 对于创建更复杂、更灵活的组件至关重要,也是调试问题的基础。 - 当你的组件功能符合“双向绑定”的语义时,优先使用
v-model
,因为它更符合 Vue 开发者的习惯,也让代码更简洁。
3.3. Slots
:实现 UI 的“控制反转”
Props
擅长传递数据,但当我们需要传递复杂的 UI 结构时,Props
就显得力不从心了。这时,Slots
(插槽)就登场了。
架构思想: Slots
是一种 控制反转 (Inversion of Control, IoC) 的体现。组件本身不决定其所有内容的具体实现,而是定义一个“坑”(插槽),把填充这个“坑”的“控制权”反转 给了父组件。
3.3.1. 默认、具名与作用域插槽
我们将创建一个通用的 BaseLayout
组件来演示所有类型的插槽。
文件路径: src/components/BaseLayout.vue
(新增)
1 | <script setup lang="ts"> |
在 App.vue
中使用它:
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
3.3.2. (TS 实战) defineSlots
:为插槽提供类型
Vue 3.3+ 引入了 defineSlots
宏,我们可以像定义 Props
和 Emits
一样,为插槽提供类型。
文件路径: src/components/BaseLayout.vue
(修改)
1 | <script setup lang="ts"> |
这为模板作者提供了极大的便利,当使用作用域插槽时,能获得完整的类型提示和自动补全。
3.4. 组件实例的交互:defineExpose
与模板 ref
架构思想: 默认情况下,组件的内部状态和方法是私有的,这是 封装 的体现。但是,在某些特定场景下(如表单校验、媒体播放),父组件需要调用子组件的公共方法。defineExpose
就是打开一个受控的、明确的“后门”。
文件路径: src/components/ValidationForm.vue
(新增)
1 | <script setup lang="ts"> |
在父组件中,通过模板 ref
来获取组件实例并调用其暴露的方法。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
3.5. 高级组件模式:应对复杂场景的架构利器
掌握了 Props
, Emits
, Slots
和 Expose
之后,你就拥有了构建绝大多数组件的能力。然而,在大型应用中,我们还会遇到更复杂的场景,例如根据状态动态渲染不同 UI、或者为了极致的性能而按需加载组件。Vue 提供了两种强大的高级模式来应对这些挑战。
3.5.1. 动态组件:<component :is="...">
is
后面可以是组件的名称或组件的引用,通过改变 is
绑定的值,就能动态切换要渲染的组件 。
**架构思想:状态驱动的 UI 渲染 **。在复杂的界面中,我们常常需要根据某个状态值来决定渲染哪个组件。与其使用繁琐的 v-if/v-else-if/...
链条,不如将“要渲染哪个组件”这个决定权本身也交给一个响应式状态来管理。这大大地 解耦了渲染逻辑和视图结构。
典型场景:设置页面中的标签页(个人资料、账户安全、通知设置)、多步骤表单向导等。
构建一个动态标签页系统
让我们构建一个简单的设置页面,可以通过点击按钮切换不同的设置面板。
第一步:创建几个“面板”子组件
文件路径: src/components/tabs/ProfileSettings.vue
(新增)
1 | <template> |
文件路径: src/components/tabs/SecuritySettings.vue
(新增)
1 | <template> |
第二步:在父组件中使用动态组件
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
这个重构后的例子不仅完整可运行,还引入了 shallowRef
和 <KeepAlive>
这两个在真实项目中与动态组件形影不离的最佳实践,并展示了如何用 TypeScript 来为组件集合提供类型。
3.5.2. 异步组件:defineAsyncComponent
架构思想:按需加载与代码分割。在一个大型单页应用 (SPA) 中,如果将所有页面的所有组件代码都打包进一个巨大的 JavaScript 文件,会导致首次加载速度极慢,严重影响用户体验。异步组件允许我们将代码分割成多个小块(chunks),只有当某个组件 实际需要被渲染时,对应的代码块才会被从服务器下载和执行。
典型场景:
- 路由懒加载:这是最常见的用法,每个页面组件都是异步的。
- 重量级组件:如图表、富文本编辑器、复杂的弹窗等,它们体积较大且不是每个用户都会立即用到。
(TS 实战) 优雅地加载一个重量级组件
假设我们有一个 UserProfileCard
组件,它依赖一个庞大的图表库,我们希望只在用户点击按钮后才加载它。
第一步:创建目标组件和加载/错误状态组件
文件路径: src/components/UserProfileCard.vue
(新增)
1 | <script setup lang="ts"> |
文件路径: src/components/common/LoadingSpinner.vue
(新增)
1 | <template> |
文件路径: src/components/common/LoadingError.vue
(新增)
1 | <template><p>组件加载失败!</p></template> |
第二步:在父组件中使用 defineAsyncComponent
和 <Suspense>
Vue 3 的 <Suspense>
组件为处理异步依赖(包括异步组件)提供了一流的内置支持,是现代 Vue 应用处理加载状态的首选方案。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
注意:defineAsyncComponent
自身的 loadingComponent
选项和 <Suspense>
的 #fallback
插槽功能上有些重叠。现代实践中,更推荐使用 <Suspense>
,因为它能统一处理组件树中 任何层级 的多个异步依赖,而 loadingComponent
只对自己负责,组合性较差。这里为了教学完整性两者都展示了。
3.6. 内置组件深度解析:驾驭布局、异步与性能核心组件
引言: 我们已经掌握了构建和组合组件的十八般武艺,但 Vue 的工具箱中还藏着几位“特种兵”。它们不是用来构建 UI 元素的,而是用来解决那些常规组件组合难以处理的、棘手的布局、异步和性能问题。精通它们,是区分资深开发者与普通开发者的重要标志。
3.6.1. <Teleport>
- “空间传送门”
核心痛点: 在复杂的 DOM 结构中,一个组件的渲染位置会受到其父元素 CSS 属性的严格限制。最典型的噩梦场景就是:一个深层嵌套的组件想要弹出一个全局模态框(Modal),但其父容器设置了 overflow: hidden
或 z-index
限制,导致模态框被裁剪或被其他元素错误地遮挡。
<Teleport>
提供了一个干净利落的解决方案:它能将一个组件的 DOM 结构,“传送”到当前 Vue 应用挂载的 DOM 树中的任何位置,同时保持其逻辑上的父子关系和状态连接不变。
用法: 它的核心是一个 to
属性,它接受一个 CSS 选择器字符串或一个真实的 DOM 节点作为目标。最常见的用法是 to="body"
,将元素直接挂载到 <body>
标签下,从而跳出所有父元素的样式限制。
(TS 实战) 构建一个不受 DOM 结构限制的全局模态框
我们将构建一个可复用的 BaseModal
组件,无论它在何处被调用,其内容都将被可靠地渲染在页面的最顶层。
第一步:创建 BaseModal
组件
文件路径: src/components/common/BaseModal.vue
(新增)
1 | <script setup lang="ts"> |
第二步:在父组件中使用 BaseModal
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
打开浏览器,点击按钮,你会发现模态框完美地居中显示在整个页面之上,完全不受红色边框父容器的 overflow: hidden
影响。这就是 <Teleport>
的威力。
3.6.2. <Suspense>
- “异步万能口袋”
核心痛点: 在组件中处理异步操作(如 API 请求)时,我们通常需要手动管理至少三种状态:loading
, error
, 和 data
。当一个页面由多个需要异步加载数据的子组件构成时,每个子组件都重复着相似的状态管理逻辑,代码变得冗长,并且难以协调统一的加载体验(比如,整个页面显示一个加载动画,而不是各自闪烁),正如我们在 3.5.2
学习的异步组件一样,而 Suspense
就是 Vue3 为我们做的一个简便封装
<Suspense>
组件就是为了 以声明式的方式优雅地处理组件树中的异步依赖 而生的。它就像一个“口袋”,你把可能需要等待的异步组件放进去,它会负责展示加载状态,直到所有异步操作完成后,再无缝切换到最终内容。
用法: 它提供了两个插槽:
#default
: 用于放置你的异步组件。#fallback
: 用于放置在异步组件准备就绪前,需要显示的“加载中”内容。
(TS 实战) 统一管理仪表盘的异步组件加载
在父组件中使用 <Suspense>
编排异步流程
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
刷新页面,你会看到 “正在加载仪表盘数据…” 这个提示显示了 2 秒(以最慢的异步组件为准),然后两个小部件 同时 优雅地出现。<Suspense>
完美地编排了整个异步加载流程,让父组件的代码保持了惊人的简洁和声明性。
3.6.3. <KeepAlive>
- “性能优化利器”
核心痛点: 在使用动态组件(<component :is="...">
)或在路由之间切换时,Vue 的默认行为是 销毁 离开的组件实例,并在下次需要时 重新创建 一个全新的实例。这会导致两个问题:
- 状态丢失: 用户在组件内的输入、滚动条位置等状态都会丢失。
- 性能开销: 对于复杂的组件,频繁地创建和销毁会带来不必要的性能开销。
<KeepAlive>
的作用就是将其包裹的动态切换的组件 缓存 在内存中,而不是销毁它们。当组件被切换出去时,它只是变为“非活动”状态;当再次切回来时,它会从缓存中被重新激活,并保留之前的所有状态。
用法: 它提供了 include
和 exclude
两个 prop,可以接收字符串、正则表达式或数组,用于精确控制哪些组件需要被缓存。
专属生命周期: 被 <KeepAlive>
管理的组件会拥有两个额外的生命周期钩子:
onActivated
: 组件从缓存中被激活时调用。onDeactivated
: 组件被切换出去、进入缓存时调用。
(TS 实战) 为动态标签页保留用户输入
我们将重用 3.5.1
节的动态标签页示例,并用 <KeepAlive>
来缓存它们,以保留输入框中的内容。
第一步:创建带输入框的标签页组件
文件路径: src/components/tabs/ProfileSettings.vue
1 | <script setup lang="ts"> |
文件路径: src/components/tabs/SecuritySettings.vue
1 | <script setup lang="ts"> |
第二步:在父组件中使用 <KeepAlive>
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
现在,在“个人资料”标签页的输入框中输入一些内容,然后切换到“账户安全”标签页,再切回来。你会惊喜地发现,你之前输入的内容 依然存在!同时,观察控制台,onActivated
和 onDeactivated
钩子被正确地触发。<KeepAlive>
为我们轻松地实现了状态保持和性能优化。
第四章:组合式艺术 · 打造可复用、可测试的逻辑单元 (Composables)
摘要: 如果说组件化是对 UI 的封装,那么组合式函数 (Composables) 就是对 逻辑 的封装。本章,我们将迎来一次思维上的重要跃遷:从“在组件里写逻辑”到“将逻辑抽离给组件用”。我们将深入理解 Composables 如何从根本上超越传统 Mixins,并亲手编写简洁、可独立测试的逻辑单元。最重要的是,你将学会如何拥抱 VueUse 这一“终极武器”,将社区的智慧融入日常开发,真正掌握 Vue 3 中最强大、最优雅的代码组织与复用模式。
4.1. 为什么需要新范式?Mixins 的“光环”与“阴影”
在软件开发的演进中,每一个新范式的出现,都是为了解决旧范式的固有问题。在 Vue 2 时代,当我们想在多个组件之间共享相似的逻辑(例如,一个列表组件和一个表格组件都需要分页逻辑),Mixins
曾是我们的首选方案。它看起来很美:将可复用的代码块混入到不同的组件中,避免了复制粘贴。
然而,随着项目复杂度的提升,这杯“方便的良药”却常常伴随着难以忍受的“副作用”,这些副作用正是我们 为什么 迫切需要 Composables 的根本原因。
Mixins 的三大“原罪”
- 黑盒里的“幽灵属性” (数据来源不清晰):当一个组件混入多个 Mixin 时,其
data
或methods
中的属性究竟来自哪里?这就像一个神秘的黑盒,属性凭空出现,你无法一眼追溯其来源。1
2
3
4
5
6
7
8
9
10
11// MyComponent.js
export default {
mixins: [SearchMixin, PaginationMixin],
created() {
// this.items 是 SearchMixin 还是 PaginationMixin 提供的?
// this.loadData() 又是哪个 Mixin 的方法?
// 如果两个 Mixin 都有 loadData(),会发生什么?(答案是:覆盖)
console.log(this.items);
this.loadData();
}
} - “静默的战争” (命名空间冲突):如果两个不同的 Mixin 恰好定义了同名的
data
属性或method
,后混入的会悄无声息地覆盖前者。这种冲突不会报错,但在运行时会导致难以追踪的 Bug,成为大型项目维护的噩梦。 - “类型推断的迷雾” (TypeScript 支持不佳):
Mixins
的动态合并特性,让 TypeScript 很难精确推断出最终组件实例的类型。我们常常需要繁琐的手动类型断言,失去了静态类型检查带来的安全感。
这些痛点指向了一个核心问题:Mixins 破坏了代码的 可预测性 和 可追溯性。我们需要一种更清晰、更安全的逻辑复用模式。
4.2. 解决方案:到底什么是 Composable?
为了解决 Mixins 的根本缺陷,Vue 3 组合式 API 带来了一种回归编程本源的优雅方案——Composables
(组合式函数)。
那么,什么 是 Composable?
一个 Composable,本质上就是一个利用 Vue 响应式 API 来封装和复用有状态逻辑的函数。
让我们拆解这个定义:
- 它是一个函数:这是最关键的一点。它不像 Mixin 那样是一个需要“混入”的配置对象,而是一个可以被正常调用的 JavaScript 函数。
- 封装有状态逻辑:它不仅仅是一个纯粹的工具函数(如
formatDate
),其内部通常包含由ref
或reactive
创建的、会随时间变化的状态。 - 利用响应式 API:这是它“活”起来的关键。它通过
ref
,computed
,watch
等 API 创造出响应式的状态和副作用。 - 清晰的契约:
- 输入明确:所有外部依赖都通过函数参数明确传入。
- 输出可控:所有暴露给外部的状态和方法,都通过
return
语句显式返回。
这种“纯函数”般的模式,天然地解决了 Mixins 的所有问题:数据来源一目了然(来自函数返回值),绝不会有命名冲突(因为你可以对返回的对象进行解构和重命名),并且与 TypeScript 的类型系统完美契合。
4.3. 如何编写?从重复代码到你的第一个 Composable
理论是灰色的,生命之树常青。让我们遵循最真实的开发路径——“先在组件内实现 -> 发现重复 -> 抽离为 Composable”——来亲手打造一个逻辑单元。
4.3.1. 场景:组件内的重复逻辑
假设我们需要在 App.vue
中控制两个独立元素的显示/隐藏。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
我们立即发现了问题:代码重复。管理一个布尔状态并提供一个切换它的方法,这个逻辑模式是通用的。是时候将它抽离出来了。
4.3.2. 抽离逻辑:创建 useToggle.ts
现在,我们来回答 如何 创建一个 Composable。
文件路径: src/composables/useToggle.ts
(新增)
1 | // 1. 导入 Vue 的响应式 API,这是 Composable 的“魔力”来源 |
这个 useToggle
函数完美体现了 Composable 的所有特征:它是一个函数,内部有 ref
状态,并且输入(initialValue
)和输出({ value, toggle }
)都极为清晰。
4.3.3. 重构组件:享用 Composable 的成果
现在,我们可以用这个简洁、可复用的 Composable 来重构 App.vue
。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
看,组件变得无比清爽!它不再关心“如何”切换状态,只关心“使用”这个切换能力。我们成功地将逻辑与视图表现分离,实现了关注点分离,这就是组合式开发的魅力。
4.4. 站在巨人肩上:如何更专业地使用 Composable
useToggle
是一个很好的入门练习,但对于更复杂的场景,比如“跟踪鼠标位置”、“与 LocalStorage 同步”或“实现输入防抖”,我们是否需要自己从头开始写呢?
答案是:绝对不要!
在你准备动手编写自己的 Composable 之前,请记住一个黄金法则:“先查阅 VueUse,再造轮子”。
VueUse
是一个包含海量高质量、可摇树(tree-shakable)、类型友好的组合式函数的集合库。它几乎涵盖了你在日常开发中可能遇到的所有通用逻辑封装场景。
4.4.1. 场景升级:同步表单数据到 LocalStorage
让我们看一个极其常见的需求:用户填写表单时,我们希望将内容实时保存到 LocalStorage,这样即使用户刷新了页面,已填写的数据也不会丢失。
如果手动实现:你需要关心 onMounted
读取、watch
深度监听、JSON 序列化与解析、错误处理等一系列繁琐细节。这套流程不仅容易出错,而且每个需要此功能的组件都得重复一遍。
现在,看专业的做法:
安装 VueUse:
1 | pnpm add @vueuse/core |
用 VueUse 一行代码解决问题:
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
一行 useStorage
就代替了手动实现的所有步骤!它更简洁、更健壮,并且经过了社区的充分测试。打开开发者工具,你会看到数据在 localStorage
中实时更新。
4.4.2. VueUse 的专业思维:成为能力调度者
VueUse 函数如此之多,我们无需记忆。而是要建立一套“意图驱动”的工作流:
- 明确意图:用一句话描述你的需求。例如:“我需要 在用户滚动到页面底部时 加载更多数据。”
- 触发检索:打开 VueUse 官网,在搜索框输入你意图的关键词。例如:
scroll bottom
。 - 快速应用:搜索结果会立即指向
useScroll
及其arrivedState
属性。参考其交互式 Demo,快速将代码应用到你的项目中。
4.5. 进阶之道:解剖专业级 Composable 的内部构造
到目前为止,我们已经学会了如何使用 VueUse 来解决问题。但要成为一名真正的专家,我们必须更进一步:像 VueUse 的作者一样思考。本节,我们将不再仅仅是“看”代码,而是要“解剖”三个构成专业级 Composable 的核心设计模式,并学习如何在你自己的逻辑单元中实现它们,从而理解其强大功能背后的精妙构造。
设计模式一:无懈可击的灵活性 · 响应式参数契约
想象一下,你封装了一个用于日志记录的 Composable useMyLogger
,一个简单的版本可能长这样:
1 | // 一个天真的实现 |
这个实现能用,但它给使用者带来了 不必要的负担。它强制使用者必须传入一个 ref
。如果我想记录一个静态字符串,或者一个 computed 属性的值呢?我不得不为了满足这个函数,去额外创建一个 ref
或 computed
,这显然不够优雅。
专业级的 Composable 通过一种更灵活的参数契约解决了这个问题。它能优雅地接受 普通值、一个 ref
,或者一个 返回值的函数 (getter)。在 VueUse 的世界里,这种模式被类型 MaybeRefOrGetter<T>
所定义。
这个“魔法”的核心,是一个名为 toValue
的小巧而强大的工具函数(Vue 3.3+ 已内置)。它的职责就是将上述三种不同形态的输入,统一“解包”成最原始的值。让我们亲手揭开它的面纱:
这是一个 toValue 的底层实现
1 | import { unref } from 'vue'; |
原理一目了然。现在,我们可以用这个模式来升级我们的 useMyLogger
,使其变得无比灵活:
1 | import { ref, toValue, watch, type MaybeRefOrGetter } from 'vue'; |
架构师思维: 在封装你自己的 Composable 时,务必让你的参数接受 MaybeRefOrGetter
类型,并在内部使用 watch(() => toValue(param), ...)
的模式。这能让你的逻辑单元获得无与伦比的灵活性和可组合性,成为一个真正专业的工具。
设计模式二:后顾无忧的健壮性 · 自动化副作用管理
在 JavaScript 中,副作用(如事件监听、定时器、WebSocket 连接)是内存泄漏的重灾区。传统的做法是在组件挂载时创建,在卸载时销毁,代码分散且极易遗忘。
1 | // 传统、易出错的模式 |
这种模式将清理的责任 推给了使用者,这违背了封装的初衷。一个真正健壮的 Composable,应该自己管理好自己的“身后事”。
Vue 3 的 onScopeDispose
API 为此提供了完美的解决方案。它允许我们在当前组件的生命周期作用域中注册一个销毁时的回调函数。当组件被卸载时,这个回调就会被自动执行。VueUse 在此基础上封装了一个更友好的 tryOnScopeDispose
工具。
让我们从零开始,构建一个具备自动清理能力的 useIntervalFn
Composable,来体会这个模式的威力:
1 | import { tryOnScopeDispose } from '@vueuse/core'; // 实际项目中直接从 VueUse 导入 |
这个模式是 VueUse 中所有副作用函数(useEventListener
, useWebSocket
等)的基石。它将复杂的生命周期管理完美地封装在内,为使用者提供了极致简洁和安全的 API。
我们可以在组建中使用他
1 | <script lang="ts" setup> |
设计模式三:拥抱复杂场景 · 可配置的 options
对象
一个好的工具,既要能开箱即用,也要能应对复杂的需求。如果一个 Composable 的行为被硬编码(例如,总是监听 window
对象,总是立即执行),那么当遇到特殊场景(如在 iframe
中操作、在 SSR 环境下,或需要防抖)时,它就会变得毫无用处。
为了解决这个问题,专业的 Composable 几乎总是接受一个可选的 options
对象作为最后一个参数,它就像一个“控制面板”,允许使用者深度定制其行为。
实现这个模式的关键在于 默认值合并 和 逻辑分支。让我们创建一个 useClickCoordinates
来演示如何构建这样一个可配置的系统。
1 | import { ref } from 'vue'; |
在组件中使用这个 composable
1 | <script setup lang="ts"> |
通过这种模式,我们可以轻松扩展出各种强大的功能:
- 依赖注入: 如上面的
target
,可以传入iframe.contentWindow
来监听iframe
内部的点击,或在测试中传入一个 mock 对象。 - 行为标志: 增加一个
isListening
ref,并提供pause
和resume
方法,通过options
中的immediate: false
来决定是否初始就启动监听。 - 功能增强: 增加一个
debounce
或throttle
选项,在useEventListener
内部对handler
进行包装。
通过学习并应用这三大设计模式,你将不仅能高效地使用 VueUse,更能亲手打造出同样专业、健壮、灵活的组合式函数,让你的代码质量和架构能力提升到一个新的层次。
第五章:跨越鸿沟 · 依赖注入 provide - Inject 精通
摘要: 简单的 Props
传递在构建复杂、可复用的“功能域”时会迅速退化为一场维护灾难。本章,我们将直面“属性逐层传递”这一维护性灾难,并学习 Vue 提供的优雅解法——依赖注入系统。我们将聚焦于 provide
和 inject
的核心用法,并以 TypeScript 的 InjectionKey
为“钥匙”,开启一扇类型安全、可预测的跨层级通信大门,并精准定位它与全局状态 Pinia
的应用边界。
在本章中,我们将回归基础,聚焦于一件事:彻底掌握依赖注入。
- 直面困境: 我们将通过一个清晰的“主题切换”场景,并提供所有相关组件的完整代码,来感受“属性钻孔 (Prop Drilling)”在真实项目中带来的巨大痛苦。
- 核心解法: 我们将引入
provide
和inject
,学习如何在组件树中直接建立跨层级的“秘密通道”。 - 类型契约: 接着,我们将为这个通道加上类型安全的“门禁”——
InjectionKey
,杜绝运行时错误。 - 最佳实践: 我们将探索如何通过这条通道安全地传输 只读的响应式状态 和 状态更新函数,以遵循单向数据流原则。
- 明确边界: 最后,我们将清晰地界定
provide/inject
在“组件子树”和Pinia
在“全局”的各自战场。
5.1. 困境的起点:“属性钻孔”
痛点背景: 想象一个应用,顶层的 App.vue
拥有主题设置,而一个深藏在三层之下的 ThemeToggleButton.vue
组件需要读取并切换这个主题。现在,我们来完整地构建出这个场景。
1 | # App.vue (持有 theme 状态和 toggleTheme 方法) |
第一步:创建最终使用 Prop 的按钮组件
文件路径: src/components/ThemeToggleButton.vue
(新增)
1 | <script setup lang="ts"> |
第二步:创建中间的、只负责传递 Prop 的组件
文件路径: src/components/TheHeader.vue
(新增)
1 | <script setup lang="ts"> |
文件路径: src/components/PageLayout.vue
(新增)
1 | <script setup lang="ts"> |
第三步:在 App.vue
中组装并管理状态
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
这就是 “属性钻孔”) 的真实形态。PageLayout
和 TheHeader
这两个中间组件,它们本身的功能与主题切换毫无关系,却被迫接收和传递了 theme
和 toggleTheme
。这严重污染了这两个组件,破坏了它们的封装性,让未来的重构和维护变成了一场噩梦。
5.2. 解决方案:provide
与 inject
的“秘密通道”
现在,我们用依赖注入来解耦这个系统。
第一步:在 App.vue
中 provide
数据和方法
文件路径: src/App.vue
(修改为 Provider)
1 | <script setup lang="ts"> |
第二步:在 ThemeToggleButton.vue
中 inject
文件路径: src/components/ThemeToggleButton.vue
(修改为 Injector)
1 | <script setup lang="ts"> |
第三步:清理中间组件
现在,PageLayout.vue
和 TheHeader.vue
的 <script>
部分可以变得完全干净,它们回归了作为布局组件的纯粹职责。
文件路径: src/components/PageLayout.vue
(清理后)
1 | <script setup lang="ts"> |
5.3. (TS 核心) InjectionKey
:为通道加上类型门禁
上面的字符串 key
虽然能工作,但在 TypeScript 项目中是 绝对不推荐 的。我们必须使用 InjectionKey
来保证类型安全和 Key
的唯一性。
第一步:创建类型安全的 Key
文件路径: src/keys.ts
(新增)
1 | import type { InjectionKey, Ref } from 'vue'; |
第二步:使用 Key
进行类型安全的注入
文件路径: src/App.vue
(修改为使用 Key
)
1 | <script setup lang="ts"> |
文件路径: src/components/ThemeToggleButton.vue
(修改为使用 Key
)
1 | <script setup lang="ts"> |
5.4. 最佳实践:单向数据流与 readonly
为了防止后代组件意外地修改来自祖先的状态(例如 theme.value = 'new value'
),我们应该遵循 单向数据流 原则。
最佳实践是:祖先提供一个只读的响应式状态,和一个专门用来修改该状态的函数。
文件路径: src/App.vue
(Final Version)
1 | <script setup lang="ts"> |
现在,ThemeToggleButton.vue
中任何尝试直接修改 theme.value
的行为都会在开发环境下收到警告,保证了数据流的清晰和可预测性。
5.5. 架构师的权衡:provide/inject
vs. Pinia
在我们的番外篇中,也就是当您阅读完provide之后可以阅读我们的Pinia番外篇,他作为全局的状态管理以及给组建传递数据已经是业内的最佳实践,所以我强烈建议您去阅读我们的番外篇,但在那之前,如果您需要更系统的学习,可以先阅读完我们的第六章以及另外一个番外篇之后,再尝试阅读pinia,这是我建议的最佳规划,尽管他会让你在不同的笔记中跳转
特性 | provide / inject | Pinia (全局状态管理) |
---|---|---|
作用域 | 局部,强依赖于组件树 | 全局,独立于任何组件,整个应用唯一 |
核心场景 | 组件子树内的状态共享,如 UI 库(<Form> -> <FormItem> )、插件、或我们示例中的主题功能。 | 全局性的、跨领域的状态,如用户登录信息、购物车、应用设置等,需要被无直接关系的组件共享。 |
核心选型原则:当你开发的逻辑是 “自上而下”、强耦合于某个组件子树的,provide/inject
是完美的工具。当你需要处理 跨越不同组件树 的全局状态时,Pinia
是不二之-选。
现在,你已经清楚了 provide/inject
的精确使用方法。但对于那些真正需要一个独立、强大且易于调试的“全局状态中心”的场景,Pinia
才是我们的终极答案。它是我们为应用安装“心脏”的关键技术。
5.6. 本章核心速查总结
分类 | 关键项 | 核心描述与架构考量 |
---|---|---|
核心 API | provide(key, value) | 在组件及其后代中提供一个值。key 应该是 InjectionKey 。 |
核心 API | inject(key, defaultValue?) | 注入一个由祖先组件提供的值。key 应该是 InjectionKey 。 |
TS 核心 | InjectionKey<T> | (推荐) 结合 Symbol 创建一个类型安全的、唯一的注入密钥。是专业协作的基石。 |
响应式 | provide(key, readonly(state)) | (最佳实践) 提供响应式数据时,使用 readonly 包装以强制单向数据流。 |
健壮性 | inject(key, ...) | (推荐) 始终检查 inject 的返回值是否为 undefined (或在 TS 环境下抛错),或提供一个默认值。 |
5.7. 高频面试题与陷阱
在 Vue 3 中,provide/inject 和全局状态管理库 (如 Pinia) 都可以解决跨组件通信问题。你是如何理解它们的区别,并会在什么场景下选择使用 provide/inject 而不是 Pinia?
provide/inject 是一种基于组件树的、局部的依赖注入方案,而 Pinia 是应用级的、全局的状态管理方案。我的核心选择标准是“状态的影响范围和内聚性”。如果我正在构建一个高内聚的功能单元,其状态只在它自己的组件子树内共享,这时 provide/inject 是完美的,它实现了“域内共享,域外隔离”。而对于像用户登录信息这种需要被应用中任何地方、跨越不同功能域共享的状态,就必须使用 Pinia。
很好。那么,当使用 provide/inject 提供一个响应式对象时,为什么推荐使用 readonly 进行包装?这背后体现了什么样的设计原则?
这体现了“单向数据流”这一核心设计原则。在 Vue 中,数据应该遵循“自上而下”的流动方式。如果祖先组件直接 provide 一个可写的 ref 或 reactive 对象,任何后代组件都可以随意修改它,这将导致数据变更的来源变得不可追溯。通过 provide(key, readonly(state)),祖先组件依然可以修改源状态,而后代组件只能读取。如果后代需要变更状态,它应该调用从祖先 provide 的一个专门的更新函数,这使得数据流变得清晰、可控、可预测,是构建健壮应用的基石。
第六章:深入 Vue 骨架:高阶指令、插件化与性能调优
摘要: 在本章,我们将深入 Vue 的“骨架层”,学习如何通过官方提供的扩展机制,为框架赋予超越其核心能力的“超能力”。我们将以“从零实现一个国际化(i18n)功能”为实战线索,首先学习如何通过 自定义指令 封装底层 DOM 操作,接着将功能封装为 标准插件 以提供全局能力。在亲身体会手动实现的复杂性后,我们将视野拔高,引入工业级的自动化 i18n 工作流。最后,我们将深入 Vue 的性能核心,掌握 v-memo
和 Diff 算法等终极性能优化武器。
- 第一部分 (指令封装): 我们将首先学习 自定义指令 的理论,并立即投入实战,尝试用一个
v-t
指令来解决 i18n 的文本翻译问题,并在此过程中理解其适用场景与局限。 - 第二部分 (插件架构): 接着,为了解决指令的局限性,我们将学习 插件 的设计模式。通过将 i18n 功能封装成一个提供全局
$t
方法的插件,来掌握 Vue 应用全局能力的正确扩展方式。 - 第三部分 (工程跃迁): 在完整体验了手动封装 i18n 的“困难”之后,我们将展示现代前端工程如何解决这一问题,引入并对比三款强大的 自动化 i18n 库。
- 第四部分 (性能精通): 最后,我们将话题从“功能扩展”转向“性能挖掘”,深入理解 Vue 的 Diff 算法 和
key
的重要性,并学习v-memo
等高级性能指令。
6.1. 自定义指令:DOM 操作的优雅封装
到目前为止,我们一直在 Vue 的虚拟 DOM (Virtual DOM) 世界里工作,通过数据驱动视图,享受着不直接操作 DOM 带来的便利和性能。然而,在某些场景下,我们仍然需要回归本源,对真实的 DOM 元素进行底层操作。
6.1.1. 指令的本质
指令的本质,是 Vue 提供给我们的一个 可复用的、用于封装底层 DOM 操作 的“逃生舱口”。
当一项功能的 核心关注点是直接操作 DOM,而不是通过 VNode 描述 UI 结构时,自定义指令就是比组件更合适的抽象。它就像一把精准的手术刀,让我们可以在 Vue 的生命周期内,安全、可控地对元素进行“手术”。
典型的适用场景包括:
- 元素聚焦:页面加载后自动聚焦某个输入框 (
v-focus
)。 - 外部事件监听:检测点击是否发生在某个元素外部,以关闭下拉菜单 (
v-click-outside
)。 - 集成第三方库:将一个需要挂载到特定 DOM 元素的、非 Vue 的 JS 库(如某些图表、动画库)进行封装。
- 权限控制:根据用户权限,直接从 DOM 中移除或禁用某个按钮 (
v-permission
)。
与组件不同,指令的职责更加专一:它不关心业务逻辑和 UI 结构,只关心它所绑定的那个 单一元素 本身。
6.1.2. 指令的生命周期钩子
为了让我们能精准地控制 DOM 操作的时机,Vue 为自定义指令提供了一套类似于组件生命周期的钩子函数。
钩子函数 | 触发时机 | 核心用途 |
---|---|---|
created | 在绑定元素的 attribute 或事件监听器被应用之前调用。 | 进行一些不依赖 DOM 的早期初始化。 |
beforeMount | 当指令第一次绑定到元素并且在挂载父组件之前调用。 | 准备工作,此时元素尚未插入 DOM。 |
mounted | 在绑定元素的父组件被挂载后调用。 | (最常用) 元素已在 DOM 中,可以安全地进行 DOM 操作、添加事件监听、初始化第三方库。 |
beforeUpdate | 在更新包含组件的 VNode 之前调用。 | 在 DOM 更新前,可以访问到更新前的 DOM 状态。 |
updated | 在包含组件的 VNode 及其子组件的 VNode 更新后调用。 | (常用) 当组件状态变化导致 DOM 更新后,需要对 DOM 进行相应调整时使用。 |
beforeUnmount | 在卸载绑定元素的父组件之前调用。 | 在元素从 DOM 中移除前,进行最后的清理准备。 |
unmounted | 当指令与元素解除绑定且父组件已卸载时调用。 | (极其重要) 进行清理操作,如移除 mounted 中添加的事件监听器,以防止内存泄漏。 |
6.1.3. (TS 实战) 类型安全的指令
在 TypeScript 项目中,为自定义指令提供精确的类型是保证代码健壮性的基石。Vue 导出的 Directive
类型允许我们通过泛型来实现这一点。
Directive<T, V>
接收两个泛型参数:
T
: 指令所绑定的 元素类型。默认为any
,但我们应尽可能精确,如HTMLElement
,HTMLInputElement
等。V
: 指令接收的 绑定值类型。例如v-example="'hello'"
,V
就是string
;v-example="123"
,V
就是number
。
示例:创建一个类型安全的 v-highlight
指令
这个指令会将元素的背景色设置为传入的颜色字符串。
文件路径: src/directives/vHighlight.ts
(新增)
1 | import type { Directive, DirectiveBinding } from 'vue'; |
通过 Directive<HTMLElement, string>
,TypeScript 现在能够理解 el
是一个 DOM 元素,拥有 style
属性;同时它也知道 binding.value
是一个字符串,为我们提供了无与伦比的代码提示和编译时安全检查。
在模板中使用指令
1 | <script setup lang="ts"> |
6.1.4. 实战叙事(一):用 v-t
指令初探 i18n
现在,我们具备了所有理论知识,让我们用它来解决一个真实的问题:国际化。我们的第一个目标是创建一个 v-t
指令,用于翻译页面上的静态文本。
第一步:创建语言包和基础逻辑
文件路径: src/locales.ts
(新增)
1 | import { ref } from 'vue'; |
第二步:创建 v-t
指令
文件路径: src/directives/vT.ts
(新增)
1 | import type { Directive } from 'vue'; |
第三步:在组件中局部注册并使用指令
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
现在,运行你的应用。你会看到文本被正确地翻译了,点击按钮,文本也能响应式地更新。我们用自定义指令成功迈出了国际化的第一步!
体验“困难”:当前方案的局限性
虽然我们的指令能工作,但它并不完美,甚至可以说是“简陋”的。作为一名追求卓越的“开拓者”,我们必须清醒地认识到其缺陷:
- 响应性繁琐:我们不得不在
mounted
钩子中手动watch
语言环境的变化来更新 DOM。如果指令逻辑更复杂,这种手动管理会变得非常容易出错。 - 内容局限:
el.textContent
只能处理纯文本。如果我们的翻译文案包含 HTML 标签,例如welcome: '欢迎来到<strong>我们的</strong>应用!'
,指令会将其作为普通字符串直接显示,而不是渲染成加粗的 HTML。 - 全局注册不便:目前我们是在
App.vue
中局部注册。如果每个需要翻译的组件都要导入并注册一次,代码会变得非常冗余。
这个指令是一次宝贵的实践,它让我们深刻理解了指令的能力边界。为了构建一个真正健壮、全局可用的 i18n 系统,我们需要一种更强大的架构模式。是时候从 指令 毕业,进入 插件 的世界了。
6.2. 插件:封装与分发全局能力
在 6.1
节的结尾,我们留下了一个悬念:v-t
指令虽然实现了基础的翻译功能,但它在响应性、内容处理能力和全局可用性上都暴露了明显的短板。这些问题并非偶然,它深刻地揭示了一个架构原则:当一项功能需要超越单一 DOM 元素的范畴,成为应用级的“公共服务”时,我们就必须从指令跃迁到一种更高级的封装模式——插件。
6.2.1. 为何插件是必然之选?
让我们再次审视 v-t
指令无法解决的问题,并以此推导出插件的必要性:
- 全局注册的需求: 我们不希望在每个需要翻译的组件中都手动导入
v-t
指令。我们需要一种“一次性安装,处处可用”的机制。 - 模板能力的局限: 指令主要操作 DOM,但在模板中我们更习惯使用
{{ }}
插值语法。我们需要一个能在模板中像{{ message }}
一样自然使用的全局翻译函数,例如{{ $t('key') }}
。 - 响应式传递的优雅: 我们需要一种比在指令内部手动
watch
更优雅、更高效的方式,来让整个应用对“语言环境切换”这一全局状态做出响应。
这三个需求共同指向了一个目标:我们需要为应用添加一个 全局的、响应式的、易于消费的“国际化服务”。在 Vue 的世界里,插件 (Plugin) 正是为承载此类“服务”而生的标准架构模式。
6.2.2. 插件的解剖:install
方法与 app
对象
一个 Vue 插件的结构极其简洁:它只需要是一个拥有 install
方法的对象。
1 | import type { App, Plugin } from 'vue'; |
当我们调用 app.use(myPlugin, { /* options */ })
时,Vue 就会执行这个 install
方法,并传入两个关键参数:
app
: 当前的 Vue 应用实例。这是我们的“万能钥匙”,通过它几乎可以访问和扩展应用的所有核心能力。options
: 用户在app.use
时传入的第二个可选参数。这使得我们的插件具备了 可配置性,是编写专业级插件的必备要素。
app
对象提供了多个用于扩展的 API,其中对我们最重要的有:
API | 功能描述 | 在 i18n 场景中的用途 |
---|---|---|
app.config.globalProperties | 在所有组件实例上挂载一个全局属性。 | (核心) 将翻译函数挂载为 $t ,使其在所有模板中 {{ $t(...) }} 可用。 |
app.provide() | 在整个应用层面提供数据,可被任何后代组件 inject 。 | 提供更底层的、响应式的 i18n API,供 Composable 或 <script> 逻辑消费。 |
app.directive() | 注册一个全局自定义指令。 | 如果我们的插件想顺便提供一个 v-t 指令,也可以在这里注册。 |
app.component() | 注册一个全局组件。 | 比如注册一个 <LanguageSwitcher> 全局组件。 |
6.2.3. (实战) 锻造一个专业的 i18nPlugin
现在,理论储备完毕,让我们动手将上一节的 i18n 逻辑重铸为一个健壮、可配置的插件。
文件路径: src/plugins/i18n.ts
(新增)
1 | import { ref, type App, type Plugin } from 'vue'; |
这个重写后的插件,相比之前的简单实现,有了质的飞跃:
- 可配置: 通过
options
对象,使用者可以指定初始语言。 - 双轨 API: 同时提供了面向模板的
$t
和面向<script>
的provide/inject
,覆盖了所有使用场景。 - 类型安全: 通过
InjectionKey
,为provide/inject
建立了类型契约。
6.2.4. (TS 核心) 完美的类型契约
与上一节一样,我们需要通过模块扩展来为 $t
和 inject
提供类型支持。
文件路径: src/types/vue-globals.d.ts
(修改或创建)
1 | // 1. 为全局属性 $t 提供类型 |
6.2.5. 激活插件,享受优雅
最后,让我们在 main.ts
中激活插件,并看看 App.vue
现在变得多么简洁。
第一步:在 main.ts
中注册
文件路径: src/main.ts
(修改)
1 | import { createApp } from 'vue' |
第二步:在 App.vue
中重构
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
我们不仅解决了 v-t
指令的所有问题,还构建了一个可配置、双轨 API、类型安全的专业级插件。这个从指令到插件的重构过程,本身就是一次宝贵的架构设计实践。现在,我们已经完全理解了手动实现 i18n 系统的原理与模式,是时候看看现代工程是如何将这个过程自动化的了。
6.3. 工程化跃迁:自动化 i18n 工作流
我们在 6.2
节中,以极客精神亲手锻造了一个功能完备的 i18n 插件。这让我们对 Vue 的扩展机制有了深刻的理解。但与此同时,我们也亲自“品尝”了手动维护语言包的苦涩。这份“痛苦”的体验,正是我们通往更高阶工程思维的垫脚石。
6.3.1. 最后的“体力活”:手动维护的隐形技术债
我们构建的插件,其架构是优雅的,但其工作流却是脆弱的。在真实的、快速迭代的项目中,locales.ts
这种手动维护模式,会迅速累积成一笔庞大的、拖慢整个团队效率的“隐形技术债”。
痛点一:繁琐的文案提取与 Key
命名。每当产品经理提出一个新的文案需求,开发者就必须执行一套枯燥的流程:在组件中写下文案 -> 切换到 locales.ts
文件 -> 构思一个全局唯一的、语义化的 Key
-> 在 zh
对象中添加 key: "中文文案"
-> 在 en
对象中添加 key: "英文翻译"
… 这个过程不仅打断心流,而且充满了犯错的可能。
痛点二:难以维系的同步性。当一个常用文案需要从“提交”改为“确认提交”时,开发者需要像侦探一样,通过 key
找到所有语言文件中对应的位置,并一一修改。一旦遗漏,就会造成不同语言版本的功能文案不一致,引发用户困扰,甚至导致线上 Bug。
痛点三:低效的翻译流程。开发者被迫扮演翻译的角色,使用各种外部工具,在代码文件和翻译网站之间反复横跳、复制粘贴。这不仅效率低下,也无法保证翻译的专业性。
这些日常开发中的“微小摩擦”,累积起来就是巨大的工程瓶颈。幸运的是,我们并非唯一面对此困境的人。社区为此早已演化出了成熟的自动化解决方案,它们的核心思想,就是将国际化的 内容生产 过程从开发者的手动劳动中彻底剥离。
6.3.2. i18n-auto-extractor
(契约驱动,深度实战)
此方案遵循“显式优于隐式”的契约驱动哲学。它要求开发者用 $at()
函数将所有需要翻译的文案包裹起来,这不仅为工具提供了清晰的提取目标,也让代码的国际化意图一目了然,极大地增强了项目的长期可维护性。
第一步:安装与初始化
我们首先在项目中安装依赖,并执行初始化命令。
1 | # 1. 安装依赖 |
该命令会扫描全盘中你使用 $at()$
包裹的内容,进行翻译
第二步:标记文案与一键提取
现在,我们可以在代码中,用 $at()
函数包裹任何需要翻译的静态中文字符串。
在 <script>
中:const pageTitle = $at('用户个人中心');
在 <template>
中:<h1>{{ $at('欢迎回来') }}</h1>
完成标记后,只需在终端再次运行提取命令,剩下的所有事情都将自动完成。
1 | npx i18n-auto-extractor |
此命令会遍历 src
目录,找到所有被 $at()
包裹的文案,将它们以中文原文为 key
存入 zh.json
,然后调用谷歌翻译 API,生成 en.json
, ja.json
, ko.json
等所有目标语言文件。繁琐的体力活,现在被压缩到了一行命令。
第三步:(核心) 在 Vue 3 中集成与实现响应式切换
要让翻译在页面上生效并支持动态切换,我们需要使用该工具专为 Vue 提供的 Composable useVueAt
。
文件路径: src/App.vue
1 | <script setup lang="ts"> |
第七章:应用的“交通枢纽” · Vue Router 全面指南
摘要: 在本章中,我们将一气呵成地掌握 Vue Router 的全部核心知识。我们将通过构建一个包含 登录、后台布局、动态用户详情 等模块的微型应用,将路由的每一个核心 API 和设计模式,都融入到真实、连贯的开发流程中。在这个过程中,Pinia
将作为我们的“中央认证系统”,JSON Server
作为“用户数据库”,SCSS
作为“视觉设计师”,与 Vue Router
协同完成一个工业级的导航与权限控制流程。
在本章中,我们将像搭建城市交通网络一样,精确地构建应用的导航系统:
- 首先,我们将从 零开始,构筑一个完整的、包含所有必要工具的路由实战环境。
- 接着,我们将学习 路由的核心组件 和 两种导航方式:声明式与编程式。
- 然后,我们将深入 路由传参 的两种核心模式:动态参数和查询参数,并与 Pinia、JSON Server 深度联动。
- 之后,我们将探讨 嵌套路由 和 懒加载,构建复杂的页面布局并优化性能。
- 最后,我们将利用 路由元信息 和 导航守卫,结合 Pinia 打造一条坚不可摧的自动化“认证防线”。
7.1. 专业基石:从零构筑一个完整的路由实战环境
第一步:初始化 Vite 项目
我们从一个全新的、最纯净的 Vite + TypeScript
项目开始。
1 | pnpm create vite vue-router-practice --template vue-ts |
第二步:安装所有核心依赖
进入项目目录,然后我们将一次性安装本指南所需的全部核心依赖。
1 | cd vue-router-practice |
1 | # 核心功能库 |
vue-router
: 我们本章的主角。pinia
: 用于后续章节的认证状态管理。sass
: SCSS 预处理器,用于编写专业样式。json-server
: 用于模拟后端 API,提供真实的数据交互。@types/node
: 为 Node.js 内置模块(如path
)提供 TypeScript 类型定义,Vite 配置中会用到。
**第三步:配置路径别名 (@
) **
为了避免在项目中出现恼人的 ../../../
相对路径,我们必须配置 @
路径别名,让其直接指向 src
目录。这需要同时告知 TypeScript(用于代码提示和类型检查)和 Vite(用于项目编译和运行)。
1. 配置 tsconfig.app.json
(告知 TypeScript)
在 Vite 的标准项目结构中,src
目录下的应用代码配置由 tsconfig.app.json
文件控制。因此,我们必须在这里添加路径别名配置,而不是在根目录的 tsconfig.json
中。
文件路径: tsconfig.app.json
(修改)
1 | { |
重要提示:请确保根目录的
tsconfig.json
文件保持其初始状态,不要在其中添加baseUrl
或paths
,否则会因为配置覆盖而导致别名在 VSCode 中不生效。
2. 配置 vite.config.ts
(告知 Vite)
现在,我们需要告诉 Vite 在编译和打包时如何识别这个 @
别名。
文件路径: vite.config.ts
(修改)
1 | // 引入 Node.js 的 'url' 和 'path' 模块,用于处理文件路径 |
提示:如果
import 'node:url'
提示找不到模块,你需要安装 Node.js 的类型定义:npm install -D @types/node
或pnpm add -D @types/node
。
3. 重启 TypeScript 服务 (关键步骤)
修改 tsconfig
文件后,VSCode 不会立即应用新配置。你需要手动重启 TS 服务:
- 在 VSCode 中按下
Ctrl+Shift+P
(Windows/Linux) 或Cmd+Shift+P
(Mac)。 - 输入并选择
TypeScript: Restart TS Server
。
完成后,代码中的路径错误提示(红色波浪线)就会消失,并且 @
别名可以正常使用了。
第四步:准备 Mock 后端
我们创建 db.json
文件,并配置 package.json
脚本来启动 json-server
。
文件路径: db.json
(新建于项目根目录)
1 | { |
文件路径: server.cjs
(新增)
1 | // 1. 引入所需库 |
文件路径: package.json
(修改 scripts
)
1 | { |
在后续的学习中,你需要 同时开启两个终端:一个运行 pnpm run dev
启动前端应用,另一个运行 pnpm run mock
启动后端 API 服务。
第五步:集成 Pinia 与 Router
最后,我们在 main.ts
中完成 Pinia 和 Router 的全局注册。
1. 创建路由配置文件
文件路径: src/router/index.ts
(新建)
1 | import { createRouter, createWebHistory } from 'vue-router'; |
2. 完成全局注册
文件路径: src/main.ts
1 | import { createApp } from "vue"; |
7.2. 创建路由实例:createRouter
与 history
模式
在上一节中,我们已经创建了 src/router/index.ts
这个文件。现在,我们来打开这个空白文件,像一位工程师一样,一步步地构建出我们应用所需的“交通枢纽”。
要创建一个路由器实例,vue-router
库为我们提供了核心的工厂函数 createRouter
。同时,我们需要决定应用的 URL 风格。现代单页应用为了追求更美观、更有利于 SEO 的 URL(例如 /about
),通常会选择基于浏览器原生 History API 的模式,为此,我们还需要 createWebHistory
这个工具。
文件路径: src/router/index.ts
1 | import { createRouter, createWebHistory } from 'vue-router'; |
createRouter
函数接收一个配置对象,其中 history
属性用于指定路由模式。除了我们首选的 createWebHistory
,Vue Router 还提供了其他模式以应对不同场景。
HTML5 模式
这是现代单页应用的 最佳实践。它利用了浏览器原生的 history.pushState
API,URL 看起来就像传统的网站一样,非常美观。
Hash 模式
这种模式会在 URL 中使用一个 #
符号(例如 https://example.com/#/about
)。它的最大优点是 兼容性极好,无需任何服务器端配置即可运行。
内存模式
这种模式不与浏览器地址栏交互,主要用于非浏览器环境,例如 服务端渲染 (SSR)。
7.3. 全局注册与核心组件:<router-link>
和 <router-view>
本章核心知识点:
在创建好路由实例后,必须通过 app.use(router)
将其 全局注册 到 Vue 应用中,使其生效。注册后,Vue Router 提供了两个 核心全局组件:
<router-view>
: 路由内容的 渲染出口。它是一个占位符,用于显示当前 URL 匹配到的组件。<router-link>
: 声明式导航 的实现方式。它被渲染为<a>
标签,用于创建导航链接,实现无页面刷新的路由跳转。
创建好的路由实例需要被 Vue 应用所知晓,才能真正地工作起来。我们在 main.ts
中通过 app.use()
来完成全局注册。
文件路径: src/main.ts
1 | import { createApp } from 'vue'; |
注册完成后,我们就可以在应用的任何组件中使用路由功能了。现在,我们需要认识两个由 Vue Router 提供的核心全局组件,它们是实现页面导航和内容展示的基石。
为此,我们先创建两个简单的“页面”组件,并为它们定义路由规则。
文件路径: src/views/HomePage.vue
(新建)
1 | <template> |
文件路径: src/views/AboutPage.vue
(新建)
1 | <template> |
文件路径: src/router/index.ts
(修改)
1 | import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; |
我们在这里直接使用了 路由懒加载 (() => import(...)
) 的写法。这是一个至关重要的 最佳实践,它能将不同页面的代码分割成独立的 JS 文件,只有在访问该页面时才会被下载,从而极大地优化应用的首屏加载速度。
现在,我们修改 App.vue
来使用 <router-link>
和 <router-view>
。
文件路径: src/App.vue
1 | <template> |
现在,运行项目,你将看到一个带有导航栏的页面。点击链接,下方的正文内容会在两个组件之间瞬时切换,而整个浏览器页面 并未刷新。
<router-view>
: 它是 路由内容的“渲染出口”。这是一个占位符,Vue Router 会将当前 URL 匹配到的组件渲染在这个位置。<router-link>
: 它是 声明式导航的最佳实践。它会被渲染成一个<a>
标签,但它会拦截浏览器的默认点击事件,通过history.pushState
API 来改变 URL 并更新视图,从而避免了代价高昂的整页刷新。
7.4. 编程式导航:useRouter
本章核心知识点:
除了使用 <router-link>
进行模板内的 声明式导航 外,我们经常需要在 <script>
逻辑中手动触发页面跳转(例如登录成功后)。这种方式称为 编程式导航。
useRouter()
: 这是vue-router
提供的一个 Composition API 钩子 (Hook),用于在组件的setup
函数中获取全局的 router 实例。router.push()
/router.replace()
: 获取到的router
实例提供了多种导航方法。push
会在历史记录中添加新条目,而replace
会替换当前条目(常用于登录等场景)。
我们在上一节学习的 <router-link>
是一种 声明式 导航,它非常适合用于那些用户直接点击的、静态的导航菜单。但在真实的应用中,有大量的场景需要在执行完一段业务逻辑后,再由代码来主动控制页面的跳转。
最经典的场景莫过于 用户登录:我们不能简单地用 <router-link>
包裹“登录”按钮,因为程序需要先进行表单验证、调用 API、等待服务器返回成功响应,然后 才能将用户导航到主页。这种由逻辑驱动的、手动的跳转,就叫做 编程式导航。
要实现编程式导航,我们首先需要获取到路由器的实例。在 <script setup>
环境中,vue-router
提供了一个名为 useRouter
的钩子 (Hook),它可以让我们轻松地获取到在 main.ts
中创建的那个全局 router
实例,这个实例就是我们进行编程式导航的“遥控器”。
第一步:创建登录页与路由
我们先创建一个 LoginPage.vue
组件,并为其配置路由。
文件路径: src/views/LoginPage.vue
(新建)
1 | <script setup lang="ts"> |
文件路径: src/router/index.ts
(修改)
1 | // ... |
为了方便访问,我们在 App.vue
中也增加一个登录页的链接。
文件路径: src/App.vue
(修改)
1 | <template> |
现在,你可以点击“登录”链接进入登录页,然后点击“点击模拟登录”按钮,会发现在 1 秒后,页面自动跳转回了主页。
push
vs replace
:历史记录的艺术
我们刚刚使用的 router.push()
方法,会在浏览器的历史记录栈中 新增 一条记录。这意味着在登录跳转后,用户可以点击浏览器的“后退”按钮,再次回到登录页面。这在登录场景下显然是不合理的。
我们需要的是一种不会留下历史记录的跳转方式。
router.push()
: 推入 新纪录到历史栈。A -> B
,历史栈为[A, B]
。router.replace()
: 替换 当前记录。A -> B
,历史栈为[B]
。
让我们用 replace
来修正登录逻辑。
文件路径: src/views/LoginPage.vue
(修改 handleLogin
函数)
1 | function handleLogin() { |
最佳实践: 对于登录、注册等一次性操作,或者任何不希望用户能够“后退”回来的页面跳转,都应该优先使用 router.replace()
。
此外,router
实例还提供了 router.go(n)
方法,允许你在历史记录中前进或后退 n
步,例如 router.go(-1)
就等同于浏览器的后退按钮。
7.5. 路由传参(一):动态路由参数与生态协同
本章核心知识点:
为了用一条路由规则匹配多种相似的 URL(如 /users/1
, /users/2
),我们使用 动态路由参数,在 path
中以冒号 :
开头定义,例如 path: '/users/:id'
。
useRoute()
:vue-router
提供的另一个核心钩子,用于获取 当前激活的路由对象。这是一个只读对象,包含了当前 URL 的所有信息。route.params
: 通过useRoute()
获取的route
对象上的params
属性,可以访问到动态路由参数的值(如route.params.id
)。
核心概念区分:
useRouter()
: 是“遥控器”,负责 执行动作,比如push
,replace
。useRoute()
: 是“GPS 定位信息”,负责 提供只读信息,比如当前路径和 URL 参数。
我们现在已经掌握了如何通过代码控制页面跳转,但这些页面都是静态的。一个真实的应用,其核心价值在于展示 动态 的数据——例如,查看用户 A 的个人资料,或是商品 B 的详情页。我们不可能为系统中的每一个用户都手动创建一条路由规则。
这就引出了路由系统中一个极其强大的概念——动态路由参数。它允许我们用 一条 规则来匹配所有同类型的路径,例如用 /users/:id
来匹配所有用户详情页。
要实现这个功能,我们需要一个新的钩子 useRoute
来 读取 当前 URL 上的信息,并与我们的 API 层和状态管理层进行深度协同。
第一步:构建专业的 API 请求层
在真实的企业级项目中,我们不会在业务逻辑中直接使用 fetch
。而是会使用 axios
并将其封装到一个独立的、可复用的模块中,以集中管理配置和拦截器。
首先,安装 axios
。
1 | pnpm add axios@1.7.2 |
接着,我们创建 apiClient
,这是我们项目中所有 API 请求的唯一出口。
文件路径: src/types/user.ts
(新建)
1 | export interface User { |
文件路径: src/api/client.ts
(新建)
1 | import axios from "axios"; |
文件路径: src/services/userService.ts
(新建)
1 | import apiClient from "../client"; |
第二步:创建数据驱动的 Pinia Store
现在,我们创建一个 userStore
,它将使用我们刚刚构建的 apiClient
来与后端通信。
文件路径: src/stores/user.ts
(新建)
1 | import { ref, computed } from "vue"; |
第三步:定义动态路由并实现组件
确保你的 json-server
正在运行。现在,我们在 router/index.ts
中添加动态路由规则。注意 path
中的 :id
部分,这个冒号前缀告诉 Vue Router, 这是一个 动态段。
文件路径: src/router/index.ts
(修改)
1 | // ... |
最后,我们创建详情页组件,将所有技术栈在此交汇。
文件路径: src/views/UserProfilePage.vue
(新建)
1 | <script setup lang="ts"> |
第四步:添加入口链接
为了能方便地测试,在 App.vue
中添加几个指向不同用户详情的链接。
文件路径: src/App.vue
(修改)
1 | <nav> |
现在,刷新你的应用。点击“用户 1”和“用户 2”,你会看到组件成功地从 URL 中获取 ID,调用 Pinia Action,通过我们封装的 apiClient
向 json-server
请求数据,并最终将不同用户的信息优雅地渲染出来。
这个实践完美地演示了一个 数据驱动的动态视图 的完整生命周期。我们通过 Vue Router (useRoute
) 捕获用户意图(ID),通过一个专业的 API Client (axios
) 执行数据请求,通过 Pinia (Action
) 管理业务逻辑与状态,最终将结果响应式地呈现在 UI 上。这就是现代前端框架生态协同工作的强大之处。
7.6. 路由传参(二):查询参数 (Query)
本章核心知识点:
当需要对资源列表进行 筛选、排序或分页 时,我们使用 查询参数 (Query)。它是 URL 中 ?
之后的部分,以 key=value
形式存在,例如 /search?q=vue&sort=price
。
route.query
: 与route.params
类似,通过useRoute()
获取的route
对象上的query
属性,可以访问到 URL 中的所有查询参数。- URL 驱动开发: 一种最佳实践,即组件的输入(如搜索框)不直接触发数据请求,而是先 更新 URL 的查询参数;然后通过
watch
侦听 URL 的变化,再根据新的查询参数去请求数据。这使得 URL 成为“唯一信源”,利于分享和收藏。
核心定位区分:
Params
(/users/:id
): 用于 定位 一个唯一的资源,是路径的一部分。Query
(/search?q=vue
): 用于 筛选 一个资源集合,是附加的查询条件。
在上一节中,我们完美地掌握了如何通过 动态路由参数 (/users/:id
) 来获取并展示一个 特定的资源。现在,我们将面临一个更常见的场景:如何展示一个 资源集合,并对其进行 筛选、排序或分页?
痛点背景: 假设我们需要一个商品搜索页面。用户输入的搜索关键词、选择的排序方式、以及当前页码,这些信息应该如何传递?如果尝试用动态参数,路径会变得非常笨拙和僵化,例如 /products/keyword/price/asc/page/1
。这种结构难以扩展和维护。
解决方案: 查询参数 (Query)。它是 URL 路径 ?
之后的部分(例如 /search?q=vue&sort=price
),专门用于传递非定位性的、描述性的筛选条件。
第一步:准备 Mock 后端
我们将在 db.json
中新增一份 products
列表数据。json-server
的强大之处在于,我们无需修改 server.cjs
,它天生就支持通过 Query String 对资源进行过滤。例如,向 /products?name_like=Pro
发起请求,它会自动返回 name
字段包含 “Pro” 的所有商品。
文件路径: db.json
(添加 products
数组)
1 | { |
第二步:构建 API 与状态管理层
我们将严格遵循之前建立的最佳实践,一步步地构建起完整的垂直分层。
1. 定义类型
文件路径: src/types/product.ts
(新建)
1 | export interface Product { |
2. 创建 API 服务
文件路径: src/api/services/productService.ts
(新建)
1 | import apiClient from "@/api/client"; |
3. 创建 Pinia Store
文件路径: src/stores/productStore.ts
(新建)
1 | import { ref } from "vue"; |
第三步:构建“URL 驱动”的搜索页面
这是本节的核心实践。我们将构建一个搜索页面,其中 URL 是驱动数据的“唯一信源”。
文件路径: src/views/SearchPage.vue
(新建)
1 | <script lang="ts" setup> |
第四步:配置路由并添加入口
文件路径: src/router/index.ts
(修改)
1 | // ... |
文件路径: src/App.vue
(修改)
1 | <nav> |
这个“URL 驱动”的模式是构建健壮 Web 应用的核心原则。它的最大好处是,用户可以 收藏、分享这个带查询参数的 URL (.../search?name_like=Vue
),当他们或其他用户再次访问时,watch
侦听器会自动触发搜索,确保看到完全相同的页面状态。这极大地提升了应用的可预测性和用户体验。
7.7. 进阶架构:嵌套路由
本章核心知识点:
当应用需要一个持久化的布局(例如,一个带有固定侧边栏和顶栏的后台界面),并且只在布局内部切换部分内容时,我们使用 嵌套路由。
- 父路由: 对应一个“布局”组件。这个布局组件内部必须包含一个自己的
<router-view>
,这个<router-view>
就是其子路由组件的渲染出口。 children
属性: 在路由配置中,我们在父路由的路由记录对象上,使用children
数组来定义其嵌套的子路由。- 相对路径:
children
数组中的子路由,其path
是 相对于父路由 的,它会被自动拼接在父路径之后 - (例如,父
/dashboard
+ 子profile
=/dashboard/profile
)。
到目前为止,我们创建的所有页面都是 顶级路由,它们会完全替换掉 App.vue
中唯一的那个 <router-view>
。但在真实的应用,尤其是中后台管理系统中,我们经常会遇到一种更复杂的布局需求:一个包含侧边栏、顶部导航的持久化布局,只在布局内部的特定区域切换内容。
痛点背景: 如果我们尝试在每个页面组件(如 UserProfilePage.vue
, SettingsPage.vue
)内部都复制一份侧边栏和顶部导航的 HTML 和 CSS,代码会变得极度冗余且难以维护。一旦导航菜单需要修改,我们就必须去修改每一个相关的页面文件。
解决方案: 嵌套路由 (Nested Routes)。它允许一个父路由组件拥有自己的 <router-view>
,作为其子路由组件的渲染出口,从而轻松实现复杂的多层级页面布局。
第一步:创建布局(父路由)组件
我们首先创建一个专门用于后台布局的组件。按照约定,这类“布局”性质的组件,我们通常放在一个新的 src/layouts
文件夹中。
文件路径: src/views/layouts/DashboardLayout.vue
(新建)
1 | <script setup lang="ts"> |
为了让布局有内容可以展示,我们再创建一个简单的“仪表盘概览”页面。
文件路径: src/views/DashboardOverview.vue
(新建)
1 | <template> |
第二步:重构路由配置
现在,我们来进行最关键的一步:在 router/index.ts
中使用 children
属性来定义嵌套关系。
文件路径: src/router/index.ts
(修改)
1 | // ...前面的路由保持不变 |
请注意,我们将之前的 /users/:id
路由 移动 到了 /dashboard
路由的 children
数组中,并修改了其 path
为相对路径 users/:id
。UserProfile
现在成为了 Dashboard
布局的一部分。
第三步:更新入口
最后,我们更新 App.vue
中的主导航,并修改登录页的跳转逻辑,让用户可以进入我们全新的仪表盘布局。
文件路径: src/App.vue
(修改)
1 | <nav> |
文件路径: src/views/LoginPage.vue
(修改 handleLogin
函数)
1 | // ... |
现在,刷新应用并点击“仪表盘”链接。你会看到 DashboardLayout
的整体布局被渲染出来,并且默认显示“仪表盘概览”的内容。接着,点击侧边栏的“个人资料”,你会发现,只有右侧的主内容区发生了变化,而侧边栏和整体布局保持不变。
嵌套路由是构建可维护、可扩展的复杂应用布局的基石。通过将共享的 UI 和逻辑封装在父路由组件中,我们极大地减少了代码冗余,并使得项目的结构层次更加清晰、更符合直觉。
7.8. 应用的安全防线:meta
与 beforeEach
的入门 Demo
本节核心知识点:
- 路由元信息 (
meta
): 学习如何使用meta
字段为路由“贴标签”,标记出哪些页面需要权限。 - 全局前置守卫 (
beforeEach
): 掌握router.beforeEach
这个最重要、最常用的导航守卫,理解其作为应用“总保安”的角色。 - 认证流程 Demo: 见证
Vue Router
与Pinia
的首次协同,共同实现一个简易但完整的路由拦截流程。
我们已经构建了包含多个页面的应用,甚至还有一个仪表盘布局。但目前,这个仪表盘是“不设防”的,任何人都可以通过直接在地址栏输入 /dashboard
来访问。这在真实世界中是绝不可接受的。
现在,我们需要为应用建立一道“安全防线”。在本节中,我们将构建一个简易的入门 Demo,它的唯一目标,是在 完全没有 API 和 Token 复杂度的干扰下,让你纯粹地、清晰地理解路由保护的 核心工作机制。
第一步:“贴标签” - 为路由添加元信息 meta
我们需要一种方法来告诉路由器,哪些页面是“公共区域”,哪些是“VIP 室”。Vue Router 为此提供了 `meta` 字段。它允许我们为路由规则附加任意的自定义数据。
我们回到 router/index.ts
,为 /dashboard
这条父级路由规则,添加一个 meta
对象,并在其中自定义一个 requiresAuth
属性。
文件路径: src/router/index.ts
(修改)
1 | // ... |
现在,/dashboard
路由及其所有子路由(如 UserProfile
),都带上了一个 requiresAuth: true
的“身份证”,等待着我们的哨兵前来检查。
第二步:创建简易的 Pinia 认证 Store
为了配合这个入门 Demo,我们创建一个极简的 authStore
,它只负责管理一个简单的登录状态。
文件路径: src/stores/authStore.ts
(新建)
1 | import { ref, computed } from 'vue'; |
第三步:“设哨兵” - 实现 beforeEach
全局前置守卫
router.beforeEach
是 Vue Router 提供的一个 全局前置守卫。它就像一个设置在所有道路入口的总哨兵,每一次 导航发生之前,都会先经过它的检查。
我们在 router/index.ts
文件的末尾,路由器实例导出之前,来注册这个守卫。
文件路径: src/router/index.ts
(修改)
1 | import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; |
第四步:提供交互 Demo
最后,我们在 App.vue
中添加两个临时按钮来模拟登录和登出,以便直观地测试我们的守卫效果。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
现在,刷新你的应用:
- 在 未登录 状态下,尝试点击“仪表盘”,你会发现页面被立刻重定向到了登录页。
- 点击“模拟登录”按钮,状态变为“已登录”。
- 再次点击“仪表盘”,你将能成功进入。
我们已经成功地构建了一个功能完备的、简易的认证防线。最重要的是,我们已经清晰地掌握了路由保护的 核心工作机制:通过 meta
字段为路由打标,再通过 beforeEach
守卫检查这个标记和 Pinia 中的状态,最终决定导航的走向。这个基础模型,将是我们下一节构建工业级 Token 认证流程的坚实地基。
7.9. 项目实战:构建完整的 Token 认证工作流
本节核心知识点:
- 后端 Token 认证: 使用
json-server
中间件,模拟一个真实的、颁发并校验 Token 的登录接口。 axios
拦截器: 实现请求拦截器,为所有需要认证的 API 请求自动附加Authorization
头。- Pinia 状态联动: 将
Token
和用户信息作为核心状态存入 Pinia,并使其成为“唯一信源”。 - 完整认证闭环: 走完从 登录 -> 获取 Token -> 存储 Token -> 请求自动携带 Token -> 后端校验 Token -> 路由守卫放行 的工业级标准流程。
我们在上一节用一个简单的布尔值开关,成功地演示了路由守卫的核心机制。但这并非真实世界的运作方式。
现在,让我们丢掉玩具枪,拿起真枪实弹,构筑一条由 `Token`、`API` 和 `状态管理` 共同驱动的、工业级的自动化认证防线。
第一步:升级 Mock 后端 (server.cjs
)
我们的 json-server
需要进化,它不仅要能提供数据,还要能扮演“认证中心”的角色。
期望功能:
- 提供一个
POST /login
接口,接收用户名密码,成功后返回用户信息和 Token。 - 为其他需要保护的接口(如
/users/:id
)添加一个校验中间件,检查请求头中是否包含合法的 Token。
文件路径: server.cjs
(修改)
1 | const jsonServer = require("json-server"); |
第二步:升级 API 请求层 (api/client.ts
)
我们的 apiClient
需要变得更“智能”,它必须能在每次发送请求前,自动地从 Pinia Store 中读取 Token,并将其附加到请求头中。这正是 axios
请求拦截器 的完美应用场景。
文件路径: src/api/client.ts
(修改请求拦截器)
1 | import axios from "axios"; |
第三步:升级 Pinia (authStore.ts
)
我们的 authStore
将不再管理一个简单的布尔值,而是要负责管理真实的 user
和 token
,并处理真正的登录 API 调用。
文件路径: src/stores/authStore.ts
(重构)
1 | import { ref, computed } from 'vue'; |
我们在这里巧妙地结合了上一章学习的 pinia-plugin-persistedstate
插件,将 token
和 user
持久化到 localStorage
。这样,即使用户刷新页面,登录状态也能被保留。
第四步:升级登录页 (LoginPage.vue
)
登录页现在需要一个真实的表单,来调用我们全新的 login
Action。
文件路径: src/views/LoginPage.vue
(修改)
1 | <script setup lang="ts"> |
第五步:升级导航守卫 (router/index.ts
)
最后一步,我们的导航守卫逻辑几乎不需要改变,因为它依赖的 authStore.isLoggedIn
Getter 现在是由真实的 token
驱动的,变得更加可靠和有意义。
文件路径: src/router/index.ts
(确认守卫逻辑)
1 | // ... |
第六步:更新应用主布局 (App.vue
)
现在,我们的 App.vue
将扮演最终的角色:根据 authStore
的真实登录状态,动态地展示不同的导航和操作项。
- 如果用户未登录: 在导航栏显示“登录”链接。
- 如果用户已登录: 显示用户的名称,并提供一个“登出”按钮。
文件路径: src/App.vue
1 | <script setup lang="ts"> |
7.10. 导航守卫全景:生命周期钩子概览
本节核心知识点:
- 导航生命周期: 建立一个关于“导航”从开始到结束的完整心智模型。
- 钩子概览: 快速了解除
beforeEach
之外的其他全局守卫和组件内守卫。 - 核心场景: 掌握每个守卫最典型的应用场景,以便在未来遇到问题时能迅速找到正确的解决方案。
我们已经深入实践了 beforeEach
这个最重要的全局前置守卫。但实际上,一次完整的导航就像一段拥有多个关键时间点的“生命周期”,Vue Router 在这些时间点上都为我们预留了“钩子”,让我们有机会介入并执行相应的逻辑。
本节,我们将像查阅清单一样,快速概览这些守卫的用法和核心场景。我们暂时不进行复杂的编码实践,而是先将这些工具收入我们的“知识库”,在后续的实战章节中遇到合适的场景时,我们再来逐步展开应用。
全局后置钩子:afterEach
- 时机: 在导航 已经成功确认,页面内容也已渲染更新 之后 被调用。
- 特点: 它 不会 接收
next
函数,也 不能 改变导航本身。它是一个纯粹的“收尾”钩子。 - 核心场景:
- 动态更新页面标题: 这是最常见的用法。根据目标路由
to.meta.title
来设置document.title
。 - 发送页面分析 (Analytics): 当需要统计页面浏览量 (PV) 时,可以在这里向分析服务器发送数据。
- 关闭全局加载指示器: 如果你在
beforeEach
中开启了一个全局 Loading 动画,afterEach
是关闭它的最佳位置。
- 动态更新页面标题: 这是最常见的用法。根据目标路由
- 语法示例:
1
2
3
4
5
6
7
8
9// 文件路径: src/router/index.ts
router.afterEach((to, from) => {
// 假设我们在 meta 中定义了 title
if (to.meta.title) {
document.title = `Prorise - ${to.meta.title}`;
} else {
document.title = 'Prorise';
}
});
全局解析守卫:beforeResolve
- 时机: 在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后 被调用。可以理解为
beforeEach
和组件内守卫都执行完毕,马上就要真正跳转前的最后一个“确认”关卡。 - 核心场景: 这是一个相对高级的钩子,通常用于确保在展示页面前,所有与该路由相关的数据或权限都已准备就绪。例如,在进入某个页面前,需要先异步请求一份所有子组件都依赖的通用数据。
组件内守卫
除了全局守卫,我们还可以在组件内部直接定义只对当前组件生效的守卫。
onBeforeRouteLeave
- 时机: 当导航正要 离开 当前组件渲染的路由时被调用。
- 核心场景: 防止用户在未保存更改的情况下意外离开。这是它最经典、最重要的应用场景。
- 语法示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15<script setup lang="ts">
import { onBeforeRouteLeave } from 'vue-router';
import { ref } from 'vue';
const isFormDirty = ref(false); // 假设这个状态会根据用户输入而改变
onBeforeRouteLeave((to, from) => {
if (isFormDirty.value) {
const answer = window.confirm('你有未保存的更改,确定要离开吗?');
if (!answer) {
return false; // 如果用户点击“取消”,则中断导航
}
}
});
</script>
onBeforeRouteUpdate
- 时机: 当 当前路由改变,但该组件被复用 时调用。
- 核心场景: 最典型的例子就是我们在
UserProfilePage.vue
中遇到的情况:从/users/1
导航到/users/2
。组件实例被复用,但路由参数id
发生了变化。我们之前使用watch
监听route.params.id
来解决,而onBeforeRouteUpdate
提供了另一种专门处理这种情况的方式。 - 语法示例:
1
2
3
4
5
6
7
8
9
10
11
12
13<script setup lang="ts">
import { onBeforeRouteUpdate } from 'vue-router';
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
onBeforeRouteUpdate((to, from) => {
// 仅当 id 变化时,才重新获取数据
if (to.params.id !== from.params.id) {
userStore.fetchUser(Number(to.params.id));
}
});
</script>
我们已经快速概览了 Vue Router 提供的导航守卫“全家桶”。现在,你只需要记住:beforeEach
负责全局准入控制,afterEach
负责全局收尾工作,而 onBeforeRouteLeave
负责保护组件内的数据。掌握这三者,你就已经能应对 99% 的业务场景了。