普通视图

发现新文章,点击刷新页面。
今天 — 2025年10月19日首页

Pinia 状态管理原理与实战全解析

作者 90后晨仔
2025年10月18日 22:43

一、前言:为什么选择 Pinia?

在 Vue2 时代,我们常用 Vuex 来做全局状态管理。

但是 Vue3 带来了全新的响应式系统(Composition API + Proxy),于是 Vue 官方团队推出了 Pinia —— 一款更轻量、更现代、更易用的状态管理库。

Pinia 的核心理念是:

“让状态管理像使用普通变量一样简单。”

相比 Vuex,它具备以下优势:

特点 Vuex Pinia
语法 基于 Mutations/Actions 直接使用函数
类型推导 较弱 TypeScript 支持友好
响应式实现 Object.defineProperty Vue3 Proxy
模块化 需手动命名空间 天然支持模块化
体积 较大 小巧轻量(约1KB)

二、Pinia 的核心原理

要理解 Pinia,先要知道它是如何在底层维持响应式的。

1. 状态的本质:Reactive

Pinia 使用 Vue3 的 reactive() 来存储状态。

每个 store 内部其实就是一个被 reactive 包裹的对象。

import { reactive } from 'vue'

const state = reactive({
  count: 0
})

当 state.count++ 改变时,所有引用它的组件都会自动更新。

这就是 Pinia 响应式的根本机制。

2. Getter 的本质:Computed

在 Pinia 中,getter 相当于 Vue 中的 computed。

getters: {
  doubleCount: (state) => state.count * 2
}

Pinia 会在内部把它转成一个 计算属性,只有依赖变化时才会重新计算。

因此它是 响应式、缓存式 的。

3. Action 的本质:普通函数 + 作用域代理

Pinia 不再强制使用 Mutation。

Action 其实就是对状态修改的封装函数。

actions: {
  increment() {
    this.count++
  }
}

Pinia 通过 Proxy 让 this 指向 store 实例,因此你可以像访问普通对象一样修改状态。

4. 模块与依赖收集机制

每一个 store 都是一个独立的响应式作用域(Reactive Scope)。

Pinia 会将它注册到全局的 store 容器中(类似一个 Map 结构),并在组件使用时完成依赖收集。

组件一旦引用某个 store 的状态,Pinia 就会追踪它的依赖关系。

当 store 内的状态改变时,Pinia 会自动触发依赖更新 —— 这就是响应式传播的原理。


三、Pinia 的安装与配置

1️⃣ 安装

npm install pinia
# 或者
yarn add pinia

2️⃣ 创建与挂载

在 main.js 中引入 Pinia 并挂载到 Vue 应用:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia())
app.mount('#app')

四、定义第一个 Store

Pinia 推荐使用 defineStore() 来定义一个 store。

// src/stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // 1. state:存放共享数据
  state: () => ({
    count: 0,
    name: 'Pinia示例'
  }),

  // 2. getters:相当于计算属性
  getters: {
    doubleCount: (state) => state.count * 2
  },

  // 3. actions:用于定义修改逻辑
  actions: {
    increment() {
      this.count++
    },
    setCount(val) {
      this.count = val
    }
  }
})

五、在组件中使用 Store

在组件中使用时,就像普通变量一样简单。

<template>
  <div>
    <h2>{{ counter.name }}</h2>
    <p>当前数量:{{ counter.count }}</p>
    <p>双倍数量:{{ counter.doubleCount }}</p>
    <button @click="counter.increment">增加</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

💡 响应式自动生效:

当 counter.count 改变时,界面会自动更新,无需手动刷新。


六、解构与响应式陷阱

Pinia store 是响应式对象,如果你用结构赋值要注意保持响应性。

错误写法 ❌:

const { count } = useCounterStore()
console.log(count) // 不会响应更新

正确写法 ✅:

import { storeToRefs } from 'pinia'

const store = useCounterStore()
const { count, doubleCount } = storeToRefs(store)

storeToRefs() 会帮你保留响应式引用。


七、模块化管理多个 Store

Pinia 天然支持多 store,无需命名空间:

// user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '张三',
    token: ''
  }),
  actions: {
    login(name) {
      this.name = name
      this.token = 'token123'
    }
  }
})

在组件中可以自由组合使用:

import { useUserStore } from '@/stores/user'
import { useCounterStore } from '@/stores/counter'

const user = useUserStore()
const counter = useCounterStore()

八、持久化存储(localStorage)

Pinia 本身不带持久化功能,但可以通过插件轻松实现:

✅ 手动持久化:

watch(
  () => store.count,
  (newVal) => {
    localStorage.setItem('count', newVal)
  }
)

✅ 使用插件(推荐):

安装:

npm i pinia-plugin-persistedstate

注册插件:

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

开启持久化:

export const useUserStore = defineStore('user', {
  state: () => ({ name: '', token: '' }),
  persist: true
})

九、Pinia + TypeScript 实践(简要示例)

Pinia 的类型推导非常强大。

在 TS 项目中可直接推断出 state 和 getter 的类型。

export const useTodoStore = defineStore('todo', {
  state: () => ({
    list: [] as string[]
  }),
  actions: {
    addTodo(item: string) {
      this.list.push(item)
    }
  }
})

// 自动推导类型
const store = useTodoStore()
store.addTodo('学习 Pinia')

十、Pinia 内部运行机制简述

Pinia 内部核心模块包括:

  1. Store 实例注册

    每个 defineStore() 都注册到全局 pinia._s 容器中。

  2. Reactive 封装

    使用 reactive() 包装 state,配合 Vue 的 effect 机制实现依赖收集。

  3. Getter 包装为 computed

    保证 getter 的懒计算与缓存特性。

  4. Action 包装代理

    使用 Proxy 代理 this 指向当前 store,并自动注入 devtools 日志。

  5. 订阅机制

    Pinia 提供 store.$subscribe(),可以监听 state 变化。


十一、实战示例:Todo 应用

<template>
  <div>
    <h2>我的待办事项</h2>
    <input v-model="newTask" @keyup.enter="addTodo" placeholder="输入任务..."/>
    <ul>
      <li v-for="(item, index) in todos.list" :key="index">
        {{ item }}
        <button @click="removeTodo(index)">删除</button>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useTodoStore } from '@/stores/todo'

const todos = useTodoStore()
const newTask = ref('')

const addTodo = () => {
  if (newTask.value.trim()) {
    todos.addTodo(newTask.value)
    newTask.value = ''
  }
}
const removeTodo = (i) => todos.list.splice(i, 1)
</script>

十二、总结

内容 关键点
状态管理核心 Vue3 的 reactive 与 computed
改进点 无需 mutation、天然模块化
类型支持 友好且强大
响应式机制 Proxy + Effect 依赖追踪
持久化 插件 pinia-plugin-persistedstate
适用场景 中大型 Vue3 项目,全局状态同步

Pinia 的设计哲学是:

“简单到你几乎忘了自己在用状态管理库。”

Vue3 状态管理完全指南:从响应式 API 到 Pinia

作者 90后晨仔
2025年10月18日 22:39

什么是状态管理?

在 Vue 开发中,状态管理是一个核心概念。简单来说,状态就是驱动应用的数据源。每一个 Vue 组件实例都在管理自己的响应式状态,让我们从一个简单的计数器组件开始理解:

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

// 状态 - 驱动应用的数据源
const count = ref(0)

// 动作 - 修改状态的方法
function increment() {
  count.value++
}
</script>

<!-- 视图 - 状态的声明式映射 -->
<template>{{ count }}</template>

这个简单的例子展示了状态管理的三个核心要素:

  • 状态:数据源 (count)
  • 视图:状态的声明式映射 (模板)
  • 动作:状态变更的逻辑 (increment)

