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

1382 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 原生前端開發規範和最佳實踐
## 📋 總體指導原則
### 核心原則
1. **語義化優先**: HTML結構語義化提升可讀性和無障礙性
2. **漸進增強**: 基於內容層、表現層、行為層的分離原則
3. **效能考量**: 避免不必要的DOM操作和記憶體洩漏
4. **可維護性**: 編寫易於理解、修改和擴展的代碼
5. **標準相容**: 遵循Web標準確保跨瀏覽器相容性
### 技術選型標準
- **HTML5**: 語義化標記現代HTML特性
- **CSS3**: 現代CSS特性SCSS預處理器
- **ES2022+**: 現代JavaScript特性可選TypeScript
- **Web Components**: 自定義元素,封裝重用組件
- **Progressive Enhancement**: 漸進增強的設計理念
## 🏗️ HTML 開發規範
### 文檔結構標準
#### 基本文檔模板
```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>
```
### 語義化標記規範
#### 正確使用語義化元素
```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>
```
#### 表單設計規範
```html
<!-- 完整的表單結構 -->
<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屬性使用
```html
<!-- 互動組件的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架構
```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方法論
```scss
// ✅ 正確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
```scss
// 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 方法
```scss
// 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 最佳實踐
```scss
// 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模組系統
```javascript
// ✅ 正確:清晰的導入導出
// 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 }
```
### 函數和類別設計規範
#### 函數設計原則
```javascript
// ✅ 正確:單一職責、純函數
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
}
```
#### 類別設計規範
```javascript
// ✅ 正確:清晰的類別設計
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
```javascript
// ✅ 正確:清晰的異步處理
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 // 沒有數據驗證
}
```
#### 事件系統設計
```javascript
// 自定義事件發射器
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操作
```javascript
// ✅ 正確安全的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
}
```
## 🔒 安全性最佳實踐
### 輸入驗證和清理
```javascript
// 輸入驗證工具類
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 防護
```javascript
// 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
}
}
```
## 🧪 測試規範
### 單元測試規範
```javascript
// 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)
})
})
})
```
### 整合測試範例
```javascript
// 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
**維護團隊**: 前端開發團隊