Vue3——第 11 篇:组合式 API(上)——setup 与响应式 API 深度解析

本篇定位 :组合式 API 是 Vue 3 最核心、最具突破性的特性,它从根本上改变了 Vue 组件逻辑的编写方式。本篇将深入讲解组合式 API 的入口------setup 函数,以及与之配套的核心响应式 API。你将理解组合式 API 的设计哲学,掌握 <script setup> 语法糖的完整用法,并能够熟练运用响应式 API 构建复杂的应用逻辑。


一、为什么需要组合式 API?

1.1 选项式 API 的局限性

在 Vue 2 中,我们使用选项式 API (Options API)编写组件,通过 data、methods、computed、watch、mounted 等选项来组织代码:

javascript 复制代码
export default {
  data() {
    return {
      count: 0,
      user: null,
      isLoading: false
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2
    }
  },
  methods: {
    increment() {
      this.count++
    },
    fetchUser() { /* ... */ }
  },
  watch: {
    count(newVal) {
      console.log('count changed', newVal)
    }
  },
  mounted() {
    this.fetchUser()
  }
}

当组件变得复杂时,选项式 API 会出现一个核心问题:关注点分散。

一个功能的逻辑(数据、计算属性、方法、生命周期钩子)被强制拆分到不同的选项中。当组件有多个功能时,代码会变得难以阅读和维护:

复制代码
data:    [功能A相关数据] [功能B相关数据] [功能C相关数据]
methods: [功能A方法] [功能B方法] [功能C方法]
computed:[功能A计算] [功能B计算] [功能C计算]
watch:   [功能A侦听] [功能B侦听] [功能C侦听]
mounted: [功能A初始化] [功能B初始化] [功能C初始化]

1.2 组合式 API 的解决方案

组合式 API 允许我们将属于同一个功能的代码聚合在一起,而不是分散在不同选项中:

vue 复制代码
<script setup>
import { ref, computed, watch, onMounted } from 'vue'

// -------- 功能 A:计数器 --------
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
  count.value++
}
watch(count, (newVal) => {
  console.log('count changed', newVal)
})

// -------- 功能 B:用户数据 --------
const user = ref(null)
const isLoading = ref(false)
async function fetchUser() {
  isLoading.value = true
  user.value = await api.getUser()
  isLoading.value = false
}
onMounted(() => {
  fetchUser()
})
</script>

组合式 API 的核心优势:

  • 逻辑复用 :可以将功能封装为组合式函数(Composables),在多个组件间共享
  • 更好的代码组织:按功能聚合,而非按选项类型
  • 更好的 TypeScript 支持:类型推导更自然
  • 更小的生产包体积:更好的 Tree Shaking 支持

二、setup 函数:组合式 API 的入口

2.1 基本用法

setup 是 Vue 3 中组件选项 的一部分,它在组件实例创建之前执行,是组合式 API 的入口点。

vue 复制代码
<script>
import { ref } from 'vue'

export default {
  setup(props, context) {
    // 在这里使用组合式 API
    const count = ref(0)
    
    function increment() {
      count.value++
    }
    
    // 返回的数据和方法将暴露给模板
    return {
      count,
      increment
    }
  }
}
</script>
<template>
  <button @click="increment">{{ count }}</button>
</template>

2.2 setup 的执行时机

setup 在 beforeCreate 之前执行,此时组件实例尚未创建。这意味着:

  • setup 中不能访问 this (this 为 undefined)
  • setup 中不能访问组件实例上的属性 (如 this.$router、this.$emit 等)
  • setup 中不能使用选项式 API (如 data、methods 等)

生命周期执行顺序:

复制代码
setup() → beforeCreate → created → onBeforeMount → onMounted → ...

2.3 setup 的语法糖:<script setup>

<script setup> 是 Vue 3.2 引入的编译时语法糖,它让组合式 API 的写法更加简洁:

vue 复制代码
<script setup>
// 所有顶层绑定自动暴露给模板
const count = ref(0)
function increment() {
  count.value++
}
</script>
<template>
  <button @click="increment">{{ count }}</button>
</template>

<script setup> 做了哪些事?

  • 自动将顶层声明的变量、函数、导入的组件暴露给模板
  • 自动执行 setup 逻辑,无需显式返回
  • 更高效:编译时优化,减少运行时代码
  • 更好的 TypeScript 支持

本系列教程将统一使用 <script setup> 语法。

