Vue3 项目实战与性能优化:组合式 API 进阶、响应式高级用法、可复用逻辑封装与新特性全解

前言:为什么你一定要吃透 Vue3 的高级特性?

大家好,我是深耕前端领域多年的老司机,见证了 Vue 从 2.x 到 3.x 的全历程。在日常开发和团队 Code Review 中,我发现一个非常普遍的问题:80% 的前端开发者,哪怕已经升级到了 Vue3,却依然在用 Vue2 的思维写代码------ 把 setup 语法糖当成了单纯的代码容器,只会用 ref 和 reactive 做基础响应式,所有逻辑都堆在组件里,完全没发挥出 Vue3 组合式 API 的强大能力,最终项目不仅维护性差,性能也没得到任何提升。

这篇文章,我会结合多年的企业级项目实战经验,从组合式 API 进阶、响应式高级用法、自定义组合式函数、Vue3 核心新特性四大模块,由浅入深、从原理到实战,把 Vue3 的高级特性讲透,同时结合性能优化的落地技巧,让你看完就能直接用到项目里,写出真正符合 Vue3 设计思想的高质量代码。

本文全程干货无废话,所有代码均为企业级可直接复用的实现,建议先收藏再慢慢看,绝对能帮你突破 Vue3 的学习瓶颈。

一、Vue3 组合式 API 进阶:彻底告别 Options API 的痛点

Vue3 最核心的升级就是组合式 API(Composition API),它彻底解决了 Vue2 中 Options API 的逻辑分散、复用困难的问题,让代码的可维护性和可复用性提升了一个量级。

1.1 setup 语法糖:Vue3 开发的标配,你真的用对了吗?

<script setup> 是 Vue3 官方主推的语法糖,也是企业级项目的标配,它大幅简化了组合式 API 的写法,让代码更简洁,开发效率更高。

1.1.1 基础用法与核心优势

只需要给 script 标签加上setup属性,里面的代码就会直接在组件的 setup 函数中执行,无需手动 return 变量和方法给模板使用。

html 复制代码
<template>
  <div>{{ count }}</div>
  <button @click="addCount">+1</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
// 直接定义,模板可直接使用,无需return
const count = ref(0)
const addCount = () => count.value++
</script>

核心优势:

  1. 代码更简洁:无需写 export default、setup 函数、return 语句,减少大量模板代码
  2. 更好的 TypeScript 支持:无需借助 Vue.extend 等装饰器,就能轻松实现完整的类型推导
  3. 更好的运行时性能:模板会被编译成同作用域内的渲染函数,无需代理上下文,性能更好
  4. 更好的 Tree-Shaking 支持:未使用的代码会被自动摇树,打包体积更小
1.1.2 核心 API 详解:defineProps/defineEmits/defineExpose

这三个是<script setup>专属的编译器宏,无需导入即可直接使用,是组件通信的核心。

  1. defineProps:接收父组件传递的 props
javascript 复制代码
// 带类型标注的props定义,支持默认值
const props = withDefaults(defineProps<{
  modelValue: boolean
  title?: string
  maskClosable?: boolean
}>(), {
  title: '提示',
  maskClosable: true
})
  1. defineEmits:向父组件触发事件
javascript 复制代码
const emit = defineEmits<{
  'update:modelValue': [value: boolean]
  'close': []
}>()
// 触发事件
const handleClose = () => {
  emit('update:modelValue', false)
  emit('close')
}
  1. defineExpose:向父组件暴露实例属性和方法 默认情况下,<script setup>中的组件是闭合的,父组件通过 ref 无法拿到子组件的任何属性,必须通过 defineExpose 显式暴露。
javascript 复制代码
// 子组件:暴露方法
defineExpose({
  close: handleClose,
  resetForm: () => form.value.resetFields()
})

// 父组件:通过ref调用
const modalRef = ref<InstanceType<typeof Modal>>()
modalRef.value?.close()
1.1.3 顶层 await 的正确用法

<script setup>中支持直接使用顶层 await,无需包裹 async 函数,编译器会自动把组件转换成异步组件。

html 复制代码
<script setup lang="ts">
// 顶层await请求初始数据
const userInfo = await fetch('/api/user/info').then(res => res.json())
</script>

**注意:**使用顶层 await 的组件,必须被Suspense组件包裹,否则会有控制台警告,这个会在后面的 Suspense 章节详细讲解。

1.1.4 避坑指南:setup 语法糖新手最容易踩的 5 个坑
  1. defineProps/defineEmits 不能在运行时代码中调用 :它们是编译器宏,只能在<script setup>的顶层作用域使用,不能在函数、循环内部调用。
  2. 顶层 await 会让组件变成异步组件:必须配合 Suspense 使用,否则组件会无法正常渲染。
  3. 响应式变量解构要谨慎:直接解构 ref/reactive 对象会丢失响应式,必须用 toRefs/toRef 处理。
  4. defineExpose 暴露的属性必须在组件挂载后才能访问:父组件在 onMounted 之前通过 ref 访问子组件属性,会拿到 undefined。
  5. 不要在 setup 中操作 DOM:setup 执行时,组件还没挂载,DOM 还没生成,必须在 onMounted 钩子中操作 DOM。

1.2 Composition API 逻辑复用:彻底替代 Mixin 的黑盒噩梦

在 Vue2 中,我们通常用 Mixin 来复用逻辑,但 Mixin 有三个致命的痛点,在大型项目中简直是灾难。

1.2.1 Mixin 的三大致命痛点
  1. 命名冲突:多个 Mixin 中的同名变量 / 方法会互相覆盖,很难排查问题。
  2. 来源不清晰:组件中使用的变量 / 方法,完全不知道来自哪个 Mixin,代码可读性极差。
  3. 紧耦合问题:Mixin 之间、Mixin 和组件之间会互相依赖,无法单独复用,可维护性差。
1.2.2 Composition API 如何完美解决 Mixin 的问题

组合式 API 的核心思想,就是把相关的逻辑封装到独立的函数中,哪里需要就哪里导入,显式使用,从根本上解决了 Mixin 的问题:

  1. 无命名冲突:导入的变量可以自定义重命名,不会互相覆盖。
  2. 来源清晰:所有变量 / 方法都是显式导入的,一眼就能看出来自哪个函数。
  3. 低耦合高内聚:每个组合式函数只负责一块逻辑,互不依赖,可单独复用。
1.2.3 实战案例:用组合式 API 封装模态框复用逻辑

我们日常开发中,几乎每个页面都有模态框,用组合式 API 可以把模态框的显示隐藏、确认取消逻辑完全抽离出来,实现全项目复用。

javascript 复制代码
// hooks/useModal.ts
import { ref, computed } from 'vue'

export function useModal() {
  const visible = ref(false)
  // 打开模态框
  const openModal = () => {
    visible.value = true
  }
  // 关闭模态框
  const closeModal = () => {
    visible.value = false
  }
  // 双向绑定的modelValue
  const modelValue = computed({
    get: () => visible.value,
    set: (val) => visible.value = val
  })

  return {
    visible,
    modelValue,
    openModal,
    closeModal
  }
}

组件中使用:

html 复制代码
<template>
  <button @click="openModal">打开模态框</button>
  <Modal v-model="modelValue" title="提示">
    模态框内容
  </Modal>
</template>

<script setup lang="ts">
import Modal from '@/components/Modal.vue'
import { useModal } from '@/hooks/useModal'

// 显式导入复用逻辑,来源清晰,无命名冲突
const { modelValue, openModal, closeModal } = useModal()
</script>

如果一个页面有多个模态框,只需要多次调用 useModal,重命名变量即可,完全不会有命名冲突的问题:

