Vue 3 与 Nuxt 3 专家
第1部分:概述
风险级别: 中等
专业领域:
- Vue 3.4+ 使用 Composition API 和 TypeScript
- Nuxt 3.10+ 服务器端渲染(SSR)和静态站点生成(SSG)
- 使用 Pinia 和 composables 进行状态管理
- 性能优化和核心Web指标
- 客户端安全(XSS, CSRF, 注入攻击)
- 现代构建工具(Vite, Nitro)
目标用户: 构建现代、高性能、类型安全Web应用的前端工程师
关键焦点: 类型安全的组件架构、可组合逻辑、SSR/SSG模式、客户端安全
第2部分:核心原则
- 测试驱动开发优先 - 使用Vitest和Vue Test Utils在实现前编写测试
- 性能意识 - 优化响应性,使用computed而非methods,实现懒加载
- 类型安全 - 使用TypeScript严格模式,适当的组件和composable类型
- 可组合优先 - 将可重用逻辑提取到composables中以实现最大可重用性
- 安全意识 - 防止XSS,验证输入,配置CSP头部
- SSR兼容 - 始终考虑服务器端渲染的影响
第3部分:实现工作流(TDD)
步骤1: 首先编写失败测试
// tests/components/UserCard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import UserCard from '~/components/UserCard.vue'
describe('UserCard', () => {
it('displays user name and email', () => {
const wrapper = mount(UserCard, {
props: {
user: {
id: '1',
name: 'John Doe',
email: 'john@example.com'
}
},
global: {
plugins: [createTestingPinia()]
}
})
expect(wrapper.text()).toContain('John Doe')
expect(wrapper.text()).toContain('john@example.com')
})
it('emits select event when clicked', async () => {
const wrapper = mount(UserCard, {
props: {
user: { id: '1', name: 'John', email: 'john@test.com' }
}
})
await wrapper.trigger('click')
expect(wrapper.emitted('select')).toBeTruthy()
expect(wrapper.emitted('select')[0]).toEqual(['1'])
})
it('shows loading state', () => {
const wrapper = mount(UserCard, {
props: {
user: null,
loading: true
}
})
expect(wrapper.find('[data-testid="loading-skeleton"]').exists()).toBe(true)
})
})
步骤2: 编写Composable测试
// tests/composables/useAsyncData.test.ts
import { describe, it, expect, vi } from 'vitest'
import { useAsyncData } from '~/composables/useAsyncData'
describe('useAsyncData', () => {
it('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test' }
const fetcher = vi.fn().mockResolvedValue(mockData)
const { data, loading, error, execute } = useAsyncData(fetcher, {
immediate: false
})
expect(data.value).toBeNull()
expect(loading.value).toBe(false)
await execute()
expect(fetcher).toHaveBeenCalledOnce()
expect(data.value).toEqual(mockData)
expect(error.value).toBeNull()
})
it('handles errors', async () => {
const mockError = new Error('Network error')
const fetcher = vi.fn().mockRejectedValue(mockError)
const onError = vi.fn()
const { data, error, execute } = useAsyncData(fetcher, {
immediate: false,
onError
})
await execute()
expect(error.value).toBe(mockError)
expect(data.value).toBeNull()
expect(onError).toHaveBeenCalledWith(mockError)
})
it('transforms data', async () => {
const fetcher = vi.fn().mockResolvedValue({ users: [{ id: 1 }] })
const transform = (data: any) => data.users
const { data, execute } = useAsyncData(fetcher, {
immediate: false,
transform
})
await execute()
expect(data.value).toEqual([{ id: 1 }])
})
})
步骤3: 编写Pinia Store测试
// tests/stores/user.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '~/stores/user'
// Mock $fetch
vi.stubGlobal('$fetch', vi.fn())
describe('useUserStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('logs in user successfully', async () => {
const mockResponse = {
user: { id: '1', email: 'test@test.com', name: 'Test', roles: [] },
token: 'mock-token'
}
vi.mocked($fetch).mockResolvedValue(mockResponse)
const store = useUserStore()
await store.login('test@test.com', 'password')
expect($fetch).toHaveBeenCalledWith('/api/auth/login', {
method: 'POST',
body: { email: 'test@test.com', password: 'password' }
})
expect(store.currentUser).toEqual(mockResponse.user)
expect(store.isAuthenticated).toBe(true)
})
it('checks user roles correctly', async () => {
const store = useUserStore()
store.currentUser = {
id: '1',
email: 'admin@test.com',
name: 'Admin',
roles: ['admin', 'user']
}
expect(store.hasRole('admin')).toBe(true)
expect(store.hasRole('superadmin')).toBe(false)
})
it('clears state on logout', async () => {
vi.mocked($fetch).mockResolvedValue({})
const store = useUserStore()
store.currentUser = { id: '1', email: 'test@test.com', name: 'Test', roles: [] }
store.token = 'token'
await store.logout()
expect(store.currentUser).toBeNull()
expect(store.token).toBeNull()
expect(store.isAuthenticated).toBe(false)
})
})
步骤4: 实现最小代码以通过测试
<!-- components/UserCard.vue -->
<script setup lang="ts">
interface User {
id: string
name: string
email: string
}
const props = defineProps<{
user: User | null
loading?: boolean
}>()
const emit = defineEmits<{
select: [id: string]
}>()
const handleClick = () => {
if (props.user) {
emit('select', props.user.id)
}
}
</script>
<template>
<div @click="handleClick" class="user-card">
<div v-if="loading" data-testid="loading-skeleton" class="skeleton">
Loading...
</div>
<template v-else-if="user">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</template>
</div>
</template>
步骤5: 运行完整验证
# 运行所有测试
npm run test
# 运行测试并覆盖
npm run test:coverage
# 运行特定测试文件
npm run test tests/components/UserCard.test.ts
# 类型检查
npm run typecheck
# 代码检查
npm run lint
# 构建以确保无错误
npm run build
第4部分:性能模式
模式1: 使用Computed而非Methods
差 - 每次渲染调用方法:
<script setup lang="ts">
const items = ref([...])
// ❌ 差: 每次渲染都重新计算
const getFilteredItems = () => {
return items.value.filter(item => item.active)
}
</script>
<template>
<div v-for="item in getFilteredItems()" :key="item.id">
{{ item.name }}
</div>
</template>
好 - Computed缓存结果:
<script setup lang="ts">
const items = ref([...])
// ✅ 好: 仅在items变化时重新计算
const filteredItems = computed(() => {
return items.value.filter(item => item.active)
})
</script>
<template>
<div v-for="item in filteredItems" :key="item.id">
{{ item.name }}
</div>
</template>
模式2: 对大型对象使用shallowRef
差 - 对大型对象的深度响应性:
// ❌ 差: 为整个对象创建深度响应代理
const largeDataset = ref<DataItem[]>([])
// 每个嵌套属性都变为响应式
largeDataset.value = await fetchLargeDataset()
好 - 不需要深度跟踪时使用浅响应性:
// ✅ 好: 只跟踪引用,而非嵌套属性
const largeDataset = shallowRef<DataItem[]>([])
// 手动触发更新
largeDataset.value = await fetchLargeDataset()
// 就地突变时使用triggerRef
largeDataset.value.push(newItem)
triggerRef(largeDataset)
模式3: 对昂贵列表使用v-memo
差 - 任何变化时重新渲染所有项:
<template>
<!-- ❌ 差: 任何变化时所有项重新渲染 -->
<div v-for="item in items" :key="item.id">
<ExpensiveComponent :data="item" />
</div>
</template>
好 - 对未变化项进行记忆化:
<template>
<!-- ✅ 好: 仅在item.id或item.updated变化时重新渲染 -->
<div
v-for="item in items"
:key="item.id"
v-memo="[item.id, item.updated]"
>
<ExpensiveComponent :data="item" />
</div>
</template>
模式4: 懒加载组件
差 - 所有组件预先加载:
<script setup lang="ts">
// ❌ 差: 即使未显示也导入
import HeavyChart from '~/components/HeavyChart.vue'
import AdminPanel from '~/components/AdminPanel.vue'
import DataTable from '~/components/DataTable.vue'
</script>
好 - 按需加载组件:
<script setup lang="ts">
// ✅ 好: 仅渲染时加载
const HeavyChart = defineAsyncComponent(() =>
import('~/components/HeavyChart.vue')
)
const AdminPanel = defineAsyncComponent({
loader: () => import('~/components/AdminPanel.vue'),
loadingComponent: LoadingSpinner,
delay: 200,
timeout: 5000
})
// 使用Nuxt lazy前缀
// components/lazy/DataTable.vue自动变为懒加载
</script>
<template>
<HeavyChart v-if="showChart" />
<AdminPanel v-if="isAdmin" />
<LazyDataTable v-if="showTable" />
</template>
模式5: 大型列表的虚拟滚动
差 - 一次性渲染所有项:
<template>
<!-- ❌ 差: 渲染10,000个DOM节点 -->
<div v-for="item in tenThousandItems" :key="item.id">
{{ item.name }}
</div>
</template>
好 - 仅渲染可见项:
<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'
const items = ref(generateLargeList(10000))
const { list, containerProps, wrapperProps } = useVirtualList(items, {
itemHeight: 50,
overscan: 5
})
</script>
<template>
<!-- ✅ 好: 仅渲染约20个可见项 -->
<div v-bind="containerProps" class="h-[400px] overflow-auto">
<div v-bind="wrapperProps">
<div v-for="{ data, index } in list" :key="index" class="h-[50px]">
{{ data.name }}
</div>
</div>
</div>
</template>
模式6: 优化观察器
差 - 不必要地观察整个对象:
// ❌ 差: 任何属性变化触发
watch(form, () => {
validateForm()
}, { deep: true })
好 - 观察特定属性:
// ✅ 好: 仅在email变化触发
watch(() => form.email, (newEmail) => {
validateEmail(newEmail)
})
// ✅ 好: 观察多个特定属性
watch(
[() => form.email, () => form.password],
([email, password]) => {
validateCredentials(email, password)
}
)
模式7: 防抖昂贵操作
差 - 每次按键运行:
<script setup lang="ts">
const searchQuery = ref('')
// ❌ 差: 每次按键API调用
watch(searchQuery, async (query) => {
results.value = await searchAPI(query)
})
</script>
好 - 防抖操作:
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
const searchQuery = ref('')
// ✅ 好: 等待用户停止输入
const debouncedSearch = useDebounceFn(async (query: string) => {
results.value = await searchAPI(query)
}, 300)
watch(searchQuery, (query) => {
debouncedSearch(query)
})
</script>
第5部分:核心职责
1. 组件架构与Composition API
- 使用script setup语法设计可扩展组件层次
- 遵循Vue 3最佳实践创建可重用composables
- 为组件和composables实现适当的TypeScript类型
- 使用ref、reactive、computed和watch管理响应性
- 通过适当的key使用和v-memo优化组件渲染
2. Nuxt 3应用开发
- 为SSR、SSG或混合渲染配置Nuxt 3应用
- 实现文件路由,包括动态路由和中间件
- 使用Nitro创建服务器路由和API端点
- 优化包大小和代码分割
- 配置自动导入和模块层架构
3. 状态管理
- 设计支持TypeScript的Pinia存储
- 实现状态持久化和水合策略
- 创建跨组件逻辑的共享composables
- 管理全局状态与局部组件状态
- 处理异步状态和加载模式
4. 性能优化
- 实现路由和组件的懒加载
- 使用Nuxt Image模块优化图像
- 配置缓存策略(客户端、服务器、CDN)
- 监控和改进核心Web指标
- 实现大型列表的虚拟滚动
5. 类型安全与开发者体验
- 配置TypeScript严格模式
- 为Nuxt自动导入生成类型
- 类型化API响应和存储状态
- 为Vue/Nuxt设置ESLint和Prettier
- 实现适当的错误处理和边界
6. 客户端安全
- 通过适当的模板净化防止XSS
- 配置内容安全策略(CSP)
- 验证和净化用户输入
- 实现安全认证流程
- 防止CSRF攻击
第6部分:前7大实现模式
模式1: 可组合优先架构
使用composables提取和跨组件重用逻辑:
// composables/useAsyncData.ts
import { ref, type Ref } from 'vue'
export interface UseAsyncDataOptions<T> {
immediate?: boolean
onError?: (error: Error) => void
transform?: (data: any) => T
}
export function useAsyncData<T>(
fetcher: () => Promise<T>,
options: UseAsyncDataOptions<T> = {}
) {
const { immediate = true, onError, transform } = options
const data: Ref<T | null> = ref(null)
const error: Ref<Error | null> = ref(null)
const loading = ref(false)
const execute = async () => {
loading.value = true
error.value = null
try {
const result = await fetcher()
data.value = transform ? transform(result) : result
} catch (e) {
error.value = e as Error
onError?.(e as Error)
} finally {
loading.value = false
}
}
if (immediate) execute()
return { data, error, loading, execute }
}
用法:
<script setup lang="ts">
import { useAsyncData } from '~/composables/useAsyncData'
interface User {
id: string
name: string
}
const { data: user, loading, error } = useAsyncData<User>(
() => $fetch('/api/user/me'),
{ immediate: true }
)
</script>
模式2: 类型安全的Pinia存储
使用组合API创建强类型存储:
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface User {
id: string
email: string
name: string
roles: string[]
}
export const useUserStore = defineStore('user', () => {
// 状态
const currentUser = ref<User | null>(null)
const token = ref<string | null>(null)
// 获取器
const isAuthenticated = computed(() => !!currentUser.value)
const hasRole = computed(() => (role: string) =>
currentUser.value?.roles.includes(role) ?? false
)
// 操作
async function login(email: string, password: string) {
const response = await $fetch<{ user: User; token: string }>('/api/auth/login', {
method: 'POST',
body: { email, password }
})
currentUser.value = response.user
token.value = response.token
// 持久化token
if (process.client) {
localStorage.setItem('auth_token', response.token)
}
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
currentUser.value = null
token.value = null
if (process.client) {
localStorage.removeItem('auth_token')
}
}
async function fetchCurrentUser() {
if (!token.value) return
try {
const user = await $fetch<User>('/api/user/me', {
headers: { Authorization: `Bearer ${token.value}` }
})
currentUser.value = user
} catch (error) {
// Token无效,清除认证状态
await logout()
}
}
return {
currentUser,
token,
isAuthenticated,
hasRole,
login,
logout,
fetchCurrentUser
}
})
模式3: Nuxt 3中间件和路由守卫
实现认证和授权中间件:
// middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to, from) => {
const userStore = useUserStore()
const publicRoutes = ['/login', '/register', '/forgot-password']
// 允许公共路由
if (publicRoutes.includes(to.path)) {
return
}
// 如果未认证,重定向到登录
if (!userStore.isAuthenticated) {
return navigateTo('/login', { redirectCode: 401 })
}
// 检查基于角色的访问
if (to.meta.requiresAdmin && !userStore.hasRole('admin')) {
return abortNavigation({
statusCode: 403,
message: '访问被拒绝'
})
}
})
带元数据的页面:
<script setup lang="ts">
definePageMeta({
requiresAdmin: true,
layout: 'admin'
})
const users = await useFetch('/api/admin/users')
</script>
模式4: 带验证的服务器API路由
使用输入验证创建类型安全API端点:
// server/api/users/[id].post.ts
import { z } from 'zod'
import { createError } from 'h3'
const updateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(18).max(120).optional()
})
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
message: '用户ID必填'
})
}
// 验证请求体
const body = await readBody(event)
const result = updateUserSchema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
message: '请求数据无效',
data: result.error.format()
})
}
// 检查认证
const session = await requireUserSession(event)
// 检查授权(用户只能更新自己,除非管理员)
if (session.user.id !== id && !session.user.roles.includes('admin')) {
throw createError({
statusCode: 403,
message: '无权更新此用户'
})
}
// 在数据库中更新用户
const updatedUser = await db.users.update(id, result.data)
return updatedUser
})
模式5: 优化组件加载
实现策略性代码分割和懒加载:
<script setup lang="ts">
// 懒加载重型组件
const HeavyChart = defineAsyncComponent(() =>
import('~/components/HeavyChart.vue')
)
const AdminPanel = defineAsyncComponent({
loader: () => import('~/components/AdminPanel.vue'),
loadingComponent: () => h('div', '加载中...'),
delay: 200,
timeout: 3000
})
const showChart = ref(false)
const userStore = useUserStore()
// 仅在需要时加载
const loadChart = () => {
showChart.value = true
}
</script>
<template>
<div>
<button @click="loadChart">显示图表</button>
<!-- 仅在showChart为true时加载组件 -->
<HeavyChart v-if="showChart" :data="chartData" />
<!-- 仅对管理员显示管理面板 -->
<AdminPanel v-if="userStore.hasRole('admin')" />
</div>
</template>
Nuxt配置以优化分割:
// nuxt.config.ts
export default defineNuxtConfig({
vite: {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-ui': ['@headlessui/vue', '@heroicons/vue'],
}
}
}
}
},
experimental: {
payloadExtraction: true, // 提取有效负载以更好缓存
componentIslands: true // 部分水合岛架构
}
})
模式6: VueUse集成用于常见逻辑
利用VueUse composables实现健壮功能:
<script setup lang="ts">
import { useLocalStorage, useMediaQuery, useIntersectionObserver } from '@vueuse/core'
import { ref, watch } from 'vue'
// 持久化深色模式
const isDark = useLocalStorage('dark-mode', false)
// 响应式断点
const isMobile = useMediaQuery('(max-width: 768px)')
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
const isDesktop = useMediaQuery('(min-width: 1025px)')
// 带交叉观察器的无限滚动
const target = ref<HTMLElement | null>(null)
const isVisible = ref(false)
useIntersectionObserver(
target,
([{ isIntersecting }]) => {
isVisible.value = isIntersecting
},
{ threshold: 0.5 }
)
// 当目标可见时加载更多
watch(isVisible, (visible) => {
if (visible && !loading.value) {
loadMore()
}
})
const loadMore = async () => {
// 加载更多项
}
</script>
<template>
<div :class="{ dark: isDark }">
<button @click="isDark = !isDark">
切换{{ isDark ? '浅色' : '深色' }}模式
</button>
<div v-if="isMobile">移动视图</div>
<div v-else-if="isTablet">平板视图</div>
<div v-else>桌面视图</div>
<!-- 项列表 -->
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>
<!-- 无限滚动的交叉观察器目标 -->
<div ref="target" class="loading-trigger">
<span v-if="isVisible">加载更多...</span>
</div>
</div>
</template>
模式7: SSR安全的数据获取
正确处理SSR/SSG的数据获取:
<script setup lang="ts">
// ✅ 正确: 使用Nuxt数据获取composables
// 这些在服务器和客户端都工作,带自动水合
// 基本获取
const { data: posts } = await useFetch('/api/posts', {
key: 'posts-list',
transform: (data) => data.posts,
getCachedData: (key) => useNuxtApp().static.data[key]
})
// 带参数
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.id}`, {
key: `post-${route.params.id}`,
watch: [() => route.params.id] // ID变化时重新获取
})
// 带懒加载(初始仅在客户端)
const { data: comments, pending } = await useLazyFetch(`/api/posts/${route.params.id}/comments`)
// 使用useAsyncData进行自定义异步操作
const { data: userData, refresh } = await useAsyncData(
'user-profile',
async () => {
const [profile, settings] = await Promise.all([
$fetch('/api/user/profile'),
$fetch('/api/user/settings')
])
return { profile, settings }
},
{
server: true, // 在服务器获取
lazy: false, // 渲染前等待数据
default: () => ({ profile: null, settings: null })
}
)
// ❌ 错误: 直接fetch调用会执行两次(服务器 + 客户端)
// const response = await fetch('/api/posts') // 不要这样做!
</script>
<template>
<div>
<article v-if="post">
<h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
</article>
<section v-if="!pending">
<h2>评论 ({{ comments?.length || 0 }})</h2>
<div v-for="comment in comments" :key="comment.id">
{{ comment.text }}
</div>
</section>
<div v-else>加载评论中...</div>
</div>
</template>
查看 references/advanced-patterns.md 获取更多模式,包括插件、模块和高级composables。
第7部分:安全
风险级别: 中等 - 客户端应用易受XSS、注入和数据暴露攻击
前3大关键漏洞
1. 跨站脚本攻击(XSS)
风险: 攻击者通过用户输入注入恶意脚本,窃取数据或执行未授权操作。
预防:
<script setup lang="ts">
import DOMPurify from 'isomorphic-dompurify'
const userInput = ref('')
const sanitizedHtml = computed(() => DOMPurify.sanitize(userInput.value))
// ✅ 安全: Vue的模板绑定自动转义HTML
const displayText = ref('<script>alert("XSS")</script>')
</script>
<template>
<!-- ✅ 安全: 自动转义 -->
<div>{{ displayText }}</div>
<!-- ⚠️ 危险: 仅与净化内容使用 -->
<div v-html="sanitizedHtml"></div>
<!-- ❌ 绝不要: 原始用户输入 -->
<!-- <div v-html="userInput"></div> -->
</template>
2. 不安全数据暴露
风险: 敏感数据通过客户端代码、API响应或状态管理泄露。
预防:
// ✅ 服务器API路由 - 在服务器上保持秘密
// server/api/payment.post.ts
export default defineEventHandler(async (event) => {
const apiKey = useRuntimeConfig().stripeSecretKey // 仅服务器
const payment = await stripe.charges.create({
amount: 1000,
currency: 'usd',
source: req.body.token
}, {
apiKey // 永不暴露给客户端
})
// 仅返回必要数据
return {
id: payment.id,
status: payment.status,
amount: payment.amount
}
})
3. CSRF(跨站请求伪造)
风险: 攻击者诱使用户在认证会话中执行不需要的操作。
预防:
// nuxt.config.ts
export default defineNuxtConfig({
// 为SSR启用CSRF保护
security: {
headers: {
crossOriginEmbedderPolicy: 'require-corp',
crossOriginOpenerPolicy: 'same-origin',
crossOriginResourcePolicy: 'same-origin'
}
}
})
// API路由中间件
// server/middleware/csrf.ts
export default defineEventHandler((event) => {
if (event.method !== 'GET' && event.method !== 'HEAD') {
const origin = getHeader(event, 'origin')
const host = getHeader(event, 'host')
if (origin && !origin.includes(host)) {
throw createError({
statusCode: 403,
message: 'CSRF验证失败'
})
}
}
})
OWASP Top 10 映射
| OWASP 类别 | 相关性 | 在Vue/Nuxt中的缓解措施 |
|---|---|---|
| A03:2021 注入 | 高 | 输入验证、参数化查询、净化 |
| A05:2021 安全配置错误 | 中等 | CSP头部、安全默认值、环境配置 |
| A06:2021 脆弱组件 | 中等 | 定期更新、审计依赖项、Snyk/npm审计 |
| A07:2021 认证失败 | 高 | 安全会话管理、适当令牌处理 |
| A08:2021 数据完整性失败 | 中等 | 签名有效负载、完整性检查、仅HTTPS |
详细安全示例和完整OWASP覆盖,请查看 references/security-examples.md。
第8部分:常见错误
错误1: 解构时失去响应性
问题:
// ❌ 错误: 失去响应性
const userStore = useUserStore()
const { currentUser } = userStore // 非响应式!
watch(currentUser, () => {
console.log('这永远不会触发!')
})
解决方案:
// ✅ 正确: 使用storeToRefs保持响应性
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { currentUser } = storeToRefs(userStore) // 响应式!
watch(currentUser, () => {
console.log('这有效!')
})
// 或直接访问
watch(() => userStore.currentUser, () => {
console.log('这也有效!')
})
错误2: 事件监听器导致内存泄漏
问题:
// ❌ 错误: 事件监听器未清理
onMounted(() => {
window.addEventListener('resize', handleResize)
})
// 组件卸载但监听器持久!
解决方案:
// ✅ 正确: 在onUnmounted中清理
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
// ✅ 更好: 使用VueUse composable
import { useEventListener } from '@vueuse/core'
useEventListener(window, 'resize', handleResize) // 自动清理!
错误3: 不正确的useFetch用法
问题:
// ❌ 错误: 在事件处理器中使用useFetch
const handleClick = async () => {
const { data } = await useFetch('/api/data') // 错误!不允许在函数中
}
// ❌ 错误: 在条件内
if (someCondition) {
const { data } = await useFetch('/api/data') // 错误!必须在顶级
}
解决方案:
// ✅ 正确: 对程序化调用使用$fetch
const handleClick = async () => {
const data = await $fetch('/api/data') // 在函数中工作
}
// ✅ 正确: 在组件顶级使用useFetch
const { data, refresh } = await useFetch('/api/data', {
immediate: false
})
const handleClick = () => {
refresh() // 触发重新获取
}
错误4: 未处理SSR/客户端差异
问题:
// ❌ 错误: 在SSR期间访问浏览器API
const windowWidth = ref(window.innerWidth) // 错误!服务器上window未定义
onMounted(() => {
localStorage.setItem('key', 'value') // 错误!服务器上localStorage未定义
})
解决方案:
// ✅ 正确: 检查环境
const windowWidth = ref(0)
onMounted(() => {
if (process.client) {
windowWidth.value = window.innerWidth
}
})
// ✅ 更好: 使用SSR安全的VueUse
import { useWindowSize, useLocalStorage } from '@vueuse/core'
const { width } = useWindowSize() // SSR安全
const stored = useLocalStorage('key', 'default') // SSR安全
错误5: 低效观察器
问题:
// ❌ 错误: 观察整个对象(任何属性变化触发)
const form = reactive({
name: '',
email: '',
phone: '',
address: ''
})
watch(form, () => {
console.log('任何字段变化都触发!')
})
解决方案:
// ✅ 正确: 观察特定属性
watch(() => form.email, (newEmail) => {
validateEmail(newEmail)
})
// ✅ 正确: 观察多个特定属性
watch([() => form.email, () => form.phone], ([email, phone]) => {
validateContactInfo(email, phone)
})
// ✅ 正确: 需要时的深度观察带立即标志
watch(form, () => {
saveFormDraft(form)
}, {
deep: true,
debounce: 500 // 防抖以避免过多调用
})
第9部分:测试
测试配置
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'tests/']
}
},
resolve: {
alias: {
'~': resolve(__dirname, './')
}
}
})
测试设置文件
// tests/setup.ts
import { config } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
// 全局插件
config.global.plugins = [createTestingPinia()]
// 模拟Nuxt composables
vi.mock('#app', () => ({
useNuxtApp: () => ({ $fetch: vi.fn() }),
useRuntimeConfig: () => ({ public: {} }),
useFetch: vi.fn(),
useAsyncData: vi.fn(),
navigateTo: vi.fn(),
definePageMeta: vi.fn()
}))
// 全局模拟$fetch
vi.stubGlobal('$fetch', vi.fn())
组件测试模式
// tests/components/Form.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import Form from '~/components/Form.vue'
describe('Form', () => {
it('validates required fields', async () => {
const wrapper = mount(Form)
await wrapper.find('form').trigger('submit')
expect(wrapper.find('.error').text()).toContain('姓名必填')
})
it('submits valid data', async () => {
const onSubmit = vi.fn()
const wrapper = mount(Form, {
props: { onSubmit }
})
await wrapper.find('input[name="name"]').setValue('John')
await wrapper.find('input[name="email"]').setValue('john@test.com')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(onSubmit).toHaveBeenCalledWith({
name: 'John',
email: 'john@test.com'
})
})
it('shows loading state during submission', async () => {
const wrapper = mount(Form, {
props: {
onSubmit: () => new Promise(r => setTimeout(r, 100))
}
})
await wrapper.find('input[name="name"]').setValue('John')
await wrapper.find('form').trigger('submit')
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
expect(wrapper.find('.loading').exists()).toBe(true)
})
})
测试异步操作
// tests/composables/useApi.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { useApi } from '~/composables/useApi'
describe('useApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('handles concurrent requests', async () => {
const results = ['first', 'second']
let callCount = 0
vi.mocked($fetch).mockImplementation(() =>
Promise.resolve(results[callCount++])
)
const { data, execute } = useApi('/api/test')
// 触发两个请求
execute()
execute()
await flushPromises()
// 应获取最新结果
expect(data.value).toBe('second')
})
it('cancels pending request on new request', async () => {
const abortSpy = vi.fn()
vi.mocked($fetch).mockImplementation((_, opts) => {
opts?.signal?.addEventListener('abort', abortSpy)
return new Promise(() => {})
})
const { execute } = useApi('/api/test')
execute()
execute() // 应取消第一个
expect(abortSpy).toHaveBeenCalled()
})
})
第10部分:关键提醒
类型安全
- 始终在
tsconfig.json中启用TypeScript严格模式 - 使用
defineProps<T>()语法类型化所有组件属性 - 为Nuxt自动导入生成类型:
nuxt prepare - 使用运行时验证(Zod)用于API输入,而不仅是TypeScript
性能
- 使用
useFetch/useAsyncData进行数据获取(SSR兼容) - 实现路由懒加载:
defineAsyncComponent() - 优化图像:使用Nuxt Image模块与适当格式(WebP, AVIF)
- 监控包大小:
nuxi analyze并设置预算 - 对不常变化的昂贵列表使用
v-memo
安全
- 绝不要对未净化用户输入使用
v-html - 在
nuxt.config.ts中配置CSP头部 - 验证所有客户端和服务器输入
- 将秘密存储在
.env文件中,永不客户端代码 - 使用
httpOnlycookie处理敏感令牌
状态管理
- 使用Pinia处理全局状态,composables处理共享逻辑
- 从
storeToRefs()提取以保持响应性 - 安全持久化认证状态(首选httpOnly cookie)
- 登出时清除敏感状态
- 避免属性传递:使用provide/inject或存储
SSR/SSG
- 访问浏览器API前始终检查
process.client - 使用Nuxt数据获取composables,而非原始fetch
- 配置
routeRules用于页面级渲染策略 - 需要时使用
<ClientOnly>处理水合不匹配 - 为静态资产设置适当缓存头部
开发者体验
- 启用Nuxt DevTools进行调试
- 使用Vue DevTools进行组件检查
- 使用Vue/Nuxt配置设置ESLint + Prettier
- 用Vitest + Vue Test Utils编写测试
- 记录复杂composables和存储
第11部分:预实现检查清单
阶段1: 编码前
- [ ] 识别需求 - 将用户故事/任务解析为特定验收标准
- [ ] 设计组件结构 - 草绘组件层次和数据流
- [ ] 规划composables - 识别要提取的可重用逻辑
- [ ] 考虑SSR - 确定渲染策略(SSR/SSG/SPA)
- [ ] 检查现有模式 - 审查代码库中类似组件/composables
- [ ] 编写测试用例 - 为预期行为创建失败测试
- [ ] 规划状态管理 - 决定本地与存储状态
阶段2: 实现期间
- [ ] TDD循环 - 编写测试 -> 实现 -> 重构 -> 重复
- [ ] 类型化所有内容 - 属性、事件、composable返回、API响应
- [ ] 使用computed - 用于派生状态而非方法
- [ ] 优化响应性 - 对大型对象使用shallowRef,观察特定属性
- [ ] 处理边界情况 - 加载状态、错误、空数据
- [ ] SSR安全 - 访问浏览器API前检查
process.client - [ ] 清理效果 - 使用onUnmounted或VueUse composables
- [ ] 安全检查 - 无v-html与用户输入,验证输入
阶段3: 提交前
- [ ] 所有测试通过 - 运行
npm run test - [ ] 类型检查通过 - 运行
npm run typecheck - [ ] 代码检查通过 - 运行
npm run lint - [ ] 构建成功 - 运行
npm run build - [ ] 手动测试 - 在浏览器中使用开发工具验证
- [ ] 性能检查 - 无控制台警告,流畅渲染
- [ ] 安全审查 - 无暴露秘密,输入已验证
- [ ] 文档 - 复杂逻辑有注释/JSDoc
验证命令
# 提交前运行所有检查
npm run test && npm run typecheck && npm run lint && npm run build
# 开发期间快速验证
npm run dev # 应启动无错误
# 完整测试套件带覆盖
npm run test:coverage
# E2E测试
npm run test:e2e
第12部分:总结
此技能提供构建现代、高性能、类型安全的Vue 3和Nuxt 3应用的专家知识。关键要点:
架构: 使用Composition API设计组件层次,将逻辑提取到composables中,使用Pinia管理状态。遵循可组合优先方法以实现最大可重用性。
Nuxt 3模式: 利用文件路由、自动导入和Nitro服务器进行全栈开发。配置渲染策略(SSR/SSG/混合)以实现最佳性能。
类型安全: 全程使用TypeScript严格模式。类型化组件、存储和API响应。结合编译时TypeScript与运行时验证(Zod)以实现健壮应用。
性能: 实现策略性代码分割、懒加载和优化数据获取与useFetch。监控核心Web指标并设置性能预算。
安全: 通过适当转义和净化防止XSS。验证所有输入。配置CSP头部。在服务器上保持秘密。实施CSRF保护。
常见陷阱: 使用storeToRefs保持响应性。清理事件监听器。使用正确数据获取API(useFetch vs $fetch)。处理SSR/客户端差异。编写高效观察器。
最佳实践:
- 保持组件专注和可组合
- 提取并独立测试composables
- 使用VueUse处理常见模式
- 配置ESLint和Prettier
- 为关键逻辑编写测试
- 监控性能和包大小
风险级别: 中等 - 主要关切是客户端安全(XSS, 数据暴露)和性能(包大小, SSR复杂性)。
高级模式,请查看 references/advanced-patterns.md。 详细安全示例,请查看 references/security-examples.md。