2.4 setup 的参数(非 <script setup> 场景)

在不使用 <script setup> 时,setup 函数接收两个参数:

javascript 复制代码
export default {
  setup(props, context) {
    // props:响应式的 props 对象
    // context:包含 attrs、slots、emit 的对象
  }
}

props:

  • 是响应式的,当父组件传入新的 props 时,props 会更新
  • 不能解构 props(会失去响应式),应使用 toRefs 或 toRef

context:

javascript 复制代码
export default {
  setup(props, { attrs, slots, emit, expose }) {
    // attrs: 非 props 的属性(如 class、style、自定义属性)
    // slots: 插槽对象
    // emit: 触发自定义事件
    // expose: 暴露公共 API(用于父组件通过 ref 访问)
  }
}

在 <script setup> 中,这些功能通过其他方式实现:

  • defineProps 接收 props
  • defineEmits 定义事件
  • useSlots、useAttrs 获取插槽和属性
  • defineExpose 暴露公共 API

三、核心响应式 API 深度解析

3.1 ref:基础响应式引用

ref 是最基础的响应式 API,它返回一个可变的响应式引用。

vue 复制代码
<script setup>
import { ref } from 'vue'

// 基本类型
const count = ref(0)
const message = ref('Hello')
const isActive = ref(true)

// 对象类型(ref 也能处理对象)
const user = ref({ name: '张三', age: 25 })

// 访问:在 JavaScript 中需要通过 .value
console.log(count.value) // 0
count.value = 10

// 在模板中自动解包,不需要 .value
</script>
<template>
  <p>{{ count }}</p>
  <p>{{ message }}</p>
  <p>{{ user.name }}</p>
</template>

ref 的深层响应式 :ref 对对象和数组会进行深层响应式处理,嵌套属性的变化也会触发更新。

javascript 复制代码
const user = ref({ profile: { age: 25 } })
// 深层更新也能触发响应
user.value.profile.age = 30  // ✅ 触发更新

ref 的 unwrapping 规则:

  • 在模板中作为顶层属性时自动解包
  • 在 reactive 对象中作为属性时自动解包
  • 在数组或集合中不会自动解包

3.2 reactive:对象的响应式代理

reactive 返回一个对象的响应式代理,适用于对象、数组、Map、Set 等。

vue 复制代码
<script setup>
import { reactive } from 'vue'

const user = reactive({
  name: '张三',
  age: 25,
  hobbies: ['阅读', '编程']
})

// 直接访问/修改,无需 .value
user.age = 30
user.hobbies.push('游泳')
</script>
<template>
  <p>{{ user.name }},{{ user.age }}岁</p>
  <p>爱好:{{ user.hobbies.join('、') }}</p>
</template>

reactive 的局限性:

  1. 只能用于对象类型,不能用于原始值
  2. 不能整体替换 :user = { name: '李四' } 会丢失响应式
  3. 解构会丢失响应式 :const { name, age } = user 后 name 和 age 是普通值
  4. 传递给函数时,如果函数对参数解构,也会丢失响应式

何时使用 reactive 而不是 ref?

  • 当对象结构固定、不需要整体替换时
  • 当你希望避免 .value 的冗余时
  • 当对象属性频繁读写时

3.3 ref 与 reactive 的对比

特性 ref reactive
支持的数据类型 任意类型 仅对象类型(对象、数组、集合)
访问方式 需要 .value 直接访问
整体替换 ✅ 可以替换 .value ❌ 不能直接替换
解构后保持响应式 ✅ 通过 .value 仍可 ❌ 解构后丢失
模板自动解包 ✅ 顶层自动解包 ✅ 直接使用
内部实现 基于 .value 的 getter/setter 基于 Proxy
适用场景 通用首选 固定结构的对象

官方推荐 :如果你不确定用哪个,优先使用 ref,因为它更通用、更灵活。

3.4 computed:计算属性

computed 用于创建派生状态 ,它会基于其他响应式数据计算新值,并缓存结果,只有依赖变化时才重新计算。

vue 复制代码
<script setup>
import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')
const price = ref(100)
const quantity = ref(3)

// 只读计算属性
const fullName = computed(() => {
  return firstName.value + lastName.value
})

// 带格式化
const totalPrice = computed(() => {
  return (price.value * quantity.value).toFixed(2)
})

