阅读视图

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

5年前端,我为什么要all in AI Agent?

一个普通 Vue/Electron 工程师的转型自白


前言

我是一个普通的前端工程师。5年经验,公司开发框架也就是 Vue2/3 + TS。自己倒腾过Electron,uni-app,能写一些简单的功能模块。没进过大厂,没写过框架,不是什么技术大神。

每天的生活就是:上班写代码,下班刷掘金/知乎/CSDN,周末偶尔看看新技术。工资一般般,饿不死,也富不了。原本以为自己会这样一直干到退休。

直到去年,年奖金只有以往的一半了!


某一天的顿悟

和往常一样,看看手机,刷刷帖子,直到刷到:《35岁程序员裸辞两月,找不到工作,感慨程序员是碗青春饭》。

那一刻,我突然意识到:我也30多了,离35也不远了。

那天晚上,我失眠了。翻来覆去想几个问题:

  • 我的核心竞争力是什么?
  • 如果明天被裁,我能做什么?
  • 5年后,我还在写代码吗?

没有答案。只有焦虑。


一次偶然的机遇

随着公司业务发展,各部门都在鼓励使用 AI,自然而然的,我也分配了相关的任务:处理后端 AI 大模型的流式返回数据。这本来也是很简单的需求:

  • fetchEventSource 发送请求
  • onMessage() 接收并处理数据
  • onError() 处理异常/错误情况

此时,问题来了:后端返回的并不是的 text/event-stream 格式,而是 application/json;在网上查了一圈,知道可以在 onOpen() 里重新发一条 GET 请求来解决这个问题。

然后,又出现新的问题了:后端异常没有正常返回,全部要前端处理!烦躁之下,我打开了DeepSeek(之前只用它写过文档),10 秒钟,它给出了完整的解决方案。

我当时震惊的不是它写出来了,而是它写出来的代码,完全符合我的需求,而且比我预设需求的还要完整!

那一刻,我突然意识到:这东西真方便。

后来的开发中,我开始疯狂用它:

  • 让它生成Vue3组件,它懂得用<script setup>,知道我喜欢用ref而不是reactive
  • 让它写TS类型,它知道我的命名规范(IPropsTResponse
  • 让它解释一段看不懂的配置,它讲得比文档还清楚

我开始想:如果 AI 这么懂前端,那我是不是可以用它做更多事?


AI突围战”

从那天起,我给自己定了一个目标:用4个月时间,成为一个会“玩AI”的前端。

我不学 PyTorch,不学 Transformer,不调模型参数。我的路线很简单:

阶段 目标 时间
第一阶段 学会让AI按我的要求生成代码(Prompt工程) 3周
第二阶段 打通Electron + AI API,做桌面工具 4周
第三阶段 让AI能调用我写的函数(Function Calling) 6周
第四阶段 做一个真正能“干活”的Agent 8周

这4个月,我经历了什么?

第一周:信心满满 → 被 Prompt 折磨(AI 就是不按格式输出)
第二周:第一个 Electron + AI 应用跑通 → 激动得发朋友圈
第四周:Function Calling 总是失败 → 怀疑人生
第六周:第一个能用的 Agent 诞生(帮我处理Git) → 比发工资还开心
第十周:做的一个桌面助手被吐槽“鸡肋” → 反思产品思维
第十六周:现在,我能用Cursor + AI 在30分钟内开发一个小工具

这4个月,我学会了什么?

  • 不是:模型原理、Attention机制、微调技术
  • 而是:怎么让AI听我的话、怎么把AI集成进Electron、怎么让AI调用我的函数、怎么用AI帮我写代码

最重要的是:我不焦虑了,因为我知道,AI时代,前端不仅没有被淘汰,反而有了新的机会。


为什么说前端是AI时代的“天选之子”?

这4个月让我想明白一件事:

AI 是发动机,前端是驾驶舱。发动机很重要,但用户接触的是驾驶舱。

我们的优势是什么?

优势 说明
UI/UX思维 我们知道怎么让AI的“答案”变成好用的“产品”
TypeScript 严格的类型定义,让 AI 生成的代码更可控
Electron经验 桌面端是 AI 的下一站(隐私、离线、本地资源)
工程化能力 组件化、模块化,这些思维在 AI 应用开发中同样重要

我不是在安慰自己,而是在这4个月我见过太多 AI 应用翻车的案例:

  • 技术很强,但 UI 一塌糊涂 → 没人用
  • 模型很准,但交互反人类 → 用户流失
  • 功能很多,但不会产品化 → 自嗨

这些都是我们前端擅长的地方。


这个专栏要写什么

所以,我开了这个专栏。

它不是:

  • ❌ 大模型原理讲解(我不懂)
  • ❌ Python/PyTorch教程(我不会)
  • ❌ 教你成为AI科学家(做不到)

它是:

  • 一个普通前端的真实转型记录(不装逼,只记录自己踩过的坑)
  • 前端视角的AI应用开发实战(Vue3/TS/Electron)
  • Agent和Vibe Coding的落地经验(能跑起来的代码)

结语

前端已死”,这句话从10年前就开始兴起了,“死”了这么多年还没死透,我认为它就是有价值的。现如今,在 AI 的加持,对前端的要求会越来越高,但这条路我会继续走下去,也会把每一步都记录下来。

如果你也在这条路上,欢迎同行。

computed 的缓存哲学:如何避免不必要的重复计算?

前言

在 Vue 应用中,计算属性 computed 是最常用也是最重要的特性之一。它让我们能够声明式地创建基于其他响应式数据的衍生状态。但很多开发者对 computed 的理解停留在表面,不知道它背后的缓存机制,也不清楚何时该用 computed、何时该用 methods。更有甚者,在 computed 中做大量复杂计算,导致性能问题而不自知。

本文将深入探讨 computed 的缓存哲学,通过原理分析和实战案例,帮我们掌握计算属性的正确使用姿势,避免重复计算,提升应用性能。

computed 的工作原理

懒计算:只在访问时求值

computed 的第一个重要特性是懒计算(Lazy Evaluation)。这意味着计算属性不会在创建时立即执行,而是在第一次读取它的值时才会进行计算:

import { ref, computed } from 'vue'
const count = ref(1)
const double = computed(() => {
  console.log('double 被计算了')
  return count.value * 2
})

// 第一次访问 double,触发计算
console.log(double.value) // 输出: "double 被计算了", 2

// 再次访问,使用缓存,不重新计算
console.log(double.value) // 只输出 2,没有计算日志

缓存机制:依赖不变就不重新计算

computed 最核心的特性是缓存。它会记录上一次计算的结果,只有当依赖的响应式数据发生变化时,才会重新计算。如同上述例子一样,当 count 的值没有变化时,重复访问 double,读取的是缓存中的值,并不会重新走计算流程。

依赖追踪:自动收集响应式依赖

computed 本质上是一个特殊的 effect,能够精确知道自己的依赖项,在计算属性执行时,访问到的响应式数据会被自动记录为依赖:

const a = ref(1)
const b = ref(2)
const c = ref(3)
const condition = ref(true)

const result = computed(() => {
  console.log('result 重新计算')
  // 只有 condition 为 true 时才会访问 a
  // 为 false 时访问 b
  if (condition.value) {
    return a.value + c.value
  } else {
    return b.value + c.value
  }
})

console.log(result.value) // 计算一次,依赖: condition, a, c

// 修改 b - 不会触发重新计算,因为当前依赖中不包含 b
b.value = 10
console.log(result.value) // 使用缓存

// 修改 condition
condition.value = false
console.log(result.value) // 重新计算,现在依赖变为 condition, b, c

// 现在修改 b 会触发重新计算
b.value = 20
console.log(result.value) // 重新计算

computed vs methods:性能对比

多次渲染时的表现差异

在开发中,我们可以使用 computed, 也可以使用 methods 来获取衍生数据。它们在功能上没有太大的区别,但在表现上缺有着本质上的区别:

<template>
  <div>
    <!-- 三次使用 computed -->
    <p>Computed: {{ double }}</p>
    <p>Computed: {{ double }}</p>
    <p>Computed: {{ double }}</p>
    
    <!-- 三次调用 methods -->
    <p>Methods: {{ getDouble() }}</p>
    <p>Methods: {{ getDouble() }}</p>
    <p>Methods: {{ getDouble() }}</p>
    
    <button @click="count++">增加</button>
  </div>
</template>

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

const count = ref(0)

// computed:只会计算一次,缓存三次使用
const double = computed(() => {
  console.log('computed 计算')
  return count.value * 2
})

// methods:每次调用都执行
function getDouble() {
  console.log('methods 执行')
  return count.value * 2
}
</script>

性能对比实验

我们可以写一个简单的例子,对比两者的性能:

<template>
  <div>
    <p>渲染次数: {{ renderCount }}</p>
    <p>Computed 结果: {{ expensiveComputed }}</p>
    <p>Methods 结果: {{ expensiveMethod() }}</p>
    <button @click="count++">更新 count</button>
    <button @click="forceUpdate++">强制更新</button>
  </div>
</template>

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

const count = ref(0)
const forceUpdate = ref(0)
const renderCount = ref(0)

// 模拟耗时计算
function expensiveOperation() {
  let result = 0
  for (let i = 0; i < 1000000; i++) {
    result += i
  }
  return result + count.value
}

// computed 版本
const expensiveComputed = computed(() => {
  console.log('耗时计算开始 (computed)')
  const start = performance.now()
  const result = expensiveOperation()
  const end = performance.now()
  console.log(`耗时计算结束,用时: ${(end - start).toFixed(2)}ms`)
  return result
})

// methods 版本
function expensiveMethod() {
  console.log('耗时计算开始 (methods)')
  const start = performance.now()
  const result = expensiveOperation()
  const end = performance.now()
  console.log(`耗时计算结束,用时: ${(end - start).toFixed(2)}ms`)
  return result
}

// 模拟重新渲染
watch(forceUpdate, () => {
  renderCount.value++
})
</script>

上述代码中:

  • 点击"更新 count"(依赖变化):
    • computed:重新计算一次
    • methods:重新计算一次
    • 此时两者的耗时基本一致,没有太大的差别
  • 点击"强制更新"(依赖未变化):
    • computed:使用缓存,不计算
    • methods:不管依赖变不变,每次渲染都重新计算!
    • 这时两者的差别就体现出来了,computed 缓存的性能更好

何时用 computed,何时用 methods

基于以上对比,我们可以得出清晰的选择原则:

  • 基于现有数据衍生出新值:用 computed
  • 事件处理、非响应式计算、需要传参等:用 methods

选择决策树

选择决策树

计算属性的性能陷阱

计算量过大:在 computed 中做复杂计算

computed 虽然会缓存结果,但如果计算本身非常耗时,第一次访问时还是会造成卡顿,因此我们并不推荐在 computed 中做大量复杂的计算:

// ❌ 不好的做法:在 computed 中做大数据处理
const processedData = computed(() => {
  // 假设 data 是一个包含 10 万条记录的数组
  return data.value
    .filter(item => item.active)
    .sort((a, b) => b.value - a.value)
    .map(item => ({
      id: item.id,
      displayName: `${item.name} - ${item.category}`,
      score: item.score * item.weight
    }))
    .reduce((acc, item) => {
      // 复杂的聚合计算
      if (!acc[item.category]) {
        acc[item.category] = []
      }
      acc[item.category].push(item)
      return acc
    }, {})
})

这样当 data 变化时,computed 会重新执行整个复杂计算,可能导致界面卡顿。这种情况,我们一般推荐用多个 computed 去处理,而不是写在一个 computed 中:

const activeItems = computed(() => 
  data.value.filter(item => item.active)
)

const sortedItems = computed(() => 
  [...activeItems.value].sort((a, b) => b.value - a.value)
)

const formattedItems = computed(() => 
  sortedItems.value.map(item => ({
    id: item.id,
    displayName: `${item.name} - ${item.category}`,
    score: item.score * item.weight
  }))
)

const groupedItems = computed(() => 
  formattedItems.value.reduce((acc, item) => {
    if (!acc[item.category]) {
      acc[item.category] = []
    }
    acc[item.category].push(item)
    return acc
  }, {})
)

依赖过多:依赖太细导致频繁重新计算

computed 依赖了太多响应式数据时,任何一个小变化都会导致重新计算:

// ❌ 不好的做法:依赖太多,频繁重新计算
const userProfile = computed(() => {
  return {
    fullName: `${user.value.firstName} ${user.value.lastName}`,
    age: user.value.age,
    email: user.value.email,
    phone: user.value.phone,
    address: `${user.value.city} ${user.value.street}`,
    permissions: user.value.roles.map(r => r.permissions).flat(),
    lastLogin: formatDate(user.value.lastLogin),
    // ... 更多依赖
  }
})

如此一来,computed 几乎每次都会重新计算,丢失了缓存优势。这种情况,也是推荐用多个 computed 去处理:

const basicInfo = computed(() => ({
  fullName: `${user.value.firstName} ${user.value.lastName}`,
  age: user.value.age,
  email: user.value.email
}))

const contactInfo = computed(() => ({
  phone: user.value.phone,
  address: `${user.value.city} ${user.value.street}`
}))

const permissionInfo = computed(() => ({
  roles: user.value.roles,
  permissions: user.value.roles.map(r => r.permissions).flat()
}))

const lastLoginInfo = computed(() => ({
  lastLogin: formatDate(user.value.lastLogin)
}))

副作用问题:computed 中修改数据

computed 中,通常是禁止修改数据的,但缺经常有人这么做,这其实是一个严重的反模式:


// ❌ 绝对禁止:在 computed 中修改数据
const doubleCount = computed(() => {
  count.value++ // 副作用!修改其他响应式数据
  return count.value * 2
})

// ❌ 同样禁止:在 computed 中调用可能修改数据的函数
const userStatus = computed(() => {
  if (!user.value) {
    fetchUser() // 副作用!异步操作
    return 'loading'
  }
  return user.value.status
})

正确做法其实是使用 watch 处理副作用:

watch(user, (newUser) => {
  if (!newUser) {
    fetchUser()
  }
})

const userStatus = computed(() => {
  return user.value?.status || 'loading'
})

为什么不能在 computed 中修改数据呢?

  1. 违反单向数据流:计算属性应该是纯函数,不应该有副作用
  2. 可能导致死循环:修改依赖 -> 触发重新计算 -> 再次修改 -> 无限循环
  3. 不可预测的行为:computed 的求值时机不确定,副作用会导致难以调试的问题

优化策略

拆分计算:一个复杂的 computed 拆成多个小的

这是最常用也最有效的优化策略。通过拆分,我们可以:

  • 减少单个 computed 的计算量
  • 提高缓存命中率
  • 让代码更容易理解
  • 便于单元测试

缓存结果:对于极耗时的计算,使用 cache 模式

有些计算即使拆分后仍然很耗时,这时我们可以考虑手动缓存策略:

// 复杂的数据处理
import { shallowRef, computed } from 'vue'

// 方案1:使用 Map 缓存历史计算结果
const calculationCache = new Map()

const expensiveData = computed(() => {
  const key = JSON.stringify({
    data: rawData.value,
    config: config.value
  })
  
  if (calculationCache.has(key)) {
    console.log('使用缓存结果')
    return calculationCache.get(key)
  }
  
  console.log('执行复杂计算')
  const result = veryExpensiveCalculation(rawData.value, config.value)
  calculationCache.set(key, result)
  
  // 限制缓存大小
  if (calculationCache.size > 100) {
    const firstKey = calculationCache.keys().next().value
    calculationCache.delete(firstKey)
  }
  
  return result
})

// 方案2:使用 LRU 缓存库(如 lru-cache)
import LRU from 'lru-cache'

const cache = new LRU({
  max: 100, // 最多缓存100个结果
  maxAge: 1000 * 60 * 5 // 缓存5分钟
})

const cachedComputation = computed(() => {
  const key = generateKey(dep1.value, dep2.value)
  
  if (cache.has(key)) {
    return cache.get(key)
  }
  
  const result = expensiveComputation(dep1.value, dep2.value)
  cache.set(key, result)
  return result
})

使用 getter 和 setter:双向绑定时控制写操作

computed 默认只有 getter,但也可以提供 setter 来实现双向绑定:

const rawValue = ref(50)

const clampedValue = computed({
  get() {
    return rawValue.value
  },
  set(newValue) {
    // 确保数值在 0-100 之间
    rawValue.value = Math.max(0, Math.min(100, newValue))
  }
})

性能优化总结

  • 拆分大型 computed:将一个大计算拆分为多个小计算
  • 避免在 computed 中修改数据:保持纯函数
  • 减少依赖粒度:只依赖真正需要的数据
  • 使用缓存策略:对极耗时计算实现手动缓存
  • 考虑使用 watch:需要副作用时用 watch 替代

使用原则

应该使用 computed 的场景:

  • 从现有数据派生新数据
  • 需要在模板中多次使用同一个表达式
  • 计算逻辑较复杂,需要命名提高可读性
  • 希望利用缓存避免重复计算

不应该使用 computed 的场景:

  • 需要传参(用 methods)
  • 每次都需要新值(如随机数、时间戳)
  • 有副作用(修改其他数据)
  • 异步操作(用 watch 或 methods)

代码审查要点

  • computed 是否足够"纯"?(没有副作用)
  • 是否可以用 computed 替代 methods?(检查是否在模板中多次调用)
  • computed 的依赖是否都是响应式的?
  • 是否过度拆分?(拆分太多也会增加开销)
  • 计算逻辑是否复杂到需要拆分为多个 computed

结语

computed 的核心价值是缓存,而缓存的核心价值是避免不必要的重复计算。只有深刻理解这一点,才能真正用好 computed,写出高性能的 Vue 应用。

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

watch 与 watchEffect:精准监听,避免副作用滥用

前言

在 Vue 应用中,除了计算属性这种衍生状态,我们还需要处理各种副作用:网络请求、DOM 操作、本地存储、定时器等。Vue3 提供了两个强大的 API:watchwatchEffect 来响应式地执行副作用。然而,很多开发者对它们的使用场景和区别认识不清,要么过度使用导致性能问题,要么使用不当导致内存泄漏。

本文将深入剖析 watchwatchEffect 的工作原理、使用场景和优化策略,帮助我们精准监听、高效管理副作用。

watch vs watchEffect:核心区别

watch

watch 的基本概念

watch 的设计理念是精准控制:我们需要明确告诉它需要监听什么,以及当监听的数据发生变化时又需要做什么:

import { ref, watch } from 'vue'

const count = ref(0)
const name = ref('张三')

// 基本用法:监听单个源
watch(count, (newValue, oldValue) => {
  console.log(`count 从 ${oldValue} 变为 ${newValue}`)
})

// 监听响应式对象
watch(name, (newValue, oldValue) => {
  console.log(`name 从 ${oldValue} 变为 ${newValue}`)
})

watch 的核心特点

  • 懒执行:只有在监听源发生变化时才执行,不会立即执行
  • 需要指定源:必须明确告诉它要监听什么
  • 可以访问新旧值:在回调中可以获得数据变化前后的值
  • 可以监听多个源:可以使用数组的形式监听多个源

watchEffect

watchEffect 的基本概念

watchEffect 的设计理念是自动追踪:它会立即执行一次,并且在执行过程中自动收集 所有 响应式依赖,当这些依赖发生变化时重新执行:

import { ref, watchEffect } from 'vue'

const count = ref(0)
const name = ref('张三')

watchEffect(() => {
  // 自动追踪 count 和 name
  console.log(`count: ${count.value}, name: ${name.value}`)
})
// 立即输出: count: 0, name: 张三

// 修改 count,自动重新执行
count.value++ // 输出: count: 1, name: 张三

watchEffect 的核心特点

  • 立即执行:创建时会立即执行一次
  • 自动收集依赖:不需要指定监听源,依赖是自动收集的
  • 无法获取旧值:回调中只有当前值,没有变化前的值
  • 语法更简洁:适合不需要旧值的场景

选择决策树

watch 与 watchEffect选择决策树

watch 的进阶用法

深度监听:deep

当我们需要监听一个对象时,默认情况下只有对象的引用变化才会触发,对象中的属性变化并不会触发监听:

const user = ref({
  name: '张三',
  address: {
    city: '北京'
  }
})

// ❌ 属性变化不会触发
watch(user, () => {
  console.log('user 变化')
})

user.value.name = '李四' // 不会触发

当我们使用 deep配置时,就可以触发深度监听,即:对象中的属性发生改变时也会触发监听:

// ✅ 使用 deep: true 监听所有嵌套属性变化
watch(user, () => {
  console.log('user 变化')
}, { deep: true })

user.value.name = '李四' // 触发
user.value.address.city = '上海' // 触发

deep 的性能分析

  • 深度监听 deep: true:需要递归遍历所有嵌套属性,对大型对象开销较大
  • 监听具体属性:只监听需要的属性,性能更好
  • 使用 computed:可以组合多个属性,但只在这些属性变化时触发

立即执行:immediate

默认情况下,watch 都是懒执行的,但有些场景我们需要在初始化时就执行一次监听,此时就需要用到 immediate 配置:

const userId = ref(1)
const userData = ref(null)

// 会立即执行一次
watch(userId, async (newId) => {
  userData.value = await fetchUser(newId)
}, { immediate: true })

监听多个源:使用数组

当需要监听多个数据源,并且希望在任何一个数据源变化时,都执行同一个回调:

const categoryId = ref('all')
const sortBy = ref('relevance')

// 监听多个源
watch([categoryId, sortBy], () => {
  console.log('筛选条件变化')
})

flush 时机:pre | post | sync 的区别

flush 选项可以控制回调的执行时机,这对 DOM 操作特别重要:

  • pre:默认值,在组件更新前执行,此时无法操作 DOM
  • post:在组件更新后执行,可以访问更新后的 DOM
  • sync:在响应式依赖变化时立即执行(谨慎使用)
import { ref, watch } from 'vue'

const count = ref(0)

// 默认 pre:在组件更新前执行
watch(count, () => {
  console.log('pre: DOM 还未更新')
}, { flush: 'pre' })

// post:在组件更新后执行,可以访问更新后的 DOM
watch(count, () => {
  console.log('post: DOM 已更新')
  // 可以安全地操作更新后的 DOM
}, { flush: 'post' })

// sync:在响应式依赖变化时立即执行(谨慎使用)
watch(count, () => {
  console.log('sync: 立即执行')
}, { flush: 'sync' })

副作用清理:避免内存泄漏

场景:监听路由变化,取消之前的请求

在处理异步操作时,最常见的场景就是竞态条件:当请求发起后,但还没返回结果时,参数又变化了。这时需要取消之前的请求:

import { watch, ref } from 'vue'
import { searchAPI } from './api'

const searchQuery = ref('')
const searchResults = ref([])
const loading = ref(false)

// ❌ 错误:没有处理竞态条件
watch(searchQuery, async (newQuery) => {
  loading.value = true
  const results = await searchAPI(newQuery) // 慢请求
  // 如果此时 query 已经变化,这个结果可能是过时的
  searchResults.value = results
  loading.value = false
})

// ✅ 正确:使用 onCleanup 取消之前的请求
watch(searchQuery, async (newQuery, oldQuery, onCleanup) => {
  const controller = new AbortController()
  
  // 注册清理函数
  onCleanup(() => {
    controller.abort()
    console.log('取消请求:', newQuery)
  })
  
  loading.value = true
  try {
    const results = await searchAPI(newQuery, { 
      signal: controller.signal 
    })
    // 只有请求没有被取消时才更新结果
    searchResults.value = results
  } catch (error) {
    if (error.name === 'AbortError') {
      // 请求被取消,忽略
      console.log('请求已取消')
    } else {
      // 其他错误
      console.error('搜索失败:', error)
    }
  } finally {
    loading.value = false
  }
})

onCleanup 的实现原理

onCleanupwatch 回调的第三个参数,它是一个函数,用来注册清理回调:

// 模拟 onCleanup 的工作原理
function createWatcher(source, callback) {
  let cleanup = null
  
  const registerCleanup = (fn) => {
    cleanup = fn
  }
  
  const runCallback = () => {
    // 执行之前的清理函数
    if (cleanup) {
      cleanup()
    }
    
    // 执行新的回调
    callback(source.value, null, registerCleanup)
  }
  
  // 监听变化
  onSourceChange(runCallback)
}

更多清理场景

清理定时器

const delay = ref(1000)

watch(delay, (newDelay, oldDelay, onCleanup) => {
  const timer = setInterval(() => {
    console.log('定时器执行')
  }, newDelay)
  
  onCleanup(() => {
    clearInterval(timer)
    console.log('定时器已清理')
  })
}, { immediate: true })

取消 WebSocket 连接

const roomId = ref('general')

watch(roomId, (newRoom, oldRoom, onCleanup) => {
  const socket = new WebSocket(`ws://server/${newRoom}`)
  
  socket.onmessage = (event) => {
    // 处理消息
  }
  
  onCleanup(() => {
    socket.close()
    console.log(`离开房间: ${oldRoom}`)
  })
}, { immediate: true })

移除事件监听

const element = ref(null)
const eventType = ref('click')

watch([element, eventType], ([el, type], [oldEl, oldType], onCleanup) => {
  if (!el) return
  
  const handler = (e) => {
    console.log(`事件触发: ${type}`, e)
  }
  
  el.addEventListener(type, handler)
  
  onCleanup(() => {
    el.removeEventListener(type, handler)
    console.log(`移除事件监听: ${type}`)
  })
}, { immediate: true })

性能陷阱与优化

过度监听:监听整个对象 vs 监听具体属性

const filters = ref({
  category: 'all',
  priceRange: [0, 1000],
  inStock: true,
  rating: 0,
  sortBy: 'price',
  keywords: ''
})

watch(filters, () => {
  // 任何 filter 属性变化都会触发 API 调用
  fetchProducts(filters.value)
}, { deep: true })
// 修改一个属性就调用一次 API,可能过于频繁

优化方案:监听特定属性

const fetchTrigger = computed(() => ({
  category: filters.value.category,
  priceRange: filters.value.priceRange,
  inStock: filters.value.inStock
}))

watch(fetchTrigger, () => {
  // 只有这三个相关属性变化才触发
  fetchProducts(filters.value)
})

使用 debounce 进一步优化

import { debounce } from 'lodash-es'

const debouncedFetch = debounce((filters) => {
  fetchProducts(filters)
}, 300)

watch(filters, () => {
  debouncedFetch(filters.value)
}, { deep: true })

频繁触发:使用 throttle 和 debounce

场景1:搜索输入 - 使用 debounce

const debouncedSearch = debounce((query) => {
  console.log('执行搜索:', query)
}, 300)

watch(searchInput, (newValue) => {
  debouncedSearch(newValue)
})

场景2:滚动位置 - 使用 throttle

const scrollPosition = ref(0)

const throttledSave = throttle((position) => {
  localStorage.setItem('scrollPosition', position)
}, 1000)

watch(scrollPosition, (newPos) => {
  throttledSave(newPos)
})

实战:实现一个可取消的异步请求监听器

完整实现

// composables/useCancellableWatch.js
import { watch } from 'vue'

export function useCancellableWatch(source, asyncFn, options = {}) {
  const { immediate = false, debounce: debounceMs = 0, onError } = options
  
  let controller = new AbortController()
  let timeoutId = null
  
  const wrappedAsyncFn = (value) => {
    // 取消之前的请求
    controller.abort()
    controller = new AbortController()
    
    // 执行新的异步函数
    asyncFn(value, controller.signal).catch(error => {
      if (error.name !== 'AbortError' && onError) {
        onError(error)
      }
    })
  }
  
  const handler = (value) => {
    if (timeoutId) {
      clearTimeout(timeoutId)
    }
    
    if (debounceMs > 0) {
      timeoutId = setTimeout(() => wrappedAsyncFn(value), debounceMs)
    } else {
      wrappedAsyncFn(value)
    }
  }
  
  // 创建监听
  const stop = watch(source, handler, { immediate })
  
  // 返回停止函数
  return () => {
    stop()
    controller.abort()
    if (timeoutId) {
      clearTimeout(timeoutId)
    }
  }
}

在组件中使用

<template>
  <div class="search-container">
    <input 
      v-model="query" 
      placeholder="搜索..."
      @input="handleInput"
    />
    <span class="loading" v-if="loading">搜索中...</span>
    
    <div class="results">
      <div v-for="item in results" :key="item.id">
        {{ item.title }}
      </div>
    </div>
    
    <div v-if="error" class="error">
      出错了: {{ error.message }}
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useCancellableWatch } from './composables/useCancellableWatch'

