目录
Pinia 状态管理
Q1: Pinia 相比 Vuex 有什么优势?如何使用 Pinia?
详细解答:
Pinia vs Vuex 对比
| 特性 | Pinia | Vuex |
|---|---|---|
| API 风格 | Composition API 风格 | Options API 风格 |
| TypeScript 支持 | 完美支持,自动类型推导 | 需要额外配置 |
| Mutations | 不需要 mutations | 需要 mutations |
| 模块化 | 自动模块化 | 需要手动配置 modules |
| DevTools 支持 | 完整支持 | 完整支持 |
| 代码分割 | 自动 | 需要手动配置 |
| 体积 | 更小(约 1kb) | 相对较大 |
| SSR 支持 | 内置支持 | 需要额外配置 |
Pinia 基础用法
1. 安装和配置
bash
npm install pinia
javascript
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
2. 定义 Store
javascript
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// Option Store 写法
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter'
}),
getters: {
doubleCount: (state) => state.count * 2,
// 访问其他 getter
doubleCountPlusOne() {
return this.doubleCount + 1
},
// 接受参数的 getter
getUserById: (state) => {
return (userId) => state.users.find(user => user.id === userId)
}
},
actions: {
increment() {
this.count++
},
async fetchData() {
const response = await fetch('/api/data')
const data = await response.json()
this.count = data.count
},
// 访问其他 store
async crossStoreAction() {
const userStore = useUserStore()
await userStore.fetchUser()
}
}
})
// Setup Store 写法(推荐)
export const useCounterStore = defineStore('counter', () => {
// state
const count = ref(0)
const name = ref('Counter')
// getters
const doubleCount = computed(() => count.value * 2)
const doubleCountPlusOne = computed(() => doubleCount.value + 1)
// actions
function increment() {
count.value++
}
async function fetchData() {
const response = await fetch('/api/data')
const data = await response.json()
count.value = data.count
}
return {
count,
name,
doubleCount,
doubleCountPlusOne,
increment,
fetchData
}
})
3. 在组件中使用
vue
<template>
<div>
<p>Count: {{ counter.count }}</p>
<p>Double: {{ counter.doubleCount }}</p>
<button @click="counter.increment">增加</button>
<button @click="handleIncrement">增加(解构)</button>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
// ❌ 错误:直接解构会失去响应式
// const { count, doubleCount } = counter
// ✅ 正确:使用 storeToRefs 保持响应式
const { count, doubleCount } = storeToRefs(counter)
// Actions 可以直接解构
const { increment } = counter
function handleIncrement() {
increment()
}
</script>
4. 修改 State
javascript
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
// 方法 1: 直接修改
counter.count++
// 方法 2: 使用 $patch (对象)
counter.$patch({
count: counter.count + 1,
name: 'New Counter'
})
// 方法 3: 使用 $patch (函数)
counter.$patch((state) => {
state.count++
state.name = 'New Counter'
})
// 方法 4: 替换整个 state
counter.$state = {
count: 10,
name: 'Reset Counter'
}
// 方法 5: 调用 action
counter.increment()
5. 订阅 State 变化
javascript
import { watch } from 'vue'
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
// 方法 1: 使用 $subscribe
counter.$subscribe((mutation, state) => {
console.log('Store changed:', mutation.type)
console.log('New state:', state)
// 持久化到 localStorage
localStorage.setItem('counter', JSON.stringify(state))
})
// 方法 2: 使用 watch
watch(
() => counter.count,
(newCount) => {
console.log('Count changed:', newCount)
}
)
// 方法 3: 订阅 actions
counter.$onAction(({
name, // action 名称
store, // store 实例
args, // 传递给 action 的参数
after, // action 执行后的钩子
onError // action 错误时的钩子
}) => {
console.log(`Action ${name} called with args:`, args)
after((result) => {
console.log('Action completed, result:', result)
})
onError((error) => {
console.error('Action error:', error)
})
})
6. 插件系统
javascript
// plugins/persistedState.js
export function persistedStatePlugin({ store }) {
// 从 localStorage 恢复状态
const savedState = localStorage.getItem(store.$id)
if (savedState) {
store.$patch(JSON.parse(savedState))
}
// 订阅状态变化并保存
store.$subscribe((mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state))
})
}
// main.js
import { createPinia } from 'pinia'
import { persistedStatePlugin } from './plugins/persistedState'
const pinia = createPinia()
pinia.use(persistedStatePlugin)
7. 复杂示例 - 用户管理 Store
javascript
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// State
const user = ref(null)
const token = ref(localStorage.getItem('token') || '')
const permissions = ref([])
const loading = ref(false)
const error = ref(null)
// Getters
const isLoggedIn = computed(() => !!token.value)
const userName = computed(() => user.value?.name || 'Guest')
const hasPermission = computed(() => {
return (permission) => permissions.value.includes(permission)
})
// Actions
async function login(credentials) {
loading.value = true
error.value = null
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
})
if (!response.ok) {
throw new Error('登录失败')
}
const data = await response.json()
token.value = data.token
user.value = data.user
permissions.value = data.permissions
// 保存 token
localStorage.setItem('token', data.token)
return true
} catch (err) {
error.value = err.message
return false
} finally {
loading.value = false
}
}
async function logout() {
try {
await fetch('/api/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token.value}`
}
})
} finally {
// 清除状态
user.value = null
token.value = ''
permissions.value = []
// 清除本地存储
localStorage.removeItem('token')
}
}
async function fetchUserProfile() {
if (!token.value) return
loading.value = true
try {
const response = await fetch('/api/user/profile', {
headers: {
'Authorization': `Bearer ${token.value}`
}
})
if (!response.ok) {
throw new Error('获取用户信息失败')
}
const data = await response.json()
user.value = data
} catch (err) {
error.value = err.message
// Token 可能已失效
if (err.message.includes('401')) {
await logout()
}
} finally {
loading.value = false
}
}
function $reset() {
user.value = null
token.value = ''
permissions.value = []
loading.value = false
error.value = null
}
return {
// State
user,
token,
permissions,
loading,
error,
// Getters
isLoggedIn,
userName,
hasPermission,
// Actions
login,
logout,
fetchUserProfile,
$reset
}
})
组件通信
Q2: Vue 3 中有哪些组件通信方式?各自的使用场景是什么?
详细解答:
1. Props / Emits(父子组件)
基本用法:
vue
<!-- Parent.vue -->
<template>
<Child
:message="parentMessage"
:count="count"
@update="handleUpdate"
@custom-event="handleCustomEvent"
/>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const parentMessage = ref('Hello from parent')
const count = ref(0)
function handleUpdate(newValue) {
console.log('Received update:', newValue)
}
function handleCustomEvent(payload) {
console.log('Custom event:', payload)
}
</script>
<!-- Child.vue -->
<template>
<div>
<p>{{ message }}</p>
<p>Count: {{ count }}</p>
<button @click="sendUpdate">发送更新</button>
<button @click="sendCustom">自定义事件</button>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
// 定义 props
const props = defineProps({
message: {
type: String,
required: true
},
count: {
type: Number,
default: 0
}
})
// 定义 emits
const emit = defineEmits(['update', 'custom-event'])
function sendUpdate() {
emit('update', 'New value')
}
function sendCustom() {
emit('custom-event', { data: 'payload' })
}
</script>
TypeScript 支持:
vue
<script setup lang="ts">
interface Props {
message: string
count?: number
user?: {
id: number
name: string
}
}
const props = withDefaults(defineProps<Props>(), {
count: 0
})
interface Emits {
(e: 'update', value: string): void
(e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
</script>
2. v-model(双向绑定)
基本 v-model:
vue
<!-- Parent.vue -->
<template>
<CustomInput v-model="inputValue" />
<p>Value: {{ inputValue }}</p>
</template>
<script setup>
import { ref } from 'vue'
const inputValue = ref('')
</script>
<!-- CustomInput.vue -->
<template>
<input
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
多个 v-model:
vue
<!-- Parent.vue -->
<template>
<UserForm
v-model:first-name="firstName"
v-model:last-name="lastName"
v-model:email="email"
/>
</template>
<script setup>
import { ref } from 'vue'
const firstName = ref('')
const lastName = ref('')
const email = ref('')
</script>
<!-- UserForm.vue -->
<template>
<input
:value="firstName"
@input="emit('update:firstName', $event.target.value)"
placeholder="First Name"
/>
<input
:value="lastName"
@input="emit('update:lastName', $event.target.value)"
placeholder="Last Name"
/>
<input
:value="email"
@input="emit('update:email', $event.target.value)"
placeholder="Email"
/>
</template>
<script setup>
defineProps(['firstName', 'lastName', 'email'])
const emit = defineEmits(['update:firstName', 'update:lastName', 'update:email'])
</script>
v-model 修饰符:
vue
<!-- Parent.vue -->
<template>
<CustomInput v-model.capitalize="text" />
</template>
<!-- CustomInput.vue -->
<template>
<input
:value="modelValue"
@input="handleInput"
/>
</template>
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: {
default: () => ({})
}
})
const emit = defineEmits(['update:modelValue'])
function handleInput(event) {
let value = event.target.value
// 应用修饰符
if (props.modelModifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
emit('update:modelValue', value)
}
</script>
3. Refs(父组件调用子组件方法)
vue
<!-- Parent.vue -->
<template>
<Child ref="childRef" />
<button @click="callChildMethod">调用子组件方法</button>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const childRef = ref(null)
function callChildMethod() {
childRef.value.childMethod()
console.log(childRef.value.childData)
}
</script>
<!-- Child.vue -->
<template>
<div>Child Component</div>
</template>
<script setup>
import { ref, defineExpose } from 'vue'
const childData = ref('Child data')
function childMethod() {
console.log('Child method called')
}
// 暴露给父组件
defineExpose({
childData,
childMethod
})
</script>
4. Provide / Inject(跨层级通信)
基本用法:
vue
<!-- Grandparent.vue -->
<template>
<Parent />
</template>
<script setup>
import { provide, ref } from 'vue'
import Parent from './Parent.vue'
const theme = ref('dark')
const user = ref({ name: 'Alice' })
// 提供数据
provide('theme', theme)
provide('user', user)
// 提供方法
provide('updateTheme', (newTheme) => {
theme.value = newTheme
})
</script>
<!-- Grandchild.vue (任意深度的子组件) -->
<template>
<div :class="theme">
<p>User: {{ user.name }}</p>
<button @click="updateTheme('light')">切换主题</button>
</div>
</template>
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const user = inject('user')
const updateTheme = inject('updateTheme')
// 提供默认值
const optionalValue = inject('optional', 'default value')
// 使用工厂函数提供默认值
const computedDefault = inject('key', () => computeExpensiveDefault())
</script>
TypeScript 支持:
typescript
// types.ts
import type { InjectionKey, Ref } from 'vue'
export interface User {
id: number
name: string
}
export const themeKey: InjectionKey<Ref<string>> = Symbol('theme')
export const userKey: InjectionKey<Ref<User>> = Symbol('user')
// Provider.vue
import { provide, ref } from 'vue'
import { themeKey, userKey } from './types'
const theme = ref('dark')
const user = ref({ id: 1, name: 'Alice' })
provide(themeKey, theme)
provide(userKey, user)
// Consumer.vue
import { inject } from 'vue'
import { themeKey, userKey } from './types'
const theme = inject(themeKey) // 类型:Ref<string> | undefined
const user = inject(userKey) // 类型:Ref<User> | undefined
5. Event Bus(兄弟组件)
使用 mitt 库:
bash
npm install mitt
javascript
// eventBus.js
import mitt from 'mitt'
export const emitter = mitt()
// ComponentA.vue
<template>
<button @click="sendMessage">发送消息</button>
</template>
<script setup>
import { emitter } from './eventBus'
function sendMessage() {
emitter.emit('message', { text: 'Hello from A' })
}
</script>
// ComponentB.vue
<template>
<p>{{ receivedMessage }}</p>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { emitter } from './eventBus'
const receivedMessage = ref('')
function handleMessage(payload) {
receivedMessage.value = payload.text
}
onMounted(() => {
emitter.on('message', handleMessage)
})
onUnmounted(() => {
emitter.off('message', handleMessage)
})
</script>
依赖注入
Q3: Provide / Inject 的高级用法和最佳实践?
详细解答:
1. 响应式注入
vue
<!-- Provider.vue -->
<template>
<div>
<button @click="count++">增加</button>
<Child />
</div>
</template>
<script setup>
import { provide, ref, readonly } from 'vue'
const count = ref(0)
// ✅ 提供响应式数据
provide('count', count)
// ✅ 提供只读响应式数据(防止子组件修改)
provide('readonlyCount', readonly(count))
// ✅ 提供修改方法
provide('increment', () => count.value++)
</script>
<!-- Consumer.vue -->
<script setup>
import { inject } from 'vue'
const count = inject('count')
const readonlyCount = inject('readonlyCount')
const increment = inject('increment')
// ❌ 这样会报错(只读)
// readonlyCount.value++
// ✅ 使用提供的方法修改
increment()
</script>
2. 应用级 Provide
javascript
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 全局配置
app.provide('config', {
apiUrl: 'https://api.example.com',
timeout: 5000
})
// 全局服务
app.provide('apiService', {
async get(url) {
const response = await fetch(url)
return response.json()
}
})
app.mount('#app')
3. 组合式函数中使用
javascript
// composables/useTheme.js
import { inject, provide, ref, readonly } from 'vue'
const ThemeSymbol = Symbol('theme')
// 提供主题
export function provideTheme() {
const theme = ref('light')
const isDark = computed(() => theme.value === 'dark')
function toggleTheme() {
theme.value = isDark.value ? 'light' : 'dark'
}
provide(ThemeSymbol, {
theme: readonly(theme),
isDark,
toggleTheme
})
return {
theme,
isDark,
toggleTheme
}
}
// 使用主题
export function useTheme() {
const themeContext = inject(ThemeSymbol)
if (!themeContext) {
throw new Error('useTheme must be used within a theme provider')
}
return themeContext
}
// App.vue
<script setup>
import { provideTheme } from './composables/useTheme'
provideTheme()
</script>
// AnyComponent.vue
<script setup>
import { useTheme } from './composables/useTheme'
const { theme, isDark, toggleTheme } = useTheme()
</script>
Composables 复用
Q4: 如何编写高质量的 Composable 函数?
详细解答:
1. 基本结构
javascript
// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
2. 接受响应式参数
javascript
// composables/useFetch.js
import { ref, unref, watchEffect } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
async function doFetch() {
loading.value = true
error.value = null
try {
// unref() 可以接受 ref 或普通值
const urlValue = unref(url)
const response = await fetch(urlValue)
data.value = await response.json()
} catch (err) {
error.value = err
} finally {
loading.value = false
}
}
// 当 url 变化时重新请求
watchEffect(() => {
doFetch()
})
return { data, error, loading }
}
// 使用
const url = ref('/api/users')
const { data, error, loading } = useFetch(url)
// 也可以传入普通字符串
const { data } = useFetch('/api/posts')
3. 副作用清理
javascript
// composables/useEventListener.js
import { onMounted, onUnmounted, watch } from 'vue'
export function useEventListener(target, event, callback, options) {
onMounted(() => {
const element = unref(target)
element.addEventListener(event, callback, options)
})
onUnmounted(() => {
const element = unref(target)
element.removeEventListener(event, callback, options)
})
}
// 使用
import { ref } from 'vue'
import { useEventListener } from './composables/useEventListener'
const buttonRef = ref(null)
useEventListener(buttonRef, 'click', () => {
console.log('Button clicked')
})
// 也可以监听 window
useEventListener(window, 'resize', () => {
console.log('Window resized')
})
4. 返回值的灵活性
javascript
// composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
// 返回对象:命名导出
return {
count,
doubled,
increment,
decrement,
reset
}
}
// 使用时可以重命名
const { count: myCount, increment: myIncrement } = useCounter(10)
5. 复杂 Composable 示例
javascript
// composables/useAsync.js
import { ref, unref, watchEffect } from 'vue'
export function useAsync(asyncFn, options = {}) {
const {
immediate = true,
initialData = null,
onSuccess,
onError,
resetOnExecute = true
} = options
const data = ref(initialData)
const error = ref(null)
const loading = ref(false)
const isReady = ref(false)
async function execute(...args) {
if (resetOnExecute) {
data.value = initialData
}
error.value = null
loading.value = true
isReady.value = false
try {
const result = await asyncFn(...args)
data.value = result
isReady.value = true
if (onSuccess) {
onSuccess(result)
}
return result
} catch (err) {
error.value = err
if (onError) {
onError(err)
}
throw err
} finally {
loading.value = false
}
}
if (immediate) {
execute()
}
return {
data,
error,
loading,
isReady,
execute
}
}
// 使用
const { data: users, loading, error, execute: refetchUsers } = useAsync(
() => fetch('/api/users').then(r => r.json()),
{
immediate: true,
onSuccess: (users) => {
console.log('Users loaded:', users.length)
},
onError: (error) => {
console.error('Failed to load users:', error)
}
}
)
总结 - 组件通信方式选择:
| 场景 | 推荐方式 | 备选方案 |
|---|---|---|
| 父→子 | Props | Provide/Inject |
| 子→父 | Emits | Refs |
| 兄弟组件 | 状态管理(Pinia) | Event Bus |
| 跨层级 | Provide/Inject | 状态管理 |
| 全局状态 | Pinia | Composables |
| 双向绑定 | v-model | Props + Emits |