// 可写计算属性(需要 getter 和 setter)
const fullNameEditable = computed({
  get: () => firstName.value + ' ' + lastName.value,
  set: (newValue) => {
    const [first, last] = newValue.split(' ')
    firstName.value = first
    lastName.value = last
  }
})

// 模板中使用
</script>
<template>
  <p>全名:{{ fullName }}</p>
  <p>总价:¥{{ totalPrice }}</p>
  <input v-model="fullNameEditable" placeholder="输入姓名" />
</template>

computed vs method:

  • computed 有缓存,依赖不变不重新计算
  • method 在每次渲染时都会执行
vue 复制代码
<script setup>
const count = ref(0)

// 每次渲染都会重新计算
function getDouble() {
  console.log('method 执行')
  return count.value * 2
}

// 依赖不变时不会重新计算
const doubleComputed = computed(() => {
  console.log('computed 执行')
  return count.value * 2
})
</script>
<template>
  <!-- count 不变,多次访问 doubleComputed 不会重新计算 -->
  <p>{{ doubleComputed }}</p>
  <p>{{ doubleComputed }}</p>
  <!-- getDouble 每次都会执行 -->
  <p>{{ getDouble() }}</p>
  <p>{{ getDouble() }}</p>
</template>

3.5 watch:侦听数据变化

watch 用于侦听响应式数据的变化,并在变化时执行回调。

vue 复制代码
<script setup>
import { ref, watch } from 'vue'

const count = ref(0)
const user = ref({ name: '张三', age: 25 })

// 侦听单个 ref
watch(count, (newVal, oldVal) => {
  console.log('count 从', oldVal, '变为', newVal)
})

// 侦听多个数据
watch([count, user], ([newCount, newUser], [oldCount, oldUser]) => {
  console.log('count 或 user 变化')
})

// 侦听 ref 的深层变化
watch(user, (newVal, oldVal) => {
  console.log('user 变化', newVal)
}, { deep: true })

// 立即执行(进入页面时先执行一次)
watch(count, (newVal) => {
  console.log('立即执行', newVal)
}, { immediate: true })

// 侦听对象中的某个属性(使用 getter 函数)
watch(() => user.value.age, (newAge) => {
  console.log('年龄变化为', newAge)
})
</script>

watch 的配置选项:

选项 说明
deep 深层侦听(默认 false)
immediate 立即执行回调(默认 false)
flush 回调执行时机:'pre'(默认,更新前)、'post'(更新后)、'sync'(同步)

watch vs watchEffect (watchEffect 将在下篇详述):

  • watch 需要明确指定要侦听的数据
  • watchEffect 自动收集依赖,但无法访问旧值

3.6 toRef 与 toRefs:保持响应式

toRef :为 reactive 对象的某个属性创建一个 ref,并保持同步。

javascript 复制代码
import { reactive, toRef } from 'vue'

const user = reactive({ name: '张三', age: 25 })
const nameRef = toRef(user, 'name')

// nameRef 与 user.name 同步
nameRef.value = '李四'
console.log(user.name) // '李四'

user.age = 30
console.log(nameRef.value) // '李四'(不变)

toRefs :将 reactive 对象的所有属性转换为 ref。

javascript 复制代码
import { reactive, toRefs } from 'vue'

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

// 解构后仍然保持响应式
name.value = '李四'  // 同步到 user
user.age = 30        // age.value 也会更新

主要用途 :在组合式函数中,将 reactive 对象解构为 ref,以便保持响应式。

3.7 readonly:只读代理

readonly 创建一个只读的响应式代理,任何修改操作都会被阻止。

javascript 复制代码
import { reactive, readonly } from 'vue'

const original = reactive({ count: 0 })
const copy = readonly(original)

// 修改 copy 会触发警告
copy.count++  // ❌ 警告:Set operation on key "count" failed

// 修改 original 会触发更新,copy 也会变化
original.count++  // ✅ 更新

使用场景 :在组合式函数中,暴露数据时使用 readonly 防止外部意外修改内部状态。

3.8 shallowRef 与 shallowReactive:浅层响应式

默认的 ref 和 reactive 都是深层响应式,会对所有嵌套属性进行代理。如果数据结构很深,且只关心根级别的变化,可以使用浅层版本来优化性能。

javascript 复制代码
import { shallowRef, shallowReactive } from 'vue'