这就是所谓的"单向数据流"概念。

为什么需要状态管理?

当应用变得复杂时,我们会遇到两个典型问题:

问题 1:多个组件共享状态

<!-- ComponentA.vue -->
<template>组件 A: {{ count }}</template>

<!-- ComponentB.vue -->  
<template>组件 B: {{ count }}</template>

如果多个视图依赖于同一份状态,传统的解决方案是通过 props 逐级传递,但这在深层次组件树中会变得非常繁琐,导致 Prop 逐级透传问题

问题 2:多组件修改同一状态

来自不同视图的交互都需要更改同一份状态时,直接通过事件或模板引用会导致代码难以维护。

解决方案:将共享状态抽取到全局单例中管理。

使用响应式 API 实现简单状态管理

Vue 的响应式系统本身就提供了状态管理的能力。

创建全局状态 Store

// store.js
import { reactive } from 'vue'

export const store = reactive({
  count: 0,
  user: null,
  todos: []
})

在组件中使用

<!-- ComponentA.vue -->
<script setup>
import { store } from './store.js'
</script>

<template>
  <div>From A: {{ store.count }}</div>
  <button @click="store.count++">+1</button>
</template>
<!-- ComponentB.vue -->
<script setup>
import { store } from './store.js'
</script>

<template>
  <div>From B: {{ store.count }}</div>
  <button @click="store.count++">+1</button>
</template>

问题:任意修改的风险

上面的实现有个问题:任何导入 store 的组件都可以随意修改状态,这在大型应用中难以维护。

改进:封装状态修改逻辑

// store.js
import { reactive } from 'vue'

export const store = reactive({
  // 状态
  count: 0,
  user: null,
  todos: [],
  
  // 动作 - 封装状态修改逻辑
  increment() {
    this.count++
  },
  
  setUser(user) {
    this.user = user
  },
  
  addTodo(todo) {
    this.todos.push(todo)
  },
  
  removeTodo(id) {
    this.todos = this.todos.filter(todo => todo.id !== id)
  }
})
<!-- ComponentB.vue -->
<script setup>
import { store } from './store.js'
</script>

<template>
  <button @click="store.increment()">
    From B: {{ store.count }}
  </button>
</template>

注意:这里使用 store.increment() 带圆括号调用,因为它不是组件方法,需要正确的 this 上下文。

使用组合式函数管理状态

// useCounter.js
import { ref } from 'vue'

// 全局状态
const globalCount = ref(1)

export function useCount() {
  // 局部状态
  const localCount = ref(1)
  
  function incrementGlobal() {
    globalCount.value++
  }
  
  function incrementLocal() {
    localCount.value++
  }
  
  return {
    globalCount: readonly(globalCount), // 使用 readonly 保护全局状态
    localCount,
    incrementGlobal,
    incrementLocal
  }
}

Pinia:现代化的状态管理库

虽然手动状态管理在简单场景中足够,但生产级应用需要更多功能:

  • 团队协作约定
  • Vue DevTools 集成
  • 模块热更新
  • 服务端渲染支持
  • 完善的 TypeScript 支持

Pinia 是 Vue 官方推荐的状态管理库,它解决了上述所有问题。

为什么选择 Pinia?

  • 类型安全:完美的 TypeScript 支持
  • DevTools 支持:时间旅行调试等
  • 模块热更新:开发时保持状态
  • 简洁的 API:学习成本低
  • 组合式 API:与 Vue 3 完美契合

安装和配置

npm install pinia
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const pinia = createPinia()
const app = createApp(App)

app.use(pinia)
app.mount('#app')

创建 Store

选项式 Store

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // 状态
  state: () => ({
    count: 0,
    user: null
  }),
  
  // 计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    isAuthenticated: (state) => state.user !== null
  },
  
  // 动作
  actions: {
    increment() {
      this.count++
    },
    async login(credentials) {
      const user = await api.login(credentials)
      this.user = user
    },
    logout() {
      this.user = null
    }
  }
})

组合式 Store(推荐)

// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // 状态
  const count = ref(0)
  const user = ref(null)
  
  // 计算属性
  const doubleCount = computed(() => count.value * 2)
  const isAuthenticated = computed(() => user.value !== null)
  
  // 动作
  function increment() {
    count.value++
  }
  
  async function login(credentials) {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })
    user.value = await response.json()
  }
  
  function logout() {
    user.value = null
  }
  
  return {
    count,
    user,
    doubleCount,
    isAuthenticated,
    increment,
    login,
    logout
  }
})

在组件中使用 Store

<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

const counterStore = useCounterStore()

// 使用 storeToRefs 保持响应式并解构
const { count, doubleCount, isAuthenticated } = storeToRefs(counterStore)
const { increment, login } = counterStore

// 直接修改状态(不推荐)
const directIncrement = () => {
  counterStore.count++
}

// 使用 action(推荐)
const actionIncrement = () => {
  counterStore.increment()
}

// 批量修改
const patchUpdate = () => {
  counterStore.$patch({
    count: counterStore.count + 1,
    user: { name: 'Updated User' }
  })
}

// 重置状态
const resetStore = () => {
  counterStore.$reset()
}

// 订阅状态变化
counterStore.$subscribe((mutation, state) => {
  console.log('状态变化:', mutation)
  console.log('新状态:', state)
})
</script>

<template>
  <div>
    <p>计数: {{ count }}</p>
    <p>双倍计数: {{ doubleCount }}</p>
    <p>认证状态: {{ isAuthenticated ? '已登录' : '未登录' }}</p>
    
    <button @click="increment">增加</button>
    <button @click="directIncrement">直接增加</button>
    <button @click="patchUpdate">批量更新</button>
    <button @click="resetStore">重置</button>
  </div>
</template>

在 Store 之间使用其他 Store

// stores/auth.js
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', () => {
  const user = ref(null)
  const token = ref('')
  
  function setAuth(userData, authToken) {
    user.value = userData
    token.value = authToken
  }
  
  return { user, token, setAuth }
})

// stores/todos.js  
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'

export const useTodosStore = defineStore('todos', () => {
  const authStore = useAuthStore()
  const todos = ref([])
  
  async function fetchTodos() {
    // 使用其他 store 的状态
    if (!authStore.token) {
      throw new Error('未认证')
    }
    
    const response = await fetch('/api/todos', {
      headers: {
        Authorization: `Bearer ${authStore.token}`
      }
    })
    todos.value = await response.json()
  }
  
  return { todos, fetchTodos }
})

高级模式和最佳实践

1. 数据持久化

// plugins/persistence.js
import { watch } from 'vue'

export function persistStore(store, key = store.$id) {
  // 从 localStorage 恢复状态
  const persisted = localStorage.getItem(key)
  if (persisted) {
    store.$patch(JSON.parse(persisted))
  }
  
  // 监听状态变化并保存
  watch(
    () => store.$state,
    (state) => {
      localStorage.setItem(key, JSON.stringify(state))
    },
    { deep: true }
  )
}

// 在 store 中使用
export const usePersistedStore = defineStore('persisted', () => {
  const state = ref({})
  
  // 在 store 创建后调用
  onMounted(() => {
    persistStore(usePersistedStore())
  })
  
  return { state }
})

2. API 集成模式

// stores/posts.js
import { defineStore } from 'pinia'

export const usePostsStore = defineStore('posts', () => {
  const posts = ref([])
  const loading = ref(false)
  const error = ref(null)
  
  async function fetchPosts() {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch('/api/posts')
      if (!response.ok) throw new Error('获取失败')
      posts.value = await response.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }
  
  async function createPost(postData) {
    const response = await fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(postData)
    })
    const newPost = await response.json()
    posts.value.push(newPost)
    return newPost
  }
  
  return {
    posts,
    loading,
    error,
    fetchPosts,
    createPost
  }
})

