第5章:工程化与性能
掌握现代前端工程化工具链,实现高性能的 Vue3 应用
📚 本章目标
通过本章学习,你将掌握:
- Vite 构建优化与分包策略
- SSR 与 Nuxt3 实践
- 可访问性 (A11y) 最佳实践
- 安全防护与国际化
- 性能监控与优化
🚀 5.1 Vite 构建与优化
5.1.1 构建配置优化
基础配置
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@components': resolve(__dirname, 'src/components'),
'@utils': resolve(__dirname, 'src/utils'),
}
},
build: {
// 构建目标
target: 'es2015',
// 输出目录
outDir: 'dist',
// 静态资源目录
assetsDir: 'assets',
// 资源内联阈值
assetsInlineLimit: 4096,
// CSS 代码分割
cssCodeSplit: true,
// 源码映射
sourcemap: false,
// 压缩配置
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
}
}
}
})
分包策略
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// 第三方库
vendor: ['vue', 'vue-router', 'pinia'],
// UI 组件库
ui: ['element-plus', '@element-plus/icons-vue'],
// 工具库
utils: ['lodash-es', 'dayjs', 'axios'],
// 图表库
charts: ['echarts', 'chart.js']
}
}
}
}
})
动态导入优化
// 路由懒加载
const routes = [
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue')
},
{
path: '/profile',
component: () => import('@/views/Profile.vue')
}
]
// 组件懒加载
export default defineComponent({
components: {
HeavyComponent: () => import('@/components/HeavyComponent.vue')
}
})
5.1.2 图片优化
使用 vite-imagetools
pnpm add -D vite-imagetools
// vite.config.ts
import { defineConfig } from 'vite'
import { imagetools } from 'vite-imagetools'
export default defineConfig({
plugins: [
vue(),
imagetools({
defaultDirectives: (url) => {
if (url.searchParams.has('webp')) {
return new URLSearchParams({
format: 'webp',
quality: '80'
})
}
return new URLSearchParams()
}
})
]
})
响应式图片组件
<!-- components/ResponsiveImage.vue -->
<template>
<picture>
<source
v-for="source in sources"
:key="source.media"
:media="source.media"
:srcset="source.srcset"
:type="source.type"
/>
<img
:src="fallbackSrc"
:alt="alt"
:loading="loading"
:decoding="decoding"
@load="onLoad"
@error="onError"
/>
</picture>
</template>
<script setup lang="ts">
interface Source {
media: string
srcset: string
type?: string
}
interface Props {
src: string
alt: string
loading?: 'lazy' | 'eager'
decoding?: 'async' | 'sync' | 'auto'
sizes?: string
}
const props = withDefaults(defineProps<Props>(), {
loading: 'lazy',
decoding: 'async'
})
// 生成响应式图片源
const sources = computed<Source[]>(() => {
const baseSrc = props.src.replace(/\.(jpg|jpeg|png)$/, '')
return [
{
media: '(min-width: 768px)',
srcset: `${baseSrc}@2x.webp 2x, ${baseSrc}@1x.webp 1x`,
type: 'image/webp'
},
{
media: '(min-width: 768px)',
srcset: `${baseSrc}@2x.jpg 2x, ${baseSrc}@1x.jpg 1x`
}
]
})
const fallbackSrc = computed(() => {
return props.src.replace(/\.(jpg|jpeg|png)$/, '@1x.$1')
})
const onLoad = () => {
console.log('Image loaded successfully')
}
const onError = () => {
console.error('Image failed to load')
}
</script>
5.1.3 预加载与预连接
// vite.config.ts
export default defineConfig({
plugins: [
vue(),
{
name: 'preload-resources',
transformIndexHtml(html) {
return html.replace(
'<head>',
`<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="dns-prefetch" href="https://api.example.com">
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
`
)
}
}
]
})
🌐 5.2 SSR 与 Nuxt3 基础
5.2.1 Nuxt3 项目搭建
# 创建 Nuxt3 项目
npx nuxi@latest init my-nuxt-app
cd my-nuxt-app
pnpm install
基础配置
// nuxt.config.ts
export default defineNuxtConfig({
// 开发工具
devtools: { enabled: true },
// CSS 框架
css: ['~/assets/css/main.css'],
// 模块
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'@nuxtjs/i18n'
],
// 运行时配置
runtimeConfig: {
// 私有配置(仅在服务器端可用)
apiSecret: '123',
// 公共配置(客户端也可用)
public: {
apiBase: '/api'
}
},
// 构建配置
build: {
transpile: ['vue-chartjs']
},
// 渲染配置
ssr: true,
// 路由配置
router: {
options: {
scrollBehaviorType: 'smooth'
}
}
})
5.2.2 数据获取
useFetch 基础用法
<!-- pages/posts/index.vue -->
<template>
<div>
<h1>Posts</h1>
<div v-if="pending">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else>
<article v-for="post in data" :key="post.id">
<h2>{{ post.title }}</h2>
<p>{{ post.excerpt }}</p>
</article>
</div>
</div>
</template>
<script setup lang="ts">
interface Post {
id: number
title: string
excerpt: string
content: string
}
// 基础数据获取
const { data, pending, error, refresh } = await useFetch<Post[]>('/api/posts')
// 带选项的数据获取
const { data: user } = await useFetch('/api/user', {
key: 'user-profile',
default: () => ({ name: '', email: '' }),
transform: (data: any) => ({
name: data.name,
email: data.email
})
})
</script>
useAsyncData 高级用法
<!-- pages/posts/[id].vue -->
<template>
<div>
<h1>{{ data?.title }}</h1>
<div v-html="data?.content"></div>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
// 异步数据获取
const { data, pending, error } = await useAsyncData(
`post-${route.params.id}`,
() => $fetch(`/api/posts/${route.params.id}`),
{
server: true, // 服务端渲染
default: () => null,
transform: (data: any) => ({
...data,
publishedAt: new Date(data.publishedAt)
})
}
)
// SEO 配置
useHead({
title: computed(() => data.value?.title || 'Post'),
meta: [
{ name: 'description', content: computed(() => data.value?.excerpt || '') }
]
})
</script>
5.2.3 中间件与插件
路由中间件
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { $pinia } = useNuxtApp()
const userStore = useUserStore($pinia)
if (!userStore.isAuthenticated) {
return navigateTo('/login')
}
})
插件系统
// plugins/api.client.ts
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
return {
provide: {
api: {
async get<T>(url: string): Promise<T> {
return $fetch(`${config.public.apiBase}${url}`)
},
async post<T>(url: string, data: any): Promise<T> {
return $fetch(`${config.public.apiBase}${url}`, {
method: 'POST',
body: data
})
}
}
}
}
})
♿ 5.3 可访问性 (A11y)
5.3.1 语义化标签
<template>
<!-- 使用语义化标签 -->
<main>
<header>
<nav aria-label="主导航">
<ul>
<li><a href="/" aria-current="page">首页</a></li>
<li><a href="/about">关于</a></li>
</ul>
</nav>
</header>
<section aria-labelledby="content-heading">
<h1 id="content-heading">主要内容</h1>
<article>
<h2>文章标题</h2>
<p>文章内容...</p>
</article>
</section>
<aside aria-label="侧边栏">
<h2>相关链接</h2>
<ul>
<li><a href="/related1">相关链接1</a></li>
</ul>
</aside>
</main>
</template>
5.3.2 焦点管理
<template>
<div>
<button @click="openModal">打开模态框</button>
<Teleport to="body">
<div
v-if="isOpen"
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
@keydown.esc="closeModal"
>
<div class="modal-content">
<h2 id="modal-title">模态框标题</h2>
<button
ref="closeButton"
@click="closeModal"
aria-label="关闭模态框"
>
×
</button>
<p>模态框内容...</p>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
const isOpen = ref(false)
const closeButton = ref<HTMLButtonElement>()
const openModal = () => {
isOpen.value = true
nextTick(() => {
closeButton.value?.focus()
})
}
const closeModal = () => {
isOpen.value = false
}
// 焦点陷阱
const trapFocus = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
const focusableElements = modal.value?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
// 焦点陷阱逻辑...
}
}
</script>
5.3.3 屏幕阅读器支持
<template>
<div>
<!-- 隐藏的屏幕阅读器文本 -->
<span class="sr-only">加载中,请稍候</span>
<!-- 动态内容更新通知 -->
<div
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
{{ screenReaderMessage }}
</div>
<!-- 表单验证 -->
<form @submit="handleSubmit">
<label for="email">邮箱地址</label>
<input
id="email"
v-model="email"
type="email"
:aria-invalid="emailError ? 'true' : 'false'"
:aria-describedby="emailError ? 'email-error' : undefined"
required
/>
<div
v-if="emailError"
id="email-error"
role="alert"
aria-live="polite"
>
{{ emailError }}
</div>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : '提交' }}
</button>
</form>
</div>
</template>
<script setup lang="ts">
const email = ref('')
const emailError = ref('')
const isSubmitting = ref(false)
const screenReaderMessage = ref('')
const validateEmail = (value: string) => {
if (!value) {
emailError.value = '邮箱地址不能为空'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
emailError.value = '请输入有效的邮箱地址'
} else {
emailError.value = ''
}
}
const handleSubmit = async () => {
validateEmail(email.value)
if (emailError.value) return
isSubmitting.value = true
screenReaderMessage.value = '正在提交表单...'
try {
await submitForm({ email: email.value })
screenReaderMessage.value = '表单提交成功'
} catch (error) {
screenReaderMessage.value = '表单提交失败,请重试'
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
🔒 5.4 安全防护
5.4.1 XSS 防护
<template>
<div>
<!-- 安全:自动转义 -->
<p>{{ userInput }}</p>
<!-- 危险:需要手动处理 -->
<div v-html="sanitizedHtml"></div>
<!-- 更安全的方式 -->
<div v-html="processedContent"></div>
</div>
</template>
<script setup lang="ts">
import DOMPurify from 'dompurify'
const userInput = ref('')
const rawHtml = ref('')
// HTML 清理
const sanitizedHtml = computed(() => {
return DOMPurify.sanitize(rawHtml.value, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: []
})
})
// 内容安全策略
const processedContent = computed(() => {
const content = userInput.value
// 移除潜在危险内容
return content
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '')
})
</script>
5.4.2 CSRF 防护
// composables/useApi.ts
export const useApi = () => {
const config = useRuntimeConfig()
const api = $fetch.create({
baseURL: config.public.apiBase,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
onRequest({ request, options }) {
// 添加 CSRF token
const token = useCookie('csrf-token')
if (token.value) {
options.headers = {
...options.headers,
'X-CSRF-Token': token.value
}
}
},
onResponseError({ response }) {
if (response.status === 419) {
// CSRF token 过期,重新获取
navigateTo('/login')
}
}
})
return { api }
}
5.4.3 内容安全策略
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
routeRules: {
'/**': {
headers: {
'Content-Security-Policy': [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://api.example.com"
].join('; ')
}
}
}
}
})
🌍 5.5 国际化与主题
5.5.1 国际化配置
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: [
{ code: 'zh', name: '中文', file: 'zh.json' },
{ code: 'en', name: 'English', file: 'en.json' }
],
defaultLocale: 'zh',
langDir: 'locales/',
strategy: 'prefix_except_default',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_redirected',
redirectOn: 'root'
}
}
})
语言文件
// locales/zh.json
{
"welcome": "欢迎",
"navigation": {
"home": "首页",
"about": "关于我们",
"contact": "联系我们"
},
"form": {
"email": "邮箱地址",
"password": "密码",
"submit": "提交"
}
}
// locales/en.json
{
"welcome": "Welcome",
"navigation": {
"home": "Home",
"about": "About Us",
"contact": "Contact Us"
},
"form": {
"email": "Email Address",
"password": "Password",
"submit": "Submit"
}
}
使用国际化
<template>
<div>
<h1>{{ $t('welcome') }}</h1>
<nav>
<a href="/">{{ $t('navigation.home') }}</a>
<a href="/about">{{ $t('navigation.about') }}</a>
</nav>
<form>
<label>{{ $t('form.email') }}</label>
<input type="email" />
<label>{{ $t('form.password') }}</label>
<input type="password" />
<button type="submit">{{ $t('form.submit') }}</button>
</form>
</div>
</template>
<script setup lang="ts">
const { locale, locales, setLocale } = useI18n()
// 切换语言
const switchLanguage = (newLocale: string) => {
setLocale(newLocale)
}
</script>
5.5.2 主题系统
CSS 变量主题
/* assets/css/themes.css */
:root {
/* 浅色主题 */
--color-bg: #ffffff;
--color-fg: #1a1a1a;
--color-primary: #3b82f6;
--color-secondary: #6b7280;
--color-accent: #f59e0b;
--color-border: #e5e7eb;
--color-surface: #f9fafb;
/* 间距 */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
/* 圆角 */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
/* 阴影 */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
[data-theme="dark"] {
--color-bg: #0f172a;
--color-fg: #f1f5f9;
--color-primary: #60a5fa;
--color-secondary: #94a3b8;
--color-accent: #fbbf24;
--color-border: #334155;
--color-surface: #1e293b;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4);
}
[data-theme="blue"] {
--color-primary: #1e40af;
--color-secondary: #3b82f6;
--color-accent: #06b6d4;
}
主题切换组件
<!-- components/ThemeToggle.vue -->
<template>
<div class="theme-toggle">
<button
v-for="theme in themes"
:key="theme.value"
:class="['theme-button', { active: currentTheme === theme.value }]"
@click="setTheme(theme.value)"
:aria-label="`切换到${theme.label}主题`"
>
<component :is="theme.icon" />
<span>{{ theme.label }}</span>
</button>
</div>
</template>
<script setup lang="ts">
interface Theme {
value: string
label: string
icon: string
}
const themes: Theme[] = [
{ value: 'light', label: '浅色', icon: 'SunIcon' },
{ value: 'dark', label: '深色', icon: 'MoonIcon' },
{ value: 'blue', label: '蓝色', icon: 'ColorSwatchIcon' }
]
const currentTheme = ref('light')
const setTheme = (theme: string) => {
currentTheme.value = theme
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('theme', theme)
}
// 初始化主题
onMounted(() => {
const savedTheme = localStorage.getItem('theme') || 'light'
setTheme(savedTheme)
})
// 监听系统主题变化
onMounted(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light')
}
}
mediaQuery.addEventListener('change', handleChange)
onUnmounted(() => {
mediaQuery.removeEventListener('change', handleChange)
})
})
</script>
<style scoped>
.theme-toggle {
display: flex;
gap: var(--space-sm);
}
.theme-button {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-fg);
cursor: pointer;
transition: all 0.2s ease;
}
.theme-button:hover {
background: var(--color-primary);
color: var(--color-bg);
}
.theme-button.active {
background: var(--color-primary);
color: var(--color-bg);
}
</style>
📊 5.6 性能监控
5.6.1 Web Vitals 监控
// composables/useWebVitals.ts
export const useWebVitals = () => {
const reportVital = (name: string, value: number, id: string) => {
// 发送到分析服务
if (typeof window !== 'undefined' && 'gtag' in window) {
gtag('event', name, {
event_category: 'Web Vitals',
value: Math.round(name === 'CLS' ? value * 1000 : value),
event_label: id,
non_interaction: true
})
}
}
const measureWebVitals = () => {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(reportVital)
getFID(reportVital)
getFCP(reportVital)
getLCP(reportVital)
getTTFB(reportVital)
})
}
return { measureWebVitals }
}
5.6.2 错误监控
// plugins/error-handler.client.ts
export default defineNuxtPlugin(() => {
// 全局错误处理
window.addEventListener('error', (event) => {
console.error('Global error:', event.error)
// 发送错误报告
reportError(event.error)
})
// Promise 错误处理
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason)
reportError(event.reason)
})
// Vue 错误处理
const nuxtApp = useNuxtApp()
nuxtApp.hook('vue:error', (error, instance, info) => {
console.error('Vue error:', error, info)
reportError(error, { component: instance?.$options.name, info })
})
})
const reportError = (error: any, context?: any) => {
// 发送到错误监控服务
if (typeof window !== 'undefined') {
fetch('/api/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: error.message,
stack: error.stack,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
context
})
}).catch(console.error)
}
}
🎯 本章总结
通过本章学习,你掌握了:
- Vite 构建优化:分包策略、图片优化、预加载配置
- SSR 与 Nuxt3:数据获取、中间件、插件系统
- 可访问性:语义化标签、焦点管理、屏幕阅读器支持
- 安全防护:XSS/CSRF 防护、内容安全策略
- 国际化主题:多语言支持、动态主题切换
- 性能监控:Web Vitals、错误监控
📝 练习题
- 配置 Vite 构建优化,实现代码分割和图片压缩
- 创建一个支持 SSR 的 Nuxt3 页面,包含数据获取和 SEO 优化
- 实现一个可访问的模态框组件,支持键盘导航和屏幕阅读器
- 配置主题系统,支持浅色/深色/自定义主题切换
- 集成性能监控,收集 Web Vitals 和错误信息