dramaling-app/docs/04_technical/03_frontend/native-development-standard...

33 KiB
Raw Blame History

原生前端開發規範和最佳實踐

📋 總體指導原則

核心原則

  1. 語義化優先: HTML結構語義化提升可讀性和無障礙性
  2. 漸進增強: 基於內容層、表現層、行為層的分離原則
  3. 效能考量: 避免不必要的DOM操作和記憶體洩漏
  4. 可維護性: 編寫易於理解、修改和擴展的代碼
  5. 標準相容: 遵循Web標準確保跨瀏覽器相容性

技術選型標準

  • HTML5: 語義化標記現代HTML特性
  • CSS3: 現代CSS特性SCSS預處理器
  • ES2022+: 現代JavaScript特性可選TypeScript
  • Web Components: 自定義元素,封裝重用組件
  • Progressive Enhancement: 漸進增強的設計理念

🏗️ HTML 開發規範

文檔結構標準

基本文檔模板

<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="description" content="頁面描述">
  <title>頁面標題 - Drama Ling</title>
  
  <!-- Preconnect to external domains -->
  <link rel="preconnect" href="https://api.dramaling.com">
  
  <!-- CSS -->
  <link rel="stylesheet" href="/styles/main.css">
  
  <!-- PWA Manifest -->
  <link rel="manifest" href="/manifest.json">
  
  <!-- Favicon -->
  <link rel="icon" href="/favicon.ico" sizes="any">
  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
</head>
<body>
  <!-- Skip link for accessibility -->
  <a class="skip-link" href="#main-content">跳至主要內容</a>
  
  <!-- Header -->
  <header class="site-header" role="banner">
    <!-- Navigation -->
  </header>
  
  <!-- Main content -->
  <main id="main-content" role="main">
    <!-- Page content -->
  </main>
  
  <!-- Footer -->
  <footer class="site-footer" role="contentinfo">
    <!-- Footer content -->
  </footer>
  
  <!-- JavaScript -->
  <script type="module" src="/scripts/main.js"></script>
</body>
</html>

語義化標記規範

正確使用語義化元素

<!-- ✅ 正確:使用語義化標記 -->
<article class="vocabulary-lesson">
  <header class="lesson-header">
    <h2 class="lesson-title">今日詞彙</h2>
    <time datetime="2025-09-10" class="lesson-date">2025年9月10日</time>
  </header>
  
  <section class="word-introduction">
    <h3>單字介紹</h3>
    <p>這個單字的定義是...</p>
  </section>
  
  <aside class="word-notes">
    <h4>學習筆記</h4>
    <p>重點提醒...</p>
  </aside>
  
  <footer class="lesson-actions">
    <button type="button" class="btn btn-primary">開始練習</button>
  </footer>
</article>

<!-- ❌ 錯誤濫用div和span -->
<div class="vocabulary-lesson">
  <div class="lesson-header">
    <div class="lesson-title">今日詞彙</div>
    <div class="lesson-date">2025年9月10日</div>
  </div>
  
  <div class="word-introduction">
    <div class="section-title">單字介紹</div>
    <div>這個單字的定義是...</div>
  </div>
</div>

表單設計規範

<!-- 完整的表單結構 -->
<form class="login-form" novalidate>
  <fieldset>
    <legend class="sr-only">登入資訊</legend>
    
    <div class="form-group">
      <label for="email" class="form-label">
        電子郵件
        <span class="required" aria-label="必填">*</span>
      </label>
      <input 
        type="email" 
        id="email" 
        name="email" 
        class="form-input"
        required
        aria-describedby="email-error"
        autocomplete="email"
      >
      <div id="email-error" class="form-error" role="alert" aria-live="polite">
        <!-- 錯誤訊息將在此顯示 -->
      </div>
    </div>
    
    <div class="form-group">
      <label for="password" class="form-label">
        密碼
        <span class="required" aria-label="必填">*</span>
      </label>
      <input 
        type="password" 
        id="password" 
        name="password" 
        class="form-input"
        required
        aria-describedby="password-error password-help"
        autocomplete="current-password"
      >
      <div id="password-help" class="form-help">
        密碼需要至少8個字符
      </div>
      <div id="password-error" class="form-error" role="alert" aria-live="polite">
        <!-- 錯誤訊息將在此顯示 -->
      </div>
    </div>
  </fieldset>
  
  <div class="form-actions">
    <button type="submit" class="btn btn-primary">登入</button>
    <button type="button" class="btn btn-secondary">取消</button>
  </div>
