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:练习题集

附录A:代码片段库

可复用的工具函数和组件代码片段,涵盖 Vue3、TypeScript、CSS 的常用模式和最佳实践。

📋 内容目录

  • Vue3 组合式函数
  • TypeScript 工具类型
  • CSS 工具类
  • 常用组件
  • 工具函数

🎯 Vue3 组合式函数

数据获取相关

useFetch - 通用数据获取

// composables/useFetch.ts
import { ref, computed, watch } from 'vue'

interface UseFetchOptions<T> {
  immediate?: boolean
  onSuccess?: (data: T) => void
  onError?: (error: Error) => void
  retry?: number
  retryDelay?: number
}

interface UseFetchReturn<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  execute: () => Promise<void>
  refresh: () => Promise<void>
  isReady: ComputedRef<boolean>
}

export function useFetch<T>(
  url: string | Ref<string>,
  options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
  const {
    immediate = true,
    onSuccess,
    onError,
    retry = 0,
    retryDelay = 1000
  } = options

  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)
  const retryCount = ref(0)

  const isReady = computed(() => !loading.value && !error.value && data.value !== null)

  const execute = async (): Promise<void> => {
    if (loading.value) return

    loading.value = true
    error.value = null

    try {
      const response = await fetch(unref(url))
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      const result = await response.json()
      data.value = result
      retryCount.value = 0
      onSuccess?.(result)
    } catch (err) {
      const errorObj = err instanceof Error ? err : new Error('Unknown error')
      error.value = errorObj
      onError?.(errorObj)

      // 重试逻辑
      if (retryCount.value < retry) {
        retryCount.value++
        setTimeout(() => {
          execute()
        }, retryDelay)
      }
    } finally {
      loading.value = false
    }
  }

  const refresh = () => execute()

  // 监听 URL 变化
  watch(url, () => {
    if (immediate) {
      execute()
    }
  }, { immediate })

  return {
    data: readonly(data),
    loading: readonly(loading),
    error: readonly(error),
    execute,
    refresh,
    isReady
  }
}

useAsyncData - 异步数据管理

// composables/useAsyncData.ts
import { ref, computed, watch } from 'vue'

interface UseAsyncDataOptions<T> {
  server?: boolean
  default?: () => T
  transform?: (data: any) => T
  watch?: any[]
  immediate?: boolean
}

interface UseAsyncDataReturn<T> {
  data: Ref<T | null>
  pending: Ref<boolean>
  error: Ref<Error | null>
  refresh: () => Promise<void>
  execute: () => Promise<void>
}

export function useAsyncData<T>(
  key: string,
  handler: () => Promise<T>,
  options: UseAsyncDataOptions<T> = {}
): UseAsyncDataReturn<T> {
  const {
    server = true,
    default: defaultValue,
    transform,
    watch: deps = [],
    immediate = true
  } = options

  const data = ref<T | null>(defaultValue?.() || null)
  const pending = ref(false)
  const error = ref<Error | null>(null)

  const execute = async (): Promise<void> => {
    if (pending.value) return

    pending.value = true
    error.value = null

    try {
      const result = await handler()
      data.value = transform ? transform(result) : result
    } catch (err) {
      error.value = err instanceof Error ? err : new Error('Unknown error')
    } finally {
      pending.value = false
    }
  }

  const refresh = () => execute()

  // 监听依赖变化
  if (deps.length > 0) {
    watch(deps, () => {
      if (immediate) {
        execute()
      }
    }, { immediate })
  } else if (immediate) {
    execute()
  }

  return {
    data: readonly(data),
    pending: readonly(pending),
    error: readonly(error),
    refresh,
    execute
  }
}

状态管理相关

useLocalStorage - 本地存储

// composables/useLocalStorage.ts
import { ref, watch } from 'vue'

export function useLocalStorage<T>(
  key: string,
  defaultValue: T,
  options: {
    serializer?: {
      read: (value: string) => T
      write: (value: T) => string
    }
    syncAcrossTabs?: boolean
  } = {}
): Ref<T> {
  const {
    serializer = JSON,
    syncAcrossTabs = true
  } = options

  const read = (): T => {
    try {
      const item = localStorage.getItem(key)
      return item ? serializer.read(item) : defaultValue
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error)
      return defaultValue
    }
  }

  const write = (value: T): void => {
    try {
      localStorage.setItem(key, serializer.write(value))
    } catch (error) {
      console.warn(`Error writing localStorage key "${key}":`, error)
    }
  }

  const storedValue = read()
  const state = ref<T>(storedValue) as Ref<T>

  // 监听状态变化,同步到 localStorage
  watch(state, (newValue) => {
    write(newValue)
  }, { deep: true })

  // 监听其他标签页的变化
  if (syncAcrossTabs) {
    window.addEventListener('storage', (e) => {
      if (e.key === key && e.newValue !== null) {
        state.value = serializer.read(e.newValue)
      }
    })
  }

  return state
}