const query = ref('')
const results = ref([])
const loading = ref(false)
const error = ref(null)

// 模拟搜索 API
async function searchAPI(query, signal) {
  loading.value = true
  error.value = null
  
  try {
    // 模拟网络请求
    await new Promise(resolve => setTimeout(resolve, 1000))
    
    // 检查是否被取消
    if (signal.aborted) {
      throw new DOMException('Aborted', 'AbortError')
    }
    
    // 模拟返回结果
    const mockResults = [
      { id: 1, title: `${query} 结果1` },
      { id: 2, title: `${query} 结果2` },
      { id: 3, title: `${query} 结果3` }
    ]
    
    results.value = mockResults
  } finally {
    loading.value = false
  }
}

// 使用我们的自定义监听器
const stopWatch = useCancellableWatch(
  query,
  async (value, signal) => {
    if (value.length < 2) {
      results.value = []
      return
    }
    await searchAPI(value, signal)
  },
  {
    immediate: false,
    debounce: 300,
    onError: (err) => {
      if (err.name !== 'AbortError') {
        error.value = err
      }
    }
  }
)

// 组件卸载时自动清理
onUnmounted(() => {
  stopWatch()
})
</script>

决策指南

需求 推荐方案 原因
需要访问新旧值 watch watch 提供新旧值参数
需要立即执行一次 watch + immediate: truewatchEffect 两者皆可,看是否需要旧值
只需要知道变化了 watchEffect 语法更简洁
监听多个相关源 watch 数组形式 可以一起处理,也可以分别处理
需要操作更新后的 DOM watch + flush: post 确保 DOM 已更新
需要取消异步操作 watch + onCleanup 提供专门的清理机制
监听对象内部属性变化 watch + 函数返回具体属性 避免 deep: true 的性能开销

结语

watch 用于精确控制,watchEffect 用于自动追踪。开发中需要选择哪个,取决于我们的具体需求:需要细粒度控制就用 watch,想要简洁的自动追踪就用 watchEffect。理解它们的本质区别,就能在合适的场景做出正确的选择。

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

v-model 的进阶用法:搞定复杂的父子组件数据通信

前言

在 Vue 开发中,父子组件之间的数据通信是一个核心话题。v-model 作为 Vue 的双向绑定指令,看似简单,实则蕴含着强大的表达能力。很多开发者对 v-model 的理解停留在"表单输入绑定"的层面,殊不知它早已进化为处理复杂父子组件通信的利器。

本文将深入剖析 v-model 的本质,从基础用法到进阶技巧,再到实战案例,帮助我们掌握这一强大的通信工具。

v-model 的本质

语法糖::modelValue + @update:modelValue

v-model 的本质其实是一个语法糖。在 Vue3 中,下面这两种写法是完全等价的:

<!-- 这种写法 -->
<ChildComponent v-model="parentData" />

<!-- 等价于这种写法 -->
<ChildComponent 
  :modelValue="parentData" 
  @update:modelValue="parentData = $event" 
/>

双向绑定的实现原理

v-model 实现双向绑定的核心是 Props 向下传递,Events 向上传递: 双向绑定的实现原理

双向绑定的具体流程

  1. 父组件通过 :modelValue 将数据传递给子组件
  2. 子组件通过 props.modelValue 接收数据并展示
  3. 当子组件内部需要修改数据时,通过 emit('update:modelValue', newValue) 通知父组件
  4. 父组件监听到事件后更新自己的数据
  5. 父组件数据更新后,再次通过 Props 传递给子组件,完成闭环

从 Vue2 的 v-bind.sync 到 Vu3 的 v-model

如果我们想在 Vue2 中处理多个双向绑定需要使用 .sync 修饰符:

<!-- Vue 2 中的 .sync -->
<ChildComponent 
  :name.sync="userName"
  :age.sync="userAge"
/>
<!-- 等价于 -->
<ChildComponent 
  :name="userName" 
  @update:name="userName = $event"
  :age="userAge" 
  @update:age="userAge = $event"
/>

而在Vue 3 统一为 v-model 语法,更加直观:

<!-- Vue 3 中的多 v-model -->
<ChildComponent
  v-model:name="userName"
  v-model:age="userAge"
/>

v-model 基础用法回顾

自定义组件支持 v-model

如果要让一个自定义组件支持 v-model,需要做两件事:

  1. 接收 modelValue :默认名称
  2. 当值变化时,触发 update:modelValue 事件
<!-- 自定义输入框组件 CustomInput.vue -->
<template>
  <div class="custom-input">
    <input
      :value="modelValue"
      @input="handleInput"
    />
  </div>
</template>

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function handleInput(e: Event) {
  const value = (e.target as HTMLInputElement).value
  emit('update:modelValue', value)
}
</script>

<!-- 使用方式 -->
<template>
  <CustomInput 
    v-model="searchText"
  />
</template>

默认 prop 和事件名称

v-model 的默认配置:

  • Prop 名称:modelValue
  • 事件名称:update:modelValue

当然,我们也可以通过修改 v-model 的参数来改变这些名称:

<!-- 指定参数名 -->
<ChildComponent v-model:title="pageTitle" />

<!-- 等价于 -->
<ChildComponent 
  :title="pageTitle" 
  @update:title="pageTitle = $event"
/>

多个 v-model 绑定

场景:一个组件需要双向绑定多个值

想象一下:在用户表单组件中,我们需要同时绑定姓名、年龄、邮箱等多个值:

<!-- 父组件 -->
<template>
  <UserForm
    v-model:name="userName"
    v-model:age="userAge"
    v-model:email="userEmail"
    @submit="handleSubmit"
  />
</template>

<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'

const userName = ref('张三')
const userAge = ref(25)
const userEmail = ref('zhangsan@example.com')

function handleSubmit() {
  console.log('提交表单', {
    name: userName.value,
    age: userAge.value,
    email: userEmail.value
  })
}
</script>

实现:指定不同的参数名

<!-- UserForm.vue -->
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label>姓名</label>
      <input
        :value="name"
        @input="$emit('update:name', $event.target.value)"
      />
    </div>
    
    <div class="form-group">
      <label>年龄</label>
      <input
        type="number"
        :value="age"
        @input="$emit('update:age', Number($event.target.value))"
      />
    </div>
    
    <div class="form-group">
      <label>邮箱</label>
      <input
        :value="email"
        @input="$emit('update:email', $event.target.value)"
        type="email"
      />
    </div>
    
    <button type="submit">提交</button>
  </form>
</template>

<script setup lang="ts">
defineProps<{
  name: string
  age: number
  email: string
}>()

const emit = defineEmits<{
  'update:name': [value: string]
  'update:age': [value: number]
  'update:email': [value: string]
  'submit': []
}>()

function handleSubmit() {
  emit('submit')
}
</script>

复杂数据结构的双向绑定

除了简单的基础类型数据的双向绑定外,有时候我们也需要双向绑定一个复杂对象:

<template>
  <AddressEditor v-model:address="userAddress" />
</template>

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

interface Address {
  province: string
  city: string
  district: string
  detail: string
  zipCode?: string
}

const userAddress = ref<Address>({
  province: '广东省',
  city: '深圳市',
  district: '南山区',
  detail: '科技园路1号'
})
</script>

这其实相当于:

<template>
  <div class="address-editor">
    <div class="address-item">
      <label>省份</label>
      <input
        :value="address.province"
        @input="updateAddress('province', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>城市</label>
      <input
        :value="address.city"
        @input="updateAddress('city', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>区县</label>
      <input
        :value="address.district"
        @input="updateAddress('district', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>详细地址</label>
      <input
        :value="address.detail"
        @input="updateAddress('detail', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>邮编</label>
      <input
        :value="address.zipCode"
        @input="updateAddress('zipCode', $event.target.value)"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
interface Address {
  province: string
  city: string
  district: string
  detail: string
  zipCode?: string
}

const props = defineProps<{
  address: Address
}>()

const emit = defineEmits<{
  'update:address': [value: Address]
}>()

function updateAddress<K extends keyof Address>(key: K, value: Address[K]) {
  emit('update:address', {
    ...props.address,
    [key]: value
  })
}
</script>

自定义 v-model 修饰符

内置修饰符的作用

修饰符 作用 适用场景
.trim 自动过滤用户输入的首尾空白字符 用户名、留言内容等不需要首尾空格的文本输入
.number 将用户输入自动转换为数值类型 年龄、数量等数字类型的输入
.lazy 将默认的 input 事件改为 change 事件触发同步 减少频繁更新,适合评论框等场景

内置修饰符的处理

在自定义组件中需要手动处理这些修饰符:

<template>
  <CustomInput 
    v-model.trim="text"     <!-- 自动去除首尾空格 -->
    v-model.number="age"    <!-- 自动转换为数字类型 -->
    v-model.lazy="comment"  <!-- 失焦后才更新 -->
  />
</template>

在自定义组件中处理这些修饰符

<!-- CustomInput.vue -->
<template>
  <input
    :value="modelValue"
    @input="handleInput"
    @change="handleChange"
  />
</template>

<script setup>
const props = defineProps<{
  modelValue: string | number
  modelModifiers?: {
    trim?: boolean
    number?: boolean
    lazy?: boolean
  }
}>()

const emit = defineEmits(['update:modelValue'])

function handleInput(e: Event) {
  if (props.modelModifiers?.lazy) {
    // lazy 模式下,只在 change 事件触发
    return
  }
  
  let value = (e.target as HTMLInputElement).value
  
  // 处理 trim 修饰符
  if (props.modelModifiers?.trim) {
    value = value.trim()
  }
  
  // 处理 number 修饰符
  if (props.modelModifiers?.number) {
    const num = parseFloat(value)
    value = isNaN(num) ? value : num
  }
  
  emit('update:modelValue', value)
}

function handleChange(e: Event) {
  if (!props.modelModifiers?.lazy) {
    return
  }
  
  let value = (e.target as HTMLInputElement).value
  
  if (props.modelModifiers?.trim) {
    value = value.trim()
  }
  
  if (props.modelModifiers?.number) {
    const num = parseFloat(value)
    value = isNaN(num) ? value : num
  }
  
  emit('update:modelValue', value)
}
</script>

常见陷阱与解决方案

不要直接修改 props

这是新手最常见的错误:

<!-- ❌ 错误:直接修改 props -->
<template>
  <input v-model="modelValue" />
</template>

<script setup>
defineProps<{
  modelValue: string
}>()
</script>

解决方案:通过事件通知父组件

<template>
  <input 
    :value="modelValue" 
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

处理非字符串类型的 v-model

对于数字、布尔值等类型,我们在使用时需要特别注意类型转换:

<template>
  <!-- ✅ 正确处理数字类型 -->
  <input
    type="number"
    :value="modelValue"
    @input="handleNumberInput"
  />
</template>

<script setup>
const props = defineProps<{
  modelValue: number
}>()

const emit = defineEmits(['update:modelValue'])