</form>

無障礙設計規範

ARIA屬性使用

<!-- 互動組件的ARIA標記 -->
<div class="vocabulary-card" role="button" tabindex="0" 
     aria-expanded="false" aria-controls="word-definition">
  <div class="word-front">
    <h3 class="word-text">Hello</h3>
    <span class="pronunciation">/həˈloʊ/</span>
  </div>
</div>

<div id="word-definition" class="word-definition" aria-hidden="true">
  <p>你好,哈囉</p>
</div>

<!-- 動態內容更新 -->
<div class="practice-result" role="status" aria-live="polite">
  <!-- 練習結果將在此顯示 -->
</div>

<!-- 模態對話框 -->
<div class="modal" role="dialog" aria-labelledby="modal-title" 
     aria-describedby="modal-description" aria-modal="true">
  <div class="modal-content">
    <h2 id="modal-title">練習完成</h2>
    <p id="modal-description">恭喜你完成了這個練習!</p>
    <button type="button" class="btn btn-primary" data-close-modal>確定</button>
  </div>
</div>

🎨 CSS 開發規範

SCSS 組織結構

模組化SCSS架構

// styles/main.scss

// 1. 工具層 - 變數、函數、混合器
@import 'base/variables';
@import 'base/functions';  
@import 'base/mixins';

// 2. 基礎層 - 重置、字體、佈局
@import 'base/reset';
@import 'base/typography';
@import 'base/layout';

// 3. 組件層 - UI組件樣式
@import 'components/buttons';
@import 'components/forms';
@import 'components/cards';
@import 'components/navigation';
@import 'components/modals';

// 4. 頁面層 - 頁面特定樣式
@import 'pages/home';
@import 'pages/vocabulary';
@import 'pages/auth';

// 5. 工具層 - 工具類
@import 'utilities/spacing';
@import 'utilities/colors';
@import 'utilities/typography';
@import 'utilities/responsive';

命名規範

BEM方法論

// ✅ 正確BEM命名
.vocabulary-card {
  display: flex;
  padding: $spacing-md;
  
  &__header {
    display: flex;
    justify-content: space-between;
    margin-bottom: $spacing-sm;
    
    &--highlighted {
      background-color: $color-primary-light;
    }
  }
  
  &__content {
    flex: 1;
  }
  
  &__word {
    font-size: $font-size-lg;
    font-weight: 600;
    color: $color-primary;
    
    &--difficult {
      color: $color-warning;
    }
  }
  
  &--disabled {
    opacity: 0.5;
    pointer-events: none;
  }
  
  &--loading {
    position: relative;
    
    &::after {
      content: '';
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      width: 20px;
      height: 20px;
      border: 2px solid $color-primary;
      border-top-color: transparent;
      border-radius: 50%;
      animation: spin 1s linear infinite;
    }
  }
}

// ❌ 錯誤:嵌套過深和命名不清楚
.vocabulary-card {
  .header {
    .title {
      .word {
        .text {
          color: blue; // 硬編碼顏色
        }
      }
    }
  }
}

CSS自定義屬性CSS Variables

// styles/base/_variables.scss