javascript 复制代码
const { 
  modelValue: addModalVisible, 
  openModal: openAddModal, 
  closeModal: closeAddModal 
} = useModal()

const { 
  modelValue: editModalVisible, 
  openModal: openEditModal, 
  closeModal: closeEditModal 
} = useModal()

这里给大家放一张 Mixin 和 Composition API 的对比示意图,一眼就能看出两者的区别:

图注:左图为 Mixin 的黑盒式复用,来源混乱;右图为 Composition API 的显式复用,逻辑清晰)

1.3 依赖注入 Provide/Inject:解决 Props 透传的终极方案

在多层嵌套的组件中,父组件要给孙组件、曾孙组件传递数据,用 Props 就必须一层一层透传,也就是我们常说的Props Drilling(props 钻取),代码冗余且维护性极差,Provide/Inject 就是解决这个问题的终极方案。

1.3.1 Props Drilling 的痛点

比如有一个三级组件结构:父组件 -> 子组件 -> 孙组件,父组件要给孙组件传递用户信息,用 Props 就必须:

  1. 父组件把用户信息传给子组件
  2. 子组件什么都不用做,只是再把用户信息传给孙组件如果嵌套层级更深,这个过程会更痛苦,一旦要修改字段名,所有层级都要改,维护成本极高。
1.3.2 Provide/Inject 的基础用法与响应式处理
  • Provide:在父组件中提供数据,无论层级多深,所有子组件都能注入。
  • Inject:在后代组件中注入父组件提供的数据。

基础用法:

html 复制代码
<!-- 父组件 -->
<script setup lang="ts">
import { provide, ref, reactive } from 'vue'
// 响应式数据
const userInfo = reactive({
  id: 1,
  name: '张三',
  role: 'admin'
})
const token = ref('xxx-xxx-xxx')

// 提供数据
provide('userInfo', userInfo)
provide('token', token)
</script>
html 复制代码
<!-- 孙组件,无需经过子组件,直接注入 -->
<script setup lang="ts">
import { inject } from 'vue'

// 注入数据,第二个参数为默认值
const userInfo = inject('userInfo', {})
const token = inject('token', '')
</script>

关键注意点要保持响应式,provide 必须传递 ref/reactive 对象,不能传递普通值。如果传递普通值,后续父组件修改值,子组件不会收到更新。

1.3.3 实战案例:全局用户状态的跨层级传递

在后台管理系统中,用户信息、主题配置、权限数据是几乎所有组件都要用到的,用 Provide/Inject 可以非常优雅地实现全局状态管理,无需引入 Vuex/Pinia。

javascript 复制代码
// 根组件App.vue
<script setup lang="ts">
import { provide, reactive, ref, readonly } from 'vue'
import { getUserInfo } from '@/api/user'

// 全局用户状态
const userInfo = reactive({
  id: 0,
  name: '',
  role: '',
  permissions: []
})
// 全局主题配置
const themeConfig = reactive({
  isDark: false,
  primaryColor: '#1890ff'
})
// 加载状态
const appLoading = ref(false)

// 初始化用户信息
const initUserInfo = async () => {
  appLoading.value = true
  try {
    const res = await getUserInfo()
    Object.assign(userInfo, res.data)
  } catch (err) {
    console.error('获取用户信息失败', err)
  } finally {
    appLoading.value = false
  }
}

// 提供只读的状态,防止子组件直接修改
provide('userInfo', readonly(userInfo))
provide('themeConfig', readonly(themeConfig))
provide('appLoading', readonly(appLoading))
// 提供修改状态的方法,保证单向数据流
provide('setThemeConfig', (config: Partial<typeof themeConfig>) => {
  Object.assign(themeConfig, config)
})
provide('initUserInfo', initUserInfo)

// 初始化
initUserInfo()
</script>

任何后代组件都可以直接注入使用:

html 复制代码
<script setup lang="ts">
import { inject } from 'vue'

const userInfo = inject('userInfo', {})
const setThemeConfig = inject('setThemeConfig', () => {})

// 切换暗黑模式
const toggleDark = () => {
  setThemeConfig({ isDark: !userInfo.isDark })
}
</script>
1.3.4 最佳实践:如何保证单向数据流不被破坏

Vue 的核心设计思想是单向数据流,父组件的状态只能由父组件修改,子组件只能读取,不能直接修改。如果子组件直接修改 inject 的状态,会导致数据流混乱,bug 很难排查。

最佳实践:

  1. readonly包裹 provide 出去的状态,让子组件无法直接修改
  2. 同时 provide 修改状态的方法,子组件只能通过调用方法来修改状态
  3. 用 Symbol 作为 key,避免命名冲突,提升代码的可维护性
javascript 复制代码
// 用Symbol定义key,避免命名冲突
const USER_INFO_KEY = Symbol('userInfo')
const SET_USER_INFO_KEY = Symbol('setUserInfo')

// 提供只读状态和修改方法
provide(USER_INFO_KEY, readonly(userInfo))
provide(SET_USER_INFO_KEY, (newInfo: Partial<typeof userInfo>) => {
  Object.assign(userInfo, newInfo)
})

// 子组件注入
const userInfo = inject(USER_INFO_KEY, {})
const setUserInfo = inject(SET_USER_INFO_KEY, () => {})
1.3.5 避坑指南:这些错误 90% 的人都犯过
  1. provide 的响应式丢失:传递了普通值,而不是 ref/reactive 对象,导致父组件修改值,子组件不更新。
  2. 子组件直接修改 inject 的状态:破坏了单向数据流,导致数据流混乱,bug 难以排查。
  3. provide 的位置错误:必须在父组件的 setup 顶层调用 provide,不能在函数、异步回调中调用。
  4. inject 的默认值无效:只有当父组件没有 provide 对应 key 的时候,默认值才会生效,如果父组件 provide 了 undefined,默认值不会生效。
  5. 跨应用 provide 无效:provide/inject 只能在同一个 Vue 应用的组件树中使用,不同应用之间无法共享。

二、Vue3 响应式高级用法:吃透原理,性能优化一步到位

Vue3 的响应式系统基于 ES6 的 Proxy 实现,相比 Vue2 的 Object.defineProperty,支持了更多的数据类型、更好的性能,同时提供了一系列高级 API,让我们可以精准控制响应式的粒度,实现极致的性能优化。

2.1 Vue3 响应式系统核心原理回顾

Vue3 的响应式核心是Proxy 代理,通过 Proxy 拦截对象的读取、修改、删除等操作,结合 Reflect 实现对象操作的转发,在拦截器中触发依赖收集和更新派发。

  • reactive:将对象转换成深层响应式代理,递归代理对象的所有嵌套属性
  • ref:将基础类型 / 对象转换成响应式引用,通过.value访问和修改值

但在实际开发中,并不是所有数据都需要深层响应式,过度使用 reactive/ref 会导致不必要的性能开销,这时候就需要用到响应式高级 API。

这里给大家放一张 Vue3 响应式系统原理图,清晰展示深层代理和浅层代理的区别:

(图注:左图为 reactive 深层递归代理,右图为 shallowReactive 浅层代理)

2.2 toRef/toRefs:彻底解决响应式丢失的问题

在日常开发中,我们经常会遇到解构响应式对象导致响应式丢失的问题,toRef/toRefs 就是专门解决这个问题的 API。

2.2.1 核心作用与原理
  • toRef :基于响应式对象的单个属性,创建一个 ref 对象,这个 ref 对象和原属性保持双向引用,修改 ref 的 value,原对象的属性会同步更新,反之亦然,同时始终保持响应式。
  • toRefs:将响应式对象的所有属性都转换成 ref 对象,返回一个普通对象,每个属性都是对应原对象属性的 ref,常用于解构响应式对象。