function handleNumberInput(e: Event) {
  const value = (e.target as HTMLInputElement).value
  // 转换为数字,处理空值
  const num = value === '' ? 0 : Number(value)
  emit('update:modelValue', num)
}
</script>

v-model 与响应式数据的配合

当使用对象作为 v-model 的值时,一定注意响应式丢失的问题:

<template>
  <!-- 这种情况没问题 -->
  <ChildComponent v-model="user" />
  
  <!-- 但这种情况会导致响应式丢失! -->
  <ChildComponent 
    v-model="user.name" 
    v-model="user.age"
  />
</template>

<script setup>
import { reactive } from 'vue'

const user = reactive({
  name: '张三',
  age: 25
})
// ❌ 这样使用 v-model 会破坏响应式
</script>

解决方案:使用 ref

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

const user = ref({
  name: '张三',
  age: 25
})
</script>

<template>
  <ChildComponent 
    v-model:name="user.value.name" 
    v-model:age="user.value.age"
  />
</template>

处理异步更新

有时需要在值变化后执行某些操作,但需要注意 Vue 的异步更新机制:

<script setup>
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits(['update:modelValue'])

function handleInput(e: Event) {
  const value = e.target.value
  emit('update:modelValue', value)
  
  // ❌ 这里的 props.modelValue 还是旧值
  console.log(props.modelValue) 
  
  // ✅ 使用 nextTick 获取更新后的值
  import { nextTick } from 'vue'
  nextTick(() => {
    console.log(props.modelValue) // 现在是最新值
  })
}
</script>

最佳实践清单

  • 优先使用多个 v-model 而不是一个包含多个字段的对象
  • 为所有 v-model 定义 TypeScript 类型,包括修饰符
  • 不要直接修改 props,始终通过事件更新
  • 处理非字符串类型时做好类型转换
  • 提供合理的默认值和空状态处理
  • 考虑使用计算属性实现复杂的转换逻辑
  • 为组件暴露 reset 等方法,方便父组件控制
  • 使用 v-model 修饰符实现可复用的输入处理逻辑

结语

好的组件设计应该是使用者友好型。当我们设计的组件让其他开发者或使用者,只需要写 v-model 就能完成复杂的双向绑定,那我们就真正掌握了 v-model 的精髓。

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

TypeScript 深度加持:让你的组合式函数拥有“钢筋铁骨”

前言

在 JavaScript 的世界里,自由往往伴随着风险。当你写下一个函数,一个月后回来修改时,你还记得它接受什么参数、返回什么值吗?当团队成员接手你的代码时,他们需要花多少时间去理解函数的使用方式?

TypeScript 的出现改变了这一切。特别是当它与 Vue3 的组合式函数相结合时,TypeScript 不再是可选项,而是构建可靠、可维护应用的必备工具。本文将深入探讨如何为组合式函数添加 TypeScript 支持,让它们从“手工作坊”升级为“工业标准”。

TypeScript 为什么要深度集成?

开发时智能提示:再也不用翻文档

没有 TypeScript 的组合式函数,就像一本没有目录的书:

// 纯 JavaScript 版本
export function useUser() {
  // 这个函数返回什么?怎么用?
  // 只能去看源码或者猜
}

// 使用时
const user = useUser()
// user 里有什么?不知道

有了 TypeScript,一切变得清晰明了:

// TypeScript 版本
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

interface UseUserReturn {
  user: Ref<User | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  fetchUser: (id: number) => Promise<void>
  updateUser: (data: Partial<User>) => Promise<void>
}

export function useUser(): UseUserReturn {
  // 实现...
}

// 使用时,编辑器会提供完美的智能提示
const { user, loading, fetchUser } = useUser()
// 鼠标悬停在 fetchUser 上,就能看到参数类型
fetchUser(123) // ✅ 正确
fetchUser('abc') // ❌ TypeScript 报错:类型错误

重构时的信心保证:改一处,TypeScript 帮你检查所有使用处

这是 TypeScript 最强大的特性之一。当我们需要修改一个组合式函数的返回类型时,TypeScript 会帮我们找到所有受影响的地方:

// 假设有一个 usePagination 组合式函数
function usePagination(initialPage = 1) {
  const page = ref(initialPage)
  const pageSize = ref(10)
  const total = ref(0)
  
  return { page, pageSize, total }
}

// 现在需要重构,将返回值改为响应式对象
function usePagination(initialPage = 1) {
  const state = reactive({
    page: initialPage,
    pageSize: 10,
    total: 0
  })
  
  return { state } // 返回方式改变了
}

// TypeScript 会立即标记所有使用了 page.value 的地方
const { state } = usePagination()
// ❌ 错误:page 不存在于返回值中
// 必须改为 state.page

这种“编译时检查”的特性,让我们在进行大规模重构时,不用担心遗漏任何使用之处。

运行时错误左移:在编译阶段发现潜在 bug

JavaScript 的错误往往在运行时才暴露,而 TypeScript 能在代码运行前就发现问题:

// ❌ JavaScript:运行时才报错
function processUser(user) {
  return user.name.toUpperCase() // 如果 user 是 null,这里会崩溃
}

// ✅ TypeScript:编译时就能发现问题
function processUser(user: User | null) {
  // ❌ 编译错误:对象可能为 null
  return user.name.toUpperCase() 
  
  // ✅ 正确处理
  return user?.name.toUpperCase() ?? ''
}

常见的 TypeScript 错误

错误1:拼写错误

const user = useUser()
user.nmae // ❌ 编译错误:属性 'nmae' 不存在于类型 'User'

错误2:类型不匹配

function updateProduct(id: number) { /* ... */ }
updateProduct('abc') // ❌ 编译错误:不能将 string 赋值给 number

错误3:忘记处理 undefined

const products = ref<Product[]>([])
const firstProduct = products.value[0]
console.log(firstProduct.price) // ❌ 编译错误:对象可能为 undefined

错误4:错误的参数个数

function fetchData(id: number, options?: FetchOptions) { /* ... */ }
fetchData(123, { cache: true }, 'extra') // ❌ 编译错误:参数过多

组合式函数的基础类型定义

为 ref 和 reactive 定义类型

Vue3 的响应式 API 与 TypeScript 配合得天衣无缝:

import { ref, reactive, computed } from 'vue'

// ref 的类型推导
const count = ref(0) // Ref<number>
const name = ref('') // Ref<string>
const isActive = ref(false) // Ref<boolean>

// 显式定义 ref 类型
const user = ref<User | null>(null) // Ref<User | null>

// 数组类型
const items = ref<Item[]>([]) // Ref<Item[]>

// reactive 的类型推导
const state = reactive({
  count: 0,
  name: '张三'
}) // { count: number; name: string }

// 显式定义 reactive 类型
interface FormState {
  username: string
  password: string
  remember: boolean
}

const form = reactive<FormState>({
  username: '',
  password: '',
  remember: false
})

// computed 的类型
const double = computed(() => count.value * 2) // ComputedRef<number>

注:基础数据类型,TypeScript 可以自行推导,因此不建议显示定义基础数据类型: const count = ref<number>(0) // ❌ 不建议这样写 const count = ref(0) // ✅

为函数的参数和返回值定义接口

这是组合式函数类型定义的核心,一个好的类型定义应该清晰地表达:

  • 函数接受什么参数
  • 函数返回什么
  • 各种边界情况
interface UseCounterOptions {
  initialValue?: number
  min?: number
  max?: number
  step?: number
}

interface UseCounterReturn {
  count: Ref<number>
  increment: (step?: number) => void
  decrement: (step?: number) => void
  reset: () => void
  set: (value: number) => void
}

export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
  const { 
    initialValue = 0, 
    min = -Infinity, 
    max = Infinity, 
    step = 1 
  } = options
  
  const count = ref(clamp(initialValue, min, max))
  
  function increment(stepSize = step) {
    const newValue = count.value + stepSize
    if (newValue <= max) {
      count.value = newValue
    }
  }
  
  // 其他方法...
  
  return {
    count,
    increment,
    decrement,
    reset: () => { count.value = clamp(initialValue, min, max) },
    set: (value) => { count.value = clamp(value, min, max) }
  }
}

实战:为 useMousePosition 定义完善的类型

我们来看一个完整的实战案例,展示如何为真实的组合式函数添加类型:

// composables/useMousePosition.ts
import { ref, onMounted, onUnmounted } from 'vue'

// 1. 定义位置接口
export interface MousePosition {
  x: number
  y: number
  timestamp: number
}

// 2. 定义配置选项
export interface UseMousePositionOptions {
  /**
   * 节流时间(毫秒),默认 0 表示不节流
   */
  throttle?: number
  
  /**
   * 监听的目标元素,默认 window
   */
  target?: HTMLElement | null | (() => HTMLElement | null)
  
  /**
   * 是否立即开始监听,默认 true
   */
  immediate?: boolean
  
  /**
   * 坐标类型,默认 'client'
   */
  type?: 'client' | 'page' | 'screen'
}

// 3. 定义返回值类型
export interface UseMousePositionReturn {
  /**
   * 当前鼠标位置
   */
  position: Ref<MousePosition>
  
  /**
   * 是否正在监听
   */
  isListening: Ref<boolean>
  
  /**
   * 开始监听
   */
  start: () => void
  
  /**
   * 停止监听
   */
  stop: () => void
  
  /**
   * 重置位置为 (0, 0)
   */
  reset: () => void
}

// 4. 工具函数:获取坐标
function getMousePosition(event: MouseEvent, type: 'client' | 'page' | 'screen'): MousePosition {
  const timestamp = Date.now()
  
  switch (type) {
    case 'client':
      return { x: event.clientX, y: event.clientY, timestamp }
    case 'page':
      return { x: event.pageX, y: event.pageY, timestamp }
    case 'screen':
      return { x: event.screenX, y: event.screenY, timestamp }
  }
}

// 5. 主函数实现
export function useMousePosition(options: UseMousePositionOptions = {}): UseMousePositionReturn {
  const {
    throttle = 0,
    target = window,
    immediate = true,
    type = 'client'
  } = options

  // 创建响应式状态
  const position = ref<MousePosition>({ x: 0, y: 0, timestamp: 0 })
  const isListening = ref(false)
  
  // 获取目标元素
  const getTarget = (): EventTarget | null => {
    if (typeof target === 'function') {
      return target()
    }
    return target
  }
  
  // 节流控制
  let lastRun = 0
  let rafId: number | null = null
  
  // 鼠标移动处理函数
  const handleMouseMove = (event: MouseEvent) => {
    const now = Date.now()
    
    // 节流处理
    if (throttle > 0 && now - lastRun < throttle) {
      return
    }
    
    // 使用 requestAnimationFrame 优化性能
    if (rafId !== null) {
      cancelAnimationFrame(rafId)
    }
    
    rafId = requestAnimationFrame(() => {
      position.value = getMousePosition(event, type)
      lastRun = now
      rafId = null
    })
  }
  
  // 开始监听
  const start = () => {
    if (isListening.value) return
    
    const targetEl = getTarget()
    if (targetEl) {
      targetEl.addEventListener('mousemove', handleMouseMove)
      isListening.value = true
    }
  }
  
  // 停止监听
  const stop = () => {
    const targetEl = getTarget()
    if (targetEl) {
      targetEl.removeEventListener('mousemove', handleMouseMove)
    }
    
    if (rafId !== null) {
      cancelAnimationFrame(rafId)
      rafId = null
    }
    
    isListening.value = false
  }
  
  // 重置位置
  const reset = () => {
    position.value = { x: 0, y: 0, timestamp: 0 }
  }
  
  // 自动开始监听
  if (immediate) {
    onMounted(() => {
      start()
    })
  }
  
  // 清理
  onUnmounted(() => {
    stop()
  })
  
  return {
    position,
    isListening,
    start,
    stop,
    reset
  }
}

泛型约束:让复用更灵活

场景:实现一个通用的 useLocalStorage

没有泛型之前,我们可能会写出这样的代码:

// ❌ 不够通用,只能处理 string
function useLocalStorage(key: string, initialValue: string) {
  const value = ref(initialValue)
  
  onMounted(() => {
    const stored = localStorage.getItem(key)
    if (stored !== null) {
      value.value = stored
    }
  })
  
  watch(value, (newValue) => {
    localStorage.setItem(key, newValue)
  })
  
  return value
}

// 想存储数字?不行
const count = useLocalStorage('count', 0) // 类型错误!

解决方案:使用泛型约束

// ✅ 使用泛型,支持任意可序列化的类型
function useLocalStorage<T>(key: string, initialValue: T) {
  // 指定 ref 的类型为 T
  const value = ref<T>(initialValue) as Ref<T>
  
  onMounted(() => {
    try {
      const stored = localStorage.getItem(key)
      if (stored !== null) {
        // 反序列化,并确保类型正确
        value.value = JSON.parse(stored) as T
      }
    } catch (e) {
      console.error(`Failed to parse localStorage key "${key}":`, e)
    }
  })
  
  watch(value, (newValue) => {
    try {
      localStorage.setItem(key, JSON.stringify(newValue))
    } catch (e) {
      console.error(`Failed to stringify value for key "${key}":`, e)
    }
  }, { deep: true })
  
  return value
}

// 现在可以存储任意类型
const count = useLocalStorage('count', 0) // Ref<number>
const user = useLocalStorage('user', { name: '张三' }) // Ref<{ name: string }>
const items = useLocalStorage('items', [1, 2, 3]) // Ref<number[]>

进阶:添加类型约束和默认值处理

// 定义可序列化类型的约束
type Serializable = 
  | string 
  | number 
  | boolean 
  | null 
  | undefined
  | Serializable[]
  | { [key: string]: Serializable }

// 扩展选项
interface UseStorageOptions<T> {
  /**
   * 存储类型,默认 localStorage
   */
  storage?: 'local' | 'session'
  
  /**
   * 序列化函数
   */
  serializer?: {
    read: (raw: string) => T
    write: (value: T) => string
  }
  
  /**
   * 监听深度
   */
  deep?: boolean
  
  /**
   * 错误处理
   */
  onError?: (error: Error) => void
}

// 增强版的 useStorage
export function useStorage<T extends Serializable>(
  key: string,
  initialValue: T,
  options: UseStorageOptions<T> = {}
): Ref<T> {
  const {
    storage = 'local',
    deep = true,
    onError = (e) => console.error(`Storage error: ${e}`)
  } = options
  
  // 默认使用 JSON 序列化
  const serializer = options.serializer ?? {
    read: (raw: string) => JSON.parse(raw) as T,
    write: (value: T) => JSON.stringify(value)
  }
  
  const storageObj = storage === 'local' ? localStorage : sessionStorage
  const value = ref<T>(initialValue) as Ref<T>
  
  // 读取存储的值
  try {
    const raw = storageObj.getItem(key)
    if (raw !== null) {
      value.value = serializer.read(raw)
    } else {
      // 初始化存储
      storageObj.setItem(key, serializer.write(initialValue))
    }
  } catch (e) {
    onError(e as Error)
  }
  
  // 监听变化
  watch(value, (newValue) => {
    try {
      storageObj.setItem(key, serializer.write(newValue))
    } catch (e) {
      onError(e as Error)
    }
  }, { deep })
  
  return value
}

// 使用示例
const settings = useStorage('settings', {
  theme: 'dark',
  fontSize: 14,
  notifications: true
})

// 类型安全
settings.value.theme = 'light' // ✅
settings.value.theme = 123 // ❌ 类型错误

// 自定义序列化
const dates = useStorage('dates', [new Date()], {
  serializer: {
    read: (raw) => JSON.parse(raw).map((d: string) => new Date(d)),
    write: (value) => JSON.stringify(value.map(d => d.toISOString()))
  }
})

实战:useAsyncData 的泛型设计

// 定义异步操作的状态
interface AsyncState<T> {
  data: T | null
  loading: boolean
  error: Error | null
}

// 定义返回值类型
interface UseAsyncDataReturn<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  execute: (...args: any[]) => Promise<T>
  refresh: () => Promise<T>
}

// 带泛型的异步数据获取组合式函数
export function useAsyncData<T>(
  fetcher: (...args: any[]) => Promise<T>,
  options: {
    immediate?: boolean
    initialData?: T | null
    onSuccess?: (data: T) => void
    onError?: (error: Error) => void
  } = {}
): UseAsyncDataReturn<T> {
  const data = ref<T | null>(options.initialData ?? null)
  const loading = ref(false)
  const error = ref<Error | null>(null)
  
  const execute = async (...args: any[]): Promise<T> => {
    loading.value = true
    error.value = null
    
    try {
      const result = await fetcher(...args)
      data.value = result
      options.onSuccess?.(result)
      return result
    } catch (e) {
      const err = e instanceof Error ? e : new Error(String(e))
      error.value = err
      options.onError?.(err)
      throw err
    } finally {
      loading.value = false
    }
  }
  
  const refresh = () => execute()
  
  if (options.immediate !== false) {
    execute()
  }
  
  return {
    data,
    loading,
    error,
    execute,
    refresh
  }
}

// 使用示例
interface User {
  id: number
  name: string
  email: string
}

const { data, loading, error } = useAsyncData<User>(
  () => fetch('/api/user').then(r => r.json())
)

// TypeScript 知道 data 是 User | null
if (data.value) {
  console.log(data.value.name) // ✅ 类型安全
}

类型推导的艺术:何时自动推导,何时显式注解?

自动推导的场景

TypeScript 的类型推导非常智能,很多情况下不需要显式注解:

简单值可以自动推导

const count = ref(0) // Ref<number>
const name = ref('') // Ref<string>
const isActive = ref(false) // Ref<boolean>

对象字面量可以推导

const user = ref({
  name: '张三',
  age: 25
}) // Ref<{ name: string; age: number }>

函数返回值可以推导

function useCounter() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment } // { count: Ref<number>; increment: () => void }
}

computed 可以推导

const double = computed(() => count.value * 2) // ComputedRef<number>

需要显式注解的场景

有些场景必须显式注解,否则类型会不正确:

空数组无法推导元素类型

const items = ref<Item[]>([]) 

null 初始值无法推导

const user = ref<User | null>(null)

复杂嵌套对象,类型太长

interface AppState {
  user: { name: string; age: number }
  settings: { theme: string }
}
const state = reactive<AppState>({ ... })

导出给外部使用的 API

export function useFeature(): FeatureReturn {
  // 明确告诉使用者返回什么
  return { ... }
}

类型推导原则

原则1:内部实现多用推导,外部接口显式注解

function useInternal() {
  // 内部实现,让 TypeScript 自己推导
  const count = ref(0)
  const double = computed(() => count.value * 2)
  return { count, double }
}

export function usePublic(): PublicAPI {
  // 导出的 API 显式注解
  const { count, double } = useInternal()
  return { count, double }
}

原则2:复杂类型提取为接口

interface User {
  name: string
  age: number
}

interface UpdateUserData {
  name?: string
  age?: number
}

function useUser() {
  const user = ref<User>({ name: '张三', age: 25 })
  const updateUser = (data: UpdateUserData) => {
    Object.assign(user.value, data)
  }
  return { user, updateUser }
}

原则3:使用 satisfies 确保类型正确(TS 4.9+)

const routes = {
  home: { path: '/', component: Home },
  about: { path: '/about', component: About }
} satisfies Record<string, Route>

原则4:使用 const 断言锁定字面量类型

const user = {
  name: '张三',
  role: 'admin'
} as const

高级技巧:类型守卫与类型收窄

使用自定义类型守卫处理异步数据的不同状态

在处理异步数据时,我们经常需要根据状态执行不同的逻辑:

// 定义三种状态类型
interface IdleState {
  status: 'idle'
}

interface LoadingState {
  status: 'loading'
}

interface SuccessState<T> {
  status: 'success'
  data: T
}

interface ErrorState {
  status: 'error'
  error: Error
}

