33 KiB
33 KiB
原生前端開發規範和最佳實踐
📋 總體指導原則
核心原則
- 語義化優先: HTML結構語義化,提升可讀性和無障礙性
- 漸進增強: 基於內容層、表現層、行為層的分離原則
- 效能考量: 避免不必要的DOM操作和記憶體洩漏
- 可維護性: 編寫易於理解、修改和擴展的代碼
- 標準相容: 遵循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 = {
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'&': '&'
}
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 = '<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)
})
})
})
整合測試範例
// 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
維護團隊: 前端開發團隊