2.2.2 toRef 与 toRefs 的区别与实战用法

场景 1:解构 reactive 对象,避免响应式丢失

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

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

// 错误写法:直接解构,基础类型会丢失响应式
const { name, age } = user
// 此时修改name,不会触发视图更新,user对象也不会变化

// 正确写法:用toRefs解构,所有属性都是ref,保持响应式
const { name, age } = toRefs(user)
// 此时修改name.value,视图会更新,user对象也会同步变化

场景 2:只需要单个属性的响应式引用

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

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

// 只需要name属性的引用
const name = toRef(user, 'name')
// 只需要城市属性的引用
const city = toRef(user.address, 'city')

// 修改name.value,user.name会同步更新
name.value = '李四'
console.log(user.name) // 李四

场景 3:给子组件传递单个响应式属性

html 复制代码
<!-- 父组件 -->
<template>
  <Child :name="toRef(user, 'name')" :age="toRef(user, 'age')" />
</template>

子组件接收的 props 是 ref 对象,始终保持响应式,不会因为父组件重新渲染而丢失响应式。

2.2.3 toRef vs ref:90% 的人都搞混了

很多新手会把 toRef 和 ref 搞混,这里用一句话讲清楚两者的核心区别:

  • ref :创建一个全新的响应式引用,和原对象没有任何关联
  • toRef :基于现有响应式对象的属性创建引用,和原属性双向绑定,共享同一个响应式连接

举个例子:

javascript 复制代码
const user = reactive({ name: '张三' })

// ref:创建新的响应式引用,和user.name无关
const nameRef = ref(user.name)
nameRef.value = '李四'
console.log(user.name) // 张三,原对象没有变化

// toRef:和user.name双向绑定
const nameToRef = toRef(user, 'name')
nameToRef.value = '李四'
console.log(user.name) // 李四,原对象同步变化
2.2.4 避坑指南:这些场景用错等于白写
  1. toRef 只能用于响应式对象:给普通对象用 toRef,创建的 ref 不会有响应式,修改也不会触发视图更新。
  2. toRefs 只能用于 reactive 对象:给 ref 对象用 toRefs,不会得到预期的结果。
  3. 解构 props 必须用 toRefs :直接解构 defineProps 的对象,会丢失响应式,必须用const { name } = toRefs(defineProps())
  4. toRef 的属性不存在时不会报错:如果原对象没有对应的属性,toRef 会创建一个值为 undefined 的 ref,不会报错,要注意边界处理。

2.3 shallowReactive/shallowRef:大数据性能优化的神器

这两个是 Vue3 提供的浅层响应式 API,也是大数据场景下性能优化的核心神器,90% 的 Vue3 性能优化,都离不开这两个 API。

2.3.1 深层响应式的性能痛点

reactive会递归代理对象的所有嵌套属性,哪怕是一个有 1000 条数据的数组,每条数据有 20 个字段,reactive 也会给每一个字段都创建 Proxy 代理,最终生成上万个 Proxy 实例,带来三个严重的性能问题:

  1. 初始化性能差:递归代理需要遍历所有嵌套属性,大数据对象初始化时间长,页面加载慢
  2. 内存占用高:大量的 Proxy 实例会占用大量内存,甚至导致页面卡顿、崩溃
  3. 更新性能差:每次修改属性,都会触发深层的依赖检查,更新效率低

而在实际开发中,很多场景我们并不需要深层响应式:

  • 大数据列表,只需要替换整个列表来触发更新,不需要修改单条数据的某个字段
  • 第三方库的实例,比如 ECharts、Map、Three.js 的实例,不需要代理内部的成百上千个属性
  • 只读的大数据,只需要渲染,不需要修改

这时候,浅层响应式 API 就派上用场了。

2.3.2 shallowReactive:浅层响应式的原理与用法

shallowReactive只会代理对象的第一层属性,不会递归代理深层的嵌套属性,只有第一层属性的修改会触发视图更新,深层属性的修改不会触发更新。

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

const data = shallowReactive({
  // 第一层属性,会被代理
  list: [],
  total: 0,
  // 深层属性,不会被代理
  user: {
    name: '张三',
    age: 20
  }
})

// 正确:修改第一层属性,会触发视图更新
data.list = [1,2,3]
data.total = 100

// 错误:修改深层属性,不会触发视图更新
data.user.name = '李四'
2.3.3 shallowRef:非对象数据的浅层响应式

shallowRef只会对.value的修改触发响应式更新,不会递归代理.value里面的嵌套属性,和 ref 的核心区别是:

  • ref(obj)会递归把 obj 转换成 reactive 代理,修改 obj 的深层属性会触发更新
  • shallowRef(obj)不会处理 obj 的内部属性,只有修改shallowRef.value = xxx才会触发更新
javascript 复制代码
import { shallowRef } from 'vue'

// 大数据列表,用shallowRef包裹
const tableData = shallowRef([])

// 正确:替换整个.value,会触发视图更新
tableData.value = await fetchTableData()

// 错误:修改内部属性,不会触发视图更新
tableData.value[0].name = '李四'
2.3.4 实战场景:大数据列表与第三方实例的性能优化

场景 1:大数据表格性能优化我之前接手过一个后台管理系统,表格一次渲染 2000 条数据,每条数据有 20 多个字段,用 reactive 包裹整个列表,页面初始化要 300 多 ms,滚动卡顿严重。后来我做了一个优化:

javascript 复制代码
// 优化前:reactive深层代理,初始化300ms+,滚动卡顿
const tableData = reactive([])
const getTableData = async () => {
  const res = await fetch('/api/table/list')
  tableData.push(...res.data)
}

// 优化后:shallowRef浅层代理,初始化50ms以内,滚动丝滑
const tableData = shallowRef([])
const getTableData = async () => {
  const res = await fetch('/api/table/list')
  // 直接替换整个.value,触发更新
  tableData.value = [...tableData.value, ...res.data]
}

优化后,初始化时间直接降到了 50ms 以内,滚动完全不卡顿,性能提升了 6 倍以上。

场景 2:第三方库实例的性能优化第三方库的实例(ECharts、Map、Three.js)内部有大量的属性和方法,完全不需要响应式代理,用 ref 包裹会导致严重的性能问题,必须用 shallowRef:

javascript 复制代码
import { shallowRef, onMounted } from 'vue'
import * as echarts from 'echarts'

// 正确:用shallowRef包裹ECharts实例,不会代理内部属性
const chartInstance = shallowRef<echarts.ECharts | null>(null)

onMounted(() => {
  // 初始化实例
  chartInstance.value = echarts.init(document.getElementById('chart'))
  // 设置配置项
  chartInstance.value.setOption({...})
})

// 组件卸载时销毁实例
onUnmounted(() => {
  chartInstance.value?.dispose()
})
2.3.5 避坑指南:修改了数据视图不更新?大概率是这里错了
  1. shallowReactive 深层修改不触发更新:只有第一层属性的修改会触发更新,深层修改不会,要更新必须替换整个第一层对象。
  2. shallowRef 内部修改不触发更新 :只有修改.value 会触发更新,内部属性修改不会,要更新可以用triggerRef强制触发更新(不推荐频繁使用)。
  3. 不要混用 shallowReactive 和 reactive:会导致响应式逻辑混乱,bug 难以排查。
  4. shallowRef 不适合需要频繁修改内部属性的场景:如果需要频繁修改内部属性,用 reactive/ref 更合适。