// 联合类型
type AsyncState<T> = 
  | IdleState 
  | LoadingState 
  | SuccessState<T> 
  | ErrorState

// 组合式函数
function useAsyncState<T>(fetcher: () => Promise<T>) {
  const state = ref<AsyncState<T>>({ status: 'idle' })
  
  const execute = async () => {
    state.value = { status: 'loading' }
    
    try {
      const data = await fetcher()
      state.value = { status: 'success', data }
    } catch (e) {
      state.value = { 
        status: 'error', 
        error: e instanceof Error ? e : new Error(String(e))
      }
    }
  }
  
  return {
    state: readonly(state),
    execute
  }
}

// 类型守卫
function isIdle<T>(state: AsyncState<T>): state is IdleState {
  return state.status === 'idle'
}

function isLoading<T>(state: AsyncState<T>): state is LoadingState {
  return state.status === 'loading'
}

function isSuccess<T>(state: AsyncState<T>): state is SuccessState<T> {
  return state.status === 'success'
}

function isError<T>(state: AsyncState<T>): state is ErrorState {
  return state.status === 'error'
}

在组件中使用类型守卫

<template>
  <div>
    <div v-if="isLoading(state)">加载中...</div>
    
    <div v-else-if="isError(state)" class="error">
      错误: {{ state.error.message }}
      <button @click="retry">重试</button>
    </div>
    
    <div v-else-if="isSuccess(state)" class="data">
      <!-- 这里 state.data 的类型是 T -->
      <pre>{{ state.data }}</pre>
    </div>
    
    <div v-else-if="isIdle(state)">
      <button @click="execute">开始加载</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useAsyncState, isSuccess, isError, isLoading, isIdle } from './composables/useAsyncState'

interface UserData {
  id: number
  name: string
}

const { state, execute } = useAsyncState<UserData>(async () => {
  const res = await fetch('/api/user')
  return res.json()
})

// 类型守卫让 TypeScript 能够收窄类型
watch(state, (newState) => {
  if (isSuccess(newState)) {
    // 这里 TypeScript 知道 newState 是 SuccessState<UserData>
    console.log('用户数据:', newState.data.name)
  } else if (isError(newState)) {
    // 这里知道是 ErrorState
    console.error('错误:', newState.error.message)
  }
})

function retry() {
  if (isError(state.value)) {
    // 只有在错误状态下才能看到错误详情
    console.log('重试,之前的错误:', state.value.error)
    execute()
  }
}
</script>

使用判别式联合类型实现状态机

// 更复杂的异步操作状态机
interface PendingState {
  status: 'pending'
}

interface LoadingState {
  status: 'loading'
  progress?: number
}

interface SuccessState<T> {
  status: 'success'
  data: T
  timestamp: number
}

interface ErrorState {
  status: 'error'
  error: Error
  retryCount: number
}

interface CancelledState {
  status: 'cancelled'
  reason?: string
}

type RequestState<T> = 
  | PendingState
  | LoadingState
  | SuccessState<T>
  | ErrorState
  | CancelledState

// 类型守卫函数可以自动生成
const guards = {
  isPending: <T>(s: RequestState<T>): s is PendingState => s.status === 'pending',
  isLoading: <T>(s: RequestState<T>): s is LoadingState => s.status === 'loading',
  isSuccess: <T>(s: RequestState<T>): s is SuccessState<T> => s.status === 'success',
  isError: <T>(s: RequestState<T>): s is ErrorState => s.status === 'error',
  isCancelled: <T>(s: RequestState<T>): s is CancelledState => s.status === 'cancelled'
}

// 使用示例
function handleRequestState<T>(state: RequestState<T>) {
  if (guards.isSuccess(state)) {
    // 这里 state.data 可用
    console.log(`数据获取成功,时间戳: ${state.timestamp}`)
  } else if (guards.isError(state)) {
    // 这里可以访问 state.retryCount
    console.log(`错误,已重试 ${state.retryCount} 次`)
  } else if (guards.isCancelled(state)) {
    // 这里可以访问 state.reason
    console.log(`已取消: ${state.reason}`)
  }
}

TypeScript 配置的最佳实践

项目配置建议

// tsconfig.json
{
  "compilerOptions": {
    // 严格模式必须开启
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    
    // Vue 3 推荐配置
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    
    // 路径别名
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@composables/*": ["src/composables/*"]
    }
  },
  
  // 包含的文件
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.vue"
  ],
  
  // 排除的文件
  "exclude": [
    "node_modules",
    "dist"
  ]
}

VSCode 配置建议

// .vscode/settings.json
{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.preferences.autoImportFileExcludePatterns": [
    "vue-router",
    "pinia"
  ],
  "typescript.suggest.autoImports": true,
  "typescript.suggest.completeFunctionCalls": true,
  
  // 保存时自动修复
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  
  // 启用 Vue 语言服务
  "volar.autoCompleteRefs": true,
  "volar.completion.preferredTagNameCase": "kebab",
  "volar.completion.preferredAttrNameCase": "kebab"
}

组合式函数 TypeScript 最佳实践清单

  • 为所有导出函数定义接口:导出的 API 必须有清晰的类型定义
  • 使用泛型增加复用性:对于需要处理多种类型的函数,使用泛型约束
  • 提供完整的 JSDoc 注释:为参数和返回值添加说明
  • 使用 readonly 保护内部状态:对于不应该被修改的 ref,使用 readonly 包装
  • 类型守卫处理联合类型:使用自定义类型守卫收窄类型范围
  • 避免 any 类型:使用 unknown 替代 any,配合类型守卫
  • 提取共用类型:将重复使用的类型提取为接口
  • 测试类型定义:使用 tsddtslint 测试类型定义的正确性

结语

当我们的组合式函数拥有了完善的 TypeScript 支持,它们就不再是普通的函数,而是拥有“钢筋铁骨”的可靠组件。这不仅提升了开发体验,更重要的是让整个应用的质量有了根本性的保障。

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

高效的数据解构:用 toRefs 和 toRef 保持响应性

前言

在 Vue3 的开发中,解构赋值是比较常用的语法特性。它能让代码更简洁,变量命名更自由。但当解构遇到 reactive 响应式数据时,一个常见的陷阱就出现了:解构后的变量失去了响应性

为什么会这样?如何既享受解构的便利,又保持数据的响应性?本文将深入探讨 toRefstoRef 这两个 API 的工作原理和使用技巧,帮你彻底解决解构带来的响应式丢失问题。

解构的诱惑与陷阱

为什么我们喜欢解构赋值?

解构赋值是 ES6 带来的语法糖,它让代码变得更加简洁优雅:

const user = reactive({ name: '张三', age: 18 })

// 没有解构之前,只能属性调用
console.log(user.name)
console.log(user.age)

// 有解构之后
const { name, age } = user
console.log(name)
console.log(age)

解构的优势

  • 按需引入:只取需要的属性
  • 命名自由:可以重命名变量
  • 代码简洁:减少重复的前缀

解构带来的问题

当我们对 reactive 响应式对象进行解构时,会丢失响应式。

这部分的内容,在上一篇文章《响应式探秘:ref vs reactive,我该选谁?》中有详细讲解,本文不再赘述!

toRefs 的魔法

原理:将 reactive 对象的每个属性都转换为 ref

toRefs 的出现正是为了解决 reactive 的解构问题。它的工作原理是:遍历 reactive 对象的所有属性,为每个属性都单独创建一个 ref,这些 ref 会保持与原对象的响应式连接:

// 简化的 toRefs 实现
function toRefs(obj) {
  const result = {}
  
  for (const key in obj) {
    // 为每个属性创建 ref
    result[key] = {
      __v_isRef: true,
      get value() {
        return obj[key]  // 读取时访问原对象
      },
      set value(newVal) {
        obj[key] = newVal // 设置时修改原对象
      }
    }
  }
  return result
}

// 使用
const user = reactive({
  name: '张三',
  age: 18
})

const refs = toRefs(user)

user 使用 toRefs 转换后,其结构是这样的:

// toRefs转换后的结构
{
  name: RefImpl { ... },
  age: RefImpl { ... }
}

有了这个结构之后,我们就可以放心、安全地解构了:

const { name, age } = refs
name.value = '李四' // 会触发 user.name 的更新
age.value++        // 会触发 user.age 的更新

使用场景:从组合式函数返回多个值时

toRefs 最常见的应用场景就是当组合式函数中返回多个响应式值时,进行处理:

import { reactive, toRefs } from 'vue'

export function useUser() {
  const state = reactive({
    user: null,
    loading: false,
    error: null,
    permissions: []
  })

  async function fetchUser(id) {
    state.loading = true
    try {
      state.user = await api.getUser(id)
      state.permissions = await api.getPermissions(id)
      state.error = null
    } catch (e) {
      state.error = e
    } finally {
      state.loading = false
    }
  }

  function updateUser(data) {
    Object.assign(state.user, data)
  }

  // ✅ 返回时使用 toRefs,让使用者可以解构
  return {
    ...toRefs(state),
    fetchUser,
    updateUser
  }
}

注意事项:响应式连接是双向的

我们一定要注意:toRefs 创建的是响应式连接是双向的,它并不是复制了一份数据,而是指向原对象属性的引用。这也是一个很常见的开发误区。

const original = reactive({
  name: '张三',
  age: 18
})

const { name, age } = toRefs(original)

// 修改 ref 会影响原对象
name.value = '李四'
console.log(original.name) // '李四'

// 修改原对象会影响 ref
original.age = 20
console.log(age.value) // 20

// 这种连接是持久的
original.name = '王五'
console.log(name.value) // '王五'

// 即使重新赋值原对象的属性,连接依然保持
original.name = '赵六'
console.log(name.value) // '赵六'

toRef 的精简用法

场景:只想处理 reactive 对象中的某一个属性

使用 toRefs 会把 reactive 对象中的所有属性都转换成 ref;但有时候我们只需要处理 reactive 对象中的某些属性,这时使用 toRef 会更加精准。toRef 是用于将 reactive 对象的指定的属性转成 ref,一次只能转换一个属性。在 toRefs 源码实现中,其本质就是通过遍历对象的属性,再通过 toRef 逐个转换。

import { reactive, toRef } from 'vue'

const state = reactive({
  count: 0,
  name: '张三',
  age: 18,
  email: 'zhang@example.com',
  // ... 可能还有很多其他属性
})

// 只关心 count 属性
const countRef = toRef(state, 'count')

// 现在可以像使用 ref 一样使用 countRef
countRef.value++ // 修改 state.count
console.log(state.count) // 1

// 修改原对象也会影响 countRef
state.count = 10
console.log(countRef.value) // 10

优势:性能更好,只创建一个 ref

相比 toRefs 会为所有属性创建 reftoRef 只创建需要属性的 ref,性能开销更小。

toRef 的另一个妙用:创建可选的响应式引用

toRef 还有个好处,可以用来处理可能不存在的属性:

const state = reactive({
  user: {
    name: '张三'
  }
})

当前 user 只存在 name 属性,如果我们直接给它添加一个新属性会怎么样呢?

state.user.profile.gender = '男'

上述代码毫无疑问会报错:Cannot set properties of undefined (setting 'gender')。但通过 toRef 我们可以安全赋值:

// 即使 profile 不存在,也能创建响应式引用
const profile = toRef(state.user, 'profile')

// 可以安全地赋值
profile.value = { gender : '男' }

性能考量

toRefs 的性能开销

toRefs 会遍历对象的所有属性,为每个属性创建一个 ref 对象。对于大型对象来说,这确实会有一定的性能开销。性能开销主要来源于以下几点:

  • 遍历开销:需要遍历所有属性
  • 内存开销:每个 ref 都是一个对象,占用内存
  • 响应式连接:每个 ref 都需要建立响应式连接

因此基于性能考虑,我们应该遵循按需使用的原则,只有在需要的时候才使用 toRefs

何时不该使用 toRefs

有些场景下,使用 toRefs 也确实可能不是最佳选择:

场景1:性能敏感的高频操作

这就是上述提到的性能开销问题。

场景2:对象在组件内部使用,不需要暴露给外部

function internalFeature() {
  const internalState = reactive({ ... })
  
  // 不需要 toRefs,直接在内部使用 state
  function doSomething() {
    internalState.prop = value
  }
  
  return {
    doSomething
  }
}

场景3:返回整个对象

function useConfig() {
  const config = reactive({
    theme: 'dark',
    language: 'zh',
    features: {...}
  })
  
  // 如果使用者很少需要解构,直接返回 reactive 更好
  return {
    config,
    updateConfig
  }
}

结语

toRefstoRef 解决了在享受解构便利的同时,又不失去 Vue 响应式系统的强大能力。理解并善用它们,我们的代码将既简洁又可靠!

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

TypeScript 强力护航:PropType 与组件事件类型的声明

前言

在 Vue 3 + TypeScript 的项目中,组件的类型安全是一个核心话题。很多开发者可能有过这样的经历:使用一个第三方组件时,完全不知道它接受哪些 Props,也不知道事件应该传递什么参数,只能去翻文档。或者在自己的项目中,修改了一个组件的 Props,结果到处报错,不得不全局搜索手动修改。

TypeScript 的出现改变了这一切。通过为组件 Props 和事件声明类型,我们不仅能获得完美的智能提示,还能让编译器在开发阶段就发现类型错误。本文将深入探讨如何在 Vue 3 中为组件定义类型安全的 Props 和事件,包括复杂的泛型组件实现。

Vue 组件类型系统的演进

Options API 中的 Prop 类型:运行时校验

在 Options API 中,我们通过对象形式定义 Props:

export default {
  props: {
    // 基础类型检查
    name: String,
    age: Number,
    
    // 带验证的写法
    email: {
      type: String,
      required: true,
      validator: (value: string) => value.includes('@')
    },
    
    // 复杂类型
    user: {
      type: Object,
      default: () => ({})
    }
  }
}

这种写法存在很多局限性:

  • 运行时类型检查:这些类型只在运行时验证,TypeScript 无法在编译时捕获错误
  • 复杂类型无法表达:user: Object 无法描述对象的内部结构
  • 没有智能提示:在模板中使用 props 时,编辑器不知道有哪些属性

Composition API 带来的类型优势

Composition API 配合 TypeScript,让类型推导变得更加强大:

<script setup lang="ts">
// 现在可以获得类型推导
const props = defineProps({
  name: String,
  age: Number
})

// props.name 被推导为 string | undefined
// props.age 被推导为 number | undefined
</script>

但这种方法仍然有局限性,无法定义复杂的嵌套类型。

为什么需要显式的 PropType?

当 Props 的类型不是简单的 String、Number 等构造函数时,就需要 PropType 来帮助 TypeScript 理解类型。我们先来看一个反例:

// ❌ 这样写,TypeScript 会报错
defineProps({
  user: {
    type: Object as User, // 'User' only refers to a type, but is being used as a value here
    required: true
  }
})

正确写法:

defineProps({
  user: {
    type: Object as PropType<User>, // 告诉 TypeScript 这是一个 User 类型
    required: true
  },
  
  // 联合类型
  status: {
    type: String as PropType<'active' | 'inactive'>,
    default: 'active'
  },
  
  // 复杂对象
  config: {
    type: Object as PropType<{
      theme: string
      fontSize: number
    }>,
    default: () => ({ theme: 'light', fontSize: 14 })
  }
})

Props 定义的三种方式

运行时声明 + 类型推导(基础写法)

<script setup lang="ts">
// 基础类型会自动推导
const props = defineProps({
  name: String,           // props.name: string | undefined
  age: Number,            // props.age: number | undefined
  isActive: Boolean,      // props.isActive: boolean | undefined
  tags: Array,            // props.tags: any[] | undefined
  user: Object            // props.user: Record<string, any> | undefined
})

// 设置默认值
const propsWithDefault = defineProps({
  count: {
    type: Number,
    default: 0
  },                      // props.count: number
  items: {
    type: Array,
    default: () => []
  }                       // props.items: any[]
})
</script>
  • 优点:写法简单,有运行时类型检查
  • 缺点:复杂类型无法表达,如 string[] 会被推导为 any[]

纯类型声明(推荐)

这是 Vue 3.3+ 推荐的方式,使用 TypeScript 接口或类型别名:

<script setup lang="ts">
// 定义 Props 接口
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

interface Config {
  theme: 'light' | 'dark'
  fontSize: number
  showAvatar?: boolean
}

interface Props {
  title: string
  count?: number
  user: User
  config: Config
  tags: string[]
  status: 'loading' | 'success' | 'error'
}

// 直接使用接口
const props = defineProps<Props>()

// 需要默认值时,使用 withDefaults
const propsWithDefault = withDefaults(defineProps<Props>(), {
  count: 0,
  tags: () => [],
  config: () => ({ theme: 'light', fontSize: 14 })
})
</script>
  • 优点:

    • 完美的类型推导
    • 支持任何复杂的 TypeScript 类型
    • 编辑器智能提示完美
  • 缺点:

    • 需要 Vue 3.3+ 版本
    • 不能同时使用运行时验证(如 validator 函数)

复杂类型的处理:PropType 工具类型

当需要运行时验证,又想保留类型时,使用 PropType:

<script setup lang="ts">
import type { PropType } from 'vue'

// 定义复杂类型
interface User {
  id: number
  name: string
  email: string
  preferences: {
    theme: 'light' | 'dark'
    notifications: boolean
  }
}

type Status = 'pending' | 'processing' | 'completed' | 'failed'

// 使用 PropType 辅助类型推导
const props = defineProps({
  // 对象类型
  user: {
    type: Object as PropType<User>,
    required: true,
    validator: (user: User) => user.name.length > 0
  },
  
  // 联合类型
  status: {
    type: String as PropType<Status>,
    default: 'pending'
  },
  
  // 数组类型
  tags: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  
  // 函数类型
  onSave: {
    type: Function as PropType<(data: User) => Promise<void>>,
    required: false
  },
  
  // 复杂的嵌套类型
  config: {
    type: Object as PropType<{
      pagination: {
        pageSize: number
        currentPage: number
      }
      filters: Record<string, any>
    }>,
    default: () => ({
      pagination: { pageSize: 10, currentPage: 1 },
      filters: {}
    })
  }
})
</script>

适用场景:

  • 需要运行时验证(如 validator)
  • 需要设置复杂的默认值逻辑
  • 需要与 Options API 混用

事件发射的类型安全

defineEmits 的基础用法

<script setup lang="ts">
// 基础写法:字符串数组
const emit = defineEmits(['change', 'update', 'delete'])

// 使用时没有任何类型提示
emit('change', 123) // 可以传任意参数
emit('update', 'any', 'thing') // 没问题
</script>

为事件负载定义类型(推荐)

<script setup lang="ts">
// 使用类型声明
interface Emits {
  // 基础事件
  (e: 'change', value: string): void
  (e: 'update:id', id: number): void
  (e: 'delete'): void
  
  // 多个参数
  (e: 'item-move', fromIndex: number, toIndex: number): void
  
  // 联合类型的事件名
  (e: 'success' | 'error', message: string): void
}

const emit = defineEmits<Emits>()

// 使用时的类型检查
emit('change', '新值')      // ✅ 正确
emit('change', 123)         // ❌ 错误:参数类型必须是 string
emit('update:id', 1)        // ✅ 正确
emit('delete')              // ✅ 正确
emit('item-move', 0, 5)     // ✅ 正确
emit('item-move', 0)        // ❌ 错误:缺少第二个参数
</script>

v-model 的类型安全

<script setup lang="ts">
// 单个 v-model
interface Emits {
  (e: 'update:modelValue', value: string): void
  (e: 'update:searchText', value: string): void
  (e: 'update:selectedIds', ids: number[]): void
}

const emit = defineEmits<Emits>()

// 多个 v-model 的使用
function handleInput(value: string) {
  emit('update:modelValue', value)
}

function handleSearch(value: string) {
  emit('update:searchText', value)
}

function handleSelect(ids: number[]) {
  emit('update:selectedIds', ids)
}
</script>

<template>
  <!-- 父组件使用时获得类型提示 -->
  <ChildComponent 
    v-model="text"
    v-model:search-text="searchText"
    v-model:selected-ids="selectedIds"
  />
</template>

泛型组件的实现技巧

使用 defineComponent 配合泛型

在 Vue 3.3 之前,需要使用 defineComponent 来创建泛型组件:

// GenericTable.ts
import { defineComponent, PropType } from 'vue'

export default defineComponent({
  name: 'GenericTable',
  
  props: {
    data: {
      type: Array as PropType<any[]>,
      required: true
    },
    columns: {
      type: Array as PropType<TableColumn<any>[]>,
      required: true
    },
    rowKey: {
      type: [String, Function] as PropType<string | ((row: any) => string)>,
      required: true
    }
  },
  
  emits: {
    'sort-change': (sort: SortState) => true,
    'row-click': (row: any, index: number) => true
  },
  
  setup(props, { emit }) {
    // 实现逻辑
    return () => {
      // 渲染函数
    }
  }
})

// 使用时需要手动指定类型
const table = GenericTable as <T extends Record<string, any>>(
  new () => {
    $props: TableProps<T>
  }
)

在 SFC 中使用

Vue 3.3 引入了 generic 属性,让泛型组件的实现变得简单:

<script setup lang="ts" generic="T extends { id: string | number }">
// T 必须包含 id 属性
defineProps<{
  items: T[]
  selectedId?: T['id']
}>()

defineEmits<{
  select: [id: T['id']]
}>()
</script>

类型推导的局限性及解决方案

问题 1:模板中的类型推导

<script setup lang="ts" generic="T">
defineProps<{
  data: T[]
  format: (item: T) => string
}>()
</script>

<template>
  <div v-for="item in data" :key="item.id">
    <!-- ❌ item.id 可能不存在于 T 上 -->
    {{ format(item) }}
  </div>
</template>
解决方案:添加泛型约束
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{
  data: T[]
  format: (item: T) => string
}>()
</script>

问题 2:事件参数的类型推导

<script setup lang="ts" generic="T">
const emit = defineEmits<{
  (e: 'update', item: T): void  // ❌ T 在这里无法推导
}>()
</script>
解决方案:使用运行时声明 + PropType
<script setup lang="ts">
import type { PropType } from 'vue'

const props = defineProps({
  items: {
    type: Array as PropType<T[]>,
    required: true
  }
})

const emit = defineEmits({
  'update': (item: any) => true
})
</script>

类型安全组件的收益

使用组件时的智能提示

当其他开发者在使用我们的组件时,VS Code 会提供完美的智能提示:

<template>
  <!-- 输入 <Table 就会弹出所有 Props 提示 -->
  <Table
    :data="users"
    :columns="columns"
    :row-key="'id'"
    @sort-change="handleSortChange"
    @row-click="handleRowClick"
  />
</template>

错误提前暴露

<script setup>
// ❌ 编译时报错:Property 'nme' does not exist on type 'User'
const columns = [
  { key: 'nme', title: '姓名' } // 拼写错误
]

// ❌ 编译时报错:Type 'string' is not assignable to type 'number'
const handleSortChange = (sort: SortState) => {
  sort.field = 123 // 类型错误
}
</script>

更好的可维护性

当需要修改组件 Props 时,TypeScript 会标记所有使用错误的地方:

// 将 Props 从 TableColumn 改为 ColumnConfig
interface TableProps<T> {
  columns: ColumnConfig<T>[] // 修改了类型
  // ...
}

// 所有使用了旧类型的地方都会报错,不需要手动查找

类型安全组件的最佳实践清单

  • 优先使用纯类型声明(defineProps())
  • 复杂类型使用 PropType 辅助
  • 为所有事件定义类型,包括负载参数
  • 使用泛型创建可复用组件,并添加必要约束
  • 导出组件的 Props 和 Emits 类型,方便使用者
  • 为插槽定义类型,提供更好的使用体验

结语

类型安全不是一蹴而就的,而是在开发过程中逐步完善的。它不仅是为了迎合 TypeScript ,更是为了让我们的代码更加健壮,让团队协作更加顺畅。

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

组件设计原则:如何设计一个高内聚、低耦合的 Vue 组件

前言

在 Vue 应用开发中,组件就像是乐高积木,组件设计可以决定这些积木的形状和接口。好的设计可以让积木自由组合,构建出各种复杂的应用;而一个坏的设计则让积木之间互不兼容,最终导致代码难以维护、难以复用、难以测试。

尤其是随着项目规模的增长,组件设计的重要性愈发凸显。本文将深入探讨高内聚低耦合的核心概念,通过大量实战案例,帮助我们掌握 Vue 组件设计的精髓。

为什么组件设计如此重要?

现实痛点

开篇之前,我们先来看一个设计不良的组件会带来哪些问题:

<!-- ❌ 反例:一个上千行的 "上帝组件" -->
<template>
  <div>
    <!-- 用户信息区域 -->
    <div class="user-section">
      <img :src="user.avatar">
      <h2>{{ user.name }}</h2>
      <!-- 几百行用户相关代码 -->
    </div>
    
    <!-- 好友列表区域 -->
    <div class="friends-section">
      <!-- 又是几百行好友列表代码 -->
    </div>
    
    <!-- 动态列表区域 -->
    <div class="activities-section">
      <!-- 还有几百行动态列表代码 -->
    </div>
  </div>
</template>

<script>
export default {
  props: ['user'], // 什么类型?不知道
  data() {
    return {
      user: {},
      friends: [],
      activities: [],
      loading: false,
      error: null,
      // ... 还有诸多数据字段
    }
  },
  methods: {
    // 所有方法全部混在一起
    fetchUser() { /* ... */ },
    fetchFriends() { /* ... */ },
    fetchActivities() { /* ... */ },
    followUser() { /* ... */ },
    unfollowUser() { /* ... */ },
    likeActivity() { /* ... */ },
    // ... 其他方法
  }
}
</script>