useSessionStorage - 会话存储

// composables/useSessionStorage.ts
import { ref, watch } from 'vue'

export function useSessionStorage<T>(
  key: string,
  defaultValue: T,
  options: {
    serializer?: {
      read: (value: string) => T
      write: (value: T) => string
    }
  } = {}
): Ref<T> {
  const { serializer = JSON } = options

  const read = (): T => {
    try {
      const item = sessionStorage.getItem(key)
      return item ? serializer.read(item) : defaultValue
    } catch (error) {
      console.warn(`Error reading sessionStorage key "${key}":`, error)
      return defaultValue
    }
  }

  const write = (value: T): void => {
    try {
      sessionStorage.setItem(key, serializer.write(value))
    } catch (error) {
      console.warn(`Error writing sessionStorage key "${key}":`, error)
    }
  }

  const storedValue = read()
  const state = ref<T>(storedValue) as Ref<T>

  watch(state, (newValue) => {
    write(newValue)
  }, { deep: true })

  return state
}

交互相关

useDebounce - 防抖

// composables/useDebounce.ts
import { ref, watch, Ref } from 'vue'

export function useDebounce<T>(
  value: Ref<T>,
  delay: number = 300
): Ref<T> {
  const debouncedValue = ref(value.value) as Ref<T>
  let timeoutId: number | null = null

  watch(value, (newValue) => {
    if (timeoutId) {
      clearTimeout(timeoutId)
    }

    timeoutId = setTimeout(() => {
      debouncedValue.value = newValue
    }, delay)
  })

  return debouncedValue
}

useThrottle - 节流

// composables/useThrottle.ts
import { ref, watch, Ref } from 'vue'

export function useThrottle<T>(
  value: Ref<T>,
  delay: number = 300
): Ref<T> {
  const throttledValue = ref(value.value) as Ref<T>
  let lastUpdate = 0

  watch(value, (newValue) => {
    const now = Date.now()
    if (now - lastUpdate >= delay) {
      throttledValue.value = newValue
      lastUpdate = now
    }
  })

  return throttledValue
}

useClickOutside - 点击外部

// composables/useClickOutside.ts
import { ref, onMounted, onUnmounted, Ref } from 'vue'

export function useClickOutside(
  target: Ref<HTMLElement | null>,
  callback: (event: MouseEvent) => void
) {
  const isOutside = ref(false)

  const handleClick = (event: MouseEvent) => {
    if (target.value && !target.value.contains(event.target as Node)) {
      isOutside.value = true
      callback(event)
    } else {
      isOutside.value = false
    }
  }

  onMounted(() => {
    document.addEventListener('click', handleClick)
  })

  onUnmounted(() => {
    document.removeEventListener('click', handleClick)
  })

  return { isOutside }
}

useIntersectionObserver - 交叉观察器

// composables/useIntersectionObserver.ts
import { ref, onMounted, onUnmounted, Ref } from 'vue'

export function useIntersectionObserver(
  target: Ref<Element | null>,
  options: IntersectionObserverInit = {}
) {
  const isIntersecting = ref(false)
  const intersectionRatio = ref(0)
  const entry = ref<IntersectionObserverEntry | null>(null)

  let observer: IntersectionObserver | null = null

  const stop = () => {
    if (observer) {
      observer.disconnect()
      observer = null
    }
  }

  onMounted(() => {
    if (target.value) {
      observer = new IntersectionObserver(([entryData]) => {
        isIntersecting.value = entryData.isIntersecting
        intersectionRatio.value = entryData.intersectionRatio
        entry.value = entryData
      }, options)

      observer.observe(target.value)
    }
  })

  onUnmounted(() => {
    stop()
  })

  return {
    isIntersecting: readonly(isIntersecting),
    intersectionRatio: readonly(intersectionRatio),
    entry: readonly(entry),
    stop
  }
}

🔧 TypeScript 工具类型

基础工具类型

// types/utils.ts

// 深度只读
export type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]
}

// 深度可选
export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}

// 深度必需
export type DeepRequired<T> = {
  [P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P]
}

// 提取函数类型
export type ExtractFunction<T> = T extends (...args: any[]) => any ? T : never

// 提取 Promise 类型
export type ExtractPromise<T> = T extends Promise<infer U> ? U : never

