HiHuo
首页
博客
手册
工具
首页
博客
手册
工具
  • Vue & CSS 进阶

    • 第0章:工具链与开发体验
    • 第1章:TypeScript 核心
    • 第2章:CSS 现代能力
    • 第3章:Vue3 核心与原理
    • 第4章:路由与状态管理
    • 第5章:工程化与性能
    • 第6章:测试与质量
    • 第7章:CSS 进阶专题
    • 第8章:项目实战A - SaaS仪表盘
    • 第9章:项目实战B - 内容社区
    • 第10章:Vue 内核深入
    • 第11章:微前端与部署
    • CSS 变量体系与主题系统深度指南
    • 前端安全与防护专题
    • CSS 专题:动效优化与微交互
    • CSS 专题:图片与图形处理
    • 实时通信与WebSocket专题
    • 开发工具与调试技巧专题
    • 新兴技术与未来趋势专题
    • 移动端开发与PWA专题
    • 附录A:代码片段库
    • 附录B:练习题集

第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)
  }
}

🎯 本章总结

通过本章学习,你掌握了:

  1. Vite 构建优化:分包策略、图片优化、预加载配置
  2. SSR 与 Nuxt3:数据获取、中间件、插件系统
  3. 可访问性:语义化标签、焦点管理、屏幕阅读器支持
  4. 安全防护:XSS/CSRF 防护、内容安全策略
  5. 国际化主题:多语言支持、动态主题切换
  6. 性能监控:Web Vitals、错误监控

📝 练习题

  1. 配置 Vite 构建优化,实现代码分割和图片压缩
  2. 创建一个支持 SSR 的 Nuxt3 页面,包含数据获取和 SEO 优化
  3. 实现一个可访问的模态框组件,支持键盘导航和屏幕阅读器
  4. 配置主题系统,支持浅色/深色/自定义主题切换
  5. 集成性能监控,收集 Web Vitals 和错误信息

🔗 相关资源

  • Vite 官方文档
  • Nuxt3 官方文档
  • Web 可访问性指南
  • Web Vitals
  • 内容安全策略
Prev
第4章:路由与状态管理
Next
第6章:测试与质量