:root {
  // 色彩系統
  --color-primary: #1976d2;
  --color-primary-light: #42a5f5;
  --color-primary-dark: #1565c0;
  
  --color-secondary: #26a69a;
  --color-accent: #9c27b0;
  
  --color-success: #21ba45;
  --color-warning: #f2c037;
  --color-error: #c10015;
  --color-info: #31ccec;
  
  // 灰階
  --color-grey-50: #fafafa;
  --color-grey-100: #f5f5f5;
  --color-grey-200: #eeeeee;
  --color-grey-300: #e0e0e0;
  --color-grey-400: #bdbdbd;
  --color-grey-500: #9e9e9e;
  
  // 字體系統
  --font-family-primary: 'Inter', 'Noto Sans TC', sans-serif;
  --font-family-secondary: 'Roboto', 'Microsoft JhengHei', sans-serif;
  --font-family-mono: 'JetBrains Mono', 'Consolas', monospace;
  
  --font-size-xs: 0.75rem;    // 12px
  --font-size-sm: 0.875rem;   // 14px  
  --font-size-base: 1rem;     // 16px
  --font-size-lg: 1.125rem;   // 18px
  --font-size-xl: 1.25rem;    // 20px
  --font-size-2xl: 1.5rem;    // 24px
  --font-size-3xl: 1.875rem;  // 30px
  
  // 間距系統
  --spacing-xs: 0.25rem;   // 4px
  --spacing-sm: 0.5rem;    // 8px
  --spacing-md: 1rem;      // 16px
  --spacing-lg: 1.5rem;    // 24px
  --spacing-xl: 2rem;      // 32px
  --spacing-2xl: 3rem;     // 48px
  --spacing-3xl: 4rem;     // 64px
  
  // 邊框圓角
  --border-radius-sm: 0.25rem;  // 4px
  --border-radius-md: 0.5rem;   // 8px
  --border-radius-lg: 0.75rem;  // 12px
  --border-radius-xl: 1rem;     // 16px
  --border-radius-full: 9999px;
  
  // 陰影系統
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
  --shadow-base: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
  
  // 轉場動畫
  --transition-fast: 150ms ease;
  --transition-base: 200ms ease;
  --transition-slow: 300ms ease;
  
  // Z-index層級
  --z-dropdown: 1000;
  --z-sticky: 1020;
  --z-fixed: 1030;
  --z-modal: 1040;
  --z-popover: 1050;
  --z-tooltip: 1060;
  --z-toast: 1070;
}

// 深色模式支援
@media (prefers-color-scheme: dark) {
  :root {
    --color-primary: #42a5f5;
    --color-primary-light: #90caf9;
    --color-primary-dark: #1976d2;
    
    // 重新定義深色模式下的色彩
  }
}

響應式設計規範

Mobile-First 方法

// styles/base/_mixins.scss

// 響應式斷點
$breakpoints: (
  'xs': 0,
  'sm': 600px,
  'md': 1024px, 
  'lg': 1440px,
  'xl': 1920px
);

// 響應式混合器
@mixin respond-above($breakpoint) {
  $bp-value: map-get($breakpoints, $breakpoint);
  @if $bp-value == 0 {
    @content;
  } @else {
    @media (min-width: $bp-value) {
      @content;
    }
  }
}

@mixin respond-below($breakpoint) {
  $bp-value: map-get($breakpoints, $breakpoint);
  @media (max-width: $bp-value - 1px) {
    @content;
  }
}

@mixin respond-between($lower, $upper) {
  $lower-bp: map-get($breakpoints, $lower);
  $upper-bp: map-get($breakpoints, $upper);
  
  @media (min-width: $lower-bp) and (max-width: $upper-bp - 1px) {
    @content;
  }
}

// 使用範例
.vocabulary-grid {
  display: grid;
  gap: var(--spacing-md);
  
  // Mobile First - 預設單欄
  grid-template-columns: 1fr;
  
  // 平板:雙欄
  @include respond-above('sm') {
    grid-template-columns: repeat(2, 1fr);
  }
  
  // 桌面:三欄
  @include respond-above('md') {
    grid-template-columns: repeat(3, 1fr);
    gap: var(--spacing-lg);
  }
  
  // 大螢幕:四欄
  @include respond-above('lg') {
    grid-template-columns: repeat(4, 1fr);
  }
}

CSS Grid 和 Flexbox 最佳實踐

// Flexbox工具類
.flex {
  display: flex;
  
  &-col { flex-direction: column; }
  &-row { flex-direction: row; }
  &-wrap { flex-wrap: wrap; }
  &-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; }
}

// CSS Grid工具類
.grid {
  display: grid;
  
  &-cols-1 { grid-template-columns: repeat(1, 1fr); }
  &-cols-2 { grid-template-columns: repeat(2, 1fr); }
  &-cols-3 { grid-template-columns: repeat(3, 1fr); }
  &-cols-4 { grid-template-columns: repeat(4, 1fr); }
  
  &-rows-1 { grid-template-rows: repeat(1, 1fr); }
  &-rows-2 { grid-template-rows: repeat(2, 1fr); }
  &-rows-3 { grid-template-rows: repeat(3, 1fr); }
  
  &-gap-sm { gap: var(--spacing-sm); }
  &-gap-md { gap: var(--spacing-md); }
  &-gap-lg { gap: var(--spacing-lg); }
}