// 提取数组元素类型
export type ExtractArray<T> = T extends (infer U)[] ? U : never

// 提取对象值类型
export type ExtractValues<T> = T extends Record<any, infer U> ? U : never

// 提取对象键类型
export type ExtractKeys<T> = T extends Record<infer K, any> ? K : never

Vue 相关工具类型

// types/vue.ts

// 提取组件 Props 类型
export type ExtractComponentProps<T> = T extends new (...args: any[]) => {
  $props: infer P
} ? P : never

// 提取组件 Emits 类型
export type ExtractComponentEmits<T> = T extends new (...args: any[]) => {
  $emit: (event: infer E, ...args: any[]) => any
} ? E : never

// 提取组合式函数返回类型
export type ExtractComposableReturn<T> = T extends (...args: any[]) => infer R ? R : never

// 提取 ref 类型
export type ExtractRefType<T> = T extends Ref<infer U> ? U : never

// 提取 computed 类型
export type ExtractComputedType<T> = T extends ComputedRef<infer U> ? U : never

// 提取 reactive 类型
export type ExtractReactiveType<T> = T extends Reactive<infer U> ? U : never

API 相关工具类型

// types/api.ts

// API 响应类型
export interface ApiResponse<T = any> {
  data: T
  status: number
  message: string
  success: boolean
}

// API 错误类型
export interface ApiError {
  message: string
  code: string
  details?: any
}

// 分页参数
export interface PaginationParams {
  page: number
  pageSize: number
  total?: number
}

// 分页响应
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: PaginationParams
}

// 排序参数
export interface SortParams {
  field: string
  order: 'asc' | 'desc'
}

// 筛选参数
export interface FilterParams {
  [key: string]: any
}

// 查询参数
export interface QueryParams extends PaginationParams {
  sort?: SortParams
  filter?: FilterParams
  search?: string
}

🎨 CSS 工具类

布局工具类

/* utilities/layout.css */

/* Flexbox 工具类 */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
.flex-wrap { flex-wrap: wrap; }
.flex-nowrap { flex-wrap: nowrap; }

.justify-start { justify-content: flex-start; }
.justify-center { justify-content: center; }
.justify-end { justify-content: flex-end; }
.justify-between { justify-content: space-between; }
.justify-around { justify-content: space-around; }

.items-start { align-items: flex-start; }
.items-center { align-items: center; }
.items-end { align-items: flex-end; }
.items-stretch { align-items: stretch; }
.items-baseline { align-items: baseline; }

.flex-1 { flex: 1; }
.flex-auto { flex: auto; }
.flex-none { flex: none; }

/* Grid 工具类 */
.grid { display: grid; }
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.grid-cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)); }

.col-span-1 { grid-column: span 1 / span 1; }
.col-span-2 { grid-column: span 2 / span 2; }
.col-span-3 { grid-column: span 3 / span 3; }
.col-span-4 { grid-column: span 4 / span 4; }
.col-span-6 { grid-column: span 6 / span 6; }
.col-span-12 { grid-column: span 12 / span 12; }

.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.gap-8 { gap: 2rem; }

/* 定位工具类 */
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.sticky { position: sticky; }

.top-0 { top: 0; }
.right-0 { right: 0; }
.bottom-0 { bottom: 0; }
.left-0 { left: 0; }

.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }

间距工具类

/* utilities/spacing.css */

/* Margin */
.m-0 { margin: 0; }
.m-1 { margin: 0.25rem; }
.m-2 { margin: 0.5rem; }
.m-3 { margin: 0.75rem; }
.m-4 { margin: 1rem; }
.m-6 { margin: 1.5rem; }
.m-8 { margin: 2rem; }

.mx-auto { margin-left: auto; margin-right: auto; }
.mx-0 { margin-left: 0; margin-right: 0; }
.mx-1 { margin-left: 0.25rem; margin-right: 0.25rem; }
.mx-2 { margin-left: 0.5rem; margin-right: 0.5rem; }

.my-0 { margin-top: 0; margin-bottom: 0; }
.my-1 { margin-top: 0.25rem; margin-bottom: 0.25rem; }
.my-2 { margin-top: 0.5rem; margin-bottom: 0.5rem; }

.mt-0 { margin-top: 0; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }

.mr-0 { margin-right: 0; }
.mr-1 { margin-right: 0.25rem; }
.mr-2 { margin-right: 0.5rem; }
.mr-4 { margin-right: 1rem; }

.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }

.ml-0 { margin-left: 0; }
.ml-1 { margin-left: 0.25rem; }
.ml-2 { margin-left: 0.5rem; }
.ml-4 { margin-left: 1rem; }