3. 类型安全的 Store(TypeScript)

// stores/types.ts
export interface User {
  id: number
  name: string
  email: string
}

export interface AuthState {
  user: User | null
  token: string
}

// stores/auth.ts
import { defineStore } from 'pinia'
import type { User, AuthState } from './types'

export const useAuthStore = defineStore('auth', {
  state: (): AuthState => ({
    user: null,
    token: ''
  }),
  
  getters: {
    isAuthenticated: (state): boolean => state.user !== null,
    userName: (state): string => state.user?.name || ''
  },
  
  actions: {
    setAuth(user: User, token: string): void {
      this.user = user
      this.token = token
    },
    
    clearAuth(): void {
      this.user = null
      this.token = ''
    }
  }
})

4. 测试 Store

// stores/__tests__/counter.spec.js
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '../counter'

describe('Counter Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  test('increment', () => {
    const store = useCounterStore()
    expect(store.count).toBe(0)
    
    store.increment()
    expect(store.count).toBe(1)
  })
  
  test('doubleCount getter', () => {
    const store = useCounterStore()
    store.count = 4
    expect(store.doubleCount).toBe(8)
  })
})

总结

什么时候使用哪种状态管理?

场景 推荐方案 理由
简单组件状态 组件内 ref/reactive 简单直接
少量组件共享 响应式全局对象 快速实现
中型应用 Pinia (组合式) 类型安全,易于测试
大型企业应用 Pinia + 严格模式 可维护性,团队协作

核心原则

  1. 单一数据源:全局状态集中管理
  2. 状态只读:通过 actions 修改状态
  3. 纯函数修改:相同的输入总是得到相同的输出
  4. 不可变更新:不直接修改原状态,而是创建新状态

Vue 内置组件全解析:提升开发效率的五大神器

作者 90后晨仔
2025年10月18日 22:21

在 Vue 开发中,除了我们日常编写的业务组件外,框架还提供了一系列内置组件,它们为我们处理常见的开发场景提供了优雅的解决方案。今天,我们就来深入探讨 Vue 的五大内置组件:TransitionTransitionGroupKeepAliveTeleportSuspense

1. Transition - 丝滑的过渡动画

什么是 Transition?

Transition 组件用于在元素或组件的插入、更新和移除时添加动画效果,让用户体验更加流畅。

基本使用

<template>
  <button @click="show = !show">切换</button>
  
  <Transition name="fade">
    <p v-if="show">Hello, Vue!</p>
  </Transition>
</template>

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

const show = ref(true)
</script>

<style scoped>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

过渡类名详解

Transition 组件会自动应用以下 6 个 CSS 类名:

  • v-enter-from:进入动画的起始状态
  • v-enter-active:进入动画的激活状态
  • v-enter-to:进入动画的结束状态
  • v-leave-from:离开动画的起始状态
  • v-leave-active:离开动画的激活状态
  • v-leave-to:离开动画的结束状态

注意:其中的 v 是默认前缀,可以通过 name 属性自定义。

JavaScript 钩子

除了 CSS 过渡,还可以使用 JavaScript 钩子:

<template>
  <Transition
    @before-enter="onBeforeEnter"
    @enter="onEnter"
    @after-enter="onAfterEnter"
    @enter-cancelled="onEnterCancelled"
    @before-leave="onBeforeLeave"
    @leave="onLeave"
    @after-leave="onAfterLeave"
    @leave-cancelled="onLeaveCancelled"
  >
    <div v-if="show">内容</div>
  </Transition>
</template>

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

const show = ref(true)

const onBeforeEnter = (el) => {
  // 元素插入 DOM 前的回调
}

const onEnter = (el, done) => {
  // 元素插入 DOM 后的回调
  // 需要手动调用 done() 来结束过渡
  done()
}
</script>

模式控制

<Transition mode="out-in">
  <button :key="isEditing" @click="isEditing = !isEditing">
    {{ isEditing ? '保存' : '编辑' }}
  </button>
</Transition>

支持的模式:

  • in-out:新元素先进入,当前元素后离开
  • out-in:当前元素先离开,新元素后进入

2. TransitionGroup - 列表过渡专家

什么是 TransitionGroup?

TransitionGroup 组件专门用于处理动态列表中元素的进入、离开和移动的动画效果。

基本使用

<template>
  <button @click="addItem">添加</button>
  <button @click="removeItem">移除</button>
  
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id">
      {{ item.text }}
    </li>
  </TransitionGroup>
</template>

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

const items = ref([
  { id: 1, text: '项目 1' },
  { id: 2, text: '项目 2' },
  { id: 3, text: '项目 3' }
])

let nextId = 4

const addItem = () => {
  items.value.push({ id: nextId++, text: `项目 ${nextId}` })
}

const removeItem = () => {
  items.value.pop()
}
</script>

<style scoped>
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}
.list-enter-from {
  opacity: 0;
  transform: translateX(30px);
}
.list-leave-to {
  opacity: 0;
  transform: translateX(-30px);
}
/* 确保离开的元素脱离文档流 */
.list-leave-active {
  position: absolute;
}
</style>

关键特性

  1. 必须设置 key:每个元素都需要唯一的 key
  2. 支持 CSS 变换:自动检测元素位置变化应用移动动画
  3. tag 属性:指定包裹容器的标签,默认为不渲染包裹元素

3. KeepAlive - 组件缓存大师

什么是 KeepAlive?

KeepAlive 组件用于缓存不活动的组件实例,避免重复渲染,保持组件状态。

基本使用

<template>
  <div>
    <button @click="currentTab = 'Home'">首页</button>
    <button @click="currentTab = 'About'">关于</button>
    
    <KeepAlive>
      <component :is="currentTab" />
    </KeepAlive>
  </div>
</template>

<script setup>
import { ref, shallowRef } from 'vue'
import Home from './Home.vue'
import About from './About.vue'

const currentTab = ref('Home')
const tabs = {
  Home,
  About
}
</script>

高级配置

<KeepAlive 
  :include="/Home|About/" 
  :exclude="['Settings']" 
  :max="10"
>
  <component :is="currentComponent" />
</KeepAlive>
  • include:只有名称匹配的组件会被缓存(字符串、正则或数组)
  • exclude:任何名称匹配的组件都不会被缓存
  • max:最多可缓存的组件实例数量

生命周期钩子

被缓存的组件会获得两个新的生命周期钩子:

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

onActivated(() => {
  // 组件被激活时调用
  console.log('组件激活')
})

onDeactivated(() => {
  // 组件被停用时调用
  console.log('组件停用')
})
</script>

4. Teleport - 任意门组件

什么是 Teleport?

Teleport 组件允许我们将组件模板的一部分"传送"到 DOM 中的其他位置,而不影响组件的逻辑关系。

基本使用

<template>
  <div class="app">
    <button @click="showModal = true">打开模态框</button>
    
    <Teleport to="body">
      <div v-if="showModal" class="modal">
        <div class="modal-content">
          <h2>模态框标题</h2>
          <p>这是模态框内容</p>
          <button @click="showModal = false">关闭</button>
        </div>
      </div>
    </Teleport>
  </div>
</template>

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

const showModal = ref(false)
</script>

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

.modal-content {
  background: white;
  padding: 20px;
  border-radius: 8px;
}
</style>

多个 Teleport 到同一目标

<template>
  <Teleport to="#modals">
    <div>第一个模态框</div>
  </Teleport>
  
  <Teleport to="#modals">
    <div>第二个模态框</div>
  </Teleport>
</template>

渲染结果:

<div id="modals">
  <div>第一个模态框</div>
  <div>第二个模态框</div>
</div>