// 實際應用範例
.page-layout {
  min-height: 100vh;
  display: grid;
  grid-template-rows: auto 1fr auto;
  grid-template-areas: 
    "header"
    "main"
    "footer";
  
  .site-header {
    grid-area: header;
  }
  
  .main-content {
    grid-area: main;
    padding: var(--spacing-lg);
  }
  
  .site-footer {
    grid-area: footer;
  }
}

📜 JavaScript 開發規範

代碼組織和模組化

ES6模組系統

// ✅ 正確:清晰的導入導出
// utils/api.js
export class APIClient {
  constructor(baseURL) {
    this.baseURL = baseURL
  }
  
  async get(endpoint, options = {}) {
    // 實作
  }
}

export const httpClient = new APIClient('/api')

// services/vocabularyService.js
import { httpClient } from '../utils/api.js'

export class VocabularyService {
  async getWord(id) {
    return await httpClient.get(`/vocabulary/${id}`)
  }
}

// 默認導出
export default VocabularyService

// ❌ 錯誤:混亂的導入導出
export { APIClient, httpClient, VocabularyService, someOtherThing }

函數和類別設計規範

函數設計原則

// ✅ 正確:單一職責、純函數
function calculateWordDifficulty(word, userLevel, attempts) {
  if (!word || userLevel < 1 || attempts < 0) {
    throw new Error('Invalid parameters for difficulty calculation')
  }
  
  const baseDifficulty = word.difficulty || 1
  const levelAdjustment = Math.max(0, baseDifficulty - userLevel)
  const attemptBonus = Math.min(0.5, attempts * 0.1)
  
  return Math.max(0.1, levelAdjustment - attemptBonus)
}

// 高階函數範例
function createThrottledFunction(func, delay) {
  let timeoutId = null
  let lastArgs = null
  
  return function(...args) {
    lastArgs = args
    
    if (timeoutId === null) {
      timeoutId = setTimeout(() => {
        func.apply(this, lastArgs)
        timeoutId = null
      }, delay)
    }
  }
}

// 使用範例
const throttledSaveProgress = createThrottledFunction(saveProgress, 1000)

// ❌ 錯誤:職責不明確、副作用多
function processWord(word) {
  // 計算難度
  const difficulty = word.difficulty - user.level + attempts * 0.1
  
  // 更新UI - 不應該在這個函數中
  document.getElementById('difficulty').textContent = difficulty
  
  // 發送API請求 - 不應該在這個函數中
  fetch('/api/update-difficulty', {
    method: 'POST',
    body: JSON.stringify({ wordId: word.id, difficulty })
  })
  
  // 記錄日誌 - 副作用
  console.log('Processed word:', word.id)
  
  return difficulty
}

類別設計規範

// ✅ 正確:清晰的類別設計
class VocabularyCard {
  #state = {}  // 私有屬性
  #observers = new Set()  // 私有屬性
  
  constructor(element, options = {}) {
    this.element = element
    this.options = { ...this.constructor.defaults, ...options }
    this.#state = this.#initializeState()
    this.#bindEventListeners()
  }
  
  // 靜態屬性
  static defaults = {
    showPronunciation: true,
    enableAudio: true,
    flipOnClick: true
  }
  