2.4 readonly:守护单向数据流的利器

readonly可以把一个响应式对象(普通对象、ref、reactive)转换成只读对象,深层的所有属性都会变成只读,无法修改,是守护单向数据流的核心 API。

2.4.1 核心作用与原理

readonly会创建一个原对象的只读代理,拦截所有的修改、删除操作,尝试修改只读对象会在控制台抛出警告,不会修改成功,同时原对象的修改会同步到只读对象中。

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

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

// 创建只读代理
const readonlyUser = readonly(user)

// 错误:修改只读对象,会抛出警告,修改失败
readonlyUser.name = '李四'

// 正确:修改原对象,只读对象会同步更新
user.name = '李四'
console.log(readonlyUser.name) // 李四
2.4.2 readonly vs shallowReadonly
  • readonly:深层只读,所有嵌套属性都无法修改
  • shallowReadonly:浅层只读,只有第一层属性无法修改,深层属性可以修改
javascript 复制代码
import { reactive, shallowReadonly } from 'vue'

const data = shallowReadonly(reactive({
  // 第一层,只读
  name: '张三',
  // 深层,可修改
  user: {
    age: 20
  }
}))

// 错误:修改第一层,抛出警告
data.name = '李四'

// 正确:修改深层属性,不会警告,修改成功
data.user.age = 21
2.4.3 实战场景:Provide/Inject 中的状态保护

这个在前面的依赖注入章节已经讲过,是 readonly 最常用的场景:父组件 provide 出去的状态,用 readonly 包裹,子组件只能读取,无法直接修改,必须调用父组件提供的方法来修改,严格保证单向数据流。

javascript 复制代码
// 父组件
const userInfo = reactive({ name: '张三' })
// 提供只读的状态
provide('userInfo', readonly(userInfo))
// 提供修改方法
provide('setUserName', (name: string) => {
  userInfo.name = name
})
2.4.4 最佳实践:什么时候该用 readonly
  1. 跨组件共享的状态:比如 Provide/Inject、Pinia/Vuex 的状态,对外暴露只读的状态,防止意外修改。
  2. props 的传递:给子组件传递响应式对象时,用 readonly 包裹,防止子组件直接修改 props。
  3. 只读的配置项:比如全局主题配置、系统常量,用 readonly 包裹,防止被意外修改。
  4. 对外暴露的内部状态:组合式函数中,对外暴露的状态用 readonly 包裹,防止外部直接修改,只能通过函数内部的方法修改。

三、自定义组合式函数 (Composables):Vue3 工程化的核心

组合式函数(Composables)是 Vue3 工程化的核心,也是发挥组合式 API 最大威力的关键,它可以把组件中重复的逻辑抽离出来,实现全项目复用,让代码更简洁、更易维护。

3.1 什么是 Composables?与 Mixin 的本质区别

Composables 是利用 Vue3 组合式 API 封装的、可复用的状态逻辑函数,它的核心特征是:

  • 命名以use开头,比如useRequestuseStorage
  • 内部使用 Vue 的响应式 API、生命周期钩子
  • 返回响应式状态和操作方法
  • 只能在 setup 函数或其他 Composables 中调用

和 Mixin 的本质区别,在前面的章节已经讲过,核心就是:Mixin 是黑盒式的隐式复用,Composables 是显式的、可预测的复用

3.2 高质量 Composables 的 5 大封装原则

  1. 单一职责原则 :一个 Composable 只做一件事,不要把多个不相关的逻辑塞到一个函数里,比如useRequest只负责处理网络请求,不要把表单逻辑也塞进去。
  2. 显式返回原则 :返回值要清晰,优先返回对象,不要返回数组,解构时可以重命名,不会有顺序问题,比如const { data, loading, run } = useRequest()
  3. 副作用清理原则 :所有的副作用(定时器、事件监听、网络请求),都必须在onUnmounted中清理,防止内存泄漏。
  4. 类型友好原则:必须用 TypeScript 编写,完善的泛型定义,让使用时有完整的类型提示,减少 bug。
  5. 无副作用初始化原则:不要在 Composable 的顶层执行副作用操作,比如立即发送请求,要通过参数控制是否立即执行,给使用者足够的控制权。

3.3 实战封装 1:useRequest 通用请求 hooks

useRequest是项目中使用频率最高的 Composable,几乎所有的网络请求都可以用它来处理,它可以帮我们统一管理 loading、data、error 状态,支持防抖、取消请求、缓存、轮询等常用功能。

3.3.1 核心功能设计
  • 自动管理 loading、data、error 状态
  • 支持手动 / 自动执行请求
  • 支持防抖、节流
  • 支持请求取消(AbortController)
  • 支持成功、失败、完成回调
  • 完善的 TypeScript 泛型支持
  • 组件卸载时自动取消请求,防止内存泄漏
3.3.2 完整 TS 实现代码
javascript 复制代码
// hooks/useRequest.ts
import { ref, shallowRef, readonly, onUnmounted } from 'vue'

interface UseRequestOptions<T = any> {
  /** 是否立即执行请求 */
  immediate?: boolean
  /** 防抖时间,单位ms */
  debounce?: number
  /** 初始数据 */
  initialData?: T
  /** 请求成功回调 */
  onSuccess?: (data: T) => void
  /** 请求失败回调 */
  onError?: (error: Error) => void
  /** 请求完成回调(无论成功失败) */
  onFinally?: () => void
}

interface UseRequestReturn<T = any, P extends any[] = any[]> {
  /** 加载状态(只读) */
  loading: Readonly<Ref<boolean>>
  /** 响应数据(只读) */
  data: Readonly<Ref<T | undefined>>
  /** 错误信息(只读) */
  error: Readonly<Ref<Error | undefined>>
  /** 执行请求的方法 */
  run: (...args: P) => Promise<T>
  /** 取消请求的方法 */
  cancel: () => void
}

/**
 * 通用网络请求hooks
 * @param requestFn 请求函数,必须返回Promise
 * @param options 配置项
 * @returns
 */
export function useRequest<T = any, P extends any[] = any[]>(
  requestFn: (...args: P) => Promise<T>,
  options: UseRequestOptions<T> = {}
): UseRequestReturn<T, P> {
  // 解构配置项,设置默认值
  const {
    immediate = true,
    debounce = 0,
    initialData,
    onSuccess,
    onError,
    onFinally
  } = options

  // 状态定义,用shallowRef减少性能开销
  const loading = ref(false)
  const data = shallowRef<T | undefined>(initialData)
  const error = shallowRef<Error | undefined>(undefined)
  
  // 请求取消控制器
  let abortController: AbortController | null = null
  // 防抖定时器
  let debounceTimer: NodeJS.Timeout | null = null

  /** 取消当前请求 */
  const cancel = () => {
    if (abortController) {
      abortController.abort()
      abortController = null
    }
    if (debounceTimer) {
      clearTimeout(debounceTimer)
      debounceTimer = null
    }
    loading.value = false
  }

  /** 执行请求 */
  const run = (...args: P): Promise<T> => {
    // 执行新请求前,取消上一次的请求
    cancel()

    return new Promise((resolve, reject) => {
      // 执行请求的核心函数
      const execRequest = () => {
        loading.value = true
        error.value = undefined
        // 创建取消控制器
        abortController = new AbortController()

        requestFn(...args)
          .then((res) => {
            // 只有请求未被取消,才更新状态
            if (abortController?.signal.aborted) return
            data.value = res
            onSuccess?.(res)
            resolve(res)
          })
          .catch((err) => {
            // 忽略取消请求的错误
            if (err.name === 'AbortError') return
            error.value = err
            onError?.(err)
            reject(err)
          })
          .finally(() => {
            // 只有请求未被取消,才更新状态
            if (abortController?.signal.aborted === false) {
              loading.value = false
              onFinally?.()
            }
            abortController = null
          })
      }

      // 防抖处理
      if (debounce > 0) {
        debounceTimer = setTimeout(execRequest, debounce)
      } else {
        execRequest()
      }
    })
  }

  // 立即执行请求
  if (immediate) {
    run()
  }

  // 组件卸载时,取消请求,清理定时器
  onUnmounted(() => {
    cancel()
  })

  // 返回只读状态,防止外部直接修改
  return {
    loading: readonly(loading),
    data: readonly(data),
    error: readonly(error),
    run,
    cancel
  }
}
3.3.3 实战用法:从基础请求到防抖、取消请求