禁用 Teleport

<Teleport to="body" :disabled="isMobile">
  <div>内容</div>
</Teleport>

5. Suspense - 异步组件管家

什么是 Suspense?

Suspense 组件用于协调组件树中的异步依赖,在等待异步组件时显示加载状态。

基本使用

<template>
  <Suspense>
    <template #default>
      <AsyncComponent />
    </template>
    
    <template #fallback>
      <div class="loading">加载中...</div>
    </template>
  </Suspense>
</template>

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

const AsyncComponent = defineAsyncComponent(() => 
  import('./AsyncComponent.vue')
)
</script>

<style scoped>
.loading {
  text-align: center;
  padding: 20px;
  color: #666;
}
</style>

异步 setup 组件

<template>
  <Suspense>
    <template #default>
      <ComponentWithAsyncSetup />
    </template>
    
    <template #fallback>
      <div>加载用户数据...</div>
    </template>
  </Suspense>
</template>

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

const ComponentWithAsyncSetup = {
  async setup() {
    const userData = await fetchUserData()
    return { userData }
  },
  template: `<div>用户: {{ userData.name }}</div>`
}

async function fetchUserData() {
  // 模拟 API 调用
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({ name: 'Vue 开发者' })
    }, 2000)
  })
}
</script>

事件处理

<template>
  <Suspense @pending="onPending" @resolve="onResolve" @fallback="onFallback">
    <AsyncComponent />
  </Suspense>
</template>

<script setup>
const onPending = () => {
  console.log('开始加载异步组件')
}

const onResolve = () => {
  console.log('异步组件加载完成')
}

const onFallback = () => {
  console.log('显示加载状态')
}
</script>

实战案例:组合使用内置组件

让我们看一个综合使用多个内置组件的例子:

<template>
  <div class="app">
    <!-- 标签页切换 -->
    <nav>
      <button 
        v-for="tab in tabs" 
        :key="tab.id"
        @click="currentTab = tab.id"
      >
        {{ tab.name }}
      </button>
    </nav>
    
    <!-- 主要内容区域 -->
    <main>
      <KeepAlive>
        <Transition name="slide" mode="out-in">
          <Suspense>
            <template #default>
              <component :is="currentTabComponent" />
            </template>
            <template #fallback>
              <div class="loading">加载中...</div>
            </template>
          </Suspense>
        </Transition>
      </KeepAlive>
    </main>
    
    <!-- 全局通知 -->
    <Teleport to="#notifications">
      <TransitionGroup name="notification">
        <div 
          v-for="notification in notifications" 
          :key="notification.id"
          class="notification"
        >
          {{ notification.message }}
        </div>
      </TransitionGroup>
    </Teleport>
  </div>
</template>

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

// 标签页状态
const currentTab = ref('home')
const tabs = [
  { id: 'home', name: '首页' },
  { id: 'profile', name: '个人资料' },
  { id: 'settings', name: '设置' }
]

// 异步组件
const currentTabComponent = computed(() => 
  defineAsyncComponent(() => import(`./${currentTab.value}.vue`))
)

// 通知系统
const notifications = ref([])

// 添加通知
const addNotification = (message) => {
  const id = Date.now()
  notifications.value.push({ id, message })
  setTimeout(() => {
    notifications.value = notifications.value.filter(n => n.id !== id)
  }, 3000)
}
</script>

<style scoped>
.slide-enter-active,
.slide-leave-active {
  transition: all 0.3s ease;
}

.slide-enter-from {
  opacity: 0;
  transform: translateX(50px);
}

.slide-leave-to {
  opacity: 0;
  transform: translateX(-50px);
}

.notification-enter-active,
.notification-leave-active {
  transition: all 0.3s ease;
}

.notification-enter-from {
  opacity: 0;
  transform: translateY(-30px);
}

.notification-leave-to {
  opacity: 0;
  transform: translateX(100%);
}

.loading {
  text-align: center;
  padding: 50px;
  font-size: 18px;
}
</style>

总结

Vue 的内置组件为我们提供了强大的工具来处理常见的开发需求:

  • Transition:为单个元素/组件添加过渡动画
  • TransitionGroup:为列表项添加排序和动画效果
  • KeepAlive:缓存组件状态,提升性能
  • Teleport:将内容渲染到 DOM 任意位置
  • Suspense:优雅处理异步组件加载状态

这些内置组件不仅功能强大,而且可以灵活组合使用,帮助我们构建更加优雅、高效的 Vue 应用。掌握它们的使用,将让你的 Vue 开发技能更上一层楼!

希望这篇文章能帮助你全面理解 Vue 的内置组件,并在实际项目中灵活运用。Happy Coding! 🚀

昨天以前首页

Vue 3 组合式函数(Composables)全面解析:从原理到实战

作者 90后晨仔
2025年10月13日 22:11

一、前言

当 Vue 3 发布时,组合式 API(Composition API) 带来了一个革命性的变化:

我们不再需要依赖 data、methods、computed 这些分散的选项,而是能用函数的方式,灵活组织逻辑。

这套函数化逻辑复用方案,就叫做 组合式函数(Composables)

简单来说:

  • Options API 更像是“配置式”;

  • Composition API 则让我们“像写逻辑一样组织组件”。

组合式函数(Composables) ,就是在这个新体系下,用于封装和复用有状态逻辑的函数。


二、什么是组合式函数?

先来看一句官方定义:

“组合式函数是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。”

也就是说,它不仅可以处理计算逻辑、请求接口、事件监听,还能和组件生命周期绑定,并且是响应式的。

按照惯例,我们命名时一般以 use 开头

// useXxx 组合式函数命名惯例
export function useMouse() { ... }
export function useFetch() { ... }
export function useEventListener() { ... }

三、基础示例:从组件逻辑到组合式函数

假设我们要做一个“鼠标追踪器”,实时显示鼠标位置。

如果直接写在组件里,可能是这样 👇

<!-- MouseComponent.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

// 定义响应式状态
const x = ref(0)
const y = ref(0)

// 事件处理函数:更新坐标
function update(e) {
  x.value = e.pageX
  y.value = e.pageY
}

// 生命周期绑定
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<template>
  鼠标坐标:{{ x }}, {{ y }}
</template>

很好,但如果我们多个页面都要复用这个逻辑呢?

那就应该把它抽出来!


四、封装成组合式函数

// mouse.js
import { ref, onMounted, onUnmounted } from 'vue'

// 约定:组合式函数以 use 开头
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  // 内部逻辑:跟踪鼠标移动
  function update(e) {
    x.value = e.pageX
    y.value = e.pageY
  }

  // 生命周期钩子
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  // 返回需要暴露的状态
  return { x, y }
}

使用起来非常简单:

<!-- MouseComponent.vue -->
<script setup>
import { useMouse } from './mouse.js'
const { x, y } = useMouse()
</script>

<template>
  鼠标坐标:{{ x }}, {{ y }}
</template>

✅ 这样写的好处是:

  • 组件逻辑更清晰;
  • 多处可复用;
  • 生命周期自动关联;
  • 每个组件都拥有独立的状态(互不干扰)。

五、进阶封装:useEventListener

假如我们还想监听滚动、键盘等事件,可以进一步抽象出一个事件监听函数 👇

// event.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

接着 useMouse 就能进一步简化:

// mouse.js
import { ref } from 'vue'
import { useEventListener } from './event'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  useEventListener(window, 'mousemove', (e) => {
    x.value = e.pageX
    y.value = e.pageY
  })

  return { x, y }
}

💡 这样我们不仅复用了逻辑,还建立了逻辑的“组合关系” ——

组合式函数可以嵌套调用另一个组合式函数


六、异步场景:useFetch 示例

除了事件逻辑,我们常常需要封装“异步请求逻辑”,比如:

// fetch.js
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))

  return { data, error }
}

使用方式:

<script setup>
import { useFetch } from './fetch.js'

const { data, error } = useFetch('https://api.example.com/posts')
</script>

<template>
  <div v-if="error">❌ 出错:{{ error.message }}</div>
  <div v-else-if="data">✅ 数据:{{ data }}</div>
  <div v-else>⏳ 加载中...</div>
</template>

七、响应式参数:动态请求的 useFetch

上面 useFetch 只会执行一次,

但如果我们希望在 URL 改变时自动重新请求呢?

就可以用 watchEffect() + toValue():

// fetch.js
import { ref, watchEffect, toValue } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  const fetchData = () => {
    data.value = null
    error.value = null

    fetch(toValue(url)) // 兼容 ref / getter / 普通字符串
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  watchEffect(() => {
    fetchData() // url 改变时会自动重新执行
  })

  return { data, error }
}

使用示例:

<script setup>
import { ref } from 'vue'
import { useFetch } from './fetch.js'

const postId = ref(1)
const { data, error } = useFetch(() => `/api/posts/${postId.value}`)

// 模拟切换文章
function nextPost() {
  postId.value++
}
</script>

<template>
  <button @click="nextPost">下一篇</button>
  <div v-if="error">❌ 出错:{{ error.message }}</div>
  <div v-else-if="data">📰 文章:{{ data.title }}</div>
  <div v-else>⏳ 加载中...</div>
</template>

✅ 这就让你的 useFetch 成为了真正“响应式的请求函数”。


八、组合式函数的使用规范

项目 推荐做法 原因
🧩 命名 useXxx() 一目了然,符合惯例
📦 返回值 返回多个 ref,不要直接返回 reactive 对象 防止解构时丢失响应性
🔁 生命周期 必须在 Vue 需要绑定当前组件实例
⚙️ 参数 建议使用 toValue() 规范化输入 兼容 ref、getter、普通值
🧹 清理 要在 onUnmounted() 清理副作用 避免内存泄漏

九、与其他模式的比较

模式 优点 缺点
Mixins 逻辑复用简单 来源不清晰、命名冲突、隐式依赖
无渲染组件 (Renderless) 可复用逻辑 + UI 会额外创建组件实例,性能差
组合式函数 (Composables) 无实例开销、逻辑清晰、依赖显式 不直接提供模板复用

✅ 结论:

纯逻辑复用 → 用组合式函数

逻辑 + UI 复用 → 用无渲染组件


十、总结

概念 说明
组合式函数 利用 Vue 组合式 API 封装可复用逻辑的函数
核心特性 可使用 ref / reactive / 生命周期钩子 / watch
优势 灵活组合、逻辑清晰、性能优秀、类型友好
常见应用 请求封装、事件监听、滚动追踪、权限控制、表单管理等
开发建议 命名统一、输入规范化、注意生命周期上下文

✨ 最后

Composables 就像是 Vue 世界里的「逻辑积木」——

你可以自由拼接、拆解、组合它们,构建出任何复杂的交互逻辑。

如果你曾觉得逻辑在组件里越堆越乱,

那是时候开始用 组合式函数 让代码“呼吸”了。

Vue 3 中 Provide / Inject 在异步时不起作用原因分析(二)?

作者 90后晨仔
2025年10月13日 21:33

本文是继续上一篇文章《Vue 3 中 Provide / Inject 在异步时不起作用原因分析(一)》

在线查看示例(需要科学上网)

示例源码下载地址:分析demo

🧩 一、核心原理(简单讲人话)

在 Vue3 中:

  • provide 是父组件提供一个依赖值

  • inject 是子组件接收这个依赖值

  • 默认情况下,provide 提供的是一个「普通的引用值」,而不是响应式的。

👉 这意味着:

如果你在父组件中 later(异步)修改了 provide 的值,而这个值不是响应式对象,那么子组件不会自动更新。


🧠 二、最简单示例:静态 provide(不响应)

<!-- App.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <button @click="changeName">修改名字</button>
    <Child />
  </div>
</template>

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

let username = '小明'

// 向子组件提供 username
provide('username', username)

function changeName() {
  username = '小红'
  console.log('父组件修改了 username =', username)
}
</script>

<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
    <p>用户名:{{ username }}</p>
  </div>
</template>

<script setup>
import { inject } from 'vue'
const username = inject('username')
</script>

🧩 运行结果:

  • 初始显示:用户名:小明

  • 点击“修改名字”按钮后,子组件界面不会更新

📖 原因:

因为 provide('username', username) 提供的是普通字符串,不具备响应式特性。


✅ 三、扩展版:让 provide 变成响应式的(推荐写法)

要让子组件能「自动响应父组件异步变化」,只需要用 ref 或 reactive 包装即可。

<!-- App.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <button @click="changeName">异步修改名字(2秒后)</button>
    <Child />
  </div>
</template>

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

const username = ref('小明')

// ✅ 提供响应式的值
provide('username', username)

function changeName() {
  setTimeout(() => {
    username.value = '小红'
    console.log('父组件异步修改 username = 小红')
  }, 2000)
}
</script>

<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
    <p>用户名:{{ username }}</p>
  </div>
</template>

<script setup>
import { inject } from 'vue'
const username = inject('username') // 自动响应
</script>

🧩 运行结果:

  • 初始显示:用户名:小明

  • 点击按钮后 2 秒 → 自动更新为:用户名:小红

✅ 因为我们注入的是 ref,Vue3 会自动处理 .value 的响应式绑定。


❌ 四、错误示例:异步 provide 失效的情况(常见坑)

有时新手会这么写:

<!-- App.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <button @click="loadData">异步加载 provide 值</button>
    <Child />
  </div>
</template>

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

let user = null

function loadData() {
  setTimeout(() => {
    user = { name: '异步用户' }
    provide('user', user) // ❌ 错误!在 setup 外部、异步中调用 provide 无效
    console.log('异步 provide 完成')
  }, 2000)
}

provide('user', user)
</script>

<!-- Child.vue -->
<template>
  <div>
    <p>子组件:{{ user }}</p>
  </div>
</template>

<script setup>
import { inject } from 'vue'
const user = inject('user')
</script>

🧩 现象:

  • 初始显示:子组件:null

  • 点击“异步加载”后,依然不变!

📖 原因:

provide 只能在组件 setup() 执行时建立依赖关系,

异步调用 provide() 没有效果,Vue 根本不会重新建立依赖注入。


🔍 五、正确的异步写法总结

场景 错误示例 正确写法
父组件 setup 后再异步修改 普通变量 ✅ 使用 ref 或 reactive
异步中重新调用 provide() ❌ 无效 ✅ 一次 provide 响应式引用即可
想实时共享对象状态 ❌ 普通对象 ✅ 用 reactive() 或 Pinia

🧱 六、总结

类型 响应式 子组件会更新? 推荐
provide('a', 普通变量) ❌ 否 ❌ 否
provide('a', ref()) ✅ 是 ✅ 是
provide('a', reactive()) ✅ 是 ✅ 是
异步重新调用 provide() ❌ 无效 ❌ 否

Vue 3 中 Provide / Inject 在异步时不起作用原因分析(一)?

作者 90后晨仔
2025年10月13日 21:29

一、先搞清楚:Provide / Inject 是什么机制

provide 和 inject 是 Vue 组件之间 祖孙通信的一种机制

它允许上层组件提供数据,而下层组件直接获取,不需要层层 props 传递。

简单关系图:

App.vue (provide)
   └── ChildA.vue
         └── ChildB.vue (inject)

App 通过 provide 提供,ChildB 直接拿到。

在 Vue 3 中:

// 父组件
import { provide } from 'vue'