  // 公開方法
  setState(newState) {
    const oldState = { ...this.#state }
    this.#state = { ...this.#state, ...newState }
    this.#notifyObservers(oldState, this.#state)
    this.render()
  }
  
  getState() {
    return { ...this.#state }  // 返回副本
  }
  
  subscribe(observer) {
    this.#observers.add(observer)
    return () => this.#observers.delete(observer)  // 返回取消訂閱函數
  }
  
  destroy() {
    this.#cleanup()
    this.#observers.clear()
  }
  
  // 私有方法
  #initializeState() {
    return {
      isFlipped: false,
      isLoading: false,
      word: null,
      error: null
    }
  }
  
  #bindEventListeners() {
    this.#handleClick = this.#handleClick.bind(this)
    this.element.addEventListener('click', this.#handleClick)
  }
  
  #handleClick(event) {
    if (this.options.flipOnClick && !this.#state.isLoading) {
      this.setState({ isFlipped: !this.#state.isFlipped })
    }
  }
  
  #notifyObservers(oldState, newState) {
    for (const observer of this.#observers) {
      try {
        observer(newState, oldState)
      } catch (error) {
        console.error('Observer error:', error)
      }
    }
  }
  
  #cleanup() {
    this.element.removeEventListener('click', this.#handleClick)
  }
  
  render() {
    // 渲染邏輯
    if (this.#state.isLoading) {
      this.element.classList.add('loading')
    } else {
      this.element.classList.remove('loading')
    }
    
    if (this.#state.isFlipped) {
      this.element.classList.add('flipped')
    } else {
      this.element.classList.remove('flipped')
    }
  }
}

異步程式設計規範

Promise 和 async/await

// ✅ 正確:清晰的異步處理
class VocabularyAPI {
  constructor() {
    this.baseURL = '/api/vocabulary'
    this.cache = new Map()
  }
  
  async getWord(id, options = {}) {
    const { useCache = true, timeout = 5000 } = options
    
    // 檢查快取
    if (useCache && this.cache.has(id)) {
      return this.cache.get(id)
    }
    
    try {
      const response = await this.#fetchWithTimeout(
        `${this.baseURL}/words/${id}`,
        { timeout }
      )
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }
      
      const word = await response.json()
      
      // 驗證數據結構
      this.#validateWordData(word)
      
      // 更新快取
      if (useCache) {
        this.cache.set(id, word)
      }
      
      return word
    } catch (error) {
      console.error(`Failed to fetch word ${id}:`, error)
      throw new APIError(`Failed to load word: ${error.message}`, {
        cause: error,
        wordId: id
      })
    }
  }
  
  async #fetchWithTimeout(url, { timeout, ...options } = {}) {
    const controller = new AbortController()
    const timeoutId = setTimeout(() => controller.abort(), timeout)
    
    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      })
      return response
    } finally {
      clearTimeout(timeoutId)
    }
  }
  
  #validateWordData(word) {
    const required = ['id', 'word', 'definition_zh']
    const missing = required.filter(field => !(field in word))
    
    if (missing.length > 0) {
      throw new Error(`Missing required fields: ${missing.join(', ')}`)
    }
  }
}

// 自定義錯誤類型
class APIError extends Error {
  constructor(message, options = {}) {
    super(message, options)
    this.name = 'APIError'
    this.timestamp = Date.now()
  }
}

// ❌ 錯誤:錯誤處理不當
async function getWord(id) {
  const response = await fetch(`/api/words/${id}`)  // 沒有錯誤處理
  const word = await response.json()  // 沒有檢查回應狀態
  return word  // 沒有數據驗證
}

事件系統設計

// 自定義事件發射器
class EventEmitter {
  constructor() {
    this.events = new Map()
  }
  
  on(eventName, listener) {
    if (!this.events.has(eventName)) {
      this.events.set(eventName, new Set())
    }
    
    this.events.get(eventName).add(listener)
    
    // 返回取消監聽函數
    return () => this.off(eventName, listener)
  }
  
  once(eventName, listener) {
    const onceListener = (...args) => {
      this.off(eventName, onceListener)
      listener.apply(this, args)
    }
    
    return this.on(eventName, onceListener)
  }
  
  off(eventName, listener) {
    const listeners = this.events.get(eventName)
    if (listeners) {
      listeners.delete(listener)
      if (listeners.size === 0) {
        this.events.delete(eventName)
      }
    }
  }
  
  emit(eventName, ...args) {
    const listeners = this.events.get(eventName)
    if (listeners) {
      for (const listener of listeners) {
        try {
          listener.apply(this, args)
        } catch (error) {
          console.error(`Error in event listener for "${eventName}":`, error)
        }
      }
    }
  }
  
  removeAllListeners(eventName) {
    if (eventName) {
      this.events.delete(eventName)
    } else {
      this.events.clear()
    }
  }
}

// 使用範例
const learningEvents = new EventEmitter()

// 監聽事件
const unsubscribe = learningEvents.on('wordCompleted', (word, score) => {
  console.log(`Completed word: ${word.text}, Score: ${score}`)
  updateProgress(word, score)
})