基础用法:自动执行请求

html 复制代码
<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">请求失败:{{ error.message }}</div>
  <div v-else>
    <div v-for="item in data" :key="item.id">{{ item.name }}</div>
  </div>
</template>

<script setup lang="ts">
import { useRequest } from '@/hooks/useRequest'
import { getUserList } from '@/api/user'

// 自动执行请求,管理所有状态
const { data, loading, error } = useRequest(getUserList)
</script>

手动执行请求:带防抖的搜索功能

html 复制代码
<template>
  <input v-model="keyword" placeholder="请输入搜索关键词" />
  <div v-if="loading">搜索中...</div>
  <div v-else>
    <div v-for="item in data" :key="item.id">{{ item.name }}</div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRequest } from '@/hooks/useRequest'
import { searchUser } from '@/api/user'

const keyword = ref('')

// 手动执行,防抖500ms
const { data, loading, run } = useRequest(searchUser, {
  immediate: false,
  debounce: 500
})

// 监听关键词变化,执行搜索
watch(keyword, (val) => {
  if (val) run(val)
})
</script>

3.4 实战封装 2:useStorage 响应式本地存储 hooks

useStorage是另一个高频使用的 Composable,它可以让 localStorage/sessionStorage 变成响应式的,修改数据自动同步到本地存储,多个组件之间共享同一个 key 时,会自动同步状态,非常实用。

3.4.1 核心功能设计
  • 支持 localStorage/sessionStorage
  • 响应式数据,修改自动同步到本地存储
  • 自动序列化 / 反序列化 JSON 数据
  • 支持过期时间设置
  • 跨组件同步状态
  • 完善的 TypeScript 泛型支持
  • 组件卸载时自动清理事件监听
3.4.2 完整 TS 实现代码
javascript 复制代码
// hooks/useStorage.ts
import { ref, readonly, onUnmounted } from 'vue'

type StorageType = 'localStorage' | 'sessionStorage'

interface UseStorageOptions<T> {
  /** 存储类型 */
  storage?: StorageType
  /** 过期时间,单位ms */
  expire?: number
  /** 序列化函数,默认JSON.stringify */
  serialize?: (value: T) => string
  /** 反序列化函数,默认JSON.parse */
  deserialize?: (value: string) => T
}

interface StorageData<T> {
  value: T
  expire?: number
}

/**
 * 响应式本地存储hooks
 * @param key 存储的key
 * @param initialValue 初始值
 * @param options 配置项
 * @returns
 */
export function useStorage<T = any>(
  key: string,
  initialValue: T,
  options: UseStorageOptions<T> = {}
) {
  // 解构配置项,设置默认值
  const {
    storage = 'localStorage',
    expire,
    serialize = JSON.stringify,
    deserialize = JSON.parse
  } = options

  const storageApi = window[storage]
  // 响应式数据
  const data = ref<T>(initialValue)

  /** 从存储中读取数据 */
  const readStorage = () => {
    try {
      const storageStr = storageApi.getItem(key)
      if (!storageStr) {
        data.value = initialValue
        return
      }
      const storageData: StorageData<T> = deserialize(storageStr)
      // 检查是否过期
      if (storageData.expire && Date.now() > storageData.expire) {
        remove()
        data.value = initialValue
        return
      }
      data.value = storageData.value
    } catch (err) {
      console.error(`读取storage[${key}]失败:`, err)
      data.value = initialValue
    }
  }

  /** 写入数据到存储 */
  const setValue = (newValue: T) => {
    try {
      data.value = newValue
      const storageData: StorageData<T> = {
        value: newValue,
        expire: expire ? Date.now() + expire : undefined
      }
      storageApi.setItem(key, serialize(storageData))
    } catch (err) {
      console.error(`写入storage[${key}]失败:`, err)
    }
  }

  /** 移除存储的数据 */
  const remove = () => {
    try {
      storageApi.removeItem(key)
      data.value = initialValue
    } catch (err) {
      console.error(`移除storage[${key}]失败:`, err)
    }
  }

  /** 监听同页面其他地方的storage变化,同步状态 */
  const handleStorageChange = (e: StorageEvent) => {
    if (e.key === key && e.storageArea === storageApi) {
      readStorage()
    }
  }

  // 初始化读取数据
  readStorage()

  // 监听storage事件
  window.addEventListener('storage', handleStorageChange)

  // 组件卸载时,移除事件监听,防止内存泄漏
  onUnmounted(() => {
    window.removeEventListener('storage', handleStorageChange)
  })

  // 返回只读的状态和操作方法
  return {
    value: readonly(data),
    setValue,
    remove
  }
}
3.4.3 实战用法:跨组件同步本地存储状态

基础用法:记住用户的主题设置

html 复制代码
<template>
  <div :class="{ dark: theme.value.isDark }">
    <button @click="toggleDark">切换暗黑模式</button>
  </div>
</template>

<script setup lang="ts">
import { useStorage } from '@/hooks/useStorage'

// 存储主题设置,永久有效
const { value: theme, setValue } = useStorage('theme-config', {
  isDark: false,
  primaryColor: '#1890ff'
})

// 切换暗黑模式
const toggleDark = () => {
  setValue({
    ...theme.value,
    isDark: !theme.value.isDark
  })
}
</script>

带过期时间的存储:记住用户的登录状态

javascript 复制代码
import { useStorage } from '@/hooks/useStorage'

// 存储token,7天过期
const { value: token, setValue: setToken, remove: clearToken } = useStorage(
  'user-token',
  '',
  {
    expire: 7 * 24 * 60 * 60 * 1000 // 7天
  }
)

3.5 避坑指南:Composables 封装最容易踩的内存泄漏问题

90% 的 Composables 内存泄漏,都是因为副作用没有及时清理,这里给大家总结了最常见的几个坑:

  1. 事件监听没有移除:在 Composables 中添加了 window/resize 等事件监听,没有在 onUnmounted 中移除,组件卸载后事件监听依然存在,导致内存泄漏。
  2. 定时器没有清除:setInterval/setTimeout 没有在 onUnmounted 中清除,组件卸载后定时器依然在执行。
  3. 网络请求没有取消:组件卸载后,异步请求还在执行,回调中修改了已经销毁的组件的状态,导致内存泄漏,必须用 AbortController 在组件卸载时取消请求。
  4. 在循环 / 条件语句中调用 Composables:Composables 必须在 setup 的顶层作用域调用,不能在循环、条件语句、异步回调中调用,否则会导致生命周期钩子无法正确绑定,出现内存泄漏。
  5. 全局状态没有销毁:在 Composables 中创建了全局的响应式状态,组件卸载后没有重置,导致状态一直存在于内存中。

四、Vue3 核心新特性实战:提升开发效率与用户体验