这个组件存在的问题:

  • 牵一发而动全身:修改用户信息的样式,可能会意外影响好友列表
  • 难以复用:想在另一个页面显示好友列表?那只能复制粘贴上百行代码
  • 难以理解:新接手的人需要花一天时间才能理清逻辑
  • 难以测试:如何单独测试好友列表的功能?

好的组件设计带来的收益

<!-- ✅ 好的设计:拆分为独立组件 -->
<template>
  <div class="user-profile-page">
    <UserInfoCard :user="user" />
    <FriendList :friends="friends" @follow="handleFollow" />
    <ActivityFeed :activities="activities" @like="handleLike" />
  </div>
</template>

<script setup>
// 容器组件:只负责数据获取和组合
const { user, friends, activities } = await fetchUserData(props.userId)

function handleFollow(userId) { /* ... */ }
function handleLike(activityId) { /* ... */ }
</script>

这个组件带来的好处:

  • 可维护性:每个组件独立修改,互不影响
  • 可复用性:这个组件可以在任何地方使用
  • 可测试性:可以为每个组件编写独立的单元测试
  • 可读性:代码即文档,一目了然

高内聚低耦合:组件设计的黄金法则

什么是高内聚?

高内聚是指组件内部的元素(数据、方法、模板等)紧密相关,共同完成一个明确的职责:

<!-- ✅ 高内聚的计数器组件:所有逻辑都服务于"计数"这个单一职责 -->
<template>
  <div class="counter">
    <button @click="decrement" :disabled="count <= min">-</button>
    <span class="count">{{ count }}</span>
    <button @click="increment" :disabled="count >= max">+</button>
  </div>
</template>

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

const props = defineProps<{
  min?: number
  max?: number
  initial?: number
}>()

// 所有数据和方法都围绕 count 展开
const count = ref(props.initial ?? 0)

function increment() {
  if (count.value < (props.max ?? Infinity)) {
    count.value++
  }
}

function decrement() {
  if (count.value > (props.min ?? -Infinity)) {
    count.value--
  }
}
</script>

<style scoped>
/* 样式也只服务于这个组件 */
.counter {
  display: flex;
  align-items: center;
  gap: 8px;
}
</style>

高内聚的特征

  • 组件名称准确地描述了它的功能
  • 组件的所有代码都是为了实现这个功能
  • 移除任何一个部分都会影响核心功能

什么是低耦合?

低耦合是指组件之间的依赖关系简单、明确,修改一个组件不需要修改另一个组件:

<!-- 父组件 -->
<template>
  <div>
    <UserCard
      :user="user"
      @follow="handleFollow"
      @unfollow="handleUnfollow"
    />
  </div>
</template>

<!-- 子组件:不知道父组件的任何信息 -->
<template>
  <div class="user-card">
    <img :src="user.avatar" :alt="user.name">
    <h3>{{ user.name }}</h3>
    <button 
      v-if="!isFollowing"
      @click="$emit('follow', user.id)"
    >
      关注
    </button>
    <button 
      v-else
      @click="$emit('unfollow', user.id)"
    >
      取消关注
    </button>
  </div>
</template>

<script setup>
defineProps<{
  user: { id: number; name: string; avatar: string }
  isFollowing?: boolean
}>()

defineEmits<{
  follow: [userId: number]
  unfollow: [userId: number]
}>()
</script>

低耦合的特征

  • 组件只通过 Props 接收数据,通过 Events 发送消息
  • 组件内部不依赖全局状态(除非必要)
  • 修改组件内部实现,不需要修改使用它的地方

内聚与耦合的关系

高内聚和低耦合是相辅相成的:

  • 高内聚是低耦合的基础:只有组件内部职责清晰,才能设计出清晰的接口
  • 低耦合让高内聚更有价值:如果组件之间耦合度高,即使每个组件内聚再好,系统也难以维护

组件划分的边界艺术

如何判断一个组件是否应该拆分?

当我们在犹豫是否要拆分一个组件时,可以问问自己这几个问题:

  • 独立复用:这个部分能否在其他地方使用?
  • 独立逻辑:这个部分是否有独立的业务逻辑?
  • 频繁变化:这个部分是否会频繁修改?
  • 代码规模:代码是否过长,如是否超过 300 行?
  • 过度拆分:是否为了拆分而拆分,导致组件冗余?

原子设计方法论

原子设计方法论是由 Brad Frost 提出的一种用于构建设计系统的方法论。它借鉴了化学中的基本概念,认为所有的用户界面(UI)都可以由一系列基本的、不可再分的元素(原子)组合而成。其核心思想是分层构建,就像搭积木一样,从最小的单元开始,逐步组合成越来越复杂的结构,这个过程分为五个层次:

原子(Atoms)→ 分子(Molecules)→ 组织(Organisms)→ 模板(Templates)→ 页面(Pages)

原子

原子 是构成用户界面的最基本、最小的元素,无法再进一步细分。其本身不具备独立的功能性,但它们定义了所有设计元素的基础样式和属性。比如一个 <label> 标签、一个 <input> 输入框、一个 <button> 按钮、颜色调色板、字体、动画等:

<template>
  <button>原子按钮</button>
</template>

分子

分子 由多个原子组合在一起形成的相对简单的 UI 组件,具有简单、明确的功能,遵循“单一职责原则”,即:只做一件事,且把这件事做得很好。比如一个“搜索框”分子可以由一个 <label> 原子(“搜索”文字)、一个 <input> 原子(输入框)和一个 <button> 原子(“搜索”按钮)组合而成。这三个原子结合在一起,就形成了一个能执行搜索功能的最小单元:

<template>
  <div class="search-bar">
    <label>搜索:<label>
    <input v-model="searchText" />
    <button @click="search">搜索</button>
  </div>
</template>

组织

组织 由分子、原子以及其他组织组合而成的相对复杂的 UI 结构。它们构成了页面中一个独立的区域,作为页面中功能完善的模块,但本身还不是一个完整的页面。比如“用户列表”,由多个“用户卡片”分子构成:

<template>
  <div class="user-list">
    <UserCard v-for="user in users" :key="user.id" :user="user" />
  </div>
</template>

模板

模板 将多个组织、分子和原子组合在一起,形成页面的 骨架和布局结构。其关注的是内容在页面上的 排布方式,展示了各组件的相对位置和功能。如一个“管理布局”模板,定义了头部组织、正文内容区域和底部组织分别放在什么位置:

<template>
  <div class="layout">
    <header />
    <main>
      <SearchBar @search="handleSearch" />
      <UserList :users="filteredUsers" />
    </main>
    <footer />
  </div>
</template>

注:模板是 抽象 的,它没有填充真实的内容,只有占位符。只是定义了 布局结构

页面

页面 是模板的具体实例。它将真实的内容(文本、图片等)填充到模板中,并精确地调整整个界面的样式和逻辑,最终呈现给用户的样子。

原子设计方法论与 Vue3 的结合

Vue3 的原子:Vue3 中的基础元素组件

在 Vue3 中,原子通常对应那些只封装了最基础 HTML 元素和样式的组件。它们通常只通过 props 接收数据,并通过 $emitv-model 向外发送事件:

<!-- 1. 原子:BaseInput.vue -->
<template>
  <div class="base-input">
    <input
      :id="id"
      :type="type"
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      v-bind="$attrs"
    />
  </div>
</template>

<script setup lang="ts">
defineProps({
  id: String,
  type: { type: String, default: 'text' },
  modelValue: [String, Number]
})
defineEmits(['update:modelValue'])
</script>

Vue3 的分子:Vue3 中的功能组件

<!-- 分子:SearchForm.vue -->
<template>
  <form class="search-form" @submit.prevent="handleSubmit">
    <BaseInput
      v-model="searchText"
      label="搜索"
      placeholder="请输入关键词..."
    />
    <BaseButton type="submit">搜索</BaseButton>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'
import BaseButton from './BaseButton.vue'

const searchText = ref('')
const emit = defineEmits(['search'])

const handleSubmit = () => {
  emit('search', searchText.value)
}
</script>

Vue3 的组织:Vue3 中的区块组件

<!-- 组织:HeaderOrganism.vue -->
<template>
  <header class="site-header">
    <div class="logo">
      <img src="/logo.png" alt="Logo" />
      <span>My App</span>
    </div>
    <nav class="nav-menu">
      <a v-for="item in navItems" :key="item.link" :href="item.link">{{ item.text }}</a>
    </nav>
    <SearchForm @search="handleGlobalSearch" />
  </header>
</template>

<script setup lang="ts">
import SearchForm from './SearchForm.vue' // 导入分子

const navItems = [ /* ... */ ]
const handleGlobalSearch = (query) => { /* 处理全局搜索 */ }
</script>

Vue3 中的模板:Vue3 中的布局或页面组件(此时无数据)

模板在 Vue 中通常对应一个布局组件或一个无具体数据的页面级组件。它负责定义页面的骨架结构,引入各种组织组件,并将它们摆放在正确的位置。此时,组件接收的 propsslot 插槽内容都是抽象的占位符:

<!-- 模板:ArticlePageTemplate.vue -->
<template>
  <div class="article-page">
    <HeaderOrganism />
    <main class="content-wrapper">
      <aside class="sidebar">
        <!-- 这里是一个插槽,用于放置侧边栏内容,具体内容由页面填充 -->
        <slot name="sidebar" />
      </aside>
      <article class="main-content">
        <!-- 这里是主要内容插槽 -->
        <slot />
      </article>
    </main>
    <FooterOrganism />
  </div>
</template>

<script setup lang="ts">
import HeaderOrganism from './HeaderOrganism.vue'
import FooterOrganism from './FooterOrganism.vue'
</script>

Vue3 中的页面:Vue2 中的完整页面组件(有数据)

<!-- 页面:ArticlePage.vue -->
<template>
  <ArticlePageTemplate>
    <!-- 向模板的 sidebar 插槽填充真实内容 -->
    <template #sidebar>
      <AuthorCard :author="article.author" />
      <RelatedArticles :articles="article.related" />
    </template>

    <!-- 向默认插槽填充文章正文 -->
    <ArticleContent :article="article" />
  </ArticlePageTemplate>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ArticlePageTemplate from './ArticlePageTemplate.vue'
import AuthorCard from './AuthorCard.vue'
import RelatedArticles from './RelatedArticles.vue'
import ArticleContent from './ArticleContent.vue'

const article = ref({})
onMounted(async () => {
  article.value = await fetchArticleData()
})
</script>

Props 设计:定义组件的公开 API

Props 设计的黄金法则

法则一:尽可能少,尽可能明确

只接收必要的数据,不要接收和组件不相关的数据:

defineProps<{
  user: User
  isEditable?: boolean
}>()

法则二:提供合理的默认值

interface Props {
  placeholder?: string
  disabled?: boolean
  maxLength?: number
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: '请输入',
  disabled: false,
  maxLength: 100
})

法则三:使用 TypeScript 定义类型

interface User {
  id: number
  name: string
  avatar: string
  role: 'admin' | 'user' | 'guest'
}

defineProps<{
  user: User
  permissions: string[]
}>()

法则四:避免传递不必要的 props

<ChildComponent :user="user" />

Props 的 4 种类型及使用场景

1. 数据型 Props:单纯的数据展示

<UserCard 
  :user="user"
  :posts="userPosts"
/>

2. 配置型 Props:控制组件行为

<DataTable
  :show-header="true"
  :allow-sort="true"
  :page-size="20"
  :theme="'dark'"
/>

3. 回调型 Props:事件处理

<FormComponent
  @submit="handleSubmit"
  @cancel="handleCancel"
/>

4. 节点型 Props:自定义渲染

<ModalComponent>
  <template #header>
    <h2>自定义标题</h2>
  </template>
  <template #footer>
    <button>确认</button>
  </template>
</ModalComponent>

Props 命名的最佳实践

1. 使用完整单词

defineProps<{
  userName: string      // 不是 uname
  userAvatar: string    // 不是 uavatar(除非是标准术语)
}>()

2. 布尔值用 is/has/should 开头

defineProps<{
  isActive: boolean     // 状态
  hasPermission: boolean // 拥有
  shouldShow: boolean   // 应该
}>()

3. 回调函数用 on 开头

defineProps<{
  onSubmit: () => void
  onClose: () => void
}>()

4. 数组等用复数

defineProps<{
  users: User[]
}>()

事件通信:让组件之间优雅地对话

组件通信的 5 种方式及选择策略

1. Props + Events:父子组件直接通信(最常用)

<!-- 父组件 -->
<ChildComponent 
  :data="parentData"
  @update="handleUpdate"
/>

<!-- 子组件 -->
<script setup>
defineProps<{ data: string }>()
const emit = defineEmits<{
  update: [value: string]
}>()
</script>

2. v-model:双向绑定的场景(表单类)

<InputComponent v-model="searchText" />

3. Slots:父组件控制渲染内容(布局类)

<CardComponent>
  <template #header>标题</template>
  内容
  <template #footer>底部</template>
</CardComponent>

4. Provide/Inject:跨多层组件传递(主题、用户信息)

// 祖先组件
provide('theme', 'dark')
// 后代组件
const theme = inject('theme')

5. Pinia:全局状态(用户信息、购物车)

const userStore = useUserStore()

事件设计的 3 个原则

原则一:只通知,不下命令

子组件只需要告诉父组件发生了什么,至于事件发生后该做什么,要怎么做,由父组件决定,子组件不作任何处理:

const emit = defineEmits<{
  'item-selected': [item: Item]
  'form-submitted': [data: FormData]
}>()

原则二:事件粒度适中

一个操作对应一个事件,不要把所有操作放在一个事件中(太粗),也不要把不需要处理的操作放在事件中(太细):