// 觸發事件
learningEvents.emit('wordCompleted', currentWord, 85)

// 清理
unsubscribe()

DOM 操作最佳實踐

安全的DOM操作

// ✅ 正確安全的DOM操作
class DOMHelper {
  static createElement(tag, options = {}) {
    const element = document.createElement(tag)
    
    if (options.className) {
      element.className = options.className
    }
    
    if (options.attributes) {
      Object.entries(options.attributes).forEach(([key, value]) => {
        element.setAttribute(key, value)
      })
    }
    
    if (options.textContent) {
      element.textContent = options.textContent  // 安全地設置文字
    }
    
    if (options.innerHTML) {
      // 清理HTML內容
      element.innerHTML = this.sanitizeHTML(options.innerHTML)
    }
    
    return element
  }
  
  static sanitizeHTML(html) {
    // 簡單的HTML清理實際專案中應使用專業庫如DOMPurify
    const temp = document.createElement('div')
    temp.textContent = html
    return temp.innerHTML
  }
  
  static findElement(selector, context = document) {
    const element = context.querySelector(selector)
    if (!element) {
      throw new Error(`Element not found: ${selector}`)
    }
    return element
  }
  
  static findElements(selector, context = document) {
    return Array.from(context.querySelectorAll(selector))
  }
  
  static onReady(callback) {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', callback)
    } else {
      callback()
    }
  }
}

// 批量DOM更新
class DOMBatcher {
  constructor() {
    this.updates = []
    this.isScheduled = false
  }
  
  schedule(updateFunction) {
    this.updates.push(updateFunction)
    
    if (!this.isScheduled) {
      this.isScheduled = true
      requestAnimationFrame(() => {
        this.flush()
      })
    }
  }
  
  flush() {
    const updates = this.updates.splice(0)
    updates.forEach(update => {
      try {
        update()
      } catch (error) {
        console.error('DOM update error:', error)
      }
    })
    this.isScheduled = false
  }
}

const domBatcher = new DOMBatcher()

// 使用範例
function updateWordCards(words) {
  words.forEach(word => {
    domBatcher.schedule(() => {
      const cardElement = document.getElementById(`word-${word.id}`)
      if (cardElement) {
        cardElement.textContent = word.text
        cardElement.className = `word-card difficulty-${word.difficulty}`
      }
    })
  })
}

// ❌ 錯誤不安全的DOM操作
function updateCard(word) {
  const card = document.getElementById('word-card')
  card.innerHTML = `<h3>${word.text}</h3><p>${word.definition}</p>`  // XSS風險
  card.style.color = 'red'  // 直接操作style
}

🔒 安全性最佳實踐

輸入驗證和清理

// 輸入驗證工具類
class InputValidator {
  static patterns = {
    email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
    password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
    username: /^[a-zA-Z0-9_]{3,20}$/,
    phone: /^\+?[\d\s\-\(\)]+$/
  }
  
  static validate(input, type, options = {}) {
    if (typeof input !== 'string') {
      return { valid: false, error: 'Input must be a string' }
    }
    
    // 長度檢查
    const { minLength = 0, maxLength = 1000 } = options
    if (input.length < minLength || input.length > maxLength) {
      return { 
        valid: false, 
        error: `Length must be between ${minLength} and ${maxLength}` 
      }
    }
    
    // 模式檢查
    if (this.patterns[type] && !this.patterns[type].test(input)) {
      return { valid: false, error: `Invalid ${type} format` }
    }
    
    return { valid: true }
  }
  
  static sanitizeString(input) {
    return input
      .replace(/[<>'"&]/g, (match) => {
        const escapeMap = {
          '<': '&lt;',
          '>': '&gt;',
          '"': '&quot;',
          "'": '&#x27;',
          '&': '&amp;'
        }
        return escapeMap[match]
      })
      .trim()
  }
  
  static normalizeWhitespace(input) {
    return input.replace(/\s+/g, ' ').trim()
  }
}

// 表單驗證範例
class FormValidator {
  constructor(form) {
    this.form = form
    this.errors = new Map()
    this.rules = new Map()
  }
  