// 浅层 ref:只有 .value 的变化触发更新
const user = shallowRef({ name: '张三', profile: { age: 25 } })
user.value.name = '李四'  // ❌ 不会触发更新
user.value = { name: '王五' }  // ✅ 触发更新

// 浅层 reactive:只有根级属性变化触发更新
const settings = shallowReactive({
  theme: 'dark',
  preferences: { language: 'zh' }
})
settings.theme = 'light'  // ✅ 触发更新
settings.preferences.language = 'en'  // ❌ 不触发更新

3.9 triggerRef:强制更新

在某些特殊场景下(如使用第三方库修改了数据),你可能需要强制触发更新。

javascript 复制代码
import { shallowRef, triggerRef } from 'vue'

const user = shallowRef({ name: '张三' })
// 直接修改对象内部属性,不会触发更新
user.value.name = '李四'
// 强制触发更新
triggerRef(user)

四、响应式 API 的最佳实践

4.1 何时使用 ref vs reactive?

决策流程图:

复制代码
需要声明响应式数据
    ↓
数据类型是原始值(string/number/boolean...)?
    ↓ 是                        ↓ 否(对象/数组)
必须使用 ref                    考虑:
    ↓                         - 是否经常整体替换?
需要解构吗?                   - 是否希望避免 .value?
    ↓ 是                    ↓
使用 ref + toRefs          如果"是",用 reactive;否则用 ref

经验法则:

  • 默认使用 ref,它更通用,限制更少
  • 当对象属性需要频繁读写且不需要整体替换时,使用 reactive
  • 在同一组件中保持一致性,不要混用得过于随意

4.2 保持响应式数据的不可变性

在组合式函数中,建议使用 readonly 保护数据:

typescript 复制代码
// composables/useUser.ts
import { ref, readonly } from 'vue'

export function useUser() {
  const user = ref({ name: '张三', age: 25 })
  
  function updateUser(newName: string) {
    user.value.name = newName
  }
  
  // 暴露只读数据,同时提供修改方法
  return {
    user: readonly(user),
    updateUser
  }
}

4.3 避免过度使用 reactive

reactive 的解构限制是一个常见的陷阱:

vue 复制代码
<script setup>
import { reactive } from 'vue'

const state = reactive({
  count: 0,
  message: 'Hello'
})

// ❌ 错误:解构后失去响应式
const { count, message } = state
function increment() {
  count++  // 不会触发更新
}

// ✅ 正确:使用 toRefs
const { count, message } = toRefs(state)
function increment() {
  count.value++  // ✅ 触发更新
}
</script>

4.4 类型推导与类型安全

使用 TypeScript 时,响应式 API 能自动推导类型:

typescript 复制代码
import { ref, reactive, computed } from 'vue'

// 自动推导类型
const count = ref(0)  // Ref<number>
const user = reactive({ name: '张三', age: 25 })  // { name: string; age: number }

// 显式类型声明
const list = ref<string[]>([])  // Ref<string[]>
const config = reactive<{ theme: 'dark' | 'light' }>({ theme: 'dark' })

五、综合实战:用户管理与搜索筛选

让我们综合运用本章所学 API,构建一个完整的用户管理页面。

vue 复制代码
<script setup lang="ts">
import { ref, reactive, computed, watch, toRefs, readonly } from 'vue'

// -------- 数据类型定义 --------
interface User {
  id: number
  name: string
  age: number
  email: string
  isActive: boolean
  createdAt: string
}

// -------- 状态管理 --------
// 使用 ref 管理列表
const users = ref<User[]>([
  { id: 1, name: '张三', age: 25, email: 'zhangsan@example.com', isActive: true, createdAt: '2024-01-15' },
  { id: 2, name: '李四', age: 30, email: 'lisi@example.com', isActive: false, createdAt: '2024-02-20' },
  { id: 3, name: '王五', age: 28, email: 'wangwu@example.com', isActive: true, createdAt: '2024-03-10' },
  { id: 4, name: '赵六', age: 22, email: 'zhaoliu@example.com', isActive: true, createdAt: '2024-04-05' },
  { id: 5, name: '孙七', age: 35, email: 'sunqi@example.com', isActive: false, createdAt: '2024-05-12' }
])

// 使用 reactive 管理筛选和排序状态(固定结构,不需要整体替换)
const filterState = reactive({
  keyword: '',
  activeFilter: 'all' as 'all' | 'active' | 'inactive',
  sortBy: 'name' as 'name' | 'age' | 'createdAt',
  sortOrder: 'asc' as 'asc' | 'desc'
})