Vue3 除了组合式 API 和响应式系统,还提供了很多实用的新特性,这些特性能帮我们解决很多开发中的痛点,大幅提升开发效率和用户体验。

4.1 Teleport 传送门:彻底解决 DOM 层级限制的痛点

Teleport 是 Vue3 提供的传送门组件,它可以把组件的模板内容渲染到指定的 DOM 节点中,完全脱离父组件的 DOM 结构限制,是解决模态框、tooltip、下拉菜单层级问题的终极方案。

4.1.1 核心作用与解决的痛点

在开发中,我们经常会遇到这样的问题:

  • 父组件设置了overflow: hidden,导致里面的模态框、下拉菜单被截断
  • 父组件的z-index层级太低,导致模态框被其他组件遮挡
  • 模态框嵌套在多层 DOM 结构中,样式和定位很难控制

Teleport 完美解决了这些问题:它可以把组件的逻辑位置保留在父组件中,但是把 DOM 结构渲染到指定的节点(比如 body 末尾),完全脱离父组件的 DOM 限制,不会被 overflow 和 z-index 影响。

这里给大家放一张 Teleport 的 DOM 结构示意图,一眼就能看懂它的作用:

(图注:组件逻辑在 App 组件树中,DOM 实际渲染在 body 末尾)

4.1.2 基础用法与 API 详解

Teleport 的用法非常简单,核心只有两个属性:

  • to:必填,指定要渲染到的目标节点,支持 CSS 选择器、DOM 节点
  • disabled:可选,是否禁用传送功能,为 true 时,内容会渲染到原来的位置

基础用法:

html 复制代码
<template>
  <div class="parent">
    <!-- 父组件有overflow: hidden -->
    <Teleport to="body">
      <!-- 这个div会被渲染到body末尾,不会被父组件截断 -->
      <div class="modal">我是模态框</div>
    </Teleport>
  </div>
</template>
4.1.3 实战案例:封装一个企业级通用模态框组件

这是 Teleport 最常用的场景,封装一个通用的模态框组件,完全不受父组件的 DOM 限制。

html 复制代码
<!-- components/Modal/index.vue -->
<template>
  <!-- Teleport把模态框内容传送到body末尾 -->
  <Teleport to="body" :disabled="!visible">
    <div class="modal-mask" v-show="visible" @click="handleMaskClick">
      <div class="modal-content" @click.stop>
        <div class="modal-header">
          <span class="modal-title">{{ title }}</span>
          <span class="modal-close" @click="handleClose">×</span>
        </div>
        <div class="modal-body">
          <slot></slot>
        </div>
        <div class="modal-footer" v-if="$slots.footer">
          <slot name="footer"></slot>
        </div>
      </div>
    </div>
  </Teleport>
</template>

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

interface Props {
  /** 双向绑定的显示状态 */
  modelValue: boolean
  /** 模态框标题 */
  title?: string
  /** 是否点击遮罩关闭 */
  maskClosable?: boolean
  /** 宽度 */
  width?: string | number
}

const props = withDefaults(defineProps<Props>(), {
  title: '提示',
  maskClosable: true,
  width: '500px'
})

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

// 双向绑定
const visible = computed({
  get: () => props.modelValue,
  set: (val) => emit('update:modelValue', val)
})

// 关闭模态框
const handleClose = () => {
  visible.value = false
  emit('close')
}

// 点击遮罩
const handleMaskClick = () => {
  if (props.maskClosable) {
    handleClose()
  }
}

// 暴露给父组件的方法
defineExpose({
  close: handleClose
})
</script>

<style scoped>
.modal-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

.modal-content {
  background: #fff;
  border-radius: 8px;
  width: v-bind(width);
  max-width: 90vw;
  max-height: 80vh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  animation: modalFadeIn 0.3s ease;
}

.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 24px;
  border-bottom: 1px solid #eee;
}

.modal-title {
  font-size: 16px;
  font-weight: 600;
}

.modal-close {
  font-size: 24px;
  cursor: pointer;
  color: #999;
  line-height: 1;
  transition: color 0.2s;
}

.modal-close:hover {
  color: #333;
}

.modal-body {
  padding: 24px;
  flex: 1;
  overflow-y: auto;
}

.modal-footer {
  padding: 16px 24px;
  border-top: 1px solid #eee;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 8px;
}