setup() {
  provide('theme', 'dark')
}
// 孙组件
import { inject } from 'vue'

setup() {
  const theme = inject('theme')
  console.log(theme) // 'dark'
}

这本质上是 Vue 在「组件初始化时」建立的一种依赖注入映射关系(依赖树)


二、误区:为什么“异步”时会失效?

很多人说“在异步组件里 inject 不到值”,其实问题出在「加载时机」上。

❌ 错误理解:

以为 inject 是“运行时全局取值”,随时都能拿到。

✅ 实际原理:

inject() 的查找是在 组件创建阶段(setup 执行时) 完成的。

也就是说:

只有当父组件已经被挂载并执行了 provide() 后,子组件在 setup 时才能拿到。

如果异步加载的子组件在 provide 之前被初始化,或者在懒加载时「上下文丢失」,那它当然拿不到值。


三、可复现测试案例(你可以直接复制运行)

我们写一个最常见的「异步子组件注入」示例。

你可以用 Vite 新建项目,然后建这三个文件:


🟢App.vue(父组件)

<template>
  <div>
    <h2>父组件</h2>
    <p>当前主题:{{ theme }}</p>
    <button @click="loadAsync">加载异步子组件</button>

    <!-- 当点击后才加载 -->
    <component :is="childComp" />
  </div>
</template>

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

// 1️⃣ 提供一个响应式值
const theme = ref('🌙 暗黑模式')
provide('theme', theme)

// 2️⃣ 模拟异步组件加载
const childComp = ref(null)
function loadAsync() {
  // 模拟异步加载组件(1 秒后返回)
  const AsyncChild = defineAsyncComponent(() =>
    new Promise(resolve => {
      setTimeout(() => resolve(import('./Child.vue')), 1000)
    })
  )
  childComp.value = AsyncChild
}
</script>

🟡Child.vue(中间组件)

<template>
  <div class="child">
    <h3>中间组件</h3>
    <GrandChild />
  </div>
</template>

<script setup>
import GrandChild from './GrandChild.vue'
</script>

<style scoped>
.child {
  border: 1px solid #aaa;
  margin: 8px;
  padding: 8px;
}
</style>

🔵GrandChild.vue(孙组件)

<template>
  <div class="grand">
    <h4>孙组件</h4>
    <p>从 provide 注入的主题:{{ theme }}</p>
  </div>
</template>

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

// 1️⃣ 注入父级 provide 的数据
const theme = inject('theme', '默认主题')

// 2️⃣ 打印验证
console.log('孙组件注入的 theme 值是:', theme)
</script>

<style scoped>
.grand {
  border: 1px dashed #666;
  margin-top: 8px;
  padding: 6px;
}
</style>

✅ 运行结果验证:

1️⃣ 页面初始只显示父组件。

2️⃣ 点击「加载异步子组件」。

3️⃣ 一秒后加载完成,控制台输出:

孙组件注入的 theme 值是:RefImpl {value: '🌙 暗黑模式'}

页面上显示:

从 provide 注入的主题:🌙 暗黑模式

👉 说明:即使是 异步组件,也能正确拿到 provide 的值。


四、那为什么有时真的“不起作用”?

有三种常见原因:

原因 说明 解决方案
1️⃣ 在 setup 外使用 inject() Vue 只能在组件初始化(setup 阶段)内建立依赖 一定要在 setup() 中调用
2️⃣ 异步组件创建时父组件上下文丢失 如果异步加载组件时没有挂在已有的上下文中(比如 createApp 动态 mount) 保证异步组件是作为「现有组件树」的子节点被渲染
3️⃣ SSR 场景中 hydration 时机问题 如果在服务器端渲染中,provide 未在客户端同步恢复 SSR 需保证 provide/inject 在同一上下文实例中执行

五、底层原理小科普(可选理解)

Vue 内部维护了一棵「依赖注入树」,

每个组件实例在初始化时会记录自己的 provides 对象:

instance.provides = Object.create(parent.provides)

所以当 inject('theme') 时,它会:

  1. 向上查找父组件的 provides;

  2. 找到对应 key;

  3. 返回对应的值(引用)。

这就是为什么:

  • 父子必须在「同一组件树上下文」中;
  • 异步不会破坏注入关系(除非脱离这棵树)。

✅ 总结重点

概念 说明
Provide / Inject 用于祖孙通信的依赖注入机制
异步组件能否注入? ✅ 能,只要仍在同一组件树中
什么时候会失效? 父未先 provide、或异步 mount 独立实例
验证方法 使用 defineAsyncComponent 懒加载组件
推荐做法 始终在 setup 内使用 provide/inject

Vue 异步组件(defineAsyncComponent)全指南:写给新手的小白实战笔记

作者 90后晨仔
2025年10月13日 21:18

在现代前端应用中,性能优化几乎是每个开发者都要面对的课题。

尤其是使用 Vue 构建大型单页应用(SPA)时,首屏加载慢、包体积大 成了常见的痛点。

这时,“异步组件”就登场了。

它能让你把页面拆成小块按需加载,只在用户真正需要时才下载对应的模块,显著减少首屏压力。

这篇文章是写给 刚入门 Vue 3 的开发者 的异步组件实战指南,

我会用简单的语言、可运行的代码和图景化的思维带你彻底搞懂——

defineAsyncComponent 到底做了什么、怎么用、有哪些坑。


一、为什么需要异步组件

🚀 核心动机:提升首屏速度,减少无用资源加载。

想象一个后台系统,首屏只展示“仪表盘”,但你的 bundle 里却打包了“用户管理”、“统计分析”、“设置中心”……

即使用户一天都没点进去,这些模块也会白白加载。

异步组件正是用来解决这种浪费的:

  • 不会被打进主包
  • 只有在组件首次渲染时,才会异步加载真实实现;
  • 这就是所谓的 按需加载 (lazy load)代码分割 (code-splitting)

二、最简单的异步加载:

defineAsyncComponent+import()

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() =>
  import('./components/MyComponent.vue')
)

使用方式完全与普通组件一致:

<template>
  <AsyncComp some-prop="Hello Vue!" />
</template>

解释一下背后的机制:

  • import() 会返回一个 Promise;

  • 打包工具(Vite / Webpack)会自动把它拆成独立的 chunk 文件

  • defineAsyncComponent() 会创建一个“外壳组件”,在内部完成加载逻辑;

  • 一旦加载完成,它会自动渲染内部真正的 MyComponent.vue;

  • 所有 props、插槽、事件 都会被自动透传。

简单来说,它是 Vue 帮你封装好的“懒加载包装器”。


三、加载中 & 加载失败状态:更友好的配置写法

网络总是有延迟或失败的时候,Vue 官方提供了更完善的配置:

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  loadingComponent: LoadingComponent, // 加载中占位
  delay: 200,                          // 多少 ms 后显示 loading
  errorComponent: ErrorComponent,      // 失败时的提示
  timeout: 3000                        // 超时视为失败
})

🧠 要点:

  • delay:默认 200ms,如果加载太快就不显示 loading,防止闪烁;
  • timeout:超过指定时间自动触发错误;
  • loadingComponent / errorComponent 都是普通组件,可以是骨架屏或重试按钮;
  • Vue 会自动处理 Promise 的状态变化。

四、SSR 场景下的新玩法:Hydration 策略(Vue 3.5+)

在服务器端渲染(SSR)场景下,HTML 首屏已经输出,但 JS 模块还没激活。

Vue 3.5 开始支持为异步组件设置「延迟激活策略」:

import { defineAsyncComponent, hydrateOnVisible } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnVisible({ rootMargin: '100px' })
})

这意味着:

  • 组件只在滚动到可视区时才激活;

  • SSR 首屏照常渲染,但 hydration(激活)被延后;

  • 从而减少初始脚本执行量,提高 TTI(可交互时间)。

