1382 lines
33 KiB
Markdown
1382 lines
33 KiB
Markdown
# 原生前端開發規範和最佳實踐
|
||
|
||
## 📋 總體指導原則
|
||
|
||
### 核心原則
|
||
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 = {
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
'&': '&'
|
||
}
|
||
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 = '<script>alert("xss")</script>'
|
||
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
|
||
**維護團隊**: 前端開發團隊 |