/* Padding */
.p-0 { padding: 0; }
.p-1 { padding: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.p-8 { padding: 2rem; }

.px-0 { padding-left: 0; padding-right: 0; }
.px-1 { padding-left: 0.25rem; padding-right: 0.25rem; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }

.py-0 { padding-top: 0; padding-bottom: 0; }
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }

.pt-0 { padding-top: 0; }
.pt-1 { padding-top: 0.25rem; }
.pt-2 { padding-top: 0.5rem; }
.pt-4 { padding-top: 1rem; }

.pr-0 { padding-right: 0; }
.pr-1 { padding-right: 0.25rem; }
.pr-2 { padding-right: 0.5rem; }
.pr-4 { padding-right: 1rem; }

.pb-0 { padding-bottom: 0; }
.pb-1 { padding-bottom: 0.25rem; }
.pb-2 { padding-bottom: 0.5rem; }
.pb-4 { padding-bottom: 1rem; }

.pl-0 { padding-left: 0; }
.pl-1 { padding-left: 0.25rem; }
.pl-2 { padding-left: 0.5rem; }
.pl-4 { padding-left: 1rem; }

文本工具类

/* utilities/typography.css */

/* 字体大小 */
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-base { font-size: 1rem; line-height: 1.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.text-4xl { font-size: 2.25rem; line-height: 2.5rem; }

/* 字体粗细 */
.font-thin { font-weight: 100; }
.font-extralight { font-weight: 200; }
.font-light { font-weight: 300; }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-extrabold { font-weight: 800; }
.font-black { font-weight: 900; }

/* 文本对齐 */
.text-left { text-align: left; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-justify { text-align: justify; }

/* 文本装饰 */
.underline { text-decoration: underline; }
.line-through { text-decoration: line-through; }
.no-underline { text-decoration: none; }

/* 文本转换 */
.uppercase { text-transform: uppercase; }
.lowercase { text-transform: lowercase; }
.capitalize { text-transform: capitalize; }
.normal-case { text-transform: none; }

/* 行高 */
.leading-none { line-height: 1; }
.leading-tight { line-height: 1.25; }
.leading-snug { line-height: 1.375; }
.leading-normal { line-height: 1.5; }
.leading-relaxed { line-height: 1.625; }
.leading-loose { line-height: 2; }

颜色工具类

/* utilities/colors.css */

/* 文本颜色 */
.text-primary { color: var(--color-primary); }
.text-secondary { color: var(--color-secondary); }
.text-success { color: var(--color-success); }
.text-warning { color: var(--color-warning); }
.text-error { color: var(--color-error); }
.text-info { color: var(--color-info); }

.text-gray-100 { color: var(--color-gray-100); }
.text-gray-200 { color: var(--color-gray-200); }
.text-gray-300 { color: var(--color-gray-300); }
.text-gray-400 { color: var(--color-gray-400); }
.text-gray-500 { color: var(--color-gray-500); }
.text-gray-600 { color: var(--color-gray-600); }
.text-gray-700 { color: var(--color-gray-700); }
.text-gray-800 { color: var(--color-gray-800); }
.text-gray-900 { color: var(--color-gray-900); }

/* 背景颜色 */
.bg-primary { background-color: var(--color-primary); }
.bg-secondary { background-color: var(--color-secondary); }
.bg-success { background-color: var(--color-success); }
.bg-warning { background-color: var(--color-warning); }
.bg-error { background-color: var(--color-error); }
.bg-info { background-color: var(--color-info); }

.bg-gray-50 { background-color: var(--color-gray-50); }
.bg-gray-100 { background-color: var(--color-gray-100); }
.bg-gray-200 { background-color: var(--color-gray-200); }
.bg-gray-300 { background-color: var(--color-gray-300); }
.bg-gray-400 { background-color: var(--color-gray-400); }
.bg-gray-500 { background-color: var(--color-gray-500); }
.bg-gray-600 { background-color: var(--color-gray-600); }
.bg-gray-700 { background-color: var(--color-gray-700); }
.bg-gray-800 { background-color: var(--color-gray-800); }
.bg-gray-900 { background-color: var(--color-gray-900); }

/* 边框颜色 */
.border-primary { border-color: var(--color-primary); }
.border-secondary { border-color: var(--color-secondary); }
.border-success { border-color: var(--color-success); }
.border-warning { border-color: var(--color-warning); }
.border-error { border-color: var(--color-error); }
.border-info { border-color: var(--color-info); }

.border-gray-200 { border-color: var(--color-gray-200); }
.border-gray-300 { border-color: var(--color-gray-300); }
.border-gray-400 { border-color: var(--color-gray-400); }
.border-gray-500 { border-color: var(--color-gray-500); }

🧩 常用组件

按钮组件

<!-- components/Button.vue -->
<template>
  <button
    :class="buttonClass"
    :disabled="disabled"
    :type="type"
    @click="handleClick"
  >
    <span v-if="loading" class="button__spinner"></span>
    <slot v-if="!loading" />
    <span v-if="loading" class="button__loading-text">加载中...</span>
  </button>
</template>

<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  loading?: boolean
  type?: 'button' | 'submit' | 'reset'
  block?: boolean
}

interface Emits {
  (e: 'click', event: MouseEvent): void
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  disabled: false,
  loading: false,
  type: 'button',
  block: false
})