// ✅ 好:一个操作一个事件
const emit = defineEmits<{
  'save-success': []
  'save-error': [error: Error]
}>()

// ❌ 差:太细或太粗
const emit = defineEmits<{
  'button-mousedown': []      // 太细,外部不需要知道
  'button-mouseup': []        // 太细
  'data-operation': [         // 太粗,不知道发生了什么
    type: 'create' | 'update' | 'delete',
    data: any
  ]
}>()

原则三:保持一致性

统一的命名风格,使用冒号 : 分隔命名空间:

const emit = defineEmits<{
  'user:created': [user: User]
  'user:updated': [user: User]
  'user:deleted': [userId: string]
}>()

插槽设计:让组件拥有无限可能

插槽的 3 种形式及适用场景

1. 默认插槽:简单的内容占位

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-content">
      <slot>
        <!-- 提供默认内容 -->
        <p>暂无内容</p>
      </slot>
    </div>
  </div>
</template>

<!-- 使用 -->
<Card>
  <p>这是卡片内容</p>
</Card>

2. 具名插槽:多个位置的定制

<!-- Modal.vue -->
<template>
  <div class="modal">
    <header>
      <slot name="header">默认标题</slot>
    </header>
    
    <main>
      <slot name="content">默认内容</slot>
    </main>
    
    <footer>
      <slot name="footer">
        <button @click="close">关闭</button>
      </slot>
    </footer>
  </div>
</template>

<!-- 使用 -->
<Modal>
  <template #header>
    <h2>自定义标题</h2>
  </template>
  
  <template #content>
    <p>自定义内容</p>
  </template>
  
  <template #footer>
    <button @click="confirm">确认</button>
    <button @click="cancel">取消</button>
  </template>
</Modal>

3. 作用域插槽:让父组件访问子组件数据

<!-- DataTable.vue -->
<template>
  <div class="data-table">
    <table>
      <tbody>
        <tr v-for="(item, index) in data" :key="index">
          <td v-for="col in columns" :key="col.key">
            <slot 
              :name="`column-${col.key}`"
              :value="item[col.key]"
              :row="item"
              :index="index"
            >
              {{ item[col.key] }}
            </slot>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<!-- 使用 -->
<DataTable :data="users" :columns="columns">
  <template #column-status="{ value, row }">
    <Badge :type="value === 'active' ? 'success' : 'default'">
      {{ value }}
    </Badge>
  </template>
</DataTable>

插槽设计的 3 个最佳实践

1. 提供合理的默认内容

<template>
  <div class="empty-state">
    <slot name="icon">
      <EmptyIcon />
    </slot>
    
    <slot name="message">
      <p>暂无数据</p>
    </slot>
    
    <slot name="action">
      <button @click="$emit('refresh')">刷新</button>
    </slot>
  </div>
</template>

2. 保持作用域数据的精简

<template>
  <!-- ✅ 好:只暴露必要的数据 -->
  <slot 
    :item="item"
    :index="index"
    :is-first="index === 0"
    :is-last="index === items.length - 1"
  />
  
  <!-- ❌ 差:暴露整个组件实例 -->
  <slot :this="this" :$el="$el" :$props="$props" />
</template>

3. 使用 TypeScript 定义插槽类型

<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

defineSlots<{
  // 默认插槽不接受 props
  default(props: {}): any
  
  // 具名插槽
  header(props: {}): any
  
  // 作用域插槽
  'user-item'(props: { 
    user: User
    index: number
    isSelected: boolean
  }): any
  
  // 可选插槽
  footer?(props: {}): any
}>()
</script>

组件设计的 SOLID 原则(Vue 视角)

SOLID 原则 Vue 中的体现 实践建议
单一职责 一个组件只做一件事 组件代码不超过 300 行,功能单一明确
开闭原则 对扩展开放,对修改关闭 多用插槽,少改内部逻辑;通过 Props 配置行为
里氏替换 子组件可替换父组件 保持 Props 接口一致,遵循相同的契约
接口隔离 Props 尽可能少 避免传递整个对象,只传必要字段;用多个小 Props 替代一个大对象
依赖倒置 依赖抽象,不依赖实现 用事件通信,不直接调用父组件方法;用 provide/inject 解耦

组件设计的 10 个坏味道(Anti-Patterns)

  1. 上帝组件:超过 500 行的组件
  2. Props 泛滥:超过 10 个 props
  3. 多层级 Props 透传:props 穿过 3 层以上
  4. 组件内直接修改 props:违反了单向数据流
  5. 模板内复杂逻辑:模板中有三元运算符嵌套
  6. CSS 全局污染:没有使用 scoped 或 CSS Modules
  7. 依赖父组件结构:组件假设父组件一定有某个 DOM 结构
  8. 过度抽象:为了复用而拆分,反而更难用
  9. 隐式通信:通过修改 store 来通知兄弟组件
  10. 没有 TypeScript:组件 API 全靠文档记忆

组件设计的检查清单

设计前思考

  • 这个组件的职责是否单一?
  • 是否真的需要拆分成独立组件?
  • 这个组件会在哪些地方被使用?

设计时检查

  • Props 命名是否清晰易懂?
  • 是否提供了合理的默认值?
  • 是否使用了 TypeScript 定义类型?
  • 事件命名是否表达了发生了什么?
  • 插槽是否有合理的默认内容?
  • 样式是否 scoped?

设计后验证

  • 组件能否独立运行?(不依赖外部数据)
  • 修改组件内部,会影响外部吗?(低耦合验证)
  • 其他开发者能看懂这个组件吗?(可读性验证)
  • 能否为这个组件写单元测试?(可测试性验证)
  • 组件文档是否清晰?(可用性验证)

结语

好的组件设计不是一蹴而就的,而是在每一次重构中不断完善的过程。当我们开始思考"这个组件是否应该拆分"、"这个 Props 命名是否合理"的时候,我们就已经走在了正确的道路上了。

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

响应式探秘:ref vs reactive,我该选谁?

前言

在 Vue3 的 Composition API 中,有两个主要的响应式 API:refreactive。很多开发者,尤其是刚从 Vue2 迁移过来的同学,常常会困惑:到底该用哪一个响应式 API ?什么时候该用 ref?什么时候该用 reactive

这个问题看似简单,实则涉及 Vue3 响应式系统的核心设计理念。本文将从源码原理出发,深入剖析两者的本质区别。

响应式原理快速回顾

Proxy:Vue3 响应式的基石

在深入 refreactive 之前,我们必须先理解 Vue3 响应式的核心:Proxy 代理。

在 Vue2 中, 使用的是 Object.defineProperty 来拦截属性的读写,但它有一个致命缺陷:无法检测属性的添加和删除,当我们需要添加属性等操作时,必须用 Vue.set()vm.$set() 等方式处理。而在 Vue3 中改用 Proxy 进行对象代理,完美解决了这个问题:

const target = { name: 'Vue' }
const handler = {
  get(target, key, receiver) {
    console.log(`读取属性: ${key}`)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log(`设置属性: ${key} = ${value}`)
    return Reflect.set(target, key, value, receiver)
  }
}

const proxy = new Proxy(target, handler)
proxy.name // 读取属性: name
proxy.name = 'Vue3' // 设置属性: name = Vue3

Proxy 的强大之处

  • 拦截所有操作:包括属性读取、赋值、删除、in 操作符等,支持 13 种数据操作的拦截
  • 动态属性响应:新增属性也能被追踪
  • 数组方法拦截:push、pop 等方法也能触发更新

关于 Proxy 的相关内容,可以查看我在《JavaScript核心机制探秘》专栏中相关的文章介绍。

reactive 的实现原理

reactive 是 Vue3 中最直接的响应式 API,它接收一个对象,返回这个对象的 Proxy 代理:

// 简化的 reactive 实现
function reactive(target) {
  // 创建 Proxy 代理
  return new Proxy(target, {
    get(target, key, receiver) {
      // 依赖收集
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      // 触发更新
      trigger(target, key)
      return result
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      // 删除属性也要触发更新
      trigger(target, key)
      return result
    }
  })
}

// 使用
const state = reactive({
  count: 0,
  user: { name: '张三' }
})

state.count++ // 触发更新
state.user.name = '李四' // 嵌套对象也会被递归代理

ref 的实现原理

ref 的设计要处理一个根本性问题:Proxy 只能代理对象,无法代理基础类型(string、number、boolean)。因此,Vue团队 给出了一个解决方案:使用 value 属性,将基础类型值包装成一个对象,再对这个对象进行 Proxy 代理。这也是为什么 ref 响应式数据,需要用 .value 的方式进行访问的原因:

// 简化的 ref 实现
function ref(value) {
  // 创建包装对象
  const wrapper = {
    value: value
  }
  
  // 将包装对象变为响应式
  return reactive(wrapper)
}

// 更接近真实源码的实现
class RefImpl {
  constructor(value) {
    this._value = value
    this.__v_isRef = true // 标记这是一个 ref
  }
  
  get value() {
    // 依赖收集
    track(this, 'value')
    return this._value
  }
  
  set value(newVal) {
    if (this._value !== newVal) {
      this._value = newVal
      // 触发更新
      trigger(this, 'value')
    }
  }
}

function ref(value) {
  return new RefImpl(value)
}

// 使用
const count = ref(0)
count.value++ // 必须通过 .value 访问

从上述代码中,我们也可以看出:ref 返回的本质上也是一个 reactive 对象!

关于 ref 和 reactive 的具体源码实现细节,可以参考我的《Vue3 源码解析》的相关文章。

ref vs reactive 的核心区别

访问方式:.value 的有无

这是两者最直观的区别:

import { ref, reactive } from 'vue'

// ref 需要 .value
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1

// reactive 不需要 .value
const state = reactive({ count: 0 })
console.log(state.count) // 0
state.count++
console.log(state.count) // 1

重新赋值:整体替换 vs 属性修改

这其实是在 Vue3 开发中,最容易踩的一个坑,我们先来看一个例子:

// ref 支持整体替换
let user = ref({ name: '张三', age: 18 })
// ✅ 可以直接替换整个对象
user.value = { name: '李四', age: 20 }

// reactive 不支持整体替换
let state = reactive({ name: '张三', age: 18 })
// ❌ 这样会丢失响应式
state = { name: '李四', age: 20 } 

// ✅ reactive 只能修改属性
state.name = '李四'
state.age = 20

// ❌ 即使使用 Object.assign 也可能出现问题
Object.assign(state, { name: '王五', age: 22 }) // ✅ 这样可以
state = Object.assign({}, state, { name: '王五' }) // ❌ 这样不行

类型推导与解构

reactive 在使用解构时也会出现问题:

const state = reactive({
  name: '张三',
  age: 18,
  profile: {
    city: '北京'
  }
})

// ❌ 解构后失去响应性
const { name, age } = state
name // '张三',但不再是响应式的

// ✅ 使用 toRefs 保持响应性
const { name, age } = toRefs(state)
name.value // 需要通过 .value 访问

// ✅ 单个属性用 toRef
const city = toRef(state.profile, 'city')
city.value = '上海' // 会触发更新

ref 在这方面的表现就很好:

// 组合式函数返回 ref 对象
function useFeature() {
  const count = ref(0)
  const name = ref('张三')
  
  return {
    count,
    name
  }
}

// 解构后依然是响应式的
const { count, name } = useFeature()
count.value++ // ✅ 正常工作

注:关于上述内容,在论坛中也存在争议:由于 reactive 本身设计特性,会导致响应式丢失问题。因此部分开发者(包括笔者),更推荐在实际开发中,直接使用 ref,弃用 reactive

深层响应性

两者都支持深层响应,但内部实现略有不同:

const refObj = ref({
  user: {
    name: '张三',
    address: {
      city: '北京'
    }
  }
})

// 深层属性也是响应式的
refObj.value.user.address.city = '上海' // 触发更新

const reactiveObj = reactive({
  user: {
    name: '张三',
    address: {
      city: '北京'
    }
  }
})

// 同样是深层响应式
reactiveObj.user.address.city = '上海' // 触发更新

什么时候用 ref?

基础类型值

这是 ref 的主要应用场景,因为 reactive 根本不能处理基础类型:

const count = ref(0)
const name = ref('张三')
const isLoading = ref(false)
const userInput = ref('')

需要整体替换的场景

当我们的数据状态需要整体重置或替换时,ref 是不二之选:

// 表单数据,经常需要重置
const formData = ref({
  username: '',
  email: '',
  password: ''
})

// 重置表单 - ref 轻松搞定
function resetForm() {
  formData.value = {
    username: '',
    email: '',
    password: ''
  }
}

// 更新整个表单 - 从 API 获取数据后整体替换
async function loadForm(id) {
  const data = await api.getForm(id)
  formData.value = data // ✅ 直接替换
}

当然,如果一定要用 reactive 呢?也是可以解决的,只是较为麻烦而已:

// 如果用 reactive,重置会很麻烦
const formDataReactive = reactive({
  username: '',
  email: '',
  password: ''
})

function resetFormReactive() {
  // 需要逐个属性重置,或者使用 Object.assign
  Object.assign(formDataReactive, {
    username: '',
    email: '',
    password: ''
  })
}

从组合式函数返回时

当编写可复用的组合式函数时,返回 ref 对象可以更利于解构:

export function useUser() {
  const user = ref(null)
  const loading = ref(false)
  const error = ref(null)
  
  async function fetchUser(id) {
    loading.value = true
    try {
      user.value = await api.getUser(id)
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  // 返回 ref 对象,使用者可以随意解构
  return {
    user,
    loading,
    error,
    fetchUser
  }
}

// 在组件中使用
const { user, loading, fetchUser } = useUser()
// 解构后依然保持响应式
watch(user, () => {}) // ✅ 正常

跨组件传递时的类型安全

当通过 props 进行父子组件通信,传递响应式数据时,ref 的类型更清晰:

<!-- 父组件 -->
<script setup>
const userData = ref({ name: '张三', age: 18 })
</script>

<template>
  <ChildComponent :data="userData" />
</template>

<!-- 子组件 -->
<script setup>
// 明确知道接收的是一个 ref
const props = defineProps<{
  data: { name: string; age: number } // 注意:这是 Ref 的内部类型
}>()

// 使用 toValue 统一处理
const data = toValue(props.data) // toValue 可以处理 ref 和普通值
</script>

获取子组件实例

当父组件想要访问子组件的方法或数据时,可以直接使用 ref 获得子组件的实例,访问子组件通过 defineExpose 暴露的方法或数据: 子组件 Child.vue

<template>
  <div>子组件</div>
</template>

<script setup>
// 子组件的方法和数据
const childMethod = () => {
  console.log('子组件方法被调用')
}

// 需要暴露给父组件的属性和方法
defineExpose({
  childMethod,
  childData: '我是子组件的数据'
})
</script>

父组件 Parent.vue

<template>
  <!-- 子组件 -->
  <Child ref="childRef" />
  <button @click="callChildMethod">调用子组件方法</button>
</template>

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

// 创建一个ref来存储子组件实例
const childRef = ref(null)

// 调用子组件方法
const callChildMethod = () => {
  if (childRef.value) {
    childRef.value.childMethod()  // 调用子组件暴露的方法
    console.log(childRef.value.childData)  // 访问子组件暴露的数据
  }
}

// 在生命周期钩子中访问
import { onMounted } from 'vue'
onMounted(() => {
  console.log('子组件实例:', childRef.value)
})
</script>

什么时候用 reactive?

深层嵌套的对象

当数据结构复杂且嵌套层级较深时,reactive 的语法更简洁:

// 复杂的状态对象
const store = reactive({
  user: {
    profile: {
      personal: {
        name: '张三',
        age: 18
      },
      contact: {
        email: 'zhang@example.com',
        phone: '1234567890'
      }
    },
    preferences: {
      theme: 'dark',
      language: 'zh-CN',
      notifications: {
        email: true,
        sms: false
      }
    }
  },
  ui: {
    sidebar: {
      collapsed: false,
      width: 240
    },
    modal: {
      visible: false,
      type: null
    }
  }
})

// 访问深层属性 - reactive 很方便
store.user.profile.personal.name = '李四'
store.ui.sidebar.collapsed = true

// 如果用 ref,每次都要 .value,略显繁琐
const storeRef = ref({
  // 同样的数据结构
})
storeRef.value.user.profile.personal.name = '李四' // 多了 .value

不需要整体替换的数据

对于不需要整体替换的数据,比如配置数据等,只用初始化一次,后期只会更改属性,reactive 很合适:

const appConfig = reactive({
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retryCount: 3,
  features: {
    logging: true,
    cache: false
  }
})

// 后续只修改属性
appConfig.timeout = 10000
appConfig.features.cache = true

性能敏感的场景

虽然差别很小,但理论上 reactiveref 少一层包装,性能略好:

// ref 多了一层对象包装
const refState = ref({ count: 0 })
// 访问路径: refState.value.count

// reactive 直接代理原始对象
const reactiveState = reactive({ count: 0 })
// 访问路径: reactiveState.count

// 在大量数据操作的场景下,reactive 可能稍有优势

注:这种说法只是出于纯理论上的,因为实际开发中,这种性能差异在99%的场景中都可以忽略不计。

为什么 reactive 解构后会失去响应性?

原因:解构破坏了 Proxy 的代理

要想理解这个问题,还是得回到 Proxy 的工作原理中,我们先用一段简单的代码模拟 reactive 的行为:

const raw = { name: '张三', age: 18 }
const proxy = new Proxy(raw, {
  get(target, key) {
    console.log(`读取 ${key}`)
    return target[key]
  },
  set(target, key, value) {
    console.log(`设置 ${key} = ${value}`)
    target[key] = value
    return true
  }
})

此时,我们对 proxy 解构 const { name } = proxy ,它都会发生哪些事呢?

  1. 读取 proxy.name ,此时会触发 get 拦截 -- 没有问题
  2. 将获取到的值 张三 赋值给 name 变量 -- 问题产生了
  3. name 被重新赋值为一个普通的字符串,和 proxy 没有任何关系了
  4. 后续对 name 的操作都只是修改一个普通变量,不会触发任何拦截

解决方案

方案一:使用 toRefs(推荐)

import { reactive, toRefs } from 'vue'

const user = reactive({
  name: '张三',
  age: 18
})

// toRefs 将每个属性转换为 ref
const { name, age } = toRefs(user)

// 现在可以安全解构了
name.value = '李四' // ✅ 触发更新
age.value++ // ✅ 触发更新

toRefs 的简化原理:

function toRefs(obj) {
  const result = {}
  // 遍历对象的所有key
  for (const key in obj) {
    result[key] = toRef(obj, key) // 为每个属性单独创建 ref
  }
  return result
}

// 创建的 ref 和原对象保持连接
const nameRef = toRef(user, 'name')
nameRef.value = '李四' // 等价于 user.name = '李四'

方案二:使用 toRef 处理单个属性

import { reactive, toRef } from 'vue'

const user = reactive({
  name: '张三',
  age: 18
})

// 只需要处理个别属性
const name = toRef(user, 'name')
const age = toRef(user, 'age')

name.value = '李四' // ✅ 触发更新

方案三:直接用 ref

如果发现需要频繁解构,可能在一开始就应该使用 ref

const user = ref({
  name: '张三',
  age: 18
})

选择决策树

基于以上分析,我们可以建立一套清晰的选择决策树:

快速选择指南

选择决策树

决策依据详解

场景 推荐方案 原因
基础数据类型 ref reactive 无法处理基础类型
需要整体重置的表单 ref 支持直接替换 .value
组合式函数返回值 ref 方便使用者解构
复杂嵌套对象 reactive 语法更简洁
一次性初始化配置 reactive 不需要整体替换
需要解构的场景 ref + toRefs 保持响应性

最终建议

  • 默认用 refref 更灵活,适用场景更广,虽然多了 .value,但换来的是确定性和可预测性
  • 在特定场景用 reactive:当需要使用复杂对象且不需要解构时,reactive 能让代码更简洁
  • 要理解并善用工具函数toRefstoRefisRefisReactive
  • 团队统一规范:无论选择哪种策略,团队内要保持一致,避免混用导致混乱
  • 无法确定用哪个时:直接用 refref 是更安全、更通用的选择

结语

ref 是更安全、更通用的选择;reactive 则是在特定场景下的优化选择。理解了它们的设计哲学和适用场景,就能帮我们在适当的场合做出正确的选择。

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

KeepAlive:组件缓存实现深度解析

在前面的文章中,我们学习了 Suspense 如何处理异步组件加载。今天,我们将探索Vue3中另一个强大的特性:KeepAlive。它允许我们在组件切换时缓存组件实例,避免重复渲染,极大地提升了用户体验和性能。理解它的实现原理,将帮助我们更好地处理需要保持状态的组件。

前言:为什么需要组件缓存?

在构建大型单页应用时,我们经常会遇到这样的场景:

  • 用户频繁切换标签页,每次切换回来表单数据却丢失了。
  • 一个复杂的图表组件每次重新进入都要重新渲染,造成性能浪费。

Vue3 的 KeepAlive 组件正是为了解决这些问题而生。本文将深入剖析 KeepAlive 的工作原理、LRU缓存策略、生命周期变化,并手写一个简易实现。

KeepAlive 组件概述

什么是 KeepAlive

KeepAlive 是 Vue 的内置组件,它能够在组件切换时,自动将组件实例保存在内存(缓存)中,而不是直接将其销毁。当组件再次被切回时,直接从缓存中恢复实例和 DOM,从而避免重复渲染和状态丢失:

<template>
  <keep-alive>
    <component :is="currentTab" />
  </keep-alive>
</template>

核心优势

  • 状态保持:表单输入、滚动位置等状态在切换后依然保留
  • 性能提升:避免重复创建和销毁组件实例,减少DOM操作
  • 数据复用:避免重复请求相同的数据,减少网络开销

KeepAlive 的工作机制

核心原理:DOM的"搬家"

很多人误以为 KeepAlive 只是简单的 display: none,其实不然:它的本质是将组件的 DOM 节点从页面上摘下来,并将组件实例和 DOM 引用保存在内存中。当再次切回来时,直接从内存中取出这个 DOM 节点重新挂上去。

这个过程可以简化为:

  • 组件失活时:container.removeChild(dom) ,移除组件节点,但在内存中保留实例
  • 组件激活时:container.appendChild(dom) ,挂载组件节点,并恢复组件状态

缓存队列的设计

KeepAlive 内部使用两个核心数据结构来管理缓存:

const cache: Map<string, VNode> = new Map();  // 缓存存储
const keys: Set<string> = new Set();          // 缓存key顺序队列
  • cache:存储组件 VNode 的 Map 结构,key 通常是组件的 id 或 key 属性
  • keys:维护缓存 key 的访问顺序,用于实现 LRU 淘汰策略

核心配置属性

<keep-alive
  :include="['ComponentA', 'ComponentB']"  
  :exclude="/ComponentC/"                  
  :max="10"                                 
>
  <component :is="currentComponent" />
</keep-alive>
  • include:只有名称匹配的组件才会被缓存,支持字符串、正则、数组
  • exclude:名称匹配的组件不会被缓存
  • max:最多缓存多少组件实例,超过时按 LRU 策略淘汰

激活与失活:特殊的生命周期

activated 和 deactivated

当组件被 KeepAlive 包裹时,它会多出两个生命周期钩子:

<script setup>
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  // 调用时机:
  // 1. 组件首次挂载
  // 2. 每次从缓存中被重新插入时
  console.log('组件被激活了')
  // 适合恢复轮询、恢复动画等
})

