普通视图
宇树发布四足机器人Unitree As2
春节期间独立开发者从 0 到 1:呼吸训练 iOS App 的工程化落地
项目:呼吸视界(iOS 已上架)
技术栈:SwiftUI + SwiftData + StoreKit 2 + WidgetKit + ActivityKit
各位新年快乐,春节期间体验到了人挤人,车挤车,闲来无事撸了个一直想做的APP,分享点技术心得大家共勉。
1. 架构目标:把“训练体验”和“增长闭环”同时做出来
这个项目不是只做一个呼吸动画,而是做一条完整链路:
- 训练引擎:稳定跑节奏(吸气/停顿/呼气)
- 多感官反馈:视觉 + 音频 + 触觉一致
- 习惯闭环:课程进度、训练记录、分享卡片
- 增长入口:提醒、Widget、Live Activity、深链
- 商业化:订阅、恢复购买、权益门控
核心分层:
- 状态中枢:
breathing-iOS/breathing/Domain/AppStore.swift - 页面编排:
breathing-iOS/breathing/UI/RootView.swift - 能力引擎:
breathing-iOS/breathing/Engines/* - 数据模型:
breathing-iOS/breathing/Data/* - 外部触达:
breathing-iOS/breathingWidget/*+BreathingLiveActivityManager
2. 单一状态中枢:AppStore 统一收口
AppStore 用 @MainActor + ObservableObject 统一管理业务状态,避免“每个页面自己存一份状态”。
@MainActor
final class AppStore: ObservableObject {
@Published var activeMode: BreathingMode
@Published var activeDuration: Int
@Published var isPro: Bool
@Published var settings: AppSettings
@Published var soundEnabled: Bool
@Published var soundscapeId: String
let breathingEngine: BreathingEngine
let hapticsEngine: HapticsEngine
private let soundscapePlayer = SoundscapePlayer()
private let liveActivityManager = BreathingLiveActivityManager()
}
同时把订阅商品 ID 固定在内部,避免散落字符串:
private enum ProProductID {
static let monthly = "com.xun.breathing.pro.monthly"
static let yearly = "com.xun.breathing.pro.yearly"
static let all = [monthly, yearly]
}
收益:UI 层只绑定状态,不再承担复杂业务判断;后续加模式/加权益不会牵一发而动全身。
3. 训练引擎:状态机 + 双 Task 保证节奏稳定
BreathingEngine 的关键是“阶段推进”和“总时长倒计时”分离:
@MainActor
final class BreathingEngine: ObservableObject {
@Published private(set) var phase: BreathPhase = .ready
@Published private(set) var isPlaying: Bool = false
@Published private(set) var timeRemaining: Int
private var cycleTask: Task<Void, Never>?
private var countdownTask: Task<Void, Never>?
private var sessionId = UUID()
}
启动时并行两条异步任务:
func start() {
guard !isPlaying else { return }
isPlaying = true
sessionId = UUID()
timeRemaining = duration
runCountdown(sessionId: sessionId)
switch courseType {
case .standard:
runBreathingLoop(sessionId: sessionId)
case .wimHof(let config):
runWimHofSession(sessionId: sessionId, config: config)
}
}
倒计时任务只做一件事:
private func runCountdown(sessionId: UUID) {
countdownTask?.cancel()
countdownTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard sessionId == self.sessionId, self.isPlaying else { return }
self.timeRemaining = max(0, self.timeRemaining - 1)
if self.timeRemaining <= 0 {
self.completeSession()
return
}
}
}
}
收益:暂停/恢复/切模式时行为稳定,不会出现“相位跳变”或“倒计时错乱”。
4. 音景引擎:缓存 + 淡入淡出,解决听感跳变
音频引擎里最关键是三点:
-
bufferCache:避免每次重新解码 mp3 -
fadeIn/fadeOut:切换音景不突兀 -
updatePlayback:统一播放入口(按isPlaying/isEnabled决策)
func updatePlayback(isPlaying: Bool, isEnabled: Bool, soundscapeId: String) {
guard isEnabled, isPlaying else {
stop()
return
}
play(soundscapeId)
}
private func loadBuffer(for soundscape: Soundscape) -> AVAudioPCMBuffer? {
if let cached = bufferCache[soundscape.id] { return cached }
guard let url = Bundle.main.url(forResource: soundscape.fileName, withExtension: "mp3") else { return nil }
do {
let file = try AVAudioFile(forReading: url)
let frameCount = AVAudioFrameCount(file.length)
guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) else { return nil }
try file.read(into: buffer)
bufferCache[soundscape.id] = buffer
return buffer
} catch {
return nil
}
}
停止时先淡出再停引擎:
fade(to: 0, duration: fadeOutDuration) { [weak self] in
self?.stopNow(resetSession: resetSession)
}
5. 通知提醒:权限、频率、撤销一体化
提醒模块用 UNUserNotificationCenter,重点是“配置即覆盖”而不是“叠加创建”。
func configure(enabled: Bool, minutes: Int, frequency: ReminderFrequency) async -> Bool {
if !enabled {
cancel()
return true
}
let allowed = await requestAuthorizationIfNeeded()
guard allowed else {
cancel()
return false
}
schedule(minutes: minutes, frequency: frequency)
return true
}
按周频次时生成固定 ID,方便后续精确取消:
let id = "\(weekdayPrefix)\(weekday)"
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
center.add(request, withCompletionHandler: nil)
6. Live Activity:状态去重,避免无效刷新
Live Activity 不是“每帧都更新”,而是先比对状态,只有变化才推送:
func update(state: BreathingLiveActivityAttributes.ContentState) {
guard #available(iOS 16.1, *) else { return }
guard let activity else { return }
guard state != lastState else { return }
lastState = state
Task {
let content = ActivityContent(state: state, staleDate: nil)
await activity.update(content)
}
}
收益:减少无意义更新,降低系统开销。
7. 数据闭环:训练记录 + 课程进度
7.1 会话记录模型(SwiftData)
@Model
final class SessionRecord {
var id: UUID
var timestamp: Date
var modeId: String
var courseId: String?
var programId: String?
var programDay: Int?
var duration: Int
var preCheckin: String?
var postCheckin: String?
}
preCheckin/postCheckin 让“训练前后变化”可追踪,这是后续留存和转化分析的基础字段。
7.2 课程进度推进
static func nextDayIndex(program: BreathingProgram, record: ProgramProgressRecord?) -> Int? {
let completed = Set(record?.completedDays ?? [])
for index in program.plan.indices {
if !completed.contains(index) {
return index
}
}
return nil
}
这个实现很朴素,但稳定,且便于后续做“断点继续”。
8. Widget 深链:缩短回流路径
Widget 直接绑定深链,用户从桌面可一跳进入训练:
private let quickURL = URL(string: "breathing://start?type=quick")!
private let emergencyURL = URL(string: "breathing://start?type=emergency")!
这比“打开 App -> 选模式 -> 开始”少至少 2 步,对高频场景(焦虑急救/会前调整)很关键。
9. 订阅链路:StoreKit 2 的最小闭环
关键流程:拉商品 -> 发起购买 -> 校验交易 -> finish -> 刷新权益。
func purchaseSelectedProduct() async {
guard let product = selectedProduct else { return }
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
await refreshSubscriptionStatus()
case .pending, .userCancelled:
break
@unknown default:
break
}
}
恢复购买也单独兜底:
try await StoreKit.AppStore.sync()
await refreshSubscriptionStatus()
10. 工程复盘:最值得复用的 4 个点
-
状态收口:
AppStore统一管理跨页面状态。 - 节奏分治:阶段循环和倒计时分为两条 Task。
- 增长内建:提醒/Widget/Live Activity 不是后补功能,而是留存系统。
- 数据先行:从第一天就保留训练前后字段,后续分析成本最低。
后记:APP已经上架,某书上反响还不错,赚钱是次要的,主要产品有人用,技术有积累就很开心啦。 体验链接:apps.apple.com/cn/app/%E5%… PS:要兑换码好说,哈哈~
![]()
![]()
恒指收跌1.82%,恒生科技指数跌2.13%
雷军:未来五年小米将重点攻坚芯片、AI、操作系统等底层核心技术
Anthropic员工股份出售预计将达到60亿美元
周大福3月又要涨价,部分门店已收到相关通知
多款eVTOL航空器、人形机器人亮相武汉
旅居在滇西小城,体验美食与美景
开工第一天,别让AI写的代码触发3.2f封号。
背景
今天是农历正月初八,春节后的第一个工作日。后台有粉丝留言,迎来的开年的第一记重磅打击。3.2f待终止通知。
踩线原因也是老生常谈了,严查分类之隐藏功能问题。
![]()
老iOSer对于这种情况已经是见怪不怪了,很多时候并非开发者想做某些Sao操作,实属无奈的多。毕竟,有业务苹果不能正面允许,不得已就采用这种上有政策下有对策的打法!
原因分析
通过进一步沟通,层层抽丝剥茧。终于定位到踩到隐藏功能的导火索,在AI加持的情况下使用了非公开的API获取业务层面需要的功能权限。从业务的角度来看功能确实实现了,从苹果监管的角度来看调用了越权的API属性。通过键值对的方式Hook数据结果。
实话讲AI背大锅,对于很多跨行的开发者来说,为了满足公司的开发需求保住饭碗使用AI的方式本身没有问题。关键的问题在于,无法Review AI所编写的代码是否合规。
所以,AI本质是一把双刃剑,在提高开发效率的同时,也需要额外考虑风控问题。
隐藏功能
隐藏功能的前身是苹果开发者指南中的-2.3.1条款。
主要意在通过一些动态下发的方式,直接或间接干预苹果审核所看到的内容。将符合苹果审核的内容作为A面,顺利通过审核,提高审核通过率。【俗称的AB面,也叫马甲包】
随着AppStore审核规则的加强,对于隐藏功能的判定不仅仅只是单纯的功能切换,而是上升到更为全面的元数据以及概念层面。
简单来说:
少做不做挂羊头卖狗肉的事情,苹果的算法比开发者想象中更加强大。
遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!
相关推荐
# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。
从 Recoil 的兴衰看前端状态管理的技术选型
从 Recoil 的兴衰看前端状态管理的技术选型
2023 年底,Meta 官方宣布 Recoil 进入“维护模式”,从它的兴衰历程中,我们看到了什么?
Recoil 的发展历程
2020 年:Recoil 横空出世
2020 年的一个下午,我正在 Twitter 上刷着动态,突然刷到一条让我想打开电脑蠢蠢欲动的动态 "Facebook 发布了 Recoil,一个实验性的状态管理库",状态管理在 React 生态里面一直是个工程负担,无论是团队角度从复杂工程的状态管理意识培养还是从开发者体验(DX)角度来看,React 状态管理一直像在等一个 “救世主”。
"Recoil 是一个实验性的状态管理库,为 React 提供了更好的状态管理体验。"
上面这是 Recoil 的宣传语,如果是2020年在写前端的同学肯定很熟悉当时的背景
当时的背景:
- Redux 虽然强大,但样板代码太多
- Context API 性能问题明显,所有消费者都会重渲染
- 社区渴望一个更简单、更现代的状态管理方案
Recoil 的核心优势:
-
解决了 Context 的性能问题
我只是想切换主题,为什么用户信息组件也要重渲染?应用越来越慢,每次状态变化都要重渲染几十个组件让我不得不拆分出非常多的 Context,有没有一种更简单的方式只订阅需要的状态。 -
比 Redux 更简单,减少了样板代码
我只是想写一个计数器,为什么要写三个文件?action、reducer、dispatch...这些概念为什么这么抽象?这将层层传递(漏传) Props 的痛苦转变为一种新的痛苦。 有没有简单的状态管理方案? -
原子化的状态管理理念
工作台应用的复杂度完全取决于状态管理的复杂度,实际的业务逻辑并没有什么复杂的,反而我们在“技术”上花费大量时间建立开发者信心,这种 ROI 是经不起推敲的。 比如更新一个用户信息,我们dispatch一个 UPDATE_USER 类型的参数, 这时候心智负担是 A组件会重新渲染吗? B组件不应该渲染,但是渲染了。 C组件我也不知道是否会重新渲染。 -
官方背书
既然是官方出的,那开发者生态自然会觉得这个方案非常可靠, 并且 TypeScript 支持优秀,我们下意识里面觉得是时候改变我们项目了。
2021-2022 年:快速成长期
Recoil 被广泛采用,成为 React 状态管理的热门选择之一。当时的成功案例和教程大量的涌现在互联网上 “Meta 内部项目开始使用”,"某知名开源项目也集成了 Recoil",生态的发展可是如火如荼,出现了 Recoil DevTools,与 React Router、React Query 等库集成良好。
在当时我们团队内部,我也开始推广 Atom 与 Selector API 的使用,并对负责的项目进行状态管理改造。当时我比喻 Atom 就是一个保险柜:
- 任何需要用钱的人都可以打开它
- 任何人都可以往里面放钱或取钱
- 保险箱里的钱变了,所有知道这个保险箱的人都会收到通知
假设我存了 100块到保险柜里
import { atom } from 'recoil';
// 创建一个 Atom,就像创建一个保险箱
const moneyState = atom({
key: 'moneyState', // 保险箱的名字(必须唯一)
default: 100, // 保险箱里的初始钱数
});
那么其他组件想交互我这个保险柜的状态像喝水一样简单
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
// 在组件中使用
function Wallet() {
// 1. 读取和写入(既能看钱,也能存钱取钱)
const [money, setMoney] = useRecoilState(moneyState);
// 2. 只读取(只能看钱,不能改)
const money = useRecoilValue(moneyState);
// 3. 只写入(只能改钱,不能看)
const setMoney = useSetRecoilState(moneyState);
return (
<div>
<p>我的钱:{money}元</p>
<button onClick={() => setMoney(money + 10)}>存10元</button>
<button onClick={() => setMoney(money - 10)}>取10元</button>
</div>
);
}
当然我们也可以使用与保险柜配套的智能计算器 selector 来与保险柜交互,他会自动管理依赖关系,举个例子,今天大A跌了,我们取出来的钱要 x0.8, 我们如果使用普通函数实现的话,组件渲染每次都会新建这个函数,每次都会重新计算,这会损耗性能。如果使用原生useCallback API 的话引入了手动管理依赖的心智负担。
import { selector } from 'recoil';
const doubleMoneyState = selector({
key: 'newMoneyState', // 计算器的名字
get: ({ get }) => { // 计算逻辑
const money = get(moneyState); // 从保险箱里读取钱数
return money * 0.8; // 跌了!
},
});
那重复计算场景,根据多个状态计算场景,过滤或者排序状态场景都可以轻松通过 selector 解决了。但在真实的实际使用过程中,发现 Recoil 的学习曲线并不是那么平滑,虽然 atom 和 selector 可以让新人2天内上手,但是 atomFamily、selectorFamily 的使用负担,selector越写越复杂导致的性能问题,以及没有彻底改造异步状态为 waitForAll,而是通过 Promise.all 的 JS API 来组合管理异步状态, 都让我感觉这次改造不如“预期” 。
Recoil 一直标记为 “试验性” ,刚开始我可能只是觉得 新项目嘛,API 可能随时变更,高速迭代,一直朝着最好的方向发展,但是渐渐的发现。企业级应用很难陪跑试验性的项目,没有人愿意为技术的升级买单。
2023 年:宣布进入维护模式
Recoil 团队宣布不再积极开发新功能,进入维护模式。
官方声明:
"Recoil 已经进入维护模式,我们将继续修复关键 bug,但不会开发新功能。我们建议用户考虑其他状态管理方案。"
从官方公开资料与社区的状态来看,我觉得 Recoil 的衰落有3个原因:
-
资源有限
- Meta 团队资源有限,无法同时维护多个状态管理方案
- 优先级调整,资源分配到其他更重要项目(AI时代的趋势)
- 维护一个"实验性"库的成本效益比不高
-
竞争激烈
- Zustand、Jotai(Recoil原作者) 等新方案更轻量、更简单
- 这些方案提供了类似的功能,但学习曲线更平缓
- 社区开始转向更活跃的方案
-
需求变化
- 前端技术栈快速演进,Recoil 的设计可能不再是最优解
- 新的范式(如 Signals)开始兴起
- 社区对状态管理的需求发生了变化
我从开始的震惊,转变为理解,也在社区开始讨论迁移方案,也在翻阅大量迁移指南和对比实践。
这次技术选型的经验
不要盲目追求"官方"方案
其实很多项目选择 Recoil 的一个重要原因是:它是 Meta 官方方案。
- "Recoil 是 Facebook 官方的,肯定可靠"
- "Meta 的技术团队很厉害,他们的方案一定最好"
- "官方方案 = 最佳方案"
但是回过头我们才发现
- 官方 ≠ 最适合我们的项目
- 官方方案也可能被放弃
- 官方方案的学习曲线可能更陡峭
- 官方方案的更新频率可能不如社区方案
Recoil 在后期面临的一个重要问题是:社区反馈响应不够及时。 提交了 Issue,但几周都没有回复,新功能的 Roadmap 始终没有看到,可持续性不够。
虽然切换到 Recoil 产生的技术收益不如预期,并且完全掉到另一个坑里(停止维护),但是团队通过这次迁移,强化和实践了 Recoil 可复制的理念。 比如每个状态都有自己的 R&R, 避免不必要的渲染。即使在后面迁移到 Zustand 我们的状态关系依然没有大的变化,状态之间的依赖关系清晰,并且可以独立测试每个状态。以及将一些老代码的 Context 也做了原子化拆分。
状态管理的技术趋势
3.1 简约主义:少即是多
当前最明显的趋势是追求简约。Zustand 的下载量超过了 Recoil 和 Jotai 的总和,大小却只有几KB,这正是与社区开发者共情 “这么简单的东西,我要写那么复杂吗?”,2018 年 统治者地位的 Redux 写个计数器demo 4个文件几十行代码,现如今。Zustand 3行代码,1个文件,开发1天内就能上手 API,更少的代码意味着更少的出错机会,团队反馈在 PeerReveiw 时的信心也增加了许多。Zustand 它让 80% 的场景变得简单,同时让 20% 的复杂场景仍然可行。
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
function Counter() {
const { count, increment } = useStore();
return <button onClick={increment}>{count}</button>;
}
3.2 按需选择
社区不再追求"大一统"解决方案,就像工具箱里有锤子、螺丝刀、扳手,我们不会用锤子去拧螺丝。而是根据场景选择:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 小型应用 | React Context + Hooks | 无需额外依赖 |
| 中型应用 | Zustand | 简单高效 |
| 大型企业应用 | Redux Toolkit | 生态成熟,工具完善 |
| 复杂状态依赖 | Jotai | 原子化设计 |
| 响应式需求 | MobX / Valtio | 自动追踪依赖 |
3.3 渐进式平滑迁移
在实际开发中,我听过很多“XXX技术好,我们要拥抱新技术!”,除去迁移风险,开发交付的核心是业务价值,重构过去稳定的模块在业务上也许完全没有收益, 就算以前写的很垃圾,经过这么多涂涂改改它也很稳定。 那我们的技术洁癖应该是渐进式迁移, 我们引入新方案与旧方案并存,新的业务享受着新技术的便利性,老的方案享受着无变更的稳定性。
那渐进式迁移的前提是什么? 迁移复杂度评估。目前主流的前端状态管理库似乎都意识到了这些工程难题:
- 概念差异越大,迁移成本越高。
- 代码结构差异越大,重构工作量越大。
- 依赖的中间件、工具越多,迁移越复杂。
从而像充电器一样在做类似的“标准”。我们从 Recoil 迁移到 Zustand 的复杂度很低,因为两者的概念类似。
后话
技术会过时,但理念是:永恒的。
贵州茅台在毕节成立销售公司,注册资本3000万
今年春节假期新能源汽车出行创新高
A股三大指数集体收涨,MLCC、玻纤、石油石化等概念走强
今年以来消费品以旧换新惠及3053.2万人次 带动销售额2045.4亿元
小米申请小米智能存储商标
Vue 3 从基础到组合式 API 全解析
目录
1.1 基础概念
MVVM 模式
MVVM 由三部分组成:Model(模型)、View(视图)、ViewModel(视图模型)。
graph LR
subgraph View_Layer["🖥️ View 层"]
direction TB
V1["📄 HTML 模板 + CSS 样式"]
V3["👆 用户交互事件<br/>click / input / submit"]
end
subgraph ViewModel_Layer["⚙️ ViewModel 层"]
direction TB
VM1["📦 响应式数据<br/>data / ref / reactive"]
VM2["🔄 计算属性<br/>computed"]
VM3["👁️ 侦听器<br/>watch"]
VM4["🔗 生命周期钩子<br/>mounted / updated"]
VM5["🛠️ 方法<br/>methods"]
end
subgraph Model_Layer["🗄️ Model 层"]
direction TB
M1["🌐 API 请求<br/>axios / fetch"]
M3["💡 业务逻辑<br/>数据处理 / 校验 / 数据模型"]
M4["🏪 状态管理<br/>Vuex / Pinia"]
end
%% View → ViewModel
V3 -- "① 用户操作触发" --> VM5
V1 -- "② v-model 双向绑定" --> VM1
%% ViewModel 内部依赖
VM1 -- "③-a 依赖数据变化<br/>触发重新计算" --> VM2
VM1 -- "③-b 依赖数据变化<br/>触发侦听器" --> VM3
VM4 -- "③-c 生命周期触发<br/>初始化加载等" --> VM5
%% ViewModel → View
VM1 -- "④ 数据驱动视图更新" --> V1
VM2 -- "⑤ 计算结果渲染到模板" --> V1
%% ViewModel → Model
VM5 -- "⑥ 调用 API" --> M1
VM3 -- "⑦ 监听变化触发业务逻辑" --> M3
%% Model → ViewModel
M1 -- "⑧ 返回数据 → 写入响应式变量" --> VM1
M4 -- "⑨ 状态变更通知 → 写入响应式变量" --> VM1
%% 自动触发渲染
M1 -. "⑧→④ 自动触发视图渲染 🔄" .-> V1
M4 -. "⑨→④ 自动触发视图渲染 🔄" .-> V1
style View_Layer fill:#E3F2FD,stroke:#1565C0,stroke-width:3px
style ViewModel_Layer fill:#FFF3E0,stroke:#E65100,stroke-width:3px
style Model_Layer fill:#E8F5E9,stroke:#2E7D32,stroke-width:3px
style V1 fill:#BBDEFB,stroke:#1565C0,stroke-width:2px
style V3 fill:#BBDEFB,stroke:#1565C0,stroke-width:2px
style VM1 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
style VM2 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
style VM3 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
style VM4 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
style VM5 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
style M1 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
style M3 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
style M4 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
linkStyle 0 stroke:#D32F2F,stroke-width:2.5px
linkStyle 1 stroke:#D32F2F,stroke-width:2.5px
linkStyle 2 stroke:#FF6F00,stroke-width:2.5px
linkStyle 3 stroke:#FF6F00,stroke-width:2.5px
linkStyle 4 stroke:#FF6F00,stroke-width:2.5px
linkStyle 5 stroke:#1565C0,stroke-width:2.5px
linkStyle 6 stroke:#1565C0,stroke-width:2.5px
linkStyle 7 stroke:#FF6F00,stroke-width:2.5px
linkStyle 8 stroke:#FF6F00,stroke-width:2.5px
linkStyle 9 stroke:#2E7D32,stroke-width:2.5px
linkStyle 10 stroke:#2E7D32,stroke-width:2.5px
linkStyle 11 stroke:#9C27B0,stroke-width:2px,stroke-dasharray:5
linkStyle 12 stroke:#9C27B0,stroke-width:2px,stroke-dasharray:5
三层职责与 Vue 中的映射:
| 缩写 | 全称 | 职责 | Vue 中的对应 |
|---|---|---|---|
| M | Model(模型) | 应用数据与业务逻辑。不关心数据如何展示,只负责存储和处理 |
reactive()/ref() 声明的响应式状态、API 请求返回的数据、Pinia store |
| V | View(视图) | 用户看到的界面。不包含业务逻辑,只负责声明式地描述 UI 结构 |
<template> 中的 HTML 模板、最终渲染出的真实 DOM |
| VM | ViewModel(视图模型) | M 与 V 之间的桥梁。监听 Model 变化自动更新 View,捕获 View 上的用户交互反向更新 Model | Vue 组件实例本身——编译器将模板转为渲染函数,响应式系统追踪依赖并触发更新 |
流程总结:
整个 MVVM 的运转可以概括为一个闭环:
-
用户交互驱动(V → VM): 用户在 View 层触发事件(click、input 等),通过
v-model或事件绑定将操作传递给 ViewModel 层的方法或响应式变量。 -
ViewModel 内部联动(VM 内部): 响应式数据变化后,
computed自动重新计算派生值,watch触发副作用逻辑,生命周期钩子在适当时机执行。 - 业务处理(VM → M): ViewModel 调用 Model 层的 API 请求或业务逻辑函数,完成数据的增删改查。
- 数据回写(M → VM): Model 层返回结果写入响应式变量,或 Pinia/Vuex 状态变更通知 ViewModel。
- 视图自动更新(VM → V): 响应式系统检测到数据变化,自动触发虚拟 DOM diff,最小化更新真实 DOM,用户看到最新界面。
核心价值: 开发者只需关注数据(M)和模板(V),中间的同步、diff、DOM 操作全部由 Vue 的 ViewModel 层自动完成。
v-model是:modelValue+@update:modelValue的编译时语法糖,本质仍由 VM 层协调,Vue 3 整体是单向数据流 + 双向绑定语法糖的设计。
1.2 项目创建(Vite)
基于原生 ES Module,毫秒级冷启动,HMR 不随项目规模变慢。
# 推荐使用 create-vue(Vue 官方脚手架,底层基于 Vite)
npm create vue@latest
典型项目结构:
my-project/
├── public/ # 静态资源(不经过构建)
├── src/
│ ├── assets/ # 需构建处理的资源(图片、样式)
│ ├── components/ # 通用组件
│ ├── composables/ # 组合式函数
│ ├── router/ # 路由配置
│ ├── stores/ # Pinia 状态管理
│ ├── views/ # 页面级组件
│ ├── App.vue
│ └── main.ts
├── index.html # 入口 HTML(Vite 以此为入口)
├── vite.config.ts
├── tsconfig.json
└── package.json
1.3 模板语法
插值与绑定
<template>
<!-- 文本插值 -->
<span>{{ message }}</span>
<!-- 属性绑定(v-bind 简写 :) -->
<img :src="imgUrl" :alt="title" />
<!-- 动态绑定多个属性 -->
<div v-bind="attrs"></div>
<!-- 等价于 <div :id="attrs.id" :class="attrs.class" /> -->
<!-- 事件绑定(v-on 简写 @) -->
<button @click="submit">提交</button>
<input @keyup.enter="search" /> <!-- 按键修饰符 -->
<form @submit.prevent="save" /> <!-- 阻止默认行为 -->
</template>
条件渲染
<template>
<!-- v-if:条件为 false 时 DOM 不存在(适合不频繁切换) -->
<div v-if="status === 'loading'">加载中</div>
<div v-else-if="status === 'error'">出错了</div>
<div v-else>{{ data }}</div>
<!-- v-show:始终渲染 DOM,通过 display 切换(适合频繁切换) -->
<div v-show="visible">我一直在 DOM 中</div>
</template>
| 指令 | DOM 行为 | 初始开销 | 切换开销 | 适用场景 |
|---|---|---|---|---|
v-if |
销毁/重建 | 低(不渲染) | 高 | 条件很少变化 |
v-show |
display: none |
高(始终渲染) | 低 | 频繁切换显示 |
列表渲染
<template>
<!-- 数组遍历 -->
<li v-for="(item, index) in list" :key="item.id">
{{ index }}. {{ item.name }}
</li>
<!-- 对象遍历 -->
<div v-for="(value, key) in obj" :key="key">
{{ key }}: {{ value }}
</div>
<!-- v-for + v-if 不能同级使用,需用 <template> 包裹 -->
<template v-for="item in list" :key="item.id">
<li v-if="item.active">{{ item.name }}</li>
</template>
</template>
key的作用: 帮助 Vue 的 diff 算法识别节点身份,复用和重排已有元素而非重新创建。务必使用唯一业务 ID,避免用index(排序/删除时会导致错误复用)。
2. 组件开发
组件是 Vue 的核心抽象单元——将 UI 拆分为独立、可复用的模块,每个组件封装自己的模板、逻辑和样式,通过明确的接口(props/emits)进行通信。
2.1 组件基础
组件定义与注册
Vue 3 推荐使用单文件组件(SFC) + <script setup> 语法,编译器自动处理注册,无需手动声明。
<!-- MyButton.vue — 单文件组件 -->
<template>
<button :class="type" @click="emit('click', $event)">
<slot />
</button>
</template>
<script setup lang="ts">
defineProps<{ type?: 'primary' | 'default' }>()
const emit = defineEmits<{ click: [e: MouseEvent] }>()
</script>
<style scoped>
.primary { background: #409eff; color: #fff; }
</style>
使用方式: 在 <script setup> 中导入即可直接在模板使用,无需注册。
<template>
<MyButton type="primary" @click="save">保存</MyButton>
</template>
<script setup lang="ts">
import MyButton from './MyButton.vue'
</script>
SFC 的价值: 一个
.vue文件 = 模板 + 逻辑 + 样式,scoped实现样式隔离,<script setup>减少样板代码,编译器自动优化。
2.2 组件通信
Vue 组件间通信方式按场景选择,核心原则:props 向下,events 向上,跨层用 provide/inject。
Props(父 → 子)
父组件通过属性向子组件传递数据,子组件只读不可修改。
<!-- Child.vue -->
<template>
<h2>{{ title }} ({{ count }})</h2>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
title: string
count?: number
}>(), {
count: 0 // 类型声明的 props 通过 withDefaults 设置默认值
})
</script>
<!-- Parent.vue -->
<Child title="订单" :count="orderCount" />
Emits(子 → 父)
子组件通过事件通知父组件,保持单向数据流。
<!-- Child.vue -->
<template>
<button @click="remove(item.id)">删除</button>
</template>
<script setup lang="ts">
const emit = defineEmits<{
update: [value: string]
delete: [id: number]
}>()
function remove(id: number) {
emit('delete', id) // 触发事件,父组件通过 @delete 监听
}
</script>
<!-- Parent.vue -->
<Child @update="handleUpdate" @delete="handleDelete" />
v-model(双向绑定语法糖)
v-model 本质是 :modelValue + @update:modelValue 的简写,支持多个 v-model。
<!-- SearchInput.vue -->
<template>
<input v-model="keyword" />
<select v-model="status">
<option value="all">全部</option>
<option value="active">启用</option>
</select>
</template>
<script setup lang="ts">
const keyword = defineModel<string>() // 默认 v-model
const status = defineModel<string>('status') // v-model:status
</script>
<!-- Parent.vue — 语法糖写法 -->
<SearchInput v-model="keyword" v-model:status="currentStatus" />
上面的 v-model:status 等价于展开写法:
<!-- Parent.vue — 展开写法(与上方完全等价) -->
<SearchInput
v-model="keyword"
:status="currentStatus"
@update:status="currentStatus = $event"
/>
v-model:status编译后就是:status+@update:status,defineModel('status')内部帮你处理了 props 接收和 emit 触发。
Provide / Inject(跨层级)
祖先组件提供数据,任意后代组件注入,避免 props 逐层透传。
<!-- 祖先组件 -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const theme = ref<'light' | 'dark'>('light')
provide('theme', theme) // key-value 形式提供
</script>
<!-- 任意深度的后代组件 -->
<template>
<div :class="theme">当前主题:{{ theme }}</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import type { Ref } from 'vue'
const theme = inject<Ref<'light' | 'dark'>>('theme') // 注入
</script>
使用 InjectionKey 实现类型安全(推荐):
字符串 key 容易拼错且无类型推导,推荐抽取 InjectionKey 常量:
// keys.ts — 统一管理 injection key
import type { InjectionKey, Ref } from 'vue'
export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
// 祖先组件中 provide
import { ThemeKey } from './keys'
provide(ThemeKey, theme) // TS 自动校验 value 类型
// 后代组件中 inject
import { ThemeKey } from './keys'
const theme = inject(ThemeKey) // 自动推导为 Ref<'light' | 'dark'> | undefined
适用场景: 主题、国际化、全局配置等需要跨多层传递但不适合放 Pinia 的数据。
Ref(父访问子实例)
通过模板 ref 获取子组件实例,直接调用子组件暴露的方法或属性。
defineExpose 的作用: 在 <script setup> 中,组件内部的变量和方法默认对外不可见(与 Options API 不同)。必须通过 defineExpose 显式声明哪些内容允许父组件通过 ref 访问。未暴露的内容,父组件拿到 ref 后也无法调用。
<!-- Child.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const secret = ref('隐藏数据') // 未暴露,父组件无法访问
function reset() { count.value = 0 }
// 只有 count 和 reset 对外可见,secret 外部不可访问
defineExpose({ count, reset })
</script>
<!-- Parent.vue -->
<template>
<Child ref="childRef" />
<button @click="resetChild">重置子组件</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
const childRef = ref<InstanceType<typeof Child>>()
function resetChild() {
childRef.value?.reset() // 调用子组件通过 defineExpose 暴露的 reset 方法
}
</script>
注意: 模板 ref 访问破坏了组件封装性,仅在表单校验、弹窗控制等必要场景使用。
2.3 插槽
插槽让父组件向子组件内部注入模板片段,实现布局和内容的解耦。
默认插槽
子组件用 <slot /> 占位,父组件传入内容替换。
<!-- Card.vue -->
<template>
<div class="card">
<slot /> <!-- 父组件传入的内容渲染在这里 -->
</div>
</template>
<Card>
<p>这段内容会替换 slot 占位</p>
</Card>
具名插槽
多个插槽通过 name 区分,父组件用 v-slot:name(简写 #name)指定。
<!-- Layout.vue -->
<template>
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</template>
<Layout>
<template #header>
<h1>页面标题</h1>
</template>
<p>默认插槽内容(main 区域)</p>
<template #footer>
<span>© 2026</span>
</template>
</Layout>
作用域插槽
子组件通过 slot 向父组件回传数据,父组件拿到数据后自定义渲染。
<!-- DataList.vue -->
<script setup lang="ts">
defineProps<{ items: { id: number; name: string }[] }>()
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="item.id" /> <!-- 回传数据 -->
</li>
</ul>
</template>
<!-- 父组件自定义每一行的渲染方式 -->
<DataList :items="list">
<template #default="{ item, index }">
<span>{{ index }}. {{ item.name }}</span>
<button @click="remove(item.id)">删除</button>
</template>
</DataList>
作用域插槽的价值: 子组件负责数据遍历和逻辑,父组件负责 UI 呈现,实现逻辑与视图的分离。常见于表格列自定义、列表项渲染等场景。
2.4 动态组件
<component :is>
根据变量动态切换渲染的组件,适用于 Tab 切换、多表单步骤等场景。
<template>
<button v-for="tab in tabs" :key="tab.label" @click="current = tab.comp">
{{ tab.label }}
</button>
<component :is="current" />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'
import TabC from './TabC.vue'
const tabs = [
{ label: '基本信息', comp: TabA },
{ label: '详细配置', comp: TabB },
{ label: '操作日志', comp: TabC },
]
const current = shallowRef(tabs[0].comp) // shallowRef 避免深度代理组件对象
</script>
<keep-alive>
缓存被切走的组件实例,切回时保留状态(表单输入、滚动位置等),避免重新创建和销毁。
<keep-alive :include="['TabA', 'TabB']" :max="5">
<component :is="current" />
</keep-alive>
| 属性 | 说明 |
|---|---|
include |
只缓存匹配的组件(名称或正则) |
exclude |
排除不缓存的组件 |
max |
最大缓存实例数,超出时销毁最久未使用的(LRU) |
被 keep-alive 缓存的组件可使用两个专属生命周期:
<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// 组件从缓存中被激活(切回)时触发,可用于刷新数据
})
onDeactivated(() => {
// 组件被缓存(切走)时触发,可用于清理定时器
})
</script>
典型场景: 后台管理的多 Tab 页面切换——用户在 Tab A 填了一半表单,切到 Tab B 再切回来,数据不丢失。
defineAsyncComponent(异步组件)
将组件的 JS 代码从主包中分离,用到时才加载,减少首屏体积。Vite 构建时会自动将其拆为独立 chunk。
import { defineAsyncComponent } from 'vue'
// 基本用法:传入返回 import() 的工厂函数
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))
// 完整配置:加载状态、超时、错误处理
const HeavyChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
loadingComponent: LoadingSpinner, // 加载中显示的组件
errorComponent: ErrorBlock, // 加载失败显示的组件
delay: 200, // 延迟 200ms 后才显示 loading(避免闪烁)
timeout: 10000, // 超过 10s 视为超时,显示 errorComponent
})
<!-- 在模板中像普通组件一样使用,Vue 自动处理懒加载 -->
<template>
<HeavyChart v-if="showChart" :data="chartData" />
</template>
适用场景: 大型图表、富文本编辑器、PDF 预览等体积较大且非首屏必需的组件。与路由懒加载(
() => import('./views/xxx.vue'))原理相同,区别在于异步组件是组件级别的按需加载。
2.5 Teleport
将组件模板的一部分渲染到DOM 树的其他位置(如 body),解决弹窗/浮层被父组件 overflow: hidden 或 z-index 影响的问题。
<template>
<button @click="visible = true">打开弹窗</button>
<!-- 内容渲染到 body 下,而非当前组件 DOM 内 -->
<Teleport to="body">
<div v-if="visible" class="modal-overlay">
<div class="modal">
<p>弹窗内容</p>
<button @click="visible = false">关闭</button>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
</script>
| 属性 | 说明 |
|---|---|
to |
CSS 选择器或 DOM 元素,指定渲染目标(如 "body"、"#modal-root") |
disabled |
为 true 时禁用传送,内容回到组件原位 |
逻辑上仍属于当前组件(props/emits/provide 照常工作),只是 DOM 位置变了。
2.6 自定义指令
封装对 DOM 的底层操作为可复用指令,命名 v-xxx。
// directives/vFocus.ts
import type { Directive } from 'vue'
export const vFocus: Directive = {
mounted(el: HTMLElement) {
el.focus() // 元素挂载后自动聚焦
}
}
<template>
<input v-focus />
</template>
<script setup lang="ts">
import { vFocus } from '@/directives/vFocus'
</script>
指令生命周期钩子:
| 钩子 | 触发时机 |
|---|---|
created |
元素属性/事件绑定前 |
beforeMount |
插入 DOM 前 |
mounted |
插入 DOM 后 ✅ 最常用 |
beforeUpdate |
组件更新前 |
updated |
组件更新后 |
beforeUnmount |
卸载前 |
unmounted |
卸载后 |
带参数的实际示例(权限指令):
// directives/vPermission.ts
import type { Directive } from 'vue'
export const vPermission: Directive<HTMLElement, string> = {
mounted(el, binding) {
// binding.value 就是 v-permission="'admin'" 中的 'admin'
const userRole = getUserRole()
if (userRole !== binding.value) {
el.parentNode?.removeChild(el) // 无权限则移除元素
}
}
}
<button v-permission="'admin'">仅管理员可见</button>
3. Composition API
Composition API 是 Vue 3 的核心编程范式,以函数为基本组织单位,替代 Options API 的 data/methods/computed/watch 分散写法,使相关逻辑聚合在一起,便于复用和维护。
3.1 响应式 API
ref
包装任意类型为响应式数据。JS/TS 中通过 .value 读写,模板中自动解包。包装对象时内部调用 reactive 实现深层响应。
import { ref } from 'vue'
const count = ref(0) // 基本类型
const user = ref<User | null>(null) // 对象类型,支持泛型
count.value++ // JS 中需要 .value
// 嵌套对象同样响应式(内部自动 reactive)
const config = ref({ theme: { color: 'blue' } })
config.value.theme.color = 'red' // ✅ 视图更新
config.value = { theme: { color: 'green' } } // ✅ 整体替换也响应式
// 数组同样深层响应式
const list = ref([{ id: 1, name: '张三' }, { id: 2, name: '李四' }])
list.value.push({ id: 3, name: '王五' }) // ✅ 新增元素,视图更新
list.value[0].name = '赵六' // ✅ 修改元素属性,视图更新
list.value = list.value.filter(i => i.id !== 2) // ✅ 整体替换,视图更新
自动解包规则 & 注意事项:
| 场景 | 需要 .value? |
说明 |
|---|---|---|
模板 {{ count }}
|
否 | 自动解包 |
| JS/TS 代码 | 是 | count.value++ |
嵌入 reactive 对象 |
否 | reactive({ count }).count++ |
| 放入数组 / Map | 是 | reactive([ref(1)])[0].value |
解构 .value
|
— | 丢失响应性,需 toRefs() 转换 |
reactive
将对象转为深层响应式代理,访问属性无需 .value。不能用于基本类型,且不能整体替换(会丢失响应性)。
import { reactive } from 'vue'
const form = reactive({
name: '',
age: 0,
address: { city: '', zip: '' } // 嵌套对象也是响应式
})
form.name = '张三' // 直接赋值,无需 .value
form.address.city = '深圳' // 深层属性也是响应式
ref vs reactive 选择:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 基本类型(string / number / boolean) | ref |
reactive 不支持基本类型 |
| 可能被整体替换的对象 | ref |
reactive 重新赋值会丢失响应性 |
| 表单等字段固定的复杂对象 | reactive |
无需 .value,代码更简洁 |
| composable 函数返回值 | ref |
解构时不丢失响应性 |
computed
基于响应式依赖自动缓存的派生值,依赖不变则不重新计算。
import { ref, computed } from 'vue'
const price = ref(100)
const quantity = ref(3)
// 只读计算属性
const total = computed(() => price.value * quantity.value)
// 可写计算属性(少用)
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (val: string) => {
const [first, last] = val.split(' ')
firstName.value = first
lastName.value = last ?? ''
}
})
与方法的区别:
computed有缓存,多次访问只在依赖变化时重新计算;方法每次调用都重新执行。
watch
监听特定响应式数据,变化时执行回调。适合需要旧值对比或有条件执行的场景。
import { ref, watch } from 'vue'
const keyword = ref('')
// 监听单个 ref
watch(keyword, (newVal, oldVal) => {
console.log(`搜索词从 "${oldVal}" 变为 "${newVal}"`)
})
// 监听多个源
watch([keyword, page], ([newKeyword, newPage], [oldKeyword, oldPage]) => {
fetchList(newKeyword, newPage)
})
// 监听 reactive 对象的某个属性(需用 getter 函数)
const form = reactive({ name: '', age: 0 })
watch(
() => form.name,
(newName) => { validate(newName) }
)
// 常用选项
watch(keyword, handler, {
immediate: true, // 创建时立即执行一次
deep: true, // 深层监听(reactive 对象默认深层,ref 对象需手动开启)
flush: 'post', // 在 DOM 更新后执行回调(默认 'pre')
})
watchEffect
自动追踪回调中用到的所有响应式依赖,不需要指定监听源。适合"用了什么就监听什么"的场景。
import { ref, watchEffect } from 'vue'
const keyword = ref('')
const page = ref(1)
// 回调中访问了 keyword 和 page,两者变化都会重新执行
const stop = watchEffect(() => {
fetchList(keyword.value, page.value)
})
stop() // 手动停止监听(组件卸载时自动停止)
watch vs watchEffect 对比:
| 维度 | watch |
watchEffect |
|---|---|---|
| 监听源 | 需显式指定 | 自动追踪回调中的依赖 |
| 旧值访问 | ✅ (newVal, oldVal)
|
❌ 无旧值 |
| 首次执行 | 默认不执行(immediate: true 开启) |
默认立即执行 |
| 适用场景 | 需要旧值对比、条件触发 | 依赖多且不需要旧值 |
nextTick
Vue 的 DOM 更新是异步批量的,修改数据后 DOM 不会立即更新。nextTick 等待 DOM 更新完成后执行回调。
import { ref, nextTick } from 'vue'
const show = ref(false)
async function expand() {
show.value = true
// 此时 DOM 尚未更新,拿不到新元素
await nextTick()
// DOM 已更新,可安全操作
document.querySelector('.detail')?.scrollIntoView()
}
响应式工具函数
| 函数 | 作用 | 典型场景 |
|---|---|---|
toRef(obj, key) |
将 reactive 对象的单个属性转为 ref | 传递单个属性给 composable |
toRefs(obj) |
将 reactive 对象的所有属性转为 ref | 解构 reactive 不丢失响应性 |
toRaw(proxy) |
返回代理的原始对象 | 传给第三方库(避免代理副作用) |
shallowRef(val) |
只有 .value 替换时触发更新,深层属性变化不触发
|
大型对象 / 组件引用 |
shallowReactive(obj) |
只有顶层属性变化触发更新 | 扁平配置对象 |
markRaw(obj) |
标记对象永不被代理 | 第三方类实例(echarts、地图等) |
import { reactive, toRefs, toRaw, shallowRef, markRaw } from 'vue'
// toRefs:解构不丢失响应性
const state = reactive({ name: '张三', age: 20 })
const { name, age } = toRefs(state) // name、age 都是 Ref
name.value = '李四' // state.name 同步变化
// shallowRef:大型对象只在整体替换时触发更新
const tableData = shallowRef<Row[]>([])
tableData.value[0].name = '新名字' // ❌ 不触发更新
tableData.value = [...tableData.value] // ✅ 整体替换才触发
// markRaw:排除不需要响应式的对象
const chart = markRaw(echarts.init(el))
3.2 依赖注入(provide / inject)
已在 2.2 组件通信 — Provide / Inject 中详细介绍,包含基本用法和 InjectionKey 类型安全写法。
核心要点回顾:
-
provide(key, value)在祖先组件提供数据 -
inject(key)在任意后代组件注入 - 推荐使用
InjectionKey<T>常量管理 key,实现自动类型推导 - 适用于主题、国际化等跨层级共享数据的场景
3.3 生命周期钩子
Composition API 中通过 onXxx 函数注册生命周期回调,对应组件从创建到销毁的各个阶段。
graph TD
A["setup()"] --> B["onBeforeMount"]
B --> C["onMounted<br/>DOM 已挂载,可访问 DOM / 发请求"]
C --> D["onBeforeUpdate"]
D --> E["onUpdated<br/>DOM 已更新"]
E --> D
C --> F["onBeforeUnmount"]
F --> G["onUnmounted<br/>组件已销毁,清理副作用"]
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted
} from 'vue'
// setup 本身等价于 beforeCreate + created,无需对应钩子
onMounted(() => {
// DOM 已渲染,适合:获取 DOM 引用、发起初始请求、初始化第三方库
initChart()
fetchData()
})
onUpdated(() => {
// 响应式数据变化导致 DOM 更新后触发
// 注意:避免在此修改响应式数据,可能导致无限循环
})
onBeforeUnmount(() => {
// 组件即将销毁,适合:清除定时器、取消订阅、销毁第三方库实例
clearInterval(timer)
chart?.dispose()
})
Options API 与 Composition API 钩子映射:
| Options API | Composition API | 说明 |
|---|---|---|
beforeCreate |
setup() 本身 |
setup 在所有 Options API 钩子之前执行 |
created |
setup() 本身 |
响应式数据已就绪,但 DOM 未挂载 |
beforeMount |
onBeforeMount |
DOM 挂载前 |
mounted |
onMounted |
DOM 已挂载 ✅ |
beforeUpdate |
onBeforeUpdate |
数据变化,DOM 更新前 |
updated |
onUpdated |
DOM 已更新 |
beforeUnmount |
onBeforeUnmount |
组件销毁前 |
unmounted |
onUnmounted |
组件已销毁 |
常用原则: 初始化请求放
onMounted(而非 setup),清理工作放onBeforeUnmount。同一钩子可多次调用,按注册顺序执行。
3.4 组合式函数(Composables)
将相关联的响应式状态 + 逻辑提取为独立函数,实现跨组件复用。命名约定以 use 开头。
// composables/useFetch.ts
import { ref, type Ref } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<string>
isFetching: Ref<boolean>
execute: () => Promise<void>
}
export function useFetch<T>(url: string | Ref<string>): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref('')
const isFetching = ref(false)
async function execute() {
isFetching.value = true
error.value = ''
try {
const resolvedUrl = typeof url === 'string' ? url : url.value
const res = await fetch(resolvedUrl)
data.value = await res.json()
} catch (e: any) {
error.value = e.message
} finally {
isFetching.value = false
}
}
execute() // 创建时自动执行一次
return { data, error, isFetching, execute }
}
<!-- 在组件中使用 -->
<template>
<div v-if="isFetching">加载中...</div>
<div v-else-if="error">{{ error }}</div>
<ul v-else>
<li v-for="item in data" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'
const { data, error, isFetching } = useFetch<Item[]>('/api/items')
</script>
Composable 设计原则:
| 原则 | 说明 | |
|---|---|---|
| 单一职责 | 一个 composable 只解决一类问题(如请求、分页、表单校验) | |
返回 ref
|
返回值使用 ref 而非 reactive,调用方解构时不丢失响应性 |
|
命名 useXxx
|
约定以 use 开头,表明这是一个组合式函数 |
|
| 可接收 ref 参数 | 参数支持 `string | Ref`,提高灵活性 |
与 Mixin 的对比: Options API 时代用 Mixin 复用逻辑,但存在命名冲突、来源不明、隐式依赖等问题。Composable 通过显式导入和返回值,完全解决了这些问题。
3.5 <script setup> 语法糖
<script setup> 是 Composition API 在 SFC 中的编译时语法糖,编译器自动处理导出、注册、类型推导,减少样板代码。
核心编译宏
编译宏无需导入,编译器自动识别:
| 宏 | 作用 | 示例 |
|---|---|---|
defineProps |
声明 props | defineProps<{ title: string }>() |
defineEmits |
声明 emits | defineEmits<{ change: [value: string] }>() |
defineExpose |
暴露实例属性/方法 | defineExpose({ reset }) |
defineModel |
声明 v-model 双向绑定 | defineModel<string>('status') |
withDefaults |
为类型声明的 props 设置默认值 | withDefaults(defineProps<P>(), { count: 0 }) |
defineOptions |
声明组件选项(如 name / inheritAttrs) | defineOptions({ name: 'MyComp' }) |
<script setup> vs 普通 <script>
<!-- ❌ 普通 script:需要手动 return、注册组件 -->
<script lang="ts">
import { ref, defineComponent } from 'vue'
import MyButton from './MyButton.vue'
export default defineComponent({
components: { MyButton },
setup() {
const count = ref(0)
function increment() { count.value++ }
return { count, increment } // 必须手动 return
}
})
</script>
<!-- ✅ script setup:顶层变量/导入组件自动暴露给模板 -->
<script setup lang="ts">
import { ref } from 'vue'
import MyButton from './MyButton.vue' // 自动注册,模板中直接用
const count = ref(0)
function increment() { count.value++ }
// 无需 return,顶层声明自动可在模板中使用
</script>
defineOptions 与属性透传(inheritAttrs)
默认情况下,父组件传给子组件的未声明为 props 的属性(如 class、style、id、data-*)会自动透传到子组件的根元素上。通过 defineOptions({ inheritAttrs: false }) 可关闭自动透传,改用 useAttrs() 手动控制。
<!-- BaseInput.vue -->
<template>
<!-- 关闭自动透传后,attrs 不会自动加到根 div 上 -->
<div class="input-wrapper">
<!-- 手动绑定到指定元素 -->
<input v-bind="attrs" />
</div>
</template>
<script setup lang="ts">
import { useAttrs } from 'vue'
defineOptions({
name: 'BaseInput', // 组件名(keep-alive include 匹配用)
inheritAttrs: false // 关闭自动透传
})
const attrs = useAttrs() // 获取所有透传属性
</script>
<!-- 父组件使用 -->
<template>
<!-- class、placeholder、@focus 都会透传到 BaseInput 内部的 <input> 上 -->
<BaseInput class="custom" placeholder="请输入" @focus="onFocus" />
</template>
| 场景 | inheritAttrs |
效果 |
|---|---|---|
| 单根元素组件(默认) | true |
attrs 自动添加到根元素 |
| 需要将 attrs 绑定到非根元素 |
false + v-bind="attrs"
|
手动控制透传目标 |
| 多根元素组件 | — | Vue 警告,必须手动 v-bind="$attrs" 指定 |