const emit = defineEmits<Emits>()

const buttonClass = computed(() => [
  'button',
  `button--${props.variant}`,
  `button--${props.size}`,
  {
    'button--disabled': props.disabled,
    'button--loading': props.loading,
    'button--block': props.block
  }
])

const handleClick = (event: MouseEvent) => {
  if (!props.disabled && !props.loading) {
    emit('click', event)
  }
}
</script>

<style scoped>
.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  border: 1px solid transparent;
  border-radius: 0.375rem;
  font-weight: 500;
  text-decoration: none;
  cursor: pointer;
  transition: all 0.2s ease;
  position: relative;
  overflow: hidden;
}

.button:focus {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

/* 变体样式 */
.button--primary {
  background-color: var(--color-primary);
  color: white;
}

.button--primary:hover:not(.button--disabled) {
  background-color: var(--color-primary-hover);
}

.button--secondary {
  background-color: var(--color-secondary);
  color: white;
}

.button--secondary:hover:not(.button--disabled) {
  background-color: var(--color-secondary-hover);
}

.button--outline {
  background-color: transparent;
  color: var(--color-primary);
  border-color: var(--color-primary);
}

.button--outline:hover:not(.button--disabled) {
  background-color: var(--color-primary);
  color: white;
}

.button--ghost {
  background-color: transparent;
  color: var(--color-primary);
}

.button--ghost:hover:not(.button--disabled) {
  background-color: var(--color-primary-light);
}

.button--danger {
  background-color: var(--color-error);
  color: white;
}

.button--danger:hover:not(.button--disabled) {
  background-color: var(--color-error-hover);
}

/* 尺寸样式 */
.button--sm {
  padding: 0.25rem 0.75rem;
  font-size: 0.875rem;
}

.button--md {
  padding: 0.5rem 1rem;
  font-size: 1rem;
}

.button--lg {
  padding: 0.75rem 1.5rem;
  font-size: 1.125rem;
}

/* 状态样式 */
.button--disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.button--loading {
  cursor: not-allowed;
}

.button--block {
  width: 100%;
}

/* 加载动画 */
.button__spinner {
  width: 1rem;
  height: 1rem;
  border: 2px solid transparent;
  border-top: 2px solid currentColor;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

.button__loading-text {
  opacity: 0.8;
}
</style>

输入框组件

<!-- components/Input.vue -->
<template>
  <div class="input-wrapper" :class="wrapperClass">
    <label v-if="label" :for="id" class="input__label">
      {{ label }}
      <span v-if="required" class="input__required">*</span>
    </label>
    
    <div class="input__container">
      <input
        :id="id"
        :type="type"
        :value="modelValue"
        :placeholder="placeholder"
        :disabled="disabled"
        :required="required"
        :readonly="readonly"
        :autocomplete="autocomplete"
        class="input"
        :class="inputClass"
        @input="handleInput"
        @blur="handleBlur"
        @focus="handleFocus"
      />
      
      <div v-if="icon" class="input__icon">
        <component :is="icon" />
      </div>
    </div>
    
    <div v-if="error" class="input__error">
      {{ error }}
    </div>
    
    <div v-if="hint" class="input__hint">
      {{ hint }}
    </div>
  </div>
</template>

<script setup lang="ts">
interface Props {
  modelValue: string | number
  type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'
  label?: string
  placeholder?: string
  hint?: string
  error?: string
  icon?: any
  required?: boolean
  disabled?: boolean
  readonly?: boolean
  autocomplete?: string
  size?: 'sm' | 'md' | 'lg'
}

interface Emits {
  (e: 'update:modelValue', value: string | number): void
  (e: 'blur', event: FocusEvent): void
  (e: 'focus', event: FocusEvent): void
}

const props = withDefaults(defineProps<Props>(), {
  type: 'text',
  size: 'md'
})

const emit = defineEmits<Emits>()

const id = `input-${Math.random().toString(36).substr(2, 9)}`

const wrapperClass = computed(() => ({
  'input-wrapper--error': !!props.error,
  'input-wrapper--disabled': props.disabled,
  'input-wrapper--required': props.required
}))

const inputClass = computed(() => ({
  'input--error': !!props.error,
  'input--disabled': props.disabled,
  [`input--${props.size}`]: true
}))

const handleInput = (event: Event) => {
  const target = event.target as HTMLInputElement
  const value = props.type === 'number' ? Number(target.value) : target.value
  emit('update:modelValue', value)
}

const handleBlur = (event: FocusEvent) => emit('blur', event)
const handleFocus = (event: FocusEvent) => emit('focus', event)
</script>

<style scoped>
.input-wrapper {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.input__label {
  font-weight: 500;
  color: var(--color-text-primary);
  font-size: 0.875rem;
}

.input__required {
  color: var(--color-error);
}

.input__container {
  position: relative;
  display: flex;
  align-items: center;
}

.input {
  width: 100%;
  border: 1px solid var(--color-border-primary);
  border-radius: 0.375rem;
  background-color: var(--color-bg-primary);
  color: var(--color-text-primary);
  transition: all 0.2s ease;
}

.input:focus {
  outline: none;
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px rgba(66, 184, 131, 0.1);
}

.input::placeholder {
  color: var(--color-text-tertiary);
}

.input--sm {
  padding: 0.25rem 0.75rem;
  font-size: 0.875rem;
}

.input--md {
  padding: 0.5rem 0.75rem;
  font-size: 1rem;
}

.input--lg {
  padding: 0.75rem 1rem;
  font-size: 1.125rem;
}

.input--error {
  border-color: var(--color-error);
}

.input--error:focus {
  border-color: var(--color-error);
  box-shadow: 0 0 0 3px rgba(244, 67, 54, 0.1);
}

.input--disabled {
  background-color: var(--color-bg-tertiary);
  color: var(--color-text-tertiary);
  cursor: not-allowed;
}

.input__icon {
  position: absolute;
  right: 0.75rem;
  color: var(--color-text-tertiary);
  pointer-events: none;
}

.input__error {
  color: var(--color-error);
  font-size: 0.875rem;
}

.input__hint {
  color: var(--color-text-secondary);
  font-size: 0.875rem;
}
</style>

模态框组件

<!-- components/Modal.vue -->
<template>
  <Teleport to="body">
    <Transition name="modal">
      <div
        v-if="modelValue"
        class="modal-overlay"
        @click="handleOverlayClick"
      >
        <div
          ref="modalRef"
          class="modal"
          :class="modalClass"
          role="dialog"
          :aria-modal="true"
          :aria-labelledby="titleId"
          @click.stop
        >
          <div class="modal__header">
            <h2 :id="titleId" class="modal__title">
              {{ title }}
            </h2>
            <button
              v-if="closable"
              class="modal__close"
              @click="handleClose"
              aria-label="关闭"
            >
              <CloseIcon />
            </button>
          </div>
          
          <div class="modal__content">
            <slot />
          </div>
          
          <div v-if="$slots.footer" class="modal__footer">
            <slot name="footer" />
          </div>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import CloseIcon from './icons/CloseIcon.vue'

interface Props {
  modelValue: boolean
  title?: string
  size?: 'sm' | 'md' | 'lg' | 'xl'
  closable?: boolean
  closeOnOverlay?: boolean
  closeOnEscape?: boolean
}

interface Emits {
  (e: 'update:modelValue', value: boolean): void
  (e: 'close'): void
}

const props = withDefaults(defineProps<Props>(), {
  size: 'md',
  closable: true,
  closeOnOverlay: true,
  closeOnEscape: true
})

const emit = defineEmits<Emits>()

const modalRef = ref<HTMLElement>()
const titleId = `modal-title-${Math.random().toString(36).substr(2, 9)}`

const modalClass = computed(() => ({
  [`modal--${props.size}`]: true
}))

const handleClose = () => {
  emit('update:modelValue', false)
  emit('close')
}

const handleOverlayClick = () => {
  if (props.closeOnOverlay) {
    handleClose()
  }
}

const handleEscape = (event: KeyboardEvent) => {
  if (event.key === 'Escape' && props.closeOnEscape) {
    handleClose()
  }
}

watch(() => props.modelValue, async (isOpen) => {
  if (isOpen) {
    document.addEventListener('keydown', handleEscape)
    await nextTick()
    modalRef.value?.focus()
  } else {
    document.removeEventListener('keydown', handleEscape)
  }
})

onUnmounted(() => {
  document.removeEventListener('keydown', handleEscape)
})
</script>

<style scoped>
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: var(--z-modal);
  padding: 1rem;
}

.modal {
  background-color: var(--color-bg-primary);
  border-radius: 0.5rem;
  box-shadow: var(--shadow-xl);
  max-width: 100%;
  max-height: 100%;
  display: flex;
  flex-direction: column;
  outline: none;
}

.modal--sm {
  width: 24rem;
}

.modal--md {
  width: 32rem;
}

.modal--lg {
  width: 48rem;
}

.modal--xl {
  width: 64rem;
}

.modal__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.5rem 1.5rem 0 1.5rem;
}

.modal__title {
  font-size: 1.25rem;
  font-weight: 600;
  color: var(--color-text-primary);
  margin: 0;
}

.modal__close {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  border: none;
  background: none;
  color: var(--color-text-secondary);
  cursor: pointer;
  border-radius: 0.25rem;
  transition: all 0.2s ease;
}

.modal__close:hover {
  background-color: var(--color-bg-tertiary);
  color: var(--color-text-primary);
}

.modal__content {
  padding: 1.5rem;
  flex: 1;
  overflow-y: auto;
}

.modal__footer {
  padding: 0 1.5rem 1.5rem 1.5rem;
  display: flex;
  gap: 0.75rem;
  justify-content: flex-end;
}

/* 过渡动画 */
.modal-enter-active,
.modal-leave-active {
  transition: opacity 0.3s ease;
}

.modal-enter-active .modal,
.modal-leave-active .modal {
  transition: transform 0.3s ease;
}

.modal-enter-from,
.modal-leave-to {
  opacity: 0;
}

.modal-enter-from .modal,
.modal-leave-to .modal {
  transform: scale(0.95) translateY(-1rem);
}
</style>

🛠 工具函数

日期处理

// utils/date.ts

export function formatDate(date: Date | string, format: string = 'YYYY-MM-DD'): string {
  const d = new Date(date)
  
  const year = d.getFullYear()
  const month = String(d.getMonth() + 1).padStart(2, '0')
  const day = String(d.getDate()).padStart(2, '0')
  const hours = String(d.getHours()).padStart(2, '0')
  const minutes = String(d.getMinutes()).padStart(2, '0')
  const seconds = String(d.getSeconds()).padStart(2, '0')
  
  return format
    .replace('YYYY', String(year))
    .replace('MM', month)
    .replace('DD', day)
    .replace('HH', hours)
    .replace('mm', minutes)
    .replace('ss', seconds)
}

export function getRelativeTime(date: Date | string): string {
  const now = new Date()
  const target = new Date(date)
  const diff = now.getTime() - target.getTime()
  
  const seconds = Math.floor(diff / 1000)
  const minutes = Math.floor(seconds / 60)
  const hours = Math.floor(minutes / 60)
  const days = Math.floor(hours / 24)
  const months = Math.floor(days / 30)
  const years = Math.floor(months / 12)
  
  if (years > 0) return `${years}年前`
  if (months > 0) return `${months}个月前`
  if (days > 0) return `${days}天前`
  if (hours > 0) return `${hours}小时前`
  if (minutes > 0) return `${minutes}分钟前`
  return '刚刚'
}

export function isToday(date: Date | string): boolean {
  const today = new Date()
  const target = new Date(date)
  
  return today.getFullYear() === target.getFullYear() &&
         today.getMonth() === target.getMonth() &&
         today.getDate() === target.getDate()
}

export function isYesterday(date: Date | string): boolean {
  const yesterday = new Date()
  yesterday.setDate(yesterday.getDate() - 1)
  const target = new Date(date)
  
  return yesterday.getFullYear() === target.getFullYear() &&
         yesterday.getMonth() === target.getMonth() &&
         yesterday.getDate() === target.getDate()
}

字符串处理

// utils/string.ts

export function capitalize(str: string): string {
  return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}

export function camelCase(str: string): string {
  return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
}

export function kebabCase(str: string): string {
  return str.replace(/([A-Z])/g, '-$1').toLowerCase()
}

export function truncate(str: string, length: number, suffix: string = '...'): string {
  if (str.length <= length) return str
  return str.slice(0, length) + suffix
}

export function slugify(str: string): string {
  return str
    .toLowerCase()
    .replace(/[^\w\s-]/g, '')
    .replace(/[\s_-]+/g, '-')
    .replace(/^-+|-+$/g, '')
}

export function highlight(text: string, query: string): string {
  if (!query) return text
  
  const regex = new RegExp(`(${query})`, 'gi')
  return text.replace(regex, '<mark>$1</mark>')
}

数组处理

// utils/array.ts

export function unique<T>(array: T[]): T[] {
  return [...new Set(array)]
}

export function groupBy<T, K extends string | number>(
  array: T[],
  key: (item: T) => K
): Record<K, T[]> {
  return array.reduce((groups, item) => {
    const groupKey = key(item)
    if (!groups[groupKey]) {
      groups[groupKey] = []
    }
    groups[groupKey].push(item)
    return groups
  }, {} as Record<K, T[]>)
}

export function chunk<T>(array: T[], size: number): T[][] {
  const chunks: T[][] = []
  for (let i = 0; i < array.length; i += size) {
    chunks.push(array.slice(i, i + size))
  }
  return chunks
}

export function shuffle<T>(array: T[]): T[] {
  const shuffled = [...array]
  for (let i = shuffled.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
  }
  return shuffled
}

export function sortBy<T>(
  array: T[],
  key: (item: T) => string | number,
  direction: 'asc' | 'desc' = 'asc'
): T[] {
  return [...array].sort((a, b) => {
    const aVal = key(a)
    const bVal = key(b)
    
    if (aVal < bVal) return direction === 'asc' ? -1 : 1
    if (aVal > bVal) return direction === 'asc' ? 1 : -1
    return 0
  })
}

对象处理

// utils/object.ts

export function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>
  keys.forEach(key => {
    if (key in obj) {
      result[key] = obj[key]
    }
  })
  return result
}