其他常见策略:

策略函数 行为
hydrateOnIdle() 浏览器空闲时激活
hydrateOnVisible() 元素进入视口时激活
hydrateOnMediaQuery() 媒体查询匹配时激活
hydrateOnInteraction('click') 用户交互后激活

你甚至可以自定义策略,在合适时机调用 hydrate() 完成手动激活。


五、搭配

使用,构建优雅的异步界面

是 Vue 专门为异步组件设计的辅助标签,它可以集中控制加载状态与回退界面。

<Suspense>
  <template #default>
    <AsyncComp />
  </template>
  <template #fallback>
    <div>正在努力加载中...</div>
  </template>
</Suspense>

的工作原理:

  • 会等待内部所有异步依赖(包括 defineAsyncComponent)加载完成;
  • 如果有 delay 或网络延迟,会自动显示 fallback 内容;
  • 当所有异步都 resolve 后,才一次性切换到真实内容;
  • 适合并行加载多个异步子组件时使用。

六、实战建议与最佳实践

1. 优先按路由懒加载:

const routes = [
  { path: '/admin', component: () => import('./views/Admin.vue') }
]

这能最大化地减少首包体积。

2. 小组件不建议懒加载:

懒加载有 HTTP 开销,过度拆包反而拖慢渲染。

3. 善用 loadingComponent 做骨架屏:

用灰色框或占位元素代替 spinner,更自然。

4. 设置合理 delay / timeout:

避免闪烁,也要能及时处理网络异常。

5. 支持重试:

function retryImport(path, retries = 3, interval = 500) {
  return new Promise((resolve, reject) => {
    const attempt = () => {
      import(path).then(resolve).catch(err => {
        if (retries-- <= 0) reject(err)
        else setTimeout(attempt, interval)
      })
    }
    attempt()
  })
}

const AsyncComp = defineAsyncComponent(() => retryImport('./Foo.vue', 2))

6. SSR 优化:

配合 hydrateOnVisible / hydrateOnIdle 让页面更快可交互。


七、常见陷阱 Q&A

Q1:defineAsyncComponent 会影响 props 或 slot 吗?

👉 不会,Vue 内部会自动透传所有 props / slot。

Q2:可以全局注册异步组件吗?

👉 可以:

app.component('MyComp', defineAsyncComponent(() => import('./MyComp.vue')))

Q3:delay=0 会怎样?

👉 loading 组件会立刻显示,建议保留短延迟防闪烁。

Q4:如何在 errorComponent 里实现重试?

👉 通过 emit 通知父组件重新渲染异步组件实例即可。


八、完整实战示例

<script setup>
import { defineAsyncComponent } from 'vue'
import LoadingSkeleton from './LoadingSkeleton.vue'
import ErrorBox from './ErrorBox.vue'

const AsyncWidget = defineAsyncComponent({
  loader: () => import('./HeavyWidget.vue'),
  loadingComponent: LoadingSkeleton,
  errorComponent: ErrorBox,
  delay: 200,
  timeout: 5000
})
</script>

<template>
  <section class="dashboard">
    <h2>📊 仪表盘</h2>
    <AsyncWidget />
  </section>
</template>

📌 ErrorBox 可加上「重试」按钮,点击后 emit 事件让父组件重新创建 AsyncWidget 实例即可。


九、总结回顾

要点 说明
defineAsyncComponent() 创建懒加载包装组件
import() 触发动态分包
loadingComponent / errorComponent 优化加载与失败体验
SSR Hydration 策略 控制何时激活异步组件
统一处理异步加载状态
实战建议 只懒加载页面级或大型组件,合理延迟与重试

掌握Vue的Provide/Inject:解锁跨层级组件通信的新姿势 🔥

作者 90后晨仔
2025年10月10日 21:52

在Vue应用开发中,组件化是我们的核心思想。但当组件层级越来越深,父子组件间的数据传递就会变得异常繁琐:你可能需要将数据从父组件传递到子组件,再传递给孙组件...如此反复,这就是所谓的"prop逐层传递"(Prop Drilling)问题。幸运的是,Vue提供了provide和inject这两个API,可以让我们优雅地实现跨层级组件通信

一、Provide/Inject是什么?🤔

Provide/Inject是Vue提供的一种依赖注入机制,它允许祖先组件作为依赖提供者,向任意深度的子孙组件注入依赖,而无需经过中间组件。

  • provide:在祖先组件中定义,提供数据或方法
  • inject:在子孙组件中使用,注入祖先提供的数据或方法

类比理解:如果把prop传递比作快递中转(每个中转站都要处理),那provide/inject就像直达空投 - 发货点直接空投到收货点,无视中间所有环节!

二、基本使用方式 🚀

1. 组合式API(Vue 3推荐)

<!-- 祖先组件:提供数据 -->
<script setup>
import { ref, provide } from 'vue'

// 提供静态数据
provide('appName', '我的Vue应用')

// 提供响应式数据
const userInfo = ref({
  name: '张三',
  age: 25
})
provide('userInfo', userInfo)

// 提供方法
const updateUser = (newInfo) => {
  userInfo.value = { ...userInfo.value, ...newInfo }
}
provide('updateUser', updateUser)
</script>
<!-- 子孙组件:注入数据 -->
<script setup>
import { inject } from 'vue'

// 注入数据(基础用法)
const appName = inject('appName')

// 注入数据(带默认值)
const userInfo = inject('userInfo', {})

// 注入方法
const updateUser = inject('updateUser', () => {})

// 使用注入的数据和方法
const handleUpdate = () => {
  updateUser({ age: 26 })
}
</script>

<template>
  <div>
    <h1>{{ appName }}</h1>
    <p>用户名:{{ userInfo.name }}</p>
    <button @click="handleUpdate">更新年龄</button>
  </div>
</template>

2. 选项式API(Vue 2/Vue 3兼容)

// 祖先组件
export default {
  data() {
    return {
      appName: '我的Vue应用',
      userInfo: {
        name: '张三',
        age: 25
      }
    }
  },
  provide() {
    return {
      appName: this.appName,
      userInfo: this.userInfo,
      updateUser: this.updateUser
    }
  },
  methods: {
    updateUser(newInfo) {
      this.userInfo = { ...this.userInfo, ...newInfo }
    }
  }
}
// 子孙组件
export default {
  inject: ['appName', 'userInfo', 'updateUser'],
  
  // 或者使用对象形式指定默认值
  inject: {
    appName: { default: '默认应用名' },
    userInfo: { default: () => ({}) },
    updateUser: { default: () => {} }
  },
  
  methods: {
    handleUpdate() {
      this.updateUser({ age: 26 })
    }
  }
}

三、解决响应式数据问题 💫

重要提醒:默认情况下,provide/inject不是响应式的。如果需要响应性,必须使用ref或reactive:

<script setup>
import { ref, reactive, provide, readonly } from 'vue'

// 响应式对象
const globalState = reactive({
  theme: 'light',
  language: 'zh-CN'
})

// 响应式基本值
const userCount = ref(0)

// 如果需要保护数据不被随意修改,可以使用readonly
provide('globalState', readonly(globalState))
provide('userCount', userCount)

// 提供修改方法,集中管理状态变更
const setTheme = (theme) => {
  globalState.theme = theme
}
provide('setTheme', setTheme)
</script>

四、实战应用:主题切换功能 🎨

让我们通过一个完整的主题切换案例,看看provide/inject的实际价值:

<!-- ThemeProvider.vue:主题提供者 -->
<template>
  <div :class="`theme-${currentTheme}`">
    <slot />
    <button @click="toggleTheme" class="theme-toggle">
      切换主题:{{ currentTheme === 'light' ? '暗黑' : '明亮' }}
    </button>
  </div>