  addRule(fieldName, validator) {
    if (!this.rules.has(fieldName)) {
      this.rules.set(fieldName, [])
    }
    this.rules.get(fieldName).push(validator)
    return this
  }
  
  validate() {
    this.errors.clear()
    
    for (const [fieldName, validators] of this.rules) {
      const field = this.form.querySelector(`[name="${fieldName}"]`)
      if (!field) continue
      
      const value = field.value
      
      for (const validator of validators) {
        const result = validator(value)
        if (!result.valid) {
          this.errors.set(fieldName, result.error)
          break  // 只顯示第一個錯誤
        }
      }
    }
    
    this.displayErrors()
    return this.errors.size === 0
  }
  
  displayErrors() {
    // 清除舊錯誤
    this.form.querySelectorAll('.form-error').forEach(el => {
      el.textContent = ''
    })
    
    // 顯示新錯誤
    for (const [fieldName, error] of this.errors) {
      const errorElement = this.form.querySelector(`#${fieldName}-error`)
      if (errorElement) {
        errorElement.textContent = error
      }
    }
  }
}

XSS 和 CSRF 防護

// CSRF Token管理
class CSRFManager {
  constructor() {
    this.token = null
    this.refreshPromise = null
  }
  
  async getToken() {
    if (!this.token || this.isTokenExpired()) {
      if (!this.refreshPromise) {
        this.refreshPromise = this.refreshToken()
      }
      await this.refreshPromise
      this.refreshPromise = null
    }
    return this.token
  }
  
  async refreshToken() {
    try {
      const response = await fetch('/api/csrf-token', {
        method: 'GET',
        credentials: 'same-origin'
      })
      
      if (!response.ok) {
        throw new Error('Failed to get CSRF token')
      }
      
      const data = await response.json()
      this.token = data.token
      this.tokenExpiry = Date.now() + (data.expiresIn * 1000)
    } catch (error) {
      console.error('CSRF token refresh failed:', error)
      throw error
    }
  }
  
  isTokenExpired() {
    return !this.tokenExpiry || Date.now() >= this.tokenExpiry
  }
}

// Content Security Policy 協助函數
class CSPHelper {
  static createNonce() {
    const array = new Uint8Array(16)
    crypto.getRandomValues(array)
    return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
  }
  
  static addNonceToScript(scriptContent) {
    const nonce = this.createNonce()
    const meta = document.createElement('meta')
    meta.httpEquiv = 'Content-Security-Policy'
    meta.content = `script-src 'self' 'nonce-${nonce}'`
    document.head.appendChild(meta)
    
    const script = document.createElement('script')
    script.nonce = nonce
    script.textContent = scriptContent
    return script
  }
}

🧪 測試規範

單元測試規範

// tests/utils/inputValidator.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { InputValidator } from '../../src/utils/inputValidator.js'

describe('InputValidator', () => {
  describe('email validation', () => {
    it('should accept valid email addresses', () => {
      const validEmails = [
        'test@example.com',
        'user.name@domain.co.uk',
        'user+tag@example.org'
      ]
      
      validEmails.forEach(email => {
        const result = InputValidator.validate(email, 'email')
        expect(result.valid).toBe(true)
      })
    })
    
    it('should reject invalid email addresses', () => {
      const invalidEmails = [
        'invalid-email',
        '@example.com',
        'user@',
        'user@.com'
      ]
      
      invalidEmails.forEach(email => {
        const result = InputValidator.validate(email, 'email')
        expect(result.valid).toBe(false)
        expect(result.error).toBeDefined()
      })
    })
    
    it('should enforce length limits', () => {
      const shortEmail = 'a@b.c'
      const longEmail = 'a'.repeat(100) + '@example.com'
      
      const shortResult = InputValidator.validate(shortEmail, 'email', { minLength: 10 })
      expect(shortResult.valid).toBe(false)
      
      const longResult = InputValidator.validate(longEmail, 'email', { maxLength: 50 })
      expect(longResult.valid).toBe(false)
    })
  })
  
  describe('sanitizeString', () => {
    it('should escape HTML special characters', () => {
      const input = '<script>alert("xss")</script>'
      const expected = '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
      const result = InputValidator.sanitizeString(input)
      
      expect(result).toBe(expected)
    })
    
    it('should trim whitespace', () => {
      const input = '  hello world  '
      const expected = 'hello world'
      const result = InputValidator.sanitizeString(input)
      
      expect(result).toBe(expected)
    })
  })
})