export function omit<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
  const result = { ...obj }
  keys.forEach(key => {
    delete result[key]
  })
  return result
}

export function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>): T {
  const result = { ...target }
  
  for (const key in source) {
    if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
      result[key] = deepMerge(result[key] || {}, source[key])
    } else {
      result[key] = source[key] as T[Extract<keyof T, string>]
    }
  }
  
  return result
}

export function isEmpty(obj: any): boolean {
  if (obj == null) return true
  if (Array.isArray(obj) || typeof obj === 'string') return obj.length === 0
  if (typeof obj === 'object') return Object.keys(obj).length === 0
  return false
}

export function get(obj: any, path: string, defaultValue?: any): any {
  const keys = path.split('.')
  let result = obj
  
  for (const key of keys) {
    if (result == null || typeof result !== 'object') {
      return defaultValue
    }
    result = result[key]
  }
  
  return result !== undefined ? result : defaultValue
}

验证函数

// utils/validation.ts

export function isEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  return emailRegex.test(email)
}

export function isPhone(phone: string): boolean {
  const phoneRegex = /^1[3-9]\d{9}$/
  return phoneRegex.test(phone)
}

export function isUrl(url: string): boolean {
  try {
    new URL(url)
    return true
  } catch {
    return false
  }
}

export function isStrongPassword(password: string): boolean {
  // 至少8位,包含大小写字母、数字和特殊字符
  const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
  return strongPasswordRegex.test(password)
}