@keyframes modalFadeIn {
  from {
    opacity: 0;
    transform: translateY(-20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
</style>

组件中使用:

html 复制代码
<template>
  <button @click="openModal">打开模态框</button>
  <Modal v-model="visible" title="用户信息">
    <div>姓名:张三</div>
    <div>年龄:20</div>
    <template #footer>
      <button @click="visible = false">取消</button>
      <button @click="handleConfirm">确定</button>
    </template>
  </Modal>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Modal from '@/components/Modal/index.vue'

const visible = ref(false)
const openModal = () => visible.value = true
const handleConfirm = () => {
  console.log('点击确定')
  visible.value = false
}
</script>
4.1.4 避坑指南:Teleport 的这些坑你一定要避开
  1. to指定的节点必须在组件挂载时已经存在 :不能传送到一个还没渲染的组件内的节点,否则会报错,通常我们都会传送到body,因为 body 在页面加载时就存在。
  2. scoped 样式依然有效:Teleport 只是改变了 DOM 的渲染位置,组件的 scoped 样式依然会生效,因为 Vue 的 scoped 样式是基于组件的,不是基于 DOM 位置的。
  3. Teleport 的事件冒泡:Teleport 内的事件会按照 Vue 的组件树冒泡,而不是 DOM 的结构冒泡,比如 Teleport 内的点击事件,会冒泡到父组件,而不是 body。
  4. 多个 Teleport 可以传送到同一个节点 :多个 Teleport 的to属性可以是同一个节点,内容会按照渲染顺序依次追加到目标节点中。
  5. disabled 属性变化时,内容会自动移动:当 disabled 从 false 变成 true 时,内容会从目标节点移回原来的位置,反之亦然。

4.2 Suspense 异步组件加载:优雅处理异步依赖

Suspense 是 Vue3 提供的异步组件加载方案,它可以优雅地处理组件中的异步依赖,在异步内容加载完成之前,显示 fallback 的 loading 内容,无需我们手动写大量的 v-if 来控制 loading 状态。

4.2.1 核心作用与解决的痛点

在 Vue2 中,处理异步组件和异步数据,我们通常会这样写:

html 复制代码
<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">加载失败</div>
    <UserInfo v-else :data="userInfo" />
    <OrderList v-if="!loading && orderList.length" :data="orderList" />
  </div>
</template>

当页面有多个异步依赖时,代码会变得非常混乱,loading 状态很难统一管理。

Suspense 完美解决了这个问题:它可以统一处理组件内的所有异步依赖,不管是异步组件,还是组件内的异步 setup,在所有异步内容加载完成之前,统一显示 loading 状态,代码非常简洁优雅。

4.2.2 基础用法与插槽详解

Suspense 提供了两个插槽:

  • #default:需要加载的异步内容
  • #fallback:异步内容加载完成之前,显示的 loading 内容

基础用法:

html 复制代码
<template>
  <Suspense>
    <!-- 异步内容 -->
    <template #default>
      <AsyncComponent />
    </template>
    <!-- 加载中显示的内容 -->
    <template #fallback>
      <div>页面加载中,请稍候...</div>
    </template>
  </Suspense>
</template>
4.2.3 实战案例 1:异步组件懒加载与 loading 处理

配合defineAsyncComponent实现组件的懒加载,在组件加载完成之前显示 loading 状态,非常适合首屏性能优化。

html 复制代码
<template>
  <div>
    <h1>首页</h1>
    <Suspense>
      <template #default>
        <!-- 懒加载的大组件,不会和首页一起打包 -->
        <BigDataChart />
        <UserStatistics />
      </template>
      <template #fallback>
        <div class="loading-skeleton">
          <!-- 骨架屏 -->
          <SkeletonItem :rows="5" />
        </div>
      </template>
    </Suspense>
  </div>
</template>

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

// 异步懒加载组件
const BigDataChart = defineAsyncComponent(() => import('@/components/BigDataChart.vue'))
const UserStatistics = defineAsyncComponent(() => import('@/components/UserStatistics.vue'))
</script>
4.2.4 实战案例 2:带异步 setup 的组件数据预加载

<script setup>中使用顶层 await,Suspense 会等待 setup 的异步操作完成,再渲染组件,无需我们手动管理 loading 状态。

异步组件:UserInfo.vue

html 复制代码
<template>
  <div>
    <div>姓名:{{ userInfo.name }}</div>
    <div>角色:{{ userInfo.role }}</div>
  </div>
</template>

<script setup lang="ts">
import { getUserInfo } from '@/api/user'

// 顶层await,Suspense会等待这个请求完成
const userInfo = await getUserInfo()
</script>

父组件中使用

html 复制代码
<template>
  <Suspense>
    <template #default>
      <UserInfo />
    </template>
    <template #fallback>
      <div>用户信息加载中...</div>
    </template>
  </Suspense>
</template>

<script setup lang="ts">
import UserInfo from './UserInfo.vue'
</script>
4.2.5 错误处理与最佳实践

Suspense 本身没有提供错误处理的插槽,我们需要用onErrorCaptured钩子来捕获异步组件中的错误,显示错误页面。

html 复制代码
<template>
  <div v-if="error">页面加载失败:{{ error.message }}</div>
  <Suspense v-else>
    <template #default>
      <UserInfo />
    </template>
    <template #fallback>
      <div>加载中...</div>
    </template>
  </Suspense>
</template>

<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'

const error = ref<Error | null>(null)

// 捕获子组件的错误
onErrorCaptured((err) => {
  error.value = err
  // 返回true,阻止错误继续向上冒泡
  return true
})
</script>

最佳实践

  1. 配合路由懒加载使用:在 Vue Router 中,用 Suspense 包裹路由视图,实现页面切换的 loading 效果,提升用户体验。
  2. 不要嵌套过多的 Suspense:嵌套过多的 Suspense 会导致 loading 状态混乱,建议在页面级使用一个 Suspense 即可。
  3. fallback 内容要简洁:fallback 内容不要有复杂的逻辑和异步依赖,否则会导致 Suspense 无法正常工作。
  4. 配合错误边界使用:一定要用 onErrorCaptured 处理错误,否则异步组件报错会导致整个页面白屏。

4.3 Transition 动画:Vue3 动画系统全解

Vue3 的 Transition 组件相比 Vue2 做了很大的优化,API 更语义化,功能更强大,配合 Teleport、Suspense 可以实现非常丝滑的动画效果,提升用户体验。

4.3.1 Vue3 Transition vs Vue2 的核心变化

最核心的变化是过渡类名的语义化调整,更符合动画的执行逻辑:

Vue2 类名 Vue3 类名 作用
v-enter v-enter-from 元素进入动画的初始状态,动画开始前添加,第一帧后移除
v-enter-active v-enter-active 元素进入动画的生效状态,整个动画过程中都存在
v-enter-to v-enter-to 元素进入动画的结束状态,动画第一帧后添加,动画结束后移除
v-leave v-leave-from 元素离开动画的初始状态,动画开始前添加,第一帧后移除
v-leave-active v-leave-active 元素离开动画的生效状态,整个动画过程中都存在
v-leave-to v-leave-to 元素离开动画的结束状态,动画第一帧后添加,动画结束后移除

其他核心变化:

  • 新增appear属性,支持组件初次渲染时执行动画
  • 更好的支持 Teleport、Suspense 组件的动画
  • 更好的 TypeScript 类型支持
  • 移除了 Vue2 中的v-on:enter-cancelled事件,统一用v-on:leave-cancelled
4.3.2 基础用法与过渡类名详解

Transition 组件用于给单个元素 / 组件添加进入离开动画,只有当组件的v-ifv-show发生变化,或者组件被动态组件切换时,才会触发动画。

基础用法:

html 复制代码
<template>
  <button @click="visible = !visible">切换</button>
  <!-- Transition组件,name属性指定动画前缀 -->
  <Transition name="fade">
    <div v-if="visible" class="box">我是动画元素</div>
  </Transition>
</template>

<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
</script>

<style scoped>
.box {
  width: 200px;
  height: 200px;
  background: #1890ff;
  margin-top: 20px;
}

/* 进入动画的初始状态 */
.fade-enter-from {
  opacity: 0;
  transform: translateX(-20px);
}
/* 进入动画的生效状态 */
.fade-enter-active {
  transition: all 0.3s ease;
}
/* 进入动画的结束状态 */
.fade-enter-to {
  opacity: 1;
  transform: translateX(0);
}

/* 离开动画的初始状态 */
.fade-leave-from {
  opacity: 1;
  transform: translateX(0);
}
/* 离开动画的生效状态 */
.fade-leave-active {
  transition: all 0.3s ease;
}
/* 离开动画的结束状态 */
.fade-leave-to {
  opacity: 0;
  transform: translateX(20px);
}
</style>
4.3.3 实战案例 1:模态框的进入离开动画

在前面的 Teleport 模态框组件中,我们已经加入了基础的动画,这里给大家实现一个更丝滑的模态框缩放动画:

css 复制代码
/* 模态框进入动画 */
.modal-enter-from {
  opacity: 0;
  transform: scale(0.8);
}
.modal-enter-active {
  transition: all 0.3s cubic-bezier(0.3, 0, 0.3, 1);
}
.modal-enter-to {
  opacity: 1;
  transform: scale(1);
}

/* 遮罩进入动画 */
.mask-enter-from {
  opacity: 0;
}
.mask-enter-active {
  transition: opacity 0.3s ease;
}
.mask-enter-to {
  opacity: 1;
}

/* 模态框离开动画 */
.modal-leave-from {
  opacity: 1;
  transform: scale(1);
}
.modal-leave-active {
  transition: all 0.3s cubic-bezier(0.3, 0, 0.3, 1);
}
.modal-leave-to {
  opacity: 0;
  transform: scale(0.8);
}

/* 遮罩离开动画 */
.mask-leave-from {
  opacity: 1;
}
.mask-leave-active {
  transition: opacity 0.3s ease;
}
.mask-leave-to {
  opacity: 0;
}
4.3.4 实战案例 2:TransitionGroup 列表动画

TransitionGroup 用于给列表元素添加动画,支持列表项的添加、删除、排序动画,核心特点是:

  • 必须给每个列表项设置唯一的key,不能用 index
  • 提供了v-move类名,用于控制列表项排序的动画
html 复制代码
<template>
  <div>
    <button @click="addItem">添加</button>
    <button @click="removeItem">删除</button>
    <button @click="shuffleList">打乱</button>
    <!-- TransitionGroup列表动画 -->
    <TransitionGroup name="list" tag="div" class="list-container">
      <div v-for="item in list" :key="item.id" class="list-item">
        {{ item.name }}
      </div>
    </TransitionGroup>
  </div>
</template>

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

const list = ref([
  { id: 1, name: '列表项1' },
  { id: 2, name: '列表项2' },
  { id: 3, name: '列表项3' }
])
let id = 4

// 添加项
const addItem = () => {
  list.value.push({ id: id++, name: `列表项${id}` })
}
// 删除项
const removeItem = () => {
  list.value.pop()
}
// 打乱列表
const shuffleList = () => {
  list.value = list.value.sort(() => Math.random() - 0.5)
}
</script>

<style scoped>
.list-container {
  margin-top: 20px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.list-item {
  padding: 12px 16px;
  border: 1px solid #eee;
  border-radius: 4px;
  transition: all 0.3s ease;
}

/* 进入动画 */
.list-enter-from {
  opacity: 0;
  transform: translateX(-20px);
}
.list-enter-active {
  transition: all 0.3s ease;
}
.list-enter-to {
  opacity: 1;
  transform: translateX(0);
}

/* 离开动画 */
.list-leave-from {
  opacity: 1;
}
.list-leave-active {
  transition: all 0.3s ease;
  position: absolute;
}
.list-leave-to {
  opacity: 0;
  transform: translateX(20px);
}

/* 排序移动动画 */
.list-move {
  transition: transform 0.3s ease;
}
</style>
4.3.5 动画性能优化最佳实践

动画性能是前端性能优化的重要部分,这里给大家总结了 Vue3 动画性能优化的核心技巧:

  1. 只用 transform 和 opacity 做动画:这两个属性可以开启 GPU 加速,不会触发重排和重绘,性能最好,绝对不要用 width、height、margin、top 等属性做动画,会触发频繁的重排,导致动画卡顿。
  2. 开启硬件加速 :给动画元素添加will-change: transform,提前告诉浏览器这个元素要做动画,浏览器会提前做好优化,动画更丝滑。
  3. 避免动画元素过多:同时执行动画的元素不要超过 10 个,否则会导致 GPU 负载过高,页面卡顿。
  4. 减少动画时长:动画时长控制在 0.3s-0.5s 之间,过长的动画会让用户觉得页面响应慢。
  5. 使用 css 动画而非 js 动画:css 动画由浏览器的合成线程执行,不会阻塞主线程,性能比 js 动画更好,除非是非常复杂的动画,否则优先用 css 动画。

五、Vue3 项目性能优化落地:把前面的知识用起来

前面讲了这么多高级特性,最终都要落地到项目的性能优化上,这里给大家总结了一套可直接落地的 Vue3 性能优化方案,把前面的知识全部串联起来。

5.1 响应式性能优化:减少不必要的 Proxy 代理

  1. 大数据用 shallowRef/shallowReactive:超过 100 条数据的列表、第三方库实例,一律用 shallowRef 包裹,避免深层递归代理带来的性能开销。
  2. 只读数据用 markRaw:不需要响应式的静态数据、配置项,用 markRaw 标记,不会被 Proxy 代理,减少内存占用。
  3. 避免频繁创建响应式对象:不要在循环、定时器中频繁创建 ref/reactive 对象,会导致频繁的 GC,页面卡顿。
  4. 及时销毁无用的响应式对象:组件卸载时,把大的响应式对象置为 null,释放内存。

5.2 代码体积优化:发挥组合式 API 的 Tree-Shaking 优势

  1. 优先使用组合式 API:组合式 API 是基于函数的,天生支持 Tree-Shaking,未使用的 API 会被自动摇树,打包体积比 Options API 小 30% 以上。
  2. 组件异步懒加载:路由组件、大组件用 defineAsyncComponent 懒加载,拆分打包 chunk,减少首屏加载体积。
  3. 按需引入第三方库:比如 Element Plus、Ant Design Vue,用按需引入,不要全量导入。
  4. 生产环境关闭 devtools :在 vite.config.ts 中配置prodSourcemap: false,关闭生产环境的 sourcemap,减少打包体积。

5.3 渲染性能优化:从 DOM 结构到动画的全链路优化

  1. 用 v-show 代替频繁切换的 v-if:v-if 会销毁重建组件,频繁切换性能差,v-show 只是切换 display,性能更好。
  2. 合理使用 v-for 的 key:不要用 index 作为 key,要用唯一的业务 id,提升 diff 算法的性能。
  3. 长列表虚拟滚动:超过 1000 条数据的长列表,用 vue-virtual-scroller 等虚拟滚动库,只渲染可视区域的内容,大幅提升渲染性能。
  4. 用 Teleport 优化 DOM 结构:模态框、tooltip 等组件用 Teleport 传送到 body 末尾,减少 DOM 嵌套层级,提升渲染性能。
  5. 动画性能优化:只用 transform 和 opacity 做动画,开启 GPU 加速,避免重排重绘。

5.4 内存优化:副作用清理与内存泄漏防范

  1. Composables 中的副作用及时清理:事件监听、定时器、网络请求,必须在 onUnmounted 中清理 / 取消。
  2. 全局事件监听及时移除:window、document 上的事件监听,组件卸载时必须移除。
  3. 避免闭包导致的内存泄漏:不要在闭包中引用大的对象,组件卸载后闭包依然存在,会导致对象无法被 GC 回收。
  4. 及时销毁第三方实例:ECharts、Map 等第三方实例,组件卸载时必须调用 dispose 方法销毁,释放内存。

六、总结与进阶学习建议

这篇文章,我们从组合式 API 进阶、响应式高级用法、自定义组合式函数、Vue3 核心新特性四大模块,由浅入深地讲解了 Vue3 的高级特性,同时结合实战案例和性能优化方案,让大家不仅能看懂,更能直接用到项目里。

Vue3 的核心优势,就是组合式 API 带来的逻辑复用能力和更精准的响应式控制,只有跳出 Vue2 的思维,真正理解 Vue3 的设计思想,才能写出高质量、高性能的 Vue3 代码。

给大家的进阶学习建议:

  1. 先落地,再深入:先把本文讲的内容用到项目里,解决实际问题,再去深入学习原理。
  2. 吃透 Vue3 官方文档:官方文档是最好的学习资料,很多细节都在文档里。
  3. 学习 Vue3 源码:想要真正精通 Vue3,一定要去看源码,理解响应式系统、虚拟 DOM、diff 算法的底层实现。
  4. 学习周边生态:Nuxt3、Pinia、VueUse 这些周边生态,能大幅提升你的开发效率。
  5. 多写多练:技术没有捷径,只有多写多练,才能真正掌握。

如果觉得这篇文章对你有帮助,欢迎点赞、收藏、评论,有任何问题都可以在评论区留言,我会一一回复。关注我,后续会分享更多 Vue3、前端工程化、性能优化的干货内容,带你一起进阶前端高级开发。

相关推荐
小小编程路2 小时前
架构与性能优化
性能优化·架构
之歆15 小时前
Day16_JavaScript 轮播图与事件工程实战(下篇)
服务器·开发语言·前端·javascript·网络·性能优化
UWA20 小时前
5秒快速开玩:小游戏性能优化实战
性能优化·游戏开发·minigame·particlesystem
MU在掘金9169521 小时前
让LLM按维度自动切换分析策略:SmartInspector 的 Prompt Skill 系统
性能优化
2501_916007471 天前
iOS应用性能优化全面指南:从内存管理到工具使用
android·ios·性能优化·小程序·uni-app·iphone·webview
代码小书生1 天前
Windows系统优化设置,电脑系统工具箱!支持远程桌面控制、性能优化调节、功能选项增强设置、驱动安装更新、系统更新管理、安全配置与系统维护!
windows·性能优化·系统优化·电脑系统·电脑技巧·windows10·电脑优化
光泽雨1 天前
ADO.NET 进阶知识与实战坑位深度解析
性能优化·架构·.net
wbs_scy1 天前
MySQL 索引特性与性能优化全解
性能优化