// 使用 ref 管理 UI 状态
const isLoading = ref(false)
const selectedUserId = ref<number | null>(null)

// -------- 计算属性:过滤和排序后的用户列表 --------
const filteredAndSortedUsers = computed(() => {
  let result = [...users.value]
  
  // 关键字搜索
  if (filterState.keyword.trim()) {
    const keyword = filterState.keyword.toLowerCase().trim()
    result = result.filter(user =>
      user.name.includes(keyword) ||
      user.email.includes(keyword)
    )
  }
  
  // 活跃状态过滤
  if (filterState.activeFilter === 'active') {
    result = result.filter(user => user.isActive)
  } else if (filterState.activeFilter === 'inactive') {
    result = result.filter(user => !user.isActive)
  }
  
  // 排序
  result.sort((a, b) => {
    let aVal: any = a[filterState.sortBy]
    let bVal: any = b[filterState.sortBy]
    
    if (typeof aVal === 'string') {
      return filterState.sortOrder === 'asc'
        ? aVal.localeCompare(bVal)
        : bVal.localeCompare(aVal)
    } else {
      return filterState.sortOrder === 'asc'
        ? aVal - bVal
        : bVal - aVal
    }
  })
  
  return result
})

// 统计信息
const stats = computed(() => {
  const total = users.value.length
  const active = users.value.filter(u => u.isActive).length
  const inactive = total - active
  return { total, active, inactive }
})

// -------- 侦听器:当筛选条件变化时记录日志(演示 watch) --------
watch(
  () => ({ keyword: filterState.keyword, activeFilter: filterState.activeFilter }),
  (newVal, oldVal) => {
    console.log('筛选条件变化:', newVal)
  },
  { deep: true }
)

// 侦听用户列表变化(深层)
watch(
  users,
  (newUsers) => {
    console.log('用户列表更新,当前共', newUsers.length, '个用户')
  },
  { deep: true }
)

// -------- 操作方法 --------
function addUser() {
  const newUser: User = {
    id: Date.now(),
    name: `新用户${users.value.length + 1}`,
    age: Math.floor(Math.random() * 30) + 20,
    email: `user${Date.now()}@example.com`,
    isActive: true,
    createdAt: new Date().toISOString().slice(0, 10)
  }
  users.value.push(newUser)
}

function removeUser(id: number) {
  users.value = users.value.filter(u => u.id !== id)
}

function toggleUserStatus(id: number) {
  const user = users.value.find(u => u.id === id)
  if (user) {
    user.isActive = !user.isActive
  }
}

function selectUser(id: number) {
  selectedUserId.value = selectedUserId.value === id ? null : id
}

// 排序切换
function toggleSortBy(key: 'name' | 'age' | 'createdAt') {
  if (filterState.sortBy === key) {
    filterState.sortOrder = filterState.sortOrder === 'asc' ? 'desc' : 'asc'
  } else {
    filterState.sortBy = key
    filterState.sortOrder = 'asc'
  }
}

// 重置筛选
function resetFilters() {
  filterState.keyword = ''
  filterState.activeFilter = 'all'
  filterState.sortBy = 'name'
  filterState.sortOrder = 'asc'
}

// 模拟加载数据
async function fetchUsers() {
  isLoading.value = true
  // 模拟 API 请求
  await new Promise(resolve => setTimeout(resolve, 1000))
  // 这里可以添加新数据
  isLoading.value = false
}
</script>