整合測試範例

// tests/integration/vocabularyCard.test.js
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { VocabularyCard } from '../../src/components/VocabularyCard.js'

describe('VocabularyCard Integration Tests', () => {
  let container
  let mockAPI
  
  beforeEach(() => {
    // 設置DOM環境
    container = document.createElement('div')
    container.innerHTML = `
      <div id="vocabulary-card" data-word-id="test-word">
        <div class="word-front"></div>
        <div class="word-back"></div>
      </div>
    `
    document.body.appendChild(container)
    
    // 模擬API
    mockAPI = {
      getWordIntroduction: vi.fn()
    }
    
    // 替換全局API
    global.vocabularyAPI = mockAPI
  })
  
  afterEach(() => {
    // 清理DOM
    document.body.removeChild(container)
    vi.restoreAllMocks()
  })
  
  it('should load and display word data on initialization', async () => {
    const mockWord = {
      id: 'test-word',
      word: 'hello',
      pronunciation: '/həˈloʊ/',
      definition_zh: '你好',
      examples: [
        { sentence: 'Hello, world!' }
      ]
    }
    
    mockAPI.getWordIntroduction.mockResolvedValue(mockWord)
    
    const cardElement = container.querySelector('#vocabulary-card')
    const vocabularyCard = new VocabularyCard(cardElement)
    
    // 等待異步載入
    await new Promise(resolve => setTimeout(resolve, 100))
    
    expect(mockAPI.getWordIntroduction).toHaveBeenCalledWith('test-word')
    expect(cardElement.querySelector('.word-text')).toHaveTextContent('hello')
    expect(cardElement.querySelector('.pronunciation')).toHaveTextContent('/həˈloʊ/')
  })
  
  it('should handle API errors gracefully', async () => {
    mockAPI.getWordIntroduction.mockRejectedValue(new Error('Network error'))
    
    const cardElement = container.querySelector('#vocabulary-card')
    const vocabularyCard = new VocabularyCard(cardElement)
    
    await new Promise(resolve => setTimeout(resolve, 100))
    
    expect(cardElement.querySelector('.error-message')).toBeInTheDocument()
  })
  
  it('should flip card when clicked', async () => {
    const mockWord = { id: 'test', word: 'test', definition_zh: 'test' }
    mockAPI.getWordIntroduction.mockResolvedValue(mockWord)
    
    const cardElement = container.querySelector('#vocabulary-card')
    const vocabularyCard = new VocabularyCard(cardElement)
    
    await new Promise(resolve => setTimeout(resolve, 100))
    
    // 模擬點擊
    const flipButton = cardElement.querySelector('.flip-btn')
    flipButton.click()
    
    expect(cardElement).toHaveClass('flipped')
  })
})

📋 代碼審查檢查清單

HTML 審查要點

  • 使用語義化HTML5元素
  • 所有圖片都有alt屬性
  • 表單元素都有對應的label
  • 適當使用ARIA屬性
  • 頁面有正確的文檔結構DOCTYPE、lang等
  • 無障礙性考量(鍵盤導航、螢幕閱讀器)

CSS 審查要點

  • 使用BEM或一致的命名規範
  • 無硬編碼的magic numbers
  • 響應式設計實現正確
  • 避免過度具體的選擇器
  • CSS變數使用合理
  • 無未使用的CSS規則

JavaScript 審查要點

  • 函數職責單一,名稱清楚
  • 適當的錯誤處理
  • 無記憶體洩漏風險
  • 適當使用async/await
  • 輸入驗證和清理
  • 事件監聽器正確清理

效能審查要點

  • 避免不必要的DOM查詢
  • 適當使用事件委託
  • 圖片和資源優化
  • 批量DOM更新
  • 避免阻塞主線程的操作

安全性審查要點

  • 用戶輸入已清理
  • 防XSS措施就位
  • CSRF保護實施
  • 適當的Content Security Policy
  • 敏感資料不在客戶端暴露

文檔狀態: 🟢 完整原生前端開發規範
最後更新: 2025-09-10
適用技術: HTML5 + CSS3 + Modern JavaScript
維護團隊: 前端開發團隊