export function isIdCard(idCard: string): boolean {
  const idCardRegex = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/
  return idCardRegex.test(idCard)
}

export function validateForm<T extends Record<string, any>>(
  data: T,
  rules: Record<keyof T, (value: any) => string | null>
): Record<keyof T, string> {
  const errors = {} as Record<keyof T, string>
  
  for (const [field, rule] of Object.entries(rules)) {
    const error = rule(data[field as keyof T])
    if (error) {
      errors[field as keyof T] = error
    }
  }
  
  return errors
}

📚 使用说明

安装依赖

# 安装必要的依赖
pnpm add @vueuse/core

导入使用

// 在组件中使用组合式函数
import { useFetch, useLocalStorage } from '@/composables'

// 在组件中使用工具函数
import { formatDate, isEmail } from '@/utils'

// 在组件中使用工具类型
import type { ApiResponse, PaginationParams } from '@/types'

自定义扩展

// 扩展组合式函数
export function useCustomHook() {
  // 自定义逻辑
  return {
    // 返回值
  }
}

// 扩展工具类型
export type CustomType<T> = {
  // 自定义类型定义
}

这个代码片段库提供了 Vue3 + TypeScript + CSS 开发中常用的工具函数、组合式函数、组件和类型定义。你可以根据项目需求进行扩展和定制。记住要保持代码的简洁性和可维护性,遵循最佳实践。

Prev
移动端开发与PWA专题
Next
附录B:练习题集