<template>
  <div class="user-management">
    <h1>👥 用户管理</h1>
    
    <!-- 统计信息 -->
    <div class="stats">
      <span>总计:{{ stats.total }}</span>
      <span class="active">活跃:{{ stats.active }}</span>
      <span class="inactive">非活跃:{{ stats.inactive }}</span>
      <button @click="fetchUsers" :disabled="isLoading">刷新数据</button>
      <button @click="addUser">➕ 添加用户</button>
    </div>

    <!-- 筛选区 -->
    <div class="filters">
      <input
        v-model="filterState.keyword"
        placeholder="🔍 搜索姓名或邮箱..."
        class="search-input"
      />
      <select v-model="filterState.activeFilter" class="filter-select">
        <option value="all">全部状态</option>
        <option value="active">活跃</option>
        <option value="inactive">非活跃</option>
      </select>
      <button @click="resetFilters" class="btn-reset">重置</button>
    </div>

    <!-- 加载状态 -->
    <div v-if="isLoading" class="loading">加载中...</div>

    <!-- 用户列表 -->
    <div v-else class="user-list">
      <table class="user-table">
        <thead>
          <tr>
            <th @click="toggleSortBy('name')" style="cursor: pointer;">
              姓名 {{ filterState.sortBy === 'name' ? (filterState.sortOrder === 'asc' ? '▲' : '▼') : '⇅' }}
            </th>
            <th @click="toggleSortBy('age')" style="cursor: pointer;">
              年龄 {{ filterState.sortBy === 'age' ? (filterState.sortOrder === 'asc' ? '▲' : '▼') : '⇅' }}
            </th>
            <th>邮箱</th>
            <th @click="toggleSortBy('createdAt')" style="cursor: pointer;">
              注册日期 {{ filterState.sortBy === 'createdAt' ? (filterState.sortOrder === 'asc' ? '▲' : '▼') : '⇅' }}
            </th>
            <th>状态</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
          <tr
            v-for="user in filteredAndSortedUsers"
            :key="user.id"
            class="user-row"
            :class="{ selected: selectedUserId === user.id }"
          >
            <td>{{ user.name }}</td>
            <td>{{ user.age }}</td>
            <td>{{ user.email }}</td>
            <td>{{ user.createdAt }}</td>
            <td>
              <span :class="['status-badge', user.isActive ? 'active' : 'inactive']">
                {{ user.isActive ? '✅ 活跃' : '❌ 非活跃' }}
              </span>
            </td>
            <td>
              <button @click="toggleUserStatus(user.id)" class="btn-toggle">
                {{ user.isActive ? '禁用' : '启用' }}
              </button>
              <button @click="selectUser(user.id)" class="btn-detail">
                {{ selectedUserId === user.id ? '收起' : '详情' }}
              </button>
              <button @click="removeUser(user.id)" class="btn-delete">删除</button>
            </td>
          </tr>
          <!-- 详情行 -->
          <tr v-if="selectedUserId !== null">
            <td colspan="6" class="detail-row">
              <div v-if="selectedUserId !== null">
                <strong>用户详情:</strong>
                <pre>{{ users.find(u => u.id === selectedUserId) }}</pre>
              </div>
            </td>
          </tr>
        </tbody>
      </table>
      <p v-if="filteredAndSortedUsers.length === 0" class="empty">
        没有匹配的用户
      </p>
    </div>
  </div>
</template>