onDeactivated(() => {
  // 调用时机:
  // 1. 从 DOM 上移除、进入缓存时
  // 2. 组件卸载时
  console.log('组件被停用了')
  // 适合清除定时器、暂停网络请求等
})
</script>

与普通生命周期的关系

被缓存的组件在切换时不会触发 unmountedmounted,而是触发 deactivatedactivated。这意味着组件实例一直活着,只是暂时休眠,其生命周期流程如下:

  • 首次进入: beforeMount -> mounted -> activated
  • 切换出去: -> deactivated
  • 切换回来: -> activated
  • 最终销毁: -> beforeUnmount -> unmounted -> deactivated

源码实现机制

Vue3 内部通过 registerLifecycleHook 来管理这些钩子:

function registerLifecycleHook(type, hook) {
  const instance = getCurrentInstance()
  if (instance) {
    (instance[type] || (instance[type] = [])).push(hook)
  }
}

// 激活时执行
function activateComponent(instance) {
  if (instance.activated) {
    instance.activated.forEach(hook => hook())
  }
}

// 失活时执行
function deactivateComponent(instance) {
  if (instance.deactivated) {
    instance.deactivated.forEach(hook => hook())
  }
}

LRU 淘汰策略深度解析

为什么需要 LRU

当设置了 max 属性后,缓存池容量有限。如果没有淘汰策略,无限缓存会导致内存溢出。LRU(Least Recently Used)算法正是解决这个问题的经典方案。

LRU 核心思想

LRU 基于"最近被访问的数据将来被访问的概率更高"这一假设:

  • 新数据插入到链表尾部
  • 每当缓存命中,将数据移到链表尾部
  • 链表满时,丢弃链表头部的数据(最久未使用)

KeepAlive 中的 LRU 实现

KeepAlive 利用 Set 的迭代顺序特性来实现 LRU,即:每次访问时先删除再添加,就实现了"移到末尾"的效果:

// 核心LRU逻辑
if (cachedVNode) {
  // 缓存命中:删除旧key,重新添加到末尾(表示最新使用)
  keys.delete(key)
  keys.add(key)
  return cachedVNode
} else {
  // 缓存未命中:添加新key
  keys.add(key)
  
  // 检查是否超过最大限制
  if (max && keys.size > max) {
    // 淘汰最久未使用的key(Set的第一个元素)
    const oldestKey = keys.values().next().value
    pruneCacheEntry(oldestKey)
  }
  cache.set(key, vnode)
  return vnode
}

手写实现简易 KeepAlive 组件

核心实现思路

// MyKeepAlive.ts
import { defineComponent, h, onBeforeUnmount, getCurrentInstance } from 'vue'

export default defineComponent({
  name: 'MyKeepAlive',
  
  props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    max: [String, Number]
  },
  
  setup(props, { slots }) {
    // 缓存容器
    const cache = new Map()
    const keys = new Set()
    
    // 当前渲染的 vnode
    let current = null
    
    // 工具函数:检查组件名是否匹配规则
    const matches = (pattern, name) => {
      if (Array.isArray(pattern)) {
        return pattern.includes(name)
      } else if (pattern instanceof RegExp) {
        return pattern.test(name)
      } else if (typeof pattern === 'string') {
        return pattern.split(',').includes(name)
      }
      return false
    }
    
    // 工具函数:获取组件名称
    const getComponentName = (vnode) => {
      const type = vnode.type
      return type.name || type.__name
    }
    
    // 淘汰缓存
    const pruneCacheEntry = (key) => {
      const cached = cache.get(key)
      if (cached && cached.component) {
        // 如果不是当前激活的组件,需要卸载
        if (cached !== current) {
          cached.component.unmount()
        }
      }
      cache.delete(key)
      keys.delete(key)
    }
    
    // 根据 include/exclude 清理缓存
    const pruneCache = (filter) => {
      cache.forEach((vnode, key) => {
        const name = getComponentName(vnode)
        if (name && filter(name)) {
          pruneCacheEntry(key)
        }
      })
    }
    
    // 监听 include/exclude 变化
    if (props.include || props.exclude) {
      watch(
        () => [props.include, props.exclude],
        ([include, exclude]) => {
          include && pruneCache(name => !matches(include, name))
          exclude && pruneCache(name => matches(exclude, name))
        },
        { flush: 'post' }
      )
    }
    
    // 组件卸载时清理所有缓存
    onBeforeUnmount(() => {
      cache.forEach((vnode) => {
        if (vnode.component) {
          vnode.component.unmount()
        }
      })
      cache.clear()
      keys.clear()
    })
    
    return () => {
      // 获取默认插槽的第一个子节点
      const vnode = slots.default?.()[0]
      if (!vnode) return null
      
      const name = getComponentName(vnode)
      
      // 检查 include/exclude
      if (
        (props.include && name && !matches(props.include, name)) ||
        (props.exclude && name && matches(props.exclude, name))
      ) {
        // 不缓存,直接返回
        return vnode
      }
      
      // 生成缓存key
      const key = vnode.key ?? vnode.type.__id ?? name
      
      // 命中缓存
      if (cache.has(key)) {
        const cachedVNode = cache.get(key)
        // 复用组件实例和DOM
        vnode.component = cachedVNode.component
        vnode.el = cachedVNode.el
        
        // 标记为 KeepAlive 组件
        vnode.shapeFlag |= 1 << 11 // ShapeFlags.COMPONENT_KEPT_ALIVE
        
        // LRU: 刷新key顺序
        keys.delete(key)
        keys.add(key)
        
        current = vnode
        return vnode
      }
      
      // 未命中缓存
      cache.set(key, vnode)
      keys.add(key)
      
      // LRU: 检查是否超过max限制
      if (props.max && keys.size > Number(props.max)) {
        const oldestKey = keys.values().next().value
        pruneCacheEntry(oldestKey)
      }
      
      // 标记为需要被 KeepAlive 的组件
      vnode.shapeFlag |= 1 << 12 // ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
      
      current = vnode
      return vnode
    }
  }
})

原生 JS 模拟演示

为了更直观地理解 KeepAlive 的"DOM搬家"原理,这里提供一个原生 JS 的简单实现:

<div id="app"></div>
<button onclick="switchTab('home')">首页</button>
<button onclick="switchTab('profile')">个人</button>

<script>
  const cache = {}
  const container = document.getElementById('app')
  let currentTab = null

  function createHomePage() {
    const div = document.createElement('div')
    div.innerHTML = `
      <h3>首页</h3>
      <input placeholder="试试输入内容..." />
    `
    return div
  }

  function createProfilePage() {
    const div = document.createElement('div')
    div.innerHTML = `<h3>个人中心</h3><p>这是个人页</p>`
    return div
  }

  function switchTab(tab) {
    // 移除当前页面
    if (currentTab && cache[currentTab]) {
      container.removeChild(cache[currentTab])
      console.log(`[缓存] ${currentTab} 已暂停 (DOM移除)`)
    }

    // 加载新页面
    if (cache[tab]) {
      // 命中缓存,直接复用DOM
      container.appendChild(cache[tab])
      console.log(`[缓存] ${tab} 命中缓存,恢复DOM`)
    } else {
      // 首次创建
      const page = tab === 'home' ? createHomePage() : createProfilePage()
      cache[tab] = page
      container.appendChild(page)
      console.log(`[缓存] ${tab} 首次创建并缓存`)
    }
    
    currentTab = tab
  }
</script>

常见陷阱

陷阱1:组件名不匹配导致缓存失效

KeepAliveinclude/exclude 是根据组件的 name 选项来匹配的,而不是文件名或路径,因此必须显示地声明组件的 name

陷阱2:滥用缓存导致内存溢出

对于频繁切换且数量众多的组件,务必设置合理的 max 值,避免无限缓存。

陷阱3:WebSocket 等全局资源重复创建

// ❌ 错误:每次激活都新建连接
onActivated(() => {
  ws = new WebSocket('wss://...') // 重复创建
})

// ✅ 正确:全局单例 + 按需消费
const socketStore = useSocketStore() // Pinia 全局单例
onActivated(() => {
  socketStore.subscribe('chat')
})
onDeactivated(() => {
  socketStore.unsubscribe('chat')
})

清除缓存的几种方式

方法1:动态修改 include/exclude

const cachedComponents = ref(['ComponentA', 'ComponentB'])
const clearCache = () => {
  cachedComponents.value = []  // 清空 include,所有组件不再缓存
}

方法2:改变 key 强制重新渲染

const componentKey = ref(0)
const forceRerender = () => {
  componentKey.value++  // key 变化,组件重新创建
}

方法3:调用 unmount(不推荐)

const clearCache = (key) => {
  // 通过 ref 访问组件实例,调用 unmount
}

结语

KeepAlive 是 Vue 中提升性能的重要工具,它通过缓存组件实例,避免重复渲染。理解它的实现原理,不仅帮助我们更好地使用它,也能在遇到性能问题时找到合适的优化方案。

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

Vue Router与响应式系统的集成

在前面的文章中,我们深入学习了 Vue3 的响应式系统、组件渲染、生命周期等核心机制。今天,我们将探索 Vue Router 是如何与 Vue 的响应式系统无缝集成的。理解路由的实现原理,将帮助我们更好地处理页面导航、路由守卫等复杂场景。

前言:路由的核心挑战

Vue Router 作为 Vue 的官方路由管理器,其最精妙的设计之一就是与 Vue 响应式系统的无缝集成。Vue 作为单页应用(SPA),在路由管理中,面临的核心挑战是:在URL变化时,不刷新页面,而是动态切换组件: 路由的核心挑战 同时,也面临诸多问题:

  • 如何监听URL变化而不刷新页面?
  • 如何让路由变化触发组件重新渲染?
  • 如何管理路由历史?

Vue Router 响应式设计总览

响应式数据的核心

Vue Router 实现响应式导航的核心是:将当前路由状态(currentRoute)作为响应式数据。当路由发生变化时,依赖这个响应式数据的组件(如 router-view)会自动重新渲染:

// 简化的核心代码
const currentRoute = shallowRef(initialRoute);

整体架构

Vue Router 的响应式集成主要包含三个层次:

  • 数据层:currentRoute 响应式对象
  • 视图层:router-view 组件监听路由变化
  • 交互层:router-link 组件和编程式导航

currentRoute:路由响应式数据的实现

核心响应式设计

在 Vue Router 4 中,当前路由状态被设计为一个 shallowRef 响应式对象:

import { shallowRef } from 'vue'

function createRouter(options) {
  // 初始化路由状态
  const START_LOCATION_NORMALIZED = {
    path: '/',
    matched: [],
    meta: {},
    // ... 其他路由属性
  }
  
  // 核心响应式数据
  const currentRoute = shallowRef(START_LOCATION_NORMALIZED)
  
  const router = {
    // 暴露当前路由为只读属性
    get currentRoute() {
      return currentRoute.value
    },
    // ... 其他方法
  }
  
  return router
}

为什么使用 shallowRef 而不是 ref?因为路由对象结构较深,shallowRef 只代理 .value 的变更,内部属性变更不需要触发响应式,这样可以获得更好的性能。

路由响应式数据的使用

Vue Router 通过依赖注入将响应式路由数据提供给所有组件:

install(app) {
  // 注册路由实例
  app.provide(routerKey, router)
  app.provide(routeLocationKey, reactive(this.currentRoute))
  
  // 注册全局组件
  app.component('RouterLink', RouterLink)
  app.component('RouterView', RouterView)
  
  // 在原型上挂载 $router 和 $route
  app.config.globalProperties.$router = router
  app.config.globalProperties.$route = reactive(this.currentRoute)
}

这样,我们在组件中就可以通过 $route 或 useRoute() 访问响应式路由数据:

<script setup>
import { useRoute } from 'vue-router'

// 返回一个响应式对象,当路由变化时会自动更新
const route = useRoute()

console.log(route.path) // 当前路径
console.log(route.params) // 路由参数
</script>

<template>
  <div>当前路径: {{ $route.path }}</div>
</template>

路由变化时如何触发更新

当路由发生变化时,Vue Router 会更新 currentRoute.value,从而触发所有依赖的重新渲染:

// 路由导航的核心逻辑
async function navigate(to, from) {
  // ... 执行导航守卫、解析组件等
  
  // 更新当前路由(触发响应式更新)
  currentRoute.value = to
  
  // 调用 afterEach 钩子
  callAfterEachGuards(to, from)
}

router-view 组件的渲染原理

router-view 的作用

router-view 是一个函数式组件,它的核心职责是:根据当前路由的匹配结果,渲染对应的组件:

<template>
  <div id="app">
    <!-- 路由匹配的组件会在这里渲染 -->
    <router-view></router-view>
  </div>
</template>

router-view 的源码实现

const RouterView = defineComponent({
  name: 'RouterView',
  setup(props, { attrs, slots }) {
    // 注入路由实例和当前路由
    const injectedRoute = inject(routeLocationKey)
    const router = inject(routerKey)
    
    // 获取深度(用于嵌套路由)
    const depth = inject(viewDepthKey, 0)
    const matchedRouteRef = computed(() => {
      // 获取当前深度对应的匹配记录
      const matched = injectedRoute.matched[depth]
      return matched
    })
    
    // 提供下一层的 depth
    provide(viewDepthKey, depth + 1)
    
    return () => {
      const match = matchedRouteRef.value
      const component = match?.components?.default
      
      if (!component) {
        return slots.default?.() || null
      }
      
      // 渲染匹配到的组件
      return h(component, {
        ...attrs,
        ref: match.instances?.default,
      })
    }
  }
})

嵌套路由的处理

router-view 通过 depth 参数支持嵌套路由:

<template>
  <div>
    <h1>用户中心</h1>
    <!-- 默认 depth = 1,会渲染子路由组件 -->
    <router-view></router-view>
  </div>
</template>

每个嵌套的 router-view 都会通过 provide/inject 获得递增的深度值,从而从 matched 数组中取出对应的组件记录。

路由钩子的实现机制

钩子函数分类

Vue Router 提供了三类导航守卫:

  • 全局守卫:beforeEach、beforeResolve、afterEach
  • 路由独享守卫:beforeEnter
  • 组件内守卫:beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave

钩子执行流程源码简析

async function navigate(to, from) {
  const guards = []
 
  // 收集所有需要执行的守卫,并按顺序执行
  // 1. 执行 beforeRouteLeave(从最深的路由记录开始)
  const leaveGuards = extractLeaveGuards(from.matched)
  guards.push(...leaveGuards)
  
  // 2. 执行全局 beforeEach
  guards.push(router.beforeEachGuards)
  
  // 3. 执行 beforeRouteUpdate(如果组件复用)
  const updateGuards = extractUpdateGuards(from.matched, to.matched)
  guards.push(...updateGuards)
  
  // 4. 执行路由配置的 beforeEnter
  const enterGuards = extractEnterGuards(to.matched)
  guards.push(...enterGuards)
  
  // 5. 执行全局 beforeResolve
  guards.push(router.beforeResolveGuards)
  
  // 串行执行所有守卫
  for (const guard of guards) {
    const result = await guard(to, from)
    // 如果守卫返回 false 或重定向路径,中断导航
    if (result === false || typeof result === 'string') {
      return result
    }
  }
  
  // 6. 执行全局 afterEach(不阻塞导航)
  callAfterEachGuards(to, from)
}

组件内守卫的实现

组件内守卫通过 Vue 的生命周期钩子集成:

// 组件内守卫的注册
export default {
  beforeRouteEnter(to, from, next) {
    // 在渲染前调用,不能访问 this
    // 可以通过 next 回调访问组件实例
    next(vm => {
      // 通过 `vm` 访问组件实例
    })
  },
  beforeRouteUpdate(to, from, next) {
    // 路由改变但组件复用时调用
    // 可以访问 this
  },
  beforeRouteLeave(to, from, next) {
    // 离开路由时调用
    // 可以访问 this
  }
}

Hash模式 vs History模式

两种模式的本质区别

Vue Router 支持两种路由模式:

