普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月7日首页

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

作者 wuhen_n
2026年3月6日 07:42

前言

在 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 与组件事件类型的声明

作者 wuhen_n
2026年3月7日 07:34

前言

在 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 组件

作者 wuhen_n
2026年3月7日 07:33

前言

在 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 命名是否合理"的时候,我们就已经走在了正确的道路上了。

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

昨天 — 2026年3月6日首页

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

作者 wuhen_n
2026年3月5日 09:22

前言

在 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:组件缓存实现深度解析

作者 wuhen_n
2026年3月4日 14:23

在前面的文章中,我们学习了 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与响应式系统的集成

作者 wuhen_n
2026年3月4日 14:25

在前面的文章中,我们深入学习了 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状态管理原理:从响应式核心到源码实现

作者 wuhen_n
2026年3月4日 15:01

在前面的文章中,我们学习了 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 的响应式原理,不仅有助于我们更好地使用它,也为我们在实际项目中设计和封装自己的组合式函数提供了思路和借鉴。

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

❌
❌