<style scoped>
.user-management {
  max-width: 1100px;
  margin: 20px auto;
  padding: 20px;
  background: #f8fafc;
  border-radius: 12px;
}
h1 { margin-bottom: 16px; }
.stats {
  display: flex;
  gap: 20px;
  margin-bottom: 16px;
  align-items: center;
  flex-wrap: wrap;
}
.stats .active { color: #48bb78; font-weight: 600; }
.stats .inactive { color: #fc8181; font-weight: 600; }
.stats button {
  padding: 6px 16px;
  border: none;
  border-radius: 6px;
  background: #4299e1;
  color: white;
  cursor: pointer;
}
.stats button:disabled { opacity: 0.6; cursor: not-allowed; }

.filters {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
  flex-wrap: wrap;
}
.search-input {
  flex: 1;
  min-width: 200px;
  padding: 8px 14px;
  border: 1px solid #e2e8f0;
  border-radius: 6px;
  font-size: 14px;
}
.filter-select {
  padding: 8px 14px;
  border: 1px solid #e2e8f0;
  border-radius: 6px;
  background: white;
}
.btn-reset {
  padding: 8px 16px;
  border: 1px solid #e2e8f0;
  border-radius: 6px;
  background: white;
  cursor: pointer;
}
.loading { text-align: center; padding: 40px; color: #a0aec0; }

.user-table {
  width: 100%;
  border-collapse: collapse;
  background: white;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.user-table th {
  background: #f7fafc;
  padding: 12px 16px;
  text-align: left;
  font-weight: 600;
  color: #4a5568;
  border-bottom: 2px solid #e2e8f0;
}
.user-table td {
  padding: 10px 16px;
  border-bottom: 1px solid #edf2f7;
}
.user-row:hover { background: #f7fafc; }
.user-row.selected { background: #ebf8ff; }
.status-badge {
  padding: 2px 12px;
  border-radius: 12px;
  font-size: 13px;
}
.status-badge.active { background: #f0fff4; color: #276749; }
.status-badge.inactive { background: #fff5f5; color: #9b2c2c; }

.btn-toggle, .btn-detail, .btn-delete {
  padding: 4px 12px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  margin-right: 4px;
}
.btn-toggle { background: #ebf8ff; color: #2b6cb0; }
.btn-toggle:hover { background: #bee3f8; }
.btn-detail { background: #faf5ff; color: #6b46c1; }
.btn-detail:hover { background: #e9d8fd; }
.btn-delete { background: #fff5f5; color: #c53030; }
.btn-delete:hover { background: #fed7d7; }

.detail-row {
  background: #f7fafc;
  padding: 16px;
}
.detail-row pre {
  background: #edf2f7;
  padding: 12px;
  border-radius: 4px;
  font-size: 13px;
  overflow-x: auto;
}
.empty {
  text-align: center;
  padding: 30px;
  color: #a0aec0;
}
</style>

六、本篇小结

知识点 核心内容 关键要点
组合式 API 按功能聚合逻辑,替代选项式 API 更好的代码组织、逻辑复用、TS 支持
setup 函数 在 beforeCreate 之前执行,不能访问 this setup 返回数据暴露给模板
<script setup> 语法糖,自动暴露顶层绑定 本系列默认使用此方式
ref 任意类型的响应式引用 需要 .value 访问
reactive 对象的响应式代理 不能整体替换,解构丢失响应式
computed 派生状态,有缓存 只读/可写两种形式
watch 侦听数据变化,执行副作用 支持 deep、immediate 选项
toRef / toRefs 保持 reactive 解构后的响应式 用于组合式函数返回
readonly 只读代理,防止外部修改 保护内部状态
shallowRef / shallowReactive 浅层响应式,优化性能 只关心根级别变化

七、思考与练习

  1. 基础练习 :将上一节的数据表格组件中的筛选逻辑提取为一个组合式函数 useTableFilter,包含 search、sort、filter 等逻辑。

  2. 实践任务 :使用 readonly 保护用户列表数据,只暴露只读版本,并提供 addUser、updateUser、deleteUser 三个方法来修改数据。

  3. 陷阱排查:分析以下代码的问题,并给出修正方案:

    vue 复制代码
    <script setup>
    import { reactive } from 'vue'
    
    const state = reactive({ count: 0 })
    const { count } = state
    
    function increment() {
      count++
    }
    </script>
  4. 深度思考 :为什么 ref 对原始值也能实现响应式,而 reactive 不能?其底层实现有什么不同?

下篇预告

第 12 篇我们将深入学习组合式 API 的高级应用------Composables(组合式函数):

  • 如何封装和复用逻辑
  • 组合式函数的设计原则
  • 与 Vue 2 mixins 的对比
  • 常见的组合式函数模式(useFetch、useLocalStorage、useDebounce 等)

准备好把逻辑复用的能力提升到新高度!🧩

相关推荐
tryCbest5 小时前
Vue3——第 13 篇:计算属性与侦听器——computed 与 watch 的进化
前端·javascript·vue.js
怒放的生命19915 小时前
本地用 pnpm 省磁盘,团队继续用 npm:极简实践
前端·npm·node.js
用户117953654786 小时前
企业团队编程软件怎么选:AI编程工具的协作与落地能力对比
javascript
闲来没事抠鼻屎6 小时前
标签打印 8 个尺寸公式:小票、价签、吊牌、面单通用
vue.js
believed_breadfruit6 小时前
我开源了一个HTML 分页导出工具,解决了复杂页面的内容截断、跨页错乱和长文档白屏问题
前端·javascript·github
福狼6 小时前
从“网墙”到柔性曲面:一次 WebGPU 波浪的逆向分析与重建
前端·逆向
宝哥聊编程6 小时前
主流 Agent 平台对比:Coze、Dify、飞书、扣子、OpenClaw 落地实操手册
前端·人工智能·飞书
时间的情敌6 小时前
Vue3 + Cesium 实战:本地上传 GLB 模型、模型上叠加 GLB、点击控制透明度
前端·vue.js·cesium
拖孩6 小时前
流量主门槛从 500 降到 100,我的小程序上线 18 小时够到了线
前端·后端·微信小程序