模式 创建方式 URL格式 服务器配置 原理
Hash createWebHashHistory() /#/home 不需要 监听 hashchange 事件 + pushState
History createWebHistory() /home 需要 HTML5 History API

Hash模式的实现

// hash.js - Hash模式实现
function createWebHashHistory(base = '') {
  // Hash模式本质上是在 History 模式基础上加了 '#' 前缀
  return createWebHistory(base ? base : '#')
}

// 处理 Hash 路径
function getHashLocation() {
  const hash = window.location.hash.slice(1) // 去掉开头的 '#'
  return hash || '/' // 空 hash 返回根路径
}

// 监听 hash 变化
window.addEventListener('hashchange', () => {
  const to = getHashLocation()
  // 更新路由状态
  changeLocation(to)
})

注:在 Vue Router 4 中,Hash 模式也统一使用 History API 进行导航,hashchange 仅作为兜底监听。

History模式的实现

// html5.js - History模式实现
function createWebHistory(base = '') {
  // 创建状态管理器
  const historyState = useHistoryState()
  const currentLocation = ref(createCurrentLocation(base))
  
  // 监听 popstate 事件
  window.addEventListener('popstate', (event) => {
    const to = createCurrentLocation(base)
    currentLocation.value = to
    // 触发路由更新
  })
  
  function push(to) {
    // 调用 history.pushState
    window.history.pushState({}, '', to)
    currentLocation.value = to
  }
  
  function replace(to) {
    window.history.replaceState({}, '', to)
    currentLocation.value = to
  }
  
  return {
    location: currentLocation,
    push,
    replace
  }
}

History模式的服务器配置

History 模式需要服务器配置支持,否则刷新页面会 404。Nginx 配置示例:

location / {
  try_files $uri $uri/ /index.html;
}

createRouter核心逻辑源码简析

createRouter的整体结构

function createRouter(options) {
  // 1. 创建路由匹配器
  const matcher = createRouterMatcher(options.routes)
  
  // 2. 创建响应式路由状态
  const currentRoute = shallowRef(START_LOCATION)
  
  // 3. 根据模式创建 history 实例
  const history = options.history
  
  // 4. 定义路由方法
  const router = {
    // 响应式路由
    currentRoute,
    
    // 导航方法
    push(to) {
      return pushWithRedirect(to)
    },
    
    replace(to) {
      return push(to, true)
    },
    
    // 后退
    back() {
      history.go(-1)
    },
    
    // 前进
    forward() {
      history.go(1)
    },
    
    // 插件安装方法
    install(app) {
      // 提供路由实例
      app.provide(routerKey, router)
      app.provide(routeLocationKey, reactive(currentRoute))
      
      // 注册全局组件
      app.component('RouterLink', RouterLink)
      app.component('RouterView', RouterView)
      
      // 挂载到全局属性
      app.config.globalProperties.$router = router
      app.config.globalProperties.$route = reactive(currentRoute)
      
      // 初始化路由
      if (currentRoute.value === START_LOCATION) {
        // 解析初始路径
        history.replace(history.location)
      }
    }
  }
  
  return router
}

createRouterMatcher的实现

路由匹配器负责将配置的路由表拍平,建立父子关系:

function createRouterMatcher(routes) {
  const matchers = []
  
  // 递归添加路由记录
  function addRoute(record, parent) {
    // 标准化路由记录
    const normalizedRecord = normalizeRouteRecord(record)
    
    // 创建匹配器
    const matcher = createRouteRecordMatcher(normalizedRecord, parent)
    
    // 处理子路由
    if (normalizedRecord.children) {
      for (const child of normalizedRecord.children) {
        addRoute(child, matcher)
      }
    }
    
    matchers.push(matcher)
  }
  
  // 初始化所有路由
  routes.forEach(route => addRoute(route))
  
  // 解析路径,返回匹配的路由记录
  function resolve(location) {
    const matched = []
    let path = location.path
    
    // 找到匹配的 matcher
    for (const matcher of matchers) {
      if (path.startsWith(matcher.path)) {
        matched.push(matcher.record)
      }
    }
    
    return {
      path,
      matched
    }
  }
  
  return {
    addRoute,
    resolve
  }
}

手写简易路由实现

// 简易路由实现
import { ref, shallowRef, reactive, computed, provide, inject } from 'vue'

const ROUTER_KEY = '__router__'
const ROUTE_KEY = '__route__'

// 创建路由
function createRouter(options) {
  // 1. 创建匹配器
  const matcher = createMatcher(options.routes)
  
  // 2. 响应式路由状态
  const currentRoute = shallowRef({
    path: '/',
    matched: []
  })
  
  // 3. 处理历史模式
  const history = options.history
  
  // 4. 监听 popstate
  window.addEventListener('popstate', () => {
    const path = window.location.pathname
    const matched = matcher.match(path)
    currentRoute.value = { path, matched }
  })
  
  // 5. 导航方法
  function push(path) {
    window.history.pushState({}, '', path)
    const matched = matcher.match(path)
    currentRoute.value = { path, matched }
  }
  
  const router = {
    currentRoute,
    push,
    install(app) {
      app.provide(ROUTER_KEY, router)
      app.provide(ROUTE_KEY, reactive(currentRoute))
      
      app.component('RouterLink', {
        props: { to: String },
        setup(props, { slots }) {
          const router = inject(ROUTER_KEY)
          return () => (
            h('a', {
              href: props.to,
              onClick: (e) => {
                e.preventDefault()
                router.push(props.to)
              }
            }, slots.default?.())
          )
        }
      })
      
      app.component('RouterView', {
        setup() {
          const route = inject(ROUTE_KEY)
          const depth = inject('depth', 0)
          provide('depth', depth + 1)
          
          return () => {
            const component = route.value.matched[depth]?.component
            return component ? h(component) : null
          }
        }
      })
    }
  }
  
  return router
}

// 简易匹配器
function createMatcher(routes) {
  const records = []
  
  function normalize(route, parent) {
    const record = {
      path: parent ? parent.path + route.path : route.path,
      component: route.component,
      parent
    }
    
    records.push(record)
    
    if (route.children) {
      route.children.forEach(child => normalize(child, record))
    }
  }
  
  routes.forEach(route => normalize(route))
  
  return {
    match(path) {
      return records.filter(record => path.startsWith(record.path))
    }
  }
}

性能优化与最佳实践

路由懒加载

const routes = [
  {
    path: '/dashboard',
    // 使用动态导入实现懒加载
    component: () => import('./views/Dashboard.vue')
  }
]

避免不必要的响应式开销

// 如果只需要一次性值,可以不用解构
const route = useRoute()
// ❌ 避免:每次路由变化都会重新计算
const id = computed(() => route.params.id)

// ✅ 推荐:直接在需要的地方使用
watch(() => route.params.id, (newId) => {
  // 只在变化时执行
})

路由守卫的最佳实践

// 全局前置守卫:适合做权限验证
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !isLoggedIn()) {
    next('/login')
  } else {
    next()
  }
})

// 组件内守卫:适合做数据预加载
beforeRouteEnter(to, from, next) {
  fetchData(to.params.id).then(data => {
    next(vm => vm.data = data)
  })
}

结语

Vue Router 与响应式系统的集成是 Vue 生态中最精妙的设计之一,理解这些原理不仅帮助我们更好地使用 Vue Router,也为处理复杂路由场景(如权限控制、动态路由、嵌套路由等)提供了理论基础。在实际开发中,合理利用路由响应式特性和导航守卫,可以构建出既高效又易维护的单页应用。

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

Pinia状态管理原理:从响应式核心到源码实现

在前面的文章中,我们学习了 Vue Router 与响应式系统的集成。今天,我们将探索 Pinia,这是 Vue 官方推荐的状态管理库。Pinia 充分利用 Vue3 的响应式系统,提供了简单、类型安全的状态管理方案。理解它的实现原理,将帮助我们更好地组织应用状态,写出更可维护的代码。

前言:状态管理的演进

随着应用规模增长,组件间共享状态变得越来越复杂: 组件通信复杂性 在 Vue2 开发中,使用 Vuex 状态管理来解决组件共享状态问题;而 Vue3 则采用了 Pinia 的方式,为什么会有这层变化呢?

传统 Vuex 的问题

  • 繁琐的 mutations:必须通过 mutations 修改状态
  • 类型支持差:TypeScript 体验不佳
  • 模块化复杂:namespaced 概念增加心智负担
  • 体积较大:包含大量模板代码

Pinia的优势:

  • 直接修改状态:无需 mutations
  • 完美的类型推导:原生 TypeScript 支持
  • 扁平化结构:没有嵌套模块
  • 轻量高效:核心逻辑精简

Pinia 的设计理念与架构

Pinia 的本质

Pinia 本质上是一个 基于 Vue 3 响应式系统 + effectScope 的全局可控副作用容器 。它的核心目标是以最简洁的方式管理全局状态,同时保持类型安全和开发体验。

整体架构分层

Pinia 的源码架构可以清晰地分为三层: Pinia三层架构 这种分层设计使得 Pinia 既保持了上层 API 的简洁性,又能够充分利用 Vue 3 底层响应式系统的能力。

Pinia 如何利用 Vue 3 响应式系统

响应式核心:reactive 与 ref

Pinia 的状态管理完全建立在 Vue 3 的响应式 API 之上。当我们在 Pinia 中定义状态时,实际上是在创建 Vue 的响应式对象 :

// Pinia 内部的核心实现
import { reactive, ref } from 'vue'

// 选项式 Store 的 state 会被转换为 reactive
const state = reactive({
  count: 0,
  user: null
})

// 组合式 Store 直接使用 ref/reactive
const count = ref(0)
const user = ref(null)

Pinia 并不会重新发明一套响应式系统,而是直接复用 Vue 的响应式能力,这意味着:

  • 状态变化自动触发视图更新:当 state 变化时,所有依赖它的组件会自动重新渲染
  • 依赖自动收集:getters 中访问 state 时,Vue 会自动收集依赖关系

effectScope:全局副作用管理

Pinia 的一个重要创新是使用 Vue 3 的 effectScope API 来管理所有 store 的副作用 :

// createPinia 源码简化
export function createPinia() {
  // 创建全局 effectScope
  const scope = effectScope(true)
  
  // 全局 state 容器
  const state = scope.run(() => ref({}))!

  const pinia = markRaw({
    _e: scope,        // 全局 scope
    _s: new Map(),    // store 注册表
    state,            // 全局 state
    install(app) {
      app.provide(piniaSymbol, pinia)
    }
  })

  return pinia
}

这种设计有以下优势:

  • 统一管理:所有 storecomputedwatcheffect 都挂载在全局 scope
  • 一键清理:调用 pinia._e.stop() 即可销毁所有 store 的副作用
  • 每个 store 独立 scope:每个 store 还有自己的 scope,支持独立销毁(store.$dispose()

Store 的创建与类型推导

defineStore 的核心逻辑

defineStore 是用户定义 store 的入口,它返回一个 useStore 函数 :

// defineStore 源码简化
export function defineStore(id, setupOrOptions) {
  return function useStore() {
    // 获取当前活跃的 pinia 实例
    const pinia = getActivePinia()
    
    // 单例模式:同一 id 的 store 只创建一次
    if (!pinia._s.has(id)) {
      createStore(id, setupOrOptions, pinia)
    }
    
    return pinia._s.get(id)
  }
}

两种 Store 定义方式的实现

Pinia 支持两种定义 store 的方式:选项式 Store组合式 Store,它们的底层实现略有不同 :

选项式 Store(Options Store)

// 用户定义
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0, name: 'Pinia' }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++
    }
  }
})

// 内部处理逻辑
function createOptionsStore(id, options, pinia) {
  const { state, getters, actions } = options
  
  // 1. 初始化 state
  pinia.state.value[id] = state ? state() : {}
  
  // 2. 创建 store 实例
  const store = reactive({})
  
  // 3. 将 state 转换为 refs 挂载到 store
  for (const key in pinia.state.value[id]) {
    store[key] = toRef(pinia.state.value[id], key)
  }
  
  // 4. 处理 getters -> 转换为 computed
  for (const key in getters) {
    store[key] = computed(() => {
      setActivePinia(pinia)
      return getters[key].call(store, store)
    })
  }
  
  // 5. 处理 actions -> 绑定 this
  for (const key in actions) {
    store[key] = wrapAction(key, actions[key])
  }
  
  return store
}

组合式 Store(Setup Store)

// 用户定义
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Pinia')
  
  const doubleCount = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  return { count, name, doubleCount, increment }
})

// 内部处理逻辑
function createSetupStore(id, setup, pinia) {
  const scope = effectScope()
  
  // 运行 setup 函数,创建响应式状态
  const setupResult = scope.run(() => setup())
  
  // 创建 store 实例(reactive 包裹整个 store)
  const store = reactive({})
  
  // 将 setup 返回的属性挂载到 store
  for (const key in setupResult) {
    const prop = setupResult[key]
    store[key] = prop
  }
  
  pinia._s.set(id, store)
  return store
}

类型推导的实现

Pinia 的类型推导之所以强大,是因为它充分利用了 TypeScript 的 类型推断条件类型

// 简化的类型定义
export function defineStore<Id, S, G, A>(
  id: Id,
  options: Omit<DefineStoreOptions<Id, S, G, A>, 'id'>
): StoreDefinition<Id, S, G, A>

// 使用时的类型推导
const store = useCounterStore()
// TypeScript 自动推导出:
// store.count: number
// store.doubleCount: number
// store.increment: () => void

Actions 的实现原理

Action 的本质

Pinia 中的 actions 就是普通的函数,但它们的 this 被自动绑定到了 store 实例上 :

// 源码中的 action 包装
function wrapAction(name, action) {
  return function(this: any) {
    // 绑定 this 为当前 store
    return action.apply(this, arguments)
  }
}

同步与异步 Action

Piniaactions 天然支持同步和异步操作,无需任何特殊处理 :

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    loading: false
  }),
  
  actions: {
    // 同步 action
    setUser(user) {
      this.user = user
    },
    
    // 异步 action
    async fetchUser(id) {
      this.loading = true
      try {
        const response = await fetch(`/api/users/${id}`)
        this.user = await response.json()
      } finally {
        this.loading = false
      }
    }
  }
})

Actions 的订阅机制

Pinia 提供了 $onAction 方法来订阅 actions 的执行 :

// 源码简化
store.$onAction(({ name, store, args, after, onError }) => {
  console.log(`Action ${name} 开始执行`)
  
  after((result) => {
    console.log(`Action ${name} 执行完成`, result)
  })
  
  onError((error) => {
    console.error(`Action ${name} 执行失败`, error)
  })
})

Getters 的实现原理

Getter 的本质是 computed

Piniagetters 底层就是 Vue 的 computed 属性:

// 源码中的 getter 处理
for (const key in getters) {
  store[key] = computed(() => {
    // 确保当前 pinia 实例活跃
    setActivePinia(pinia)
    // 调用 getter 函数,绑定 this 为 store
    return getters[key].call(store, store)
  })
}

这意味着 getters 具备 computed 的所有特性:

  • 缓存性:只有依赖变化时才重新计算
  • 懒计算:只有在被访问时才执行
  • 响应式依赖收集:自动追踪依赖的 state

Getter 的互相调用

getters 之间可以相互调用,就像计算属性可以组合一样 :

getters: {
  doubleCount: (state) => state.count * 2,
  
  // 通过 this 访问其他 getter
  quadrupleCount(): number {
    return this.doubleCount * 2
  }
}

Pinia vs Vuex:核心差异对比

设计理念对比

维度 Pinia Vuex
API 设计 简洁直观,无 mutations 严格区分 state/getters/mutations/actions
TypeScript 支持 原生支持 需要手动声明类型,支持有限
模块化 store 自然拆分 单一 store + 模块嵌套
响应式系统 直接使用 reactive/computed 内部实现响应式
代码体积 轻量(约1KB) 相对较大

核心差异详解

Mutations 的废除

Pinia 最大的改变是移除了 mutations。在 Vuex 中,修改状态必须通过 mutations(同步)和 actions(异步): Vuex 方式:

mutations: {
  add(state) {
    state.count++
  }
},
actions: {
  increment({ commit }) {
    commit('add')
  }
}

而在 Pinia 中,actions 可以直接修改状态:

actions: {
  increment() {
    this.count++  // 直接修改
  }
}

模块化设计

  • Vuex:单一 store,通过 modules 拆分,需要处理命名空间
  • Pinia:每个 store 独立,按需引入,天然支持代码分割

TypeScript 支持

Pinia 在设计之初就充分考虑 TypeScript,几乎所有 API 都支持类型推导。

源码简析:Pinia 的核心逻辑

createPinia:全局容器创建

// 源码简化自 pinia/src/createPinia.ts
export function createPinia() {
  const scope = effectScope(true)
  
  // 全局状态容器
  const state = scope.run(() => ref({}))
  
  const pinia = markRaw({
    // 唯一标识
    __pinia: true,
    
    // 全局 effectScope
    _e: scope,
    
    // store 注册表
    _s: new Map(),
    
    // 全局状态
    state,
    
    // 插件数组
    _p: [],
    
    // Vue 插件安装方法
    install(app) {
      // 设置为当前活跃 pinia
      setActivePinia(pinia)
      
      // 通过 provide 注入
      app.provide(piniaSymbol, pinia)
      
      // 挂载 $pinia 到全局属性
      app.config.globalProperties.$pinia = pinia
      
      // 使用效果域来管理响应式
      pinia._e.run(() => {
        app.runWithContext(() => {
          // 初始化
        })
      })
    }
  })
  
  return pinia
}

响应式 store 的创建过程

// 源码简化自 pinia/src/store.ts
function createStore(id, options, pinia) {
  // 创建 store 的作用域
  const scope = effectScope()
  
  // 创建 store 实例(整个 store 是 reactive 的)
  const store = reactive({})
  
  // 初始化 state
  pinia.state.value[id] = options.state ? options.state() : {}
  
  // 将 state 转换为 ref 并挂载
  for (const key in pinia.state.value[id]) {
    store[key] = toRef(pinia.state.value[id], key)
  }
  
  // 处理 getters(转换为 computed)
  if (options.getters) {
    for (const key in options.getters) {
      store[key] = computed(() => {
        setActivePinia(pinia)
        return options.getters[key].call(store, store)
      })
    }
  }
  
  // 处理 actions(绑定 this)
  if (options.actions) {
    for (const key in options.actions) {
      store[key] = function(...args) {
        return options.actions[key].apply(store, args)
      }
    }
  }
  
  // 缓存 store 实例
  pinia._s.set(id, store)
  
  return store
}

storeToRefs 的实现原理

为什么直接从 store 解构会失去响应式?因为 store 本身是一个 reactive 对象,解构会得到原始值 。storeToRefs 的源码揭示了解决方案:

// 源码简化自 pinia/src/storeToRefs.ts 
export function storeToRefs(store) {
  // 将 store 转换为原始对象,避免重复代理
  store = toRaw(store)
  
  const refs = {}
  
  for (const key in store) {
    const value = store[key]
    
    // 只转换响应式数据(state 和 getters)
    if (isRef(value) || isReactive(value)) {
      // 使用 toRef 保持响应式连接
      refs[key] = toRef(store, key)
    }
  }
  
  return refs
}

这个实现的核心在于:

  • toRaw(store):脱掉 storeProxy 外壳,获取原始对象
  • 只转换响应式数据:过滤掉 actions 等非响应式属性
  • toRef 包装:创建 ref 引用,保持与原始数据的响应式连接

结语

Pinia 的成功告诉我们,优秀的状态管理库不一定要复杂,而是要在保持简洁的同时,充分利用框架底层的能力。理解 Pinia 的响应式原理,不仅有助于我们更好地使用它,也为我们在实际项目中设计和封装自己的组合式函数提供了思路和借鉴。

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

❌