</template>

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

const currentTheme = ref('light')

const themeConfig = computed(() => ({
  isLight: currentTheme.value === 'light',
  colors: currentTheme.value === 'light' 
    ? { primary: '#007bff', background: '#ffffff', text: '#333333' }
    : { primary: '#4dabf7', background: '#1a1a1a', text: '#ffffff' }
}))

const toggleTheme = () => {
  currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light'
}

// 提供主题相关数据和方法
provide('theme', currentTheme)
provide('themeConfig', themeConfig)
provide('toggleTheme', toggleTheme)
</script>

<style>
.theme-light { background: #f5f5f5; color: #333; }
.theme-dark { background: #333; color: #fff; }
.theme-toggle {
  padding: 10px 20px;
  margin: 20px;
  border: none;
  border-radius: 5px;
  cursor: pointer;
}
</style>
<!-- DeeplyNestedComponent.vue:深层嵌套的子孙组件 -->
<template>
  <div class="component" :style="{
    backgroundColor: themeConfig.colors.background,
    color: themeConfig.colors.text
  }">
    <h3>深层嵌套组件</h3>
    <p>当前主题:{{ theme }}</p>
    <button 
      @click="toggleTheme"
      :style="{ backgroundColor: themeConfig.colors.primary }"
    >
      从这里也能切换主题!
    </button>
  </div>
</template>

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

// 直接注入主题相关数据,无需中间组件传递
const theme = inject('theme')
const themeConfig = inject('themeConfig')
const toggleTheme = inject('toggleTheme')
</script>

这个案例的亮点:无论组件嵌套多深,都可以直接访问和修改主题,完全跳过了中间组件

五、最佳实践与注意事项 📝

1. 合理使用场景

场景 推荐程度 说明
全局配置(主题、语言) ✅ 强烈推荐 避免层层传递
用户登录信息 ✅ 推荐 多处需要用户数据
表单上下文 ✅ 推荐 复杂表单字段管理
简单父子通信 ❌ 不推荐 使用props更直观
全局状态管理 ⚠️ 谨慎使用 复杂场景用Pinia/Vuex

2. 类型安全(TypeScript)

// keys.ts - 定义注入键名
import type { InjectionKey } from 'vue'

export interface UserInfo {
  name: string
  age: number
}

export const userInfoKey = Symbol() as InjectionKey<UserInfo>
export const themeKey = Symbol() as InjectionKey<string>

// 提供者组件
provide(userInfoKey, { name: '张三', age: 25 })

// 注入者组件
const userInfo = inject(userInfoKey)

3. 避免的陷阱

  • 不要滥用:只在真正需要跨层级通信时使用
  • 保持响应性:记得使用ref/reactive包装数据
  • 明确数据流:在大型项目中,过度使用会使数据流难以追踪
  • 提供修改方法:避免直接在注入组件修改数据,通过提供的方法修改

六、与Vuex/Pinia的对比 🤼

特性 Provide/Inject Vuex/Pinia
学习成本
类型安全 需要额外配置 优秀
调试工具 有限 强大
适用规模 中小型应用/组件库 中大型应用
测试难度 简单 中等

选择建议:组件库开发和中型应用用provide/inject,大型复杂应用用Pinia/Vuex。

七、总结 💎

Provide/Inject是Vue中一个强大的特性,它让我们能够:

  • ✈️ 实现跨层级组件通信,跳过中间环节
  • 🎯 减少props传递,简化组件接口
  • 🔧 提高组件复用性,降低耦合度
  • 💪 灵活处理全局数据,无需引入状态管理

记住:就像任何强大的工具一样,provide/inject需要谨慎使用。在正确的场景下使用它,能让你的Vue应用更加优雅和可维护!

希望这篇指南能帮助你掌握provide/inject,如果有任何问题,欢迎在评论区讨论!🚀

Vue中为什么要有 Provide / Inject?

作者 90后晨仔
2025年10月10日 21:49

provide 和 inject 是 Vue 提供的一对“依赖注入(Dependency Injection) ”机制。

它的作用是:

👉 在跨层级组件之间传递数据

无需通过 props 一层层传递。

简单来说:

  • provide:由上层组件(祖先组件)提供数据。

  • inject:由下层组件(后代组件)接收数据。

适合场景:

当一个数据需要被很多深层子组件使用时(比如主题色、语言、配置对象等),使用 provide/inject 可以避免“多层 props 传递的麻烦”。


🧩 二、基本用法

(1)在祖先组件中提供数据

// App.vue
import { provide } from 'vue'

export default {
  setup() {
    provide('theme', 'dark')
  }
}

(2)在任意后代组件中注入数据

// Child.vue
import { inject } from 'vue'

export default {
  setup() {
    const theme = inject('theme')
    console.log(theme) // 输出: 'dark'
  }
}

🔁 三、响应式数据的注入

provide 默认不会保持响应式

如果你希望数据变化能被子组件自动更新,需要使用 ref 或 reactive:

// 父组件
import { ref, provide } from 'vue'

export default {
  setup() {
    const theme = ref('light')
    provide('theme', theme)
    return { theme }
  }
}
// 子组件
import { inject } from 'vue'

export default {
  setup() {
    const theme = inject('theme')
    return { theme }
  }
}

✅ 这样当父组件修改 theme.value 时,子组件会自动响应更新。


🧠 四、默认值的使用

如果注入的 key 在上层没有提供,inject 会返回 undefined。

为了安全,可以给它设置一个默认值:

const theme = inject('theme', 'light')

或者传入一个函数返回默认值:

const theme = inject('theme', () => 'light')

🧮 五、在选项式 API 中使用

对于使用 data、methods 的老写法,也能用:

export default {
  provide() {
    return {
      theme: 'dark'
    }
  },
  inject: ['theme']
}

⚙️ 六、进阶:修改注入的数据

子组件如果想修改祖先组件提供的数据,要注意:

  • 如果父组件提供的是普通值(非响应式),子组件改不了。

  • 如果父组件提供的是 ref 或 reactive 对象,子组件可以修改。

例如:

// 父组件
const user = reactive({ name: 'Tom' })
provide('user', user)

// 子组件
const user = inject('user')
user.name = 'Jerry' // ✅ 可以修改

🧩 七、和 Props 的区别

对比项 Props Provide / Inject
用途 父 → 子通信 祖先 → 任意后代
层级 只能相邻组件 可跨多层级
响应式 默认响应式 需手动包裹 ref / reactive
使用场景 一般父子通信 全局配置、依赖共享(如主题、国际化)

🌈 八、实际应用场景举例

  1. 主题系统(Theme)

    顶层组件提供主题信息,子组件统一读取。

  2. 表单组件库

    Form 组件通过 provide 向所有子 Input 组件共享表单上下文。

  3. 国际化(i18n)

    顶层提供语言配置,任意组件可注入读取当前语言。


🧾 九、总结一句话

provide / inject 是 Vue 组件间的“依赖注入系统”,用于跨层级共享数据。 它的核心思想是“祖先提供,后代注入”,适合场景是避免层层传 props,提高组件复用性。

xcode 16 删除 Provisioning Profiles 文件的有效路径

作者 90后晨仔
2025年9月27日 14:14

最近遇到一个问题需要删除指定的Provisioning Profiles文件的时候发现网上搜索的结果给的一些在终端删除的路径不对。原来查看了xcode目录才发现这个目录已经变的和之前不一样了。所以记录一下吧!

Snip20250927_1.png

xcode 16及以上的Provisioning Profiles路径已经变成如下地址了: ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles

如果需要在终端快速找到Provisioning Profiles文件的位置就用上边的吧!

❌
❌