開發規範
本規範約束所有 Swift Web 專案(framework / starter / 由 sw create 產生的下游業務專案)的開發行為,涵蓋從檔案命名到 Git 協作的全部主題。
雙重保障:
- 多數硬性規則已內建 ESLint / Stylelint 設定,違反會在 pre-commit / CI 階段直接報錯;
- 同時透過 @swift-web/standards 倉的 sync skill 將 AI 緊湊版規範注入下游
CLAUDE.md,讓 Claude / Cursor / Copilot 在寫程式碼前自動載入護欄。本頁是 人類閱讀版(含大量程式碼範例 + 設計動機解說);AI 緊湊版見 standards 倉
rules.md。本頁覆蓋主源的子集,完整規範以 standardsrules/為準——主源近期新增的「全域 CSS 選擇器約束」「Focus reflow-safe」「觸控目標與熱區」「標準開發流程 SOP」「客戶端安全(快取 util / cookie)」「使用規範(usage-review 編號規則)」等主題本頁暫未展開,後續按需補齊。
規則優先順序
衝突時依下列順序採納:
| 順位 | 來源 | 適用場景 |
|---|---|---|
| 1 | 各倉 CLAUDE.md 頂部 P0 規則 / 工程閘門 | P0 護欄 / 歸屬 / release / 文檔同步(最高) |
| 2 | 本規範(standards 倉 rules/) | applier 共用基礎規範 + 標準開發流程 SOP |
| 3 | 專案倉庫自身的 CLAUDE.md 專案特有規範 | 各倉特有目錄結構、命名約定、測試規範、內部 API |
| 4 | 其他 AI 工具設定檔 | .cursorrules / .github/copilot-instructions.md 等 |
上層覆蓋下層,衝突時主動告知使用者矯正下層。
一、檔案命名規範
檔名違反命名風格屬 P0。AI 新建檔案前必須先對照風格——命名錯了,grep 找不到、Nuxt 自動匯入路徑也會跟著錯。
1.1 Vue 元件 / 頁面 / 版面
| 位置 | 命名風格 | 範例 |
|---|---|---|
app/components/ | PascalCase | LoginForm.vue / AdSwiper.vue / PaginationBar.vue |
app/pages/ | kebab-case | about-us.vue / product-detail.vue / [id].vue |
app/layouts/ | 全小寫 | default.vue / empty.vue |
✅ app/components/LoginForm.vue
✅ app/pages/product-detail.vue
✅ app/layouts/default.vue
❌ app/components/login-form.vue (應 PascalCase)
❌ app/pages/ProductDetail.vue (應 kebab-case)
❌ app/layouts/Default.vue (應全小寫)
為什麼三種風格不統一? Nuxt 4 的約定:components/ 走 PascalCase 讓樣板裡 <LoginForm /> 可讀;pages/ 走 kebab-case 讓 URL 與檔名一致;layouts/ 全小寫是 Nuxt 預設的簡約偏好。
1.2 TypeScript / JavaScript 檔案
| 位置 | 命名風格 | 範例 |
|---|---|---|
app/composables/ | use + PascalCase | useModal.ts / useToast.ts / useLoginCheck.ts |
app/store/ | kebab-case | user-info.ts / system-info.ts |
app/plugins/ | kebab-case | analytics.client.ts / error-handler.ts |
app/middleware/ | kebab-case + .global 後綴 | login.global.ts / guest.global.ts |
app/utils/ | kebab-case | aes-dynamic.ts / format-date.ts |
server/api/ | kebab-case + HTTP 方法後綴 | health.get.ts / user-info.post.ts |
app/api/ | 由 generate-api 產生,禁止手改 | index.ts / modules/ / types/ |
✅ app/composables/useToast.ts (use + PascalCase)
✅ app/store/user-info.ts (kebab-case)
✅ app/middleware/login.global.ts (kebab + .global)
✅ server/api/health.get.ts (kebab + HTTP 方法)
❌ app/composables/use-toast.ts (應 use + PascalCase)
❌ app/composables/UseToast.ts (use 必須小寫)
❌ app/store/userInfo.ts (應 kebab-case)
❌ app/middleware/login.ts (全域中介層必須帶 .global)
1.3 速查對照表
| 物件類別 | 風格 | 範例 |
|---|---|---|
| 業務元件檔案 | PascalCase | LoginForm.vue |
| 核心元件檔案 | Sw + PascalCase(framework) | SwAsyncText.vue |
| 頁面檔案 | kebab-case | product-detail.vue |
| 版面檔案 | 全小寫 | default.vue |
| Composable 檔案 | use + PascalCase | useToast.ts |
| Utils 檔案 | kebab-case | aes-dynamic.ts |
| Store 檔案 | kebab-case | user-info.ts |
| Middleware 檔案 | kebab-case + .global | login.global.ts |
| Plugin 檔案 | kebab-case + .client/.server | analytics.client.ts |
| Server API 檔案 | kebab-case + HTTP 方法 | health.get.ts |
MR review 樣板: 檔名
<file>違反命名風格(應為<style>),與目錄約定不符。grep / 自動匯入路徑會受影響,請改名。
二、元件結構規範
2.1 三層元件分層
app/pages/ 路由頁面:資料取得、版面組合、路由參數處理
↓ 使用
app/components/ 業務元件:可重複使用的頁面級 UI 邏輯
↓ 使用(Nuxt 自動匯入)
@swift-web/framework 框架元件:Sw* 基礎元件(SwImage、SwButton、SwModal …)
核心原則:
- 頁面檔案
<template>主體應是元件的組合,單頁 ≤ 150 行 - 複雜 UI 邏輯必須下沉到
app/components/,頁面保持簡潔 - 業務元件可以直接使用框架
Sw*元件,但業務元件不得被框架反向引用(違反「下層不依賴上層」的分層原則)
反例:
<!-- ❌ pages/product-detail.vue 單檔超過 300 行,混雜資料取得 + 複雜 UI -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
const route = useRoute()
const { data: product } = await useAsyncData(() => api.product.detail({ id: route.params.id }))
const { data: comments } = await useAsyncData(() => api.comment.list({ productId: route.params.id }))
const isFavorited = ref(false)
const showShareDialog = ref(false)
const shareToWechat = () => { /* 30 行 */ }
const shareToWeibo = () => { /* 30 行 */ }
const handleAddToCart = () => { /* 50 行 */ }
// ...
</script>
<template>
<div class="sw-product-detail">
<!-- 80 行 hero 區 -->
<!-- 60 行 評論列表 -->
<!-- 40 行 分享彈窗 -->
<!-- 50 行 加入購物車浮層 -->
</div>
</template>
正例:
<!-- ✅ pages/product-detail.vue 拆為 4 個元件 -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
const route = useRoute()
const { data: product } = await useAsyncData(() => api.product.detail({ id: route.params.id }))
useSeo({
title: product.value?.name,
description: product.value?.summary,
image: product.value?.cover,
})
</script>
<template>
<div class="sw-product-detail">
<ProductDetailHero :product="product" />
<ProductDetailDescription :product="product" />
<ProductDetailComments :product-id="route.params.id" />
<ProductDetailActions :product="product" />
</div>
</template>
2.2 元件檔頭 JSDoc(強制)
app/components/ 下每個元件必須在 <script setup> 頂部寫完整的 JSDoc 註解區塊(Props / 事件 / ≥1 個使用範例,複雜元件 ≥ 3 個)。
完整樣板:
<script setup lang="ts">
/**
* LoginForm — 使用者登入表單,用於登入頁與登入彈窗
*
* ── Props ────────────────────────────────────────────────────────
* @prop mode {'page' | 'dialog'} 必填。顯示模式。
* - page:頁面模式,顯示完整背景
* - dialog:彈窗模式,無背景
* @prop redirectUrl {string} 可選。登入成功後跳轉位址。預設跳首頁。
*
* ── 事件 ─────────────────────────────────────────────────────────
* @event success 登入成功時觸發,攜帶使用者資訊
* @event cancel 使用者點擊取消時觸發(僅 dialog 模式)
*
* ── 使用範例 ─────────────────────────────────────────────────────
*
* // 範例 1:頁面模式
* <LoginForm mode="page" />
*
* // 範例 2:彈窗模式 + 監聽事件
* <LoginForm
* mode="dialog"
* redirect-url="/mine"
* @success="onLoginSuccess"
* @cancel="closeDialog"
* />
*
* // 範例 3:與 useModal 搭配
* const modal = useModal()
* modal.open(LoginForm, { mode: 'dialog' })
*/
interface Props {
mode: 'page' | 'dialog'
redirectUrl?: string
}
const props = withDefaults(defineProps<Props>(), {
redirectUrl: '/',
})
const emit = defineEmits<{
success: [userInfo: UserInfo]
cancel: []
}>()
</script>
註解語言規則:
- 所有註解說明文字使用中文
- Prop 名稱、類型、事件名保留英文(程式碼識別字不譯)
為什麼必須寫 JSDoc? Swift Web 重度依賴 AI 編碼助手協作。元件檔頭註解是 AI(Claude / Cursor)維護元件時第一手的語意資訊,比依靠 grep / 呼叫點反推快得多。MR 評審也依賴這塊說明判斷變更是否合理。
MR review 樣板: 業務元件缺 JSDoc 檔頭註解(Props / 事件 / 使用範例),AI 維護與評審都依賴這塊說明,請補上。
2.3 Props 設計規範
規則 1:可選 prop 必須顯式提供預設值
// ✅ 正確:用 withDefaults 顯式宣告
const props = withDefaults(defineProps<Props>(), {
variant: 'primary', // 有意義的預設值
disabled: false,
label: undefined, // 無合適預設值時顯式寫 undefined
})
// ❌ 錯誤:依賴 TS 預設 undefined 隱式
const props = defineProps<Props>()
規則 2:列舉類型用嚴格聯合類型,禁用裸 string
// ✅ 正確:聯合類型讓呼叫方有 IDE 補全 + 類型保護
interface Props {
variant?: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
}
// ❌ 錯誤:裸 string 任何字串都過編譯
interface Props {
variant?: string
size?: string
}
規則 3:加參數優先選用選項物件模式(fn({ a, b })),不用多參數(fn(a, b, c))
// ✅ 正確:選項物件,未來加參數不 break 呼叫方
interface UploadOptions {
file: File
onProgress?: (percent: number) => void
retries?: number
}
function uploadFile(opts: UploadOptions): Promise<string> { /* ... */ }
uploadFile({ file, onProgress, retries: 3 })
// ❌ 錯誤:位置參數,加第 4 個參數所有呼叫點都要改
function uploadFile(file: File, onProgress?: (p: number) => void, retries?: number): Promise<string> { /* ... */ }
規則 4:透傳屬性用 v-bind="$attrs"
讓呼叫方能透傳原生 HTML 屬性(disabled / aria-* / data-* 等):
<template>
<!-- ✅ 業務封裝的按鈕元件 -->
<button v-bind="$attrs" class="sw-primary-btn" @click="onClick">
<slot />
</button>
</template>
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
</script>
呼叫方就能直接:
<PrimaryBtn :disabled="loading" aria-label="提交訂單" data-testid="submit">
提交
</PrimaryBtn>
三、CSS 規範
完整 Token 清單 / Tailwind 同源 / 響應式工具類等參考見 樣式與 Token。本節是 CSS 編碼規範(AI 速查 + MR 評審清單)。
3.1 根 class 強約束(強制)
Vue 元件 / 頁面的根 DOM 元素,若寫了 class,必須以 .sw-<元件名 kebab> 開頭(與 framework Sw* 前綴同源);完全沒寫 class 的根自動豁免(如 <NuxtLayout> / <ClientOnly> / <DialogRoot> 等無 DOM 輸出的包裝元件)。
<!-- ✅ app/components/LoginForm.vue → 根 class .sw-login-form -->
<template>
<form class="sw-login-form">
<input class="email" />
<input class="password" />
<button class="submit">登入</button>
</form>
</template>
<!-- ✅ app/pages/about-us.vue → 根 class .sw-about-us -->
<template>
<div class="sw-about-us">
<h1>關於我們</h1>
</div>
</template>
<!-- ✅ 根是包裝元件且不寫 class —— 自動豁免 -->
<template>
<ClientOnly>
<div class="sw-inner">...</div>
</ClientOnly>
</template>
<!-- ❌ 寫了 class 但不帶 sw- 前綴 -->
<template>
<form class="login-form">...</form>
<form class="my-login">...</form>
</template>
4 種邊緣情形:
- 多根元件(Portal / Teleport 產生兄弟根):每個根獨立判定——寫了 class 就必須帶
sw-<元件名>-<部位>複合前綴,沒寫則放過<template> <Teleport to="body"> <div class="sw-toast-overlay"> <div class="sw-toast-content">{{ message }}</div> </div> </Teleport> </template> - Pass-through wrapper(無自身 DOM):class 附到實際算繪的 DOM 上
<!-- 包裝 SwImage 但自己不算繪 wrapper --> <template> <SwImage class="sw-product-cover" v-bind="$attrs" /> </template> - 純邏輯元件(零 DOM 產出,例如只 emit 全域事件):自然 no-op,無需任何標註;如需明確表達可選加
@no-root-class檔頭註解<script setup lang="ts"> /** * GlobalEventBus — 純邏輯元件,零 DOM 產出 * @no-root-class(可選標註,明確表達「本元件不適用根 class 規則」) */ onMounted(() => { /* ... */ }) </script> <template></template> - 條件算繪多分支(
v-if切換根元素):每個分支獨立按規則判定——寫了 class 才查 sw- 前綴<template> <article v-if="isArticle" class="sw-content-article"> <slot /> </article> <section v-else class="sw-content-section"> <slot /> </section> </template>
設計動機(AI 友善):
- 根 class 與檔名嚴格映射 → grep / Claude / Cursor 能從樣式或 DOM 精確反查到元件檔案
- 與 framework
.sw-前綴統一 → 全棧 class 命名一致,AI 不必區分「內建元件 vs 業務元件」兩套命名風格 - 強制前綴消除「該不該
.sw-」的猜測,AI 寫新元件時直接套用 - 「無 class 自動豁免」避免對包裝元件(NuxtLayout / ClientOnly 等)的誤報
💡
.sw-class 前綴不能取代 Vuemulti-word-component-names規則。前者防 CSS class 衝突,後者防元件名與 HTML 原生標籤衝突。兩者並存不冗餘。
Lint / Format 兜底:
@swift-web/standards/eslint已發布root-class-sw-prefix規則(v2 語意:寫了 class 才檢查前綴),預設 severitywarn;CI 想硬擋可用eslint --max-warnings=0自選升級@swift-web/standards/prettier+@swift-web/standards/stylelint集中 base 配置 (各倉prettier.config.ts/stylelint.config.ts用import base+ spread override 消費,統一升級 + 專案自訂兩全)- MR Review 同時依靠人工 + AI 評審做二道防線
3.2 子元素 class(扁平 kebab)
scoped 樣式天然隔離子元素,直接用簡單 kebab,不加 sw- 前綴:
<template>
<form class="sw-login-form">
<div class="header">登入</div>
<div class="body">
<input class="email" />
<input class="password" />
</div>
<div class="footer">
<button class="submit">登入</button>
<button class="cancel">取消</button>
</div>
</form>
</template>
<style scoped>
.sw-login-form .header { font-size: 1.25rem; }
.sw-login-form .body { padding: 1rem; }
.sw-login-form .submit { background: var(--primary); }
</style>
3.3 狀態修飾子(is- / has- 前綴)
狀態類用 is- 或 has- 前綴,與子元素類(名詞形態)區分:
/* ✅ 正確:狀態用形容詞形態 + is/has 前綴 */
.sw-login-form .field.has-error { border-color: var(--destructive); }
.sw-login-form .btn.is-loading { opacity: 0.6; cursor: wait; }
.sw-login-form .btn.is-disabled { opacity: 0.4; pointer-events: none; }
.sw-product-card.is-selected { box-shadow: 0 0 0 2px var(--ring); }
/* ❌ 錯誤:狀態用名詞形態,與子元素衝突 */
.sw-login-form .error { /* 是子元素還是狀態? */ }
.sw-login-form .loading { /* 同上 */ }
語意對照表:
| 類別 | 形態 | 含義 | 範例 |
|---|---|---|---|
| 子元素 | 名詞,plain kebab | 「是什麼東西」 | .header / .error-msg / .submit |
| 狀態修飾 | 形容詞,is-<x> / has-<x> | 「處於什麼狀態」 | .is-loading / .is-selected / .has-error |
3.4 禁止寫法
/* ❌ BEM 雙底線(與 framework 風格不一致) */
.sw-login-form__header { }
.sw-login-form__submit--disabled { }
/* ❌ 子 class 帶 sw- 前綴(scoped 下冗餘) */
.sw-login-form .sw-header { }
.sw-login-form .sw-body { }
/* ❌ 根 class 不帶 sw- 前綴(違反根 class 強約束) */
.login-form { }
.app-navbar { }
/* ❌ 子 class 堆疊元件根前綴(多此一舉) */
.sw-login-form-header-item-label { }
/* ❌ 狀態用名詞形態(與子元素歧義) */
.sw-btn .loading { } /* 應改 .is-loading */
.sw-form .error { } /* 應改 .has-error */
3.5 巢狀策略
- 淺層巢狀(1-2 層)推薦用於語意分組
- 不強制 CSS 巢狀結構與 DOM 層級嚴格對齊(scoped 已隔離,扁平完全可接受)
- 深層巢狀(≥3 層)不推薦,AI 編輯時容易搞錯作用域邊界
/* ✅ 推薦:淺層巢狀作語意分組 */
.sw-login-form {
display: flex;
flex-direction: column;
.field {
margin-bottom: 1rem;
&.has-error { border-color: var(--destructive); }
}
.submit {
background: var(--primary);
}
}
/* ✅ 也合規:完全扁平(scoped 自動隔離) */
.sw-login-form { display: flex; flex-direction: column; }
.sw-login-form .field { margin-bottom: 1rem; }
.sw-login-form .field.has-error { border-color: var(--destructive); }
.sw-login-form .submit { background: var(--primary); }
/* ❌ 不推薦:深巢狀 + 嚴格 DOM 層級對齊(4 層) */
.sw-card {
.header {
.row {
.col {
.title { color: red; }
}
}
}
}
3.6 CSS Token(Shadcn v4 語意命名)
- 所有顏色、圓角、陰影、版面尺寸必須用 framework 注入的 Shadcn v4 語意 token
- 不寫 fallback:framework
tokens.css的:root預設值已兜底 - 硬編碼色值零容忍(除
transparent/currentColor/ 漸層 stop / 媒體語境例外)
/* ✅ 正確:語意 token,無 fallback */
.sw-ad-swiper {
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--foreground);
}
.sw-error-banner {
background: var(--destructive);
color: var(--destructive-foreground);
}
/* ❌ 帶 fallback(多此一舉,:root 已兜底) */
color: var(--primary, #3b82f6);
/* ❌ 已徹底移除的舊 token 命名(獨立色值,已不存在) */
background: var(--color-bg-dark);
border-color: var(--border-color);
padding: var(--spacing-section);
/* ❌ 手寫 CSS 直接引 Tailwind @theme 橋接變數 —— 改引語意 token var(--primary) */
color: var(--color-primary);
/* ❌ 硬編碼色值 */
background: #f5f5f5;
color: #1a1a1a;
border: 1px solid #e5e5e5;
常用 token 速查(完整清單見 樣式與 Token):
| 類別 | 變數 |
|---|---|
| 底色 | --background / --foreground |
| 主色 | --primary / --primary-foreground |
| 弱化 | --muted / --muted-foreground |
| 邊框 | --border / --input / --ring |
| 語意 | --destructive / --success / --warning / --info |
| 尺寸 | --radius / --shadow-sm / --shadow-md / --shadow-lg |
| 版面 | --nav-height / --container-padding / --content-max-width |
MR review 樣板: 此處硬編碼色值 / 引了已移除的舊 token / 寫了 fallback,請改用
var(--xxx)語意 token(清單見 樣式與 Token)。
3.7 Hover 互動統一用 filter
廢棄單獨的 hover 色 token(--primary-hover 等),用 filter: brightness() 一行搞定 + dark mode 自動對稱:
/* ✅ 正確:filter brightness 一行搞定,dark mode 自動 */
.submit {
background: var(--primary);
color: var(--primary-foreground);
transition: filter 0.15s ease;
}
.submit:hover { filter: brightness(0.92); }
.submit:active { filter: brightness(0.85); }
.danger-btn {
background: var(--destructive);
color: var(--destructive-foreground);
transition: filter 0.15s ease;
}
.danger-btn:hover { filter: brightness(0.92); }
/* ❌ 錯誤:單獨維護 hover 色 token */
.submit { background: var(--primary); }
.submit:hover { background: var(--primary-hover); } /* 已廢棄 --primary-hover */
為什麼用 filter? brightness 濾鏡在淺色主題(變暗)和深色主題(變亮)都自然對稱——背景色明度本身決定 hover 朝哪個方向走,無需雙套 token。
3.8 CSS 單位約束
核心約束表(rem 為主、px 用於像素精確細節,與 standards css-tokens 規則一致):
| 屬性類別 | 單位 |
|---|---|
font-size / letter-spacing / text-indent | rem(a11y 友善) |
line-height | 無單位數(如 1.5),禁止 px / rem |
padding / margin / gap / width / height / top/right/bottom/left | rem |
border-radius ≥ 0.5rem(8px) | rem |
border-radius < 8px 小圓角 | px 例外(半像素算繪穩定) |
border-width / box-shadow / text-shadow offset | px |
媒體查詢 @media 斷點 | px |
| 物理固定元素(小 icon / 分隔線等) | px |
em | 禁用(SwIcon 的 1em 是唯一例外) |
正例:
.sw-product-card {
padding: 1.5rem; /* ✅ 間距用 rem */
font-size: 1rem; /* ✅ 字體用 rem */
line-height: 1.6; /* ✅ 行高無單位 */
border-radius: 0.75rem; /* ✅ 大圓角用 rem */
border: 1px solid var(--border); /* ✅ 邊框寬度用 px */
box-shadow: 0 2px 8px rgb(0 0 0 / 0.06); /* ✅ 陰影 offset 用 px */
}
.tag {
border-radius: 4px; /* ✅ 小圓角(< 8px)用 px 例外 */
}
@media (max-width: 768px) { /* ✅ 媒體查詢用 px */
.sw-product-card { padding: 1rem; }
}
反例:
.sw-product-card {
padding: 24px; /* ❌ 間距應 rem */
font-size: 16px; /* ❌ 字體應 rem */
line-height: 26px; /* ❌ 行高應無單位(如 1.6) */
margin: 0.5em 1em; /* ❌ 禁用 em(巢狀複合難追蹤) */
}
@media (max-width: 48em) { /* ❌ 媒體查詢應 px */
.sw-product-card { padding: 1rem; }
}
理由:rem 主、a11y 友善(使用者調大瀏覽器字級時版面跟隨);px 例外用於細節像素精確(邊框 / 陰影)和邊界穩定(媒體查詢);em 禁用因巢狀複合難追蹤。
stylelint declaration-property-unit-allowed-list 已設定,pre-commit + CI 均 error 級。
3.9 響應式斷點
與 framework responsive.css 保持一致:
| 斷點 | 範圍 |
|---|---|
| 行動端(mobile) | < 768px |
| 平板端(tablet) | 768px – 1023px |
| 桌面端(desktop) | ≥ 1024px |
/* ✅ 正確:與 framework 斷點一致 */
.sw-product-list { display: grid; grid-template-columns: 1fr; }
@media (min-width: 768px) {
.sw-product-list { grid-template-columns: 1fr 1fr; }
}
@media (min-width: 1024px) {
.sw-product-list { grid-template-columns: 1fr 1fr 1fr; }
}
四、TypeScript 規範
4.1 禁止 any / 隱式 any
需要彈性類型時使用 unknown + 類型斷言:
// ✅ 正確:unknown 強制呼叫方收窄類型
function parseResponse(data: unknown): UserInfo {
// 實際專案中應該先校驗結構
return data as UserInfo
}
interface ApiEnvelope {
code: number
data: unknown
}
function unwrap<T>(envelope: ApiEnvelope): T {
if (envelope.code !== 0) throw new Error(`API ${envelope.code}`)
return envelope.data as T
}
// ❌ 錯誤:any 完全放棄類型檢查
function parseResponse(data: any): any {
return data
}
// ❌ 錯誤:函式參數 / 回傳值無類型(隱式 any)
function format(date) {
return date.toLocaleString()
}
MR review 樣板: 禁止
any,用unknown+ 類型斷言;介面欄位補全。
4.2 用 as const 固定字面量類型
常數陣列 / 物件用 as const 鎖字面量類型,再 [number] 推導聯合類型,避免後續手寫聯合漂移:
// ✅ 正確:as const + [number] 自動推導聯合類型
const VARIANTS = ['primary', 'secondary', 'ghost'] as const
type Variant = (typeof VARIANTS)[number]
// Variant = 'primary' | 'secondary' | 'ghost'
const RENDER_MODES = ['ssr', 'csr', 'isr', 'ssg'] as const
type RenderMode = (typeof RENDER_MODES)[number]
// ✅ 物件 as const 鎖結構
const STATUS_LABELS = {
pending: '待支付',
paid: '已支付',
cancelled: '已取消',
} as const
type Status = keyof typeof STATUS_LABELS
// ❌ 錯誤:丟失字面量資訊(推斷為 string[])
const VARIANTS = ['primary', 'secondary', 'ghost']
// typeof VARIANTS = string[],無法精確推導
// ❌ 錯誤:手寫聯合類型(與陣列同步漂移風險)
type Variant = 'primary' | 'secondary' | 'ghost'
const VARIANTS: Variant[] = ['primary', 'secondary'] // ←漏 'ghost',編譯還能過
4.3 變數命名約定
// ── 一般變數 / 函式:camelCase ──
const pageTitle = ref('')
const userInfo = useUserStore()
function fetchUserInfo() {}
async function uploadFile(opts: UploadOptions): Promise<string> {}
// ── 布林值:is- / has- / can- / should- 開頭 ──
const isLoading = ref(false)
const hasError = computed(() => !!errorMsg.value)
const canSubmit = computed(() => form.valid && !isLoading.value)
const shouldShowDialog = ref(false)
// ── 模組級真常數:SCREAMING_SNAKE_CASE ──
const MAX_RETRY = 3
const DEFAULT_PAGE_SIZE = 20
const API_TIMEOUT_MS = 10_000
// ── 類型 / 介面:PascalCase,不加 I 前綴 ──
interface UserInfo { id: number; name: string }
type ApiResponse<T> = { data: T; code: number }
// ❌ interface IUserInfo(不要加 I 前綴)
// ── 模組內部輔助函式:_ 前綴 ──
function _deriveKey(requestId: string) {}
function _validateInternal(payload: unknown) {}
4.4 樣板 ref 命名
樣板參考以 Ref 結尾,便於一眼區分一般 reactive ref:
// ✅ 正確:Ref 後綴
const inputRef = ref<HTMLInputElement>()
const containerRef = ref<HTMLDivElement>()
const listRef = ref<HTMLUListElement>()
const swImageRef = ref<InstanceType<typeof SwImage>>()
// 樣板裡
// <input ref="inputRef" />
// <SwImage ref="swImageRef" />
// ❌ 錯誤:與一般 ref 視覺混淆
const input = ref<HTMLInputElement>()
const container = ref<HTMLDivElement>()
4.5 錯誤處理約定
拋錯:
// ✅ Nuxt / Nitro 路徑用 createError(框架理解的 statusCode)
throw createError({
statusCode: 404,
statusMessage: '商品不存在',
data: { productId: id },
})
// ✅ 純 logic 路徑用 Error 子類
throw new Error('剖析失敗:缺少必填欄位 phone')
// ❌ 禁止:throw 字串字面量(丟失 stack trace)
throw '登入失敗'
catch 子句中 e 是 unknown:
// ✅ 正確:先判類型再存取 .message
try {
await someApi()
} catch (e) {
if (e instanceof Error) {
console.error(e.message, e.stack)
} else {
console.error('未知錯誤', e)
}
}
// ✅ 業務錯誤用自訂 Error 類
export class LoginExpiredError extends Error {
constructor() {
super('登入已過期')
this.name = 'LoginExpiredError'
}
}
// catch 處理點
try { await fetchProfile() }
catch (e) {
if (e instanceof LoginExpiredError) {
await navigateTo('/login')
return
}
throw e
}
// ❌ 禁止:吞錯誤(不 console.warn 不 throw)
try { await someApi() } catch {}
// ❌ 錯誤:直接存取 e.message(TS 報 ts-2571)
catch (e) {
console.error(e.message) // e 是 unknown 類型
}
4.6 類型匯出位置
- 跨模組共用的類型放在專門的
types.ts或目標模組的index.ts - 僅本模組使用的類型緊鄰使用處定義,不要為「通用化」提前外移
// ✅ 跨模組共用:app/types/user.ts
export interface UserInfo { id: number; name: string; phone: string }
export type UserRole = 'admin' | 'member' | 'guest'
// ✅ 僅本模組使用:緊鄰定義
// app/composables/useLoginForm.ts
interface LoginFormState { // 不外移
phone: string
code: string
agreed: boolean
}
export function useLoginForm() {
const state = reactive<LoginFormState>({ phone: '', code: '', agreed: false })
return { state }
}
// ❌ 反例:把僅本模組用的類型放進 types/,污染全域
// app/types/login-form-state.ts ← 沒有第二個模組用,外移即過早抽象
五、i18n 鍵命名規範
5.1 核心規則:中文原文作 key
// ✅ 推薦:直接用中文原文
$t('登录')
$t('请输入手机号')
$t('订单总计 {amount} 元', { amount: 99 })
$t('欢迎 {username},今天是 {date}', { username, date })
// ❌ 反對:另起英文鍵名
$t('login.button')
$t('order.total.amount')
$t('common.welcome.message')
為什麼不用英文鍵名:
- 可讀性:業務程式碼裡直接看見原文,無需在樣板和 locales 檔案之間反覆跳轉確認含義
- AI 友善:Claude / Cursor 可直接判斷鍵的語意,無需依賴鍵名的「約定式描述」
- 降低廢鍵風險:英文鍵名易堆積廢鍵,中文原文鍵改文案 = 改 key(自然顯式遷移)
- 多語言擴充無障礙:中文 locale 是 identity 映射(key === value),其他 locale 檔案按 key 翻譯即可
5.2 長文案處理
key 長度限制:≤ 50 個字元。超過時改用簡短中文描述作為 key,全文放在翻譯檔案裡:
// ✅ 長文案:用簡短描述作 key
$t('首页SEO描述')
$t('关于页SEO描述')
$t('用户协议第一段')
// ❌ 禁止:把長句直接作為 key
$t('一个现代化的 Nuxt 4 脚手架模板,内置 lint、测试、国际化、主题等一切所需。')
5.3 例外場景(允許英文 / 短碼 key)
| 場景 | key 範例 | 理由 |
|---|---|---|
| 路由 / pathname | routes.home | 與 URL 強繫結,中文不合適 |
| 狀態列舉映射 | status.pending / status.paid | 後端回傳英文列舉,需雙向映射 |
| 極長段落(> 200 字元) | legal.terms.section1 | 中文鍵太長影響程式碼可讀性 |
| 富文本帶 HTML 標籤 | tooltip.html.welcome | 原文含標籤影響程式碼格式化 |
5.4 佔位符約定
變數插值用大括號命名佔位符,不用位置參數:
// ✅ 正確:命名佔位符
$t('订单总计 {amount} 元', { amount: 99 })
$t('欢迎 {username},今天是 {date}', { username, date })
// ❌ 錯誤:位置參數(與中文原文 key 風格不搭)
$t('订单总计 {0} 元', [99])
$t('Welcome {0}, today is {1}', [username, date])
5.5 翻譯檔案格式
平鋪結構 + 按 key 字典序排序:
// ✅ i18n/locales/zh-CN.json
{
"关于页SEO描述": "了解 Swift Web:一个基于 Nuxt 4 的现代化工程模板...",
"立即开始": "立即开始",
"登录": "登录",
"首页": "首页",
"首页SEO描述": "一个现代化的 Nuxt 4 脚手架模板,内置 lint、测试、国际化..."
}
// ✅ i18n/locales/en.json(key 仍是中文原文)
{
"关于页SEO描述": "Learn about Swift Web: a Nuxt 4 project template...",
"立即开始": "Get Started",
"登录": "Sign In",
"首页": "Home",
"首页SEO描述": "A modern Nuxt 4 starter template with everything you need..."
}
三個語言檔案(zh-CN / zh-TW / en)必須完整填寫所有 key,缺譯時用中文原文兜底。
5.6 複數 / 性別等進階語法
複數形式用 vue-i18n 的 $tc + | 分隔:
// 中文不區分單複數,三段都寫中文即可
$tc('{n} 条评论 | {n} 条评论 | {n} 条评论', count, { n: count })
// 其他 locale 按規則展開
// en.json: { "{n} 条评论 | {n} 条评论 | {n} 条评论": "no comments | one comment | {n} comments" }
六、算繪策略宣告
💡 快速選型先看:算繪模式快速指南(5 分鐘決策矩陣 + 詳情頁決策樹 + 反模式速查)。本章 6.x 各小節是規範條款(強制要求);理論深挖見 算繪模式選用指南(理論篇)。
framework 定位口號:ISR 主推 + SSR 守付費 + CSR 給使用者態 + 混合頁 hydrate。詳細業務畫像與決策矩陣見 快速指南。
6.1 強制規範
每個
app/pages/**/*.vue必須在<script setup>頂層呼叫defineRouteRules(),ESLint 會檢查,缺失則報錯。
未宣告算繪策略的頁面每次請求都會觸發完整伺服器端算繪,是伺服器高負載的主要原因。合理使用 ISR 可將多數頁面降為靜態 HTML 直出,回應速度提升數倍。
額外強約束(由 @swift-web/framework/eslint 自動啟用):
- 付費內容主體(
swift-web/no-isr-on-paid-paths,預設 9 段:/read/**、/play/**、/chapter/**、/video/**、/watch/**、/audio/**、/episode/**、/player/**、/viewer/**):禁用isr/swr/prerender;只能ssr: true或ssr: false。理由:防 DOM 篡改繞過付費(參考 WSJ 實踐) - 使用者態 / 即時互動(
swift-web/no-isr-on-user-state-paths,預設 14 段:/mine/**、/orders/**、/settings/**、/chat/**、/live/**、/profile/**、/account/**、/wallet/**、/vip/**、/subscription(s)/**、/dashboard/**、/message(s)/**):必須ssr: false
以上為預設段名,applier 可透過兩條規則的 segments options 覆寫。
- 可快取頁禁讀 query(
swift-web/no-query-in-cacheable-ssr):isr/prerender/swr/cache頁的 SSR 執行路徑不得讀route.query。HTML 快取在反向代理層是 path-only key(忽略全部 query),讀了會讓?order=hot命中?order=new的快取 ⇒ 內容錯配。修法與 path 段的邊界要求見 渲染模式選用指南 - 禁寫無效 routeRules(
swift-web/no-ineffective-route-rules):defineRouteRules裡不寫swr/cache/static(快取策略統一只寫isr,framework 會在建置期翻譯成 node-server 真正認識的cache),也不寫空物件{}
6.2 策略速查 + 決策樹
| 策略 | 寫法 | 適用場景 |
|---|---|---|
| ISR(增量靜態再生) | { isr: 3600 } | 內容定期更新的頁面(列表 / 首頁) |
| ISR(詳情 / 靜態頁) | { isr: 3600 } | 更新不頻繁的詳情頁、靜態頁(區間上限;isr: true 已移除,fw#414) |
| SSG(預先算繪) | { prerender: true } | 內容固定不變(關於頁、協議頁) |
| CSR(純客戶端) | { ssr: false } | 必須登入才能存取的個人頁面 |
| SSR(即時算繪) | { ssr: true } 或 {} | 內容必須即時(慎用) |
決策樹:
| 業務特徵 | 推薦策略 | 關鍵理由 |
|---|---|---|
| 0-2 參數穩定(分類 / 分頁 / 標籤) | RESTful URL 路徑化 + ISR | 快取 key 規範化 + SEO 友善 + canonical 單一 |
| 3+ 篩選維度(搜尋 / 高維篩選) | CSR (ssr: false) | 快取爆炸 + duplicate content 風險 + SEO 收益低 |
| 詳情頁(內容穩定) | ISR 長 maxAge / on-demand revalidate | 命中率高,SEO 必須 |
| 列表 / 內容首頁 | ISR 短 maxAge | 平衡新鮮度與命中率 |
| 使用者態頁(個人中心 / 後台 / 訂單) | CSR (ssr: false) | 含敏感資料,禁 ISR |
| 錯誤頁 / 404 | SSR + useSeo({ robots: 'noindex,nofollow' }) | 防烙快取 + 防 SEO 洩漏 |
| 極少變動(about / terms / 協議) | prerender: true(SSG) | 建構時一次生成,零執行時開銷,靜態最優;動態參數頁面禁用 |
詳細決策與坑點見 Nuxt 算繪模式選用指南。
6.3 ISR 工作原理
第 1 次請求 → 伺服器端算繪 → 快取 HTML → 回傳給使用者
快取期內請求 → 直接回傳快取 HTML(零 Node.js 開銷)
快取到期後 → 下一個請求仍回傳舊快取(使用者不等待)
同時背景重新算繪 → 更新快取
isr: 3600:區間上限,一小時背景重驗一次,詳情頁 / 靜態頁推薦(isr: true已移除,fw#414)isr: 600/isr: 1800:首頁 / 列表推薦(新內容 10–30 分鐘內可見;isr只接受 60–3600 的整數)
6.4 各策略程式碼範例
<!-- ✅ 內容詳情頁:更新不頻繁,ISR 永久快取 -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
</script>
<!-- ✅ 首頁 / 列表頁:每小時重新整理 -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
</script>
<!-- ✅ 關於頁 / 隱私協議:內容固定,建構時生成 -->
<script setup lang="ts">
defineRouteRules({ prerender: true })
</script>
<!-- ✅ 個人中心 / 收藏 / 歷史:必須登入,純客戶端算繪 -->
<script setup lang="ts">
defineRouteRules({ ssr: false })
</script>
<!-- ✅ 極少數需要即時資料的頁面(搜尋結果、訂單列表) -->
<script setup lang="ts">
defineRouteRules({ ssr: true })
</script>
注意:
defineRouteRules是編譯期巨集,參數必須是字面量物件,不能使用變數。批次設定同類路由可在nuxt.config.ts的routeRules中集中宣告,頁面內的宣告優先順序更高。
6.5 最嚴重風險:使用者態洩漏(先看這一節)
ISR 把首訪者看到的 HTML 烙進快取供所有人重用。如果首訪者是登入使用者,他的私人資料會廣播給後續所有訪問者——這是安全/隱私事故,比快取爆炸或資料陳舊嚴重得多。
風險嚴重性排序:使用者態洩漏 > 快取指紋錯配(效能)> 快取內容污染(功能)。
觸發洩漏的程式碼模式(ISR / prerender 頁面裡出現任一即高危):
useFetch預設帶 cookie 轉發後端 → 後端讀 cookie 回傳個人化資料- 呼叫 private API(後端標註
x-sw-visibility: private;OpenAPI 源見app/api/visibility.json,YAPI 源由執行期護欄 L5 兜底) useRequestHeaders(['cookie', 'authorization'])讀使用者態 headerevent.context.user(server middleware 注入的使用者物件)- 私有頁面(個人中心 / 訂單 / 後台)忘標
ssr: false
四道防線(按優先順序):
- 使用者態頁面
ssr: false(最簡單) - Public / Private API 分離(判據是後端標註,不是前端目錄)+ 混合頁面強制拆分(見第八章)
- Nitro
cache.varies限定場景 - 把變數提到 URL 路徑
自檢鐵律:HTML 輸出對所有未登入使用者必須完全相同;任何「因使用者而變」的內容(登入態 / 會員價 / 收藏 / 訂單 / 進度)一律 CSR。
6.6 ISR 必踩坑速查
A 快取指紋錯配:
- POST body 不參與快取 key — 翻頁/篩選用 GET 或 RESTful 路徑
- query 順序不規範化 — 高維 query 快取爆炸
- 上下文敏感參數(i18n / 登入態 / UA / 多租戶 host / geo)不在 URL → 必須放 URL 或
cache.varies
B 快取內容污染:
- 錯誤回應被烙進快取 —
useFetch失敗時必須throw createError(...) - ISR 頁面裡
set-cookie不進快取 → 個人化副作用必須移到 client
C 選型反模式:
- 詳情頁用 SSR → 改 ISR
- 個人中心用 ISR → 改 CSR
- 高維篩選用 ISR → 改 CSR
D SSR 執行環境(詳見第十一章):
- runtime 頂層存取
window/document→ SSR 500 - 跨元件狀態用
ref→ 串資料;用useState取代
F Nitro 預設值:
swr: true預設啟用maxAge: 1秒預設- cache key =
path + searchParams,cookie/header 進 key 必須顯式varies
6.7 寫程式碼自檢清單
- 頁面有
defineRouteRules({ ... })顯式宣告 - ISR 頁面的 SSR 執行路徑沒呼叫 private API(
onMounted/ 事件處理器裡呼叫是合法的) - ISR 頁面沒解構
useRoute().query.*≥ 3 個 - ISR 頁面用 GET 而非 POST 翻頁
- 錯誤頁
useSeo({ robots: 'noindex,nofollow', silenceLengthWarnings: true }) - runtime 模組沒頂層
window/document/localStorage - 跨元件共用狀態用
useState而非ref
6.8 標準頁面樣板結構
完整可參考的頁面骨架:
<script setup lang="ts">
// 1. 算繪策略(必填,ESLint 強制檢查)
defineRouteRules({ isr: 3600 })
// 2. 頁面元資料
definePageMeta({ layout: 'default' })
// 3. 路由與國際化
const route = useRoute()
const { t } = useI18n()
// 4. 資料取得(必須在 setup 頂層 await,讓 SSR 拿到資料)
const { data: product, status, error } = await useAsyncData(
`product-${route.params.id}`,
() => api.product.detail({ id: route.params.id }),
)
// 5. SEO 資訊(用 useSeo,含 og / twitter / canonical / robots)
useSeo({
title: product.value?.seo?.title ?? product.value?.name,
description: product.value?.seo?.description ?? product.value?.summary,
image: product.value?.cover,
type: 'article',
article: {
author: product.value?.author,
datePublished: product.value?.publishedAt,
dateModified: product.value?.updatedAt,
},
})
// 6. 錯誤兜底:錯誤頁一定要 throw createError,避免錯誤頁被 ISR 快取
if (error.value) {
throw createError({
statusCode: error.value.statusCode ?? 500,
statusMessage: error.value.statusMessage ?? '商品載入失敗',
})
}
</script>
<template>
<div class="sw-product-detail">
<ProductDetailSkeleton v-if="status === 'pending'" />
<ProductDetailContent v-else :product="product" />
</div>
</template>
<style scoped>
.sw-product-detail {
padding: 1.5rem;
background: var(--background);
}
</style>
七、SEO 規範
7.1 useSeo() 取代 useSeoMeta()
framework 提供 useSeo() composable,取代裸用 useSeoMeta()。useSeo() 自帶 og / twitter / canonical / JSON-LD / robots / dev 期長度警告等多項能力,是目前推薦的 API。
硬規則:每個 app/pages/**/*.vue 和 app/error.vue 必須呼叫 useSeo(),ESLint swift-web/require-useSeo-in-pages 兜底(缺失即報錯)。
// ✅ 正確:useSeo
useSeo({
title: '商品詳情',
description: '高品質優選商品,限時折扣',
image: '/og/product.jpg',
type: 'article',
article: {
author: '張三',
datePublished: '2026-01-01',
dateModified: '2026-05-08',
},
})
// ❌ 反例:裸用 useSeoMeta
useSeoMeta({
title: '商品詳情',
description: '高品質優選商品,限時折扣',
ogTitle: '商品詳情',
ogDescription: '高品質優選商品,限時折扣',
ogImage: '/og/product.jpg',
twitterCard: 'summary_large_image',
// ↑ og / twitter / canonical 都要手拼,重複且容易漏
})
MR review 樣板: 此頁用
useSeoMeta(),請改用useSeo()。useSeo()自帶 og / twitter / canonical / JSON-LD / robots,無需手動拼。
7.2 私有頁 / 中轉頁 noindex
私有頁(login / register / mine / profile/* / admin / orders 等)必須配 useSeo({ robots: 'private' }):
// ✅ 私有頁:noindex + 靜音長度告警
useSeo({
title: '登入',
description: '登入 Swift Web',
robots: 'private', // 等價於 noindex,nofollow
silenceLengthWarnings: true, // 私有頁 title/desc 天然短,關閉長度警告
})
// ✅ 中轉 / 重新導向頁:noindex
useSeo({
title: '正在跳轉',
robots: 'noindex',
silenceLengthWarnings: true,
})
// ✅ 錯誤頁:noindex,nofollow
// app/error.vue
useSeo({
title: '頁面不存在',
robots: 'noindex,nofollow',
silenceLengthWarnings: true,
})
為什麼私有 / noindex 頁一定要 silenceLengthWarnings: true? 這類頁 title/desc 天然短,dev console 長度告警無意義,會洗版掩蓋真實告警。
7.3 公開頁 SEO 完整欄位(詳情頁)
useSeo({
// ── 基礎 ──
title: data.value?.seo?.title ?? data.value?.name,
description: data.value?.seo?.description ?? data.value?.summary,
keywords: data.value?.tags?.slice(0, 6).join('、'), // ≤ 10 個
// ── 分享卡片圖(必傳,詳情頁用業務真實封面) ──
image: data.value?.cover ?? '/og/default.jpg',
// ── Article schema(詳情頁必填) ──
type: 'article',
article: {
author: data.value?.author,
datePublished: data.value?.publishedAt,
dateModified: data.value?.updatedAt,
},
// canonical 預設取目前 URL,一般無需手填
})
7.4 欄位長度建議
| 欄位 | 推薦長度 | 備註 |
|---|---|---|
title | 10–70 字元 | 偏短:搜尋結果不顯眼;偏長:被搜尋引擎截斷 |
description | 50–200 字元 | 偏短:浪費 SEO 最佳化機會;偏長:被截斷 |
keywords | ≤ 10 個 / 200 字元 | 關鍵字堆砌反作用 |
更詳細的最佳實踐(Core Web Vitals、HTML 語意化、Schema.org、GEO 等)見 Swift Web SEO 最佳實踐。
八、Public / Private API 分離
完整指南:算繪模式選用指南 §九。
8.1 語意紅線(本章的全部要點)
ISR / prerender 頁面的 SSR 執行路徑上,不得呼叫 private API。
private = 回應因使用者而異(登入態 / 使用者身分 / 會員價 / 收藏狀態 / 訂單等)。 ISR 把首訪者看到的 HTML 烙進快取供所有人複用 —— 私有資料混進去 = 首訪者的資料廣播給所有訪客,是安全 / 隱私事故,比快取爆炸或資料陳舊嚴重得多。
「SSR 執行路徑」指 <script setup> 同步頂層,以及 useAsyncData / useFetch 等 SSR 包裝器的
直接回呼。事件處理器 / onMounted / 普通函式體內的呼叫是 client 觸發,不受此限 ——
這正是下面「混合頁拆分」的合法形態。
歷史約定已廢棄:早期設想過 app/api/{public,private}/ 實體分目錄 + useFooPrivateApi
命名前綴。codegen 落地後 app/api/ 的結構由產生器決定,該設想未被任何倉採用。
別再按它寫程式或審 PR。
8.2 怎麼知道哪個 API 是 private
事實源是後端標註,不是前端目錄名:
| 後端源 | 標註方式 | 前端產物 |
|---|---|---|
| OpenAPI(自研 server) | 端點帶 x-sw-visibility: public | private | generate-api 產出 app/api/visibility.json(<module>.<fn> → public | private),並在產生函式 JSDoc 帶 @swVisibility |
| YAPI(公司舊後端) | 暫無標註位 | 無 manifest —— 靜態機檢失效,改由 §8.6 的執行期護欄 L5 兜底 |
visibility.json 只有 OpenAPI 源的專案才有。公司舊後端走 YAPI、不支援該標註 ⇒
§8.5 的 isr-no-private-api 在那類專案上靜默 no-op。
但那類專案並非裸奔 —— §8.6 的執行期護欄 L5 不依賴任何後端標註,是它們的主力防線。
想自研後端的專案:只要 API 文件走 OpenAPI 並給端點標
x-sw-visibility, 就能額外吃到兩道開發期機檢(比只有 L5 早得多就能發現問題)。 接入方式見 Codegen 模組 · generate-api 雙源。
app/api/ 的實際結構由 generate-api 決定(index.ts / modules/ / types/,OpenAPI 源
額外多一個 visibility.json),禁止手改。消費形態是 auto-import 的全域 api 物件
(api.movie.detail()),頁面裡沒有 import 語句。
8.3 混合資料頁面強制拆分
主體 public + 附加 private 的頁面(詳情頁最常見)必須拆:
- 主請求走 public API + ISR 快取(SSR 路徑)
- 附加私有資訊(
is_favorited/ 會員價 / 個人推薦 / 進度)單獨在onMounted後 CSR 拉取並回填
8.4 反例 / 正例
// ❌ ISR 頁在 SSR 路徑呼叫 private API —— 使用者訂單被烙進快取,全員看到
defineRouteRules({ isr: 3600 })
const { data } = await useAsyncData(() => api.user.orders())
// ❌ 主體 + 附加資訊混在同一請求 —— is_favorited 跟著主體一起被快取
const { data } = await useAsyncData(() =>
api.movie.detail({ id, userId: useUserStore().info?.user_id }),
)
// ✅ SSR 路徑只呼叫 public API
defineRouteRules({ isr: 3600 })
const { data } = await useAsyncData(() => api.movie.detail({ id }))
// ✅ 附加私有資訊單獨 CSR 回填(client 路徑,lint 不報也確實安全)
const isFavorited = ref(false)
onMounted(async () => {
isFavorited.value = await api.movie.hasFavorite({ id })
})
8.5 ESLint 強制(已落地)
framework 從路由路徑段維度機檢兜底「ISR 不碰用戶態 / 付費內容」,兩條規則歸 @swift-web/framework/eslint、recommended 預設 error:
swift-web/no-isr-on-paid-paths:付費內容路徑段(read/play/video/watch/audio等,可segments覆蓋)禁isr/swr/prerender。swift-web/no-isr-on-user-state-paths:用戶態路徑段(mine/orders/wallet/vip等,可segments覆蓋)必須ssr: false。
另有兩條同屬 recommended、同為 error 的 routeRules 規則:
swift-web/no-ineffective-route-rules:defineRouteRules裡禁寫swr/cache/static,也禁空物件{}。前三個雖是 nitro 合法鍵,但整套規範、文件、決策樹都圍繞isr寫——直寫它們只會讓人和 AI 都難判斷,收益為零;空物件則是「宣告了等於沒宣告」。swift-web/no-query-in-cacheable-ssr:可快取頁(isr/prerender/swr/cache)的 SSR 路徑禁讀route.query。這是反向代理層 path-only cache key 的前置條件——讀了會讓不同參數共用同一份快取、內容錯配。詳見 渲染模式選用指南。
判定刻意保守(寧漏報不誤報):只報三種確定在 SSR 期求值的位置——<script setup>頂層直接讀、computed(() => route.query.x)回呼內、SSR 資料包裝器(useAsyncData/useFetch/callOnce等)的直接回呼內。任何一般函式主體內(onMounted/ 事件處理器 / watch 回呼)一律放過——那正是本規則推薦的 CSR 回填形態。⚠️
computed是主戰場:applier 的實際寫法就是const order = computed(() => String(route.query.order ?? '')),別以為「惰性求值所以不算 SSR」——被 SSR 路徑消費時它就在 SSR 期跑。
按路由路徑判定,與 isr-no-private-api 的按呼叫的 API 判定互補:後者精確但需要
visibility.json(僅 OpenAPI 源有),前兩條粗粒度但任何源都生效。
swift-web/isr-no-private-api:§8.1 那條語意紅線的直接機檢。讀app/api/visibility.json(路徑可用manifest選項覆蓋);同檔案內宣告了isr/prerender,且 private API 呼叫出現在 SSR 執行路徑時報錯,client 觸發路徑不報。⚠️ manifest 不存在時靜默 no-op (YAPI 源專案零影響,也就是說那類專案這道機檢等於沒有 —— 由下面的 L5 兜底)。
8.6 執行期護欄 L5:可快取頁的 SSR 路徑不得攜帶使用者狀態
這是 YAPI 源專案唯一生效的一層,也是所有專案的最後防線。 它不依賴任何後端標註。
它守的事故形態:ISR 頁的首個訪問者恰好已登入 → 他的 token 隨 SSR 請求發到後端 →
後端回個人化資料(is_favorited / 會員價 / 進度)→ 烙進共享快取廣播給所有訪客。
本機 dev 完全發現不了(dev 不走快取)。
判據不是「呼叫的是不是 private 介面」,而是「這次渲染會不會因使用者而變」 —— 同一個
/movie/detail,帶不帶 token 後端就可能回不同內容。而「帶沒帶身分」是個精確、通用、
零後端依賴的訊號,這正是它能覆蓋 YAPI 存量專案的原因。
SSR 期發請求
├─ 沒有 token? → 整段跳過(未登入訪客,零開銷)
├─ 本頁可快取嗎? → 查 routeRules,isr / prerender / cache / swr 四鍵任一為真即算
│ (取不到路由上下文時按「不判」處理,避免誤傷 nitro 任務)
└─ 是 ⇒ 分檔處理
├─ dev + 真實登入使用者(有 user_id) → 直接 throw,當場攔住
├─ dev + 僅裝置匿名(無 user_id) → console.warn,不打掛頁面
└─ dev 與 prod 都執行:剝離 token 後重發
└─ 後端回 errorCode 2002(「要登入」)→ **顯式 throw**,不走靜默重登重試
為什麼剝離是安全的:實測舊後端 7 個公開端點 token-less 全部回 status=y ⇒ 它是
按介面鑑權、不是閘道強制,剝 token 不會把正常 ISR 頁打掛,只有真正依賴身分的介面才會抱怨。
為什麼 2002 必須自己 throw:舊後端私有端點無 token 時回 HTTP 200 + errorCode 2002, 不是 4xx。而 nitro 的「狀態碼 ≥400 不入快取」保護只看 HTTP 狀態碼 —— 200 照快取不誤。 另外,剝離過身分的這次不能走重登重試(重試會把身分加回去,等於繞過護欄),故走單獨分支直接拋。
已知邊界:L5 只守 framework 的 request() 通道。applier 直接用 raw useFetch / $fetch
呼叫後端時,Nuxt 預設會轉發 cookie,L5 完全看不見。可快取頁的取數一律走 request()
(或 generate-api 產出的 api.*),別繞開。
九、Framework 元件優先(避免重複造輪子)
PR 引入了與框架已有元件 / utility 功能重疊的自實作,必須改用 framework 提供的版本。
| 業務自實作徵兆 | 框架已有應改用 |
|---|---|
原生 <img> / 自封裝 image | SwImage(自帶 ratio + 懶載入 + avif/webp srcset) |
原生 <button> / 自封裝按鈕 | SwButton(標準按鈕,配 |
原生 <a> / 路由跳轉 | SwLink(預設一律 SwLink;<NuxtLink> 僅限 custom v-slot 具名例外) |
| 自實作 modal / dialog | SwModal |
| 自實作 toast / message | useToast() |
| 自實作彈窗確認 | useModal() |
| 主題切換 | useTheme() |
| AES 加解密 | decryptBinary / decryptBase64 / encryptDynamic |
| MD5 / 檔案 hash | md5 / md5File |
| IndexedDB 操作 | dbPut / dbGet / dbClear |
| 本地快取(帶加密) | useLocal / useSession |
| HTTP 請求 | request / createApiFetch(帶統一 AES + 錯誤處理) |
| 上傳檔案分片 | useUploader / createUploader |
| 二維碼生成 / 掃描 | SwQrcodeGenerator / SwQrcodeScanner |
| 影片 / 音訊播放器 | SwVideoPlayer / SwAudioPlayer / SwVideoPreview |
| 富文本編輯 | SwEditor |
| 表單欄位封裝 | SwFormField |
| 檔案上傳 | SwUploadImage / SwUploadVideo |
| 全域狀態 | useUserStore / useSystemStore(不要重新建 user / system 類 store) |
| SEO 元資訊 | useSeo()(自帶 og / twitter / canonical / JSON-LD / robots) |
反例:
<!-- ❌ 自實作 image 缺 ratio + 懶載入 + 現代格式 -->
<template>
<img :src="product.cover" :alt="product.name" />
</template>
正例:
<!-- ✅ 改用 SwImage -->
<template>
<SwImage
:src="product.cover"
:alt="product.name"
:ratio="16 / 9"
loading="lazy"
/>
</template>
MR review 樣板: 此處自實作
<img>缺少 ratio + 懶載入 + avif/webp srcset,請改用SwImage。詳見 framework/CLAUDE.md 第六章 + components/sw-image。
十、SSR 安全
10.1 頂層禁止存取瀏覽器 API
<script setup> 頂層(或 framework src/runtime/)存取 document / window / localStorage / sessionStorage —— SSR 期會爆掉。
// ❌ 錯誤:頂層直接存取 window
<script setup lang="ts">
const userAgent = window.navigator.userAgent // SSR 期 ReferenceError
const stored = localStorage.getItem('token') // 同上
</script>
// ✅ 正確:onMounted 守衛(僅 client 執行)
<script setup lang="ts">
const userAgent = ref('')
const stored = ref<string | null>(null)
onMounted(() => {
userAgent.value = window.navigator.userAgent
stored.value = localStorage.getItem('token')
})
</script>
// ✅ 正確:import.meta.client 守衛
<script setup lang="ts">
let stored: string | null = null
if (import.meta.client) {
stored = localStorage.getItem('token')
}
</script>
10.2 跨元件共用狀態用 useState 而非 ref
// ❌ 錯誤:模組級 ref 跨請求會串資料
// app/composables/useCart.ts
const items = ref<CartItem[]>([])
export function useCart() { return { items } }
// ✅ 正確:useState 每個請求獨立
// app/composables/useCart.ts
export function useCart() {
const items = useState<CartItem[]>('cart-items', () => [])
return { items }
}
為什麼? Nuxt SSR 是單行程多請求,模組級 ref 是單例,多個使用者的請求會共用同一個 ref 實例,造成串資料。useState 把狀態繫結到當前請求 context,每請求獨立。
MR review 樣板: 頂層存取
window在 SSR 期會爆炸。包到onMounted或加import.meta.client守衛。
十一、Git 協作規範
11.1 分支命名
分支模型推薦並預設 GitLab feature-branch flow(不是唯一允許的做法,機械層只保護兩條固定分支本身):dev/main(測試線,預設分支)與 main(生產線)都是 protected、push = No one(maintainer 也推不上),所有變更走短期分支 + MR。
適用對象 = applier(下游業務專案)。swift-web 內部 5 倉(framework / cli / starter / docs / standards)的實際開發是直接 commit + push
dev、不走 MR。
一次任務的完整循環:
git switch dev/main && git pull --ff-only # 1. 從最新 dev/main 出发(本地 dev/main 永遠只 pull,不 commit)
git switch -c feat/dark-theme # 2. 短期分支,一個任務一條
# …開發、按 task 原子 commit…
git push -u origin feat/dark-theme # 3. 推分支,開 MR → dev/main
git switch dev/main # 4. 提完 MR 立刻切回(bot 挑刺要改時再切回去)
# MR 合併 → 遠端分支自動刪;本地:git pull --ff-only && git branch -D feat/dark-theme
發版:dev/main → main 一個 MR(不拆、不 squash);按目前 review 和檢查門禁合併。CI 不部署,完整 E2E 本地執行並留證;實際發布由維護者/維運在 CI 之外手動執行。hotfix/ 同樣先走 MR 進 dev/main,再走發布 MR。
長期分支 dev/vN 已退役(2026-08,standards#51)。它賣的是「不受保護、隨便推」,可短期分支同樣隨便推;它多出來的只有「長命 + 共享」,而全部麻煩正來自這四個字——不能拿它當 MR 源(合併會刪源分支)、cherry-pick 後與 dev/main 永久 SHA 分叉、git log 虛高、做完的活躺在分支上 CI 看不見,最後不得不寫一個 sync-devmain 工具來抵消模型的固有缺陷。存量 dev/v1 的退役步驟見 11.10。
| 前綴 | 用途 | 範例 |
|---|---|---|
feat/ | 新功能開發 | feat/sw-image-avif-srcset |
fix/ | bug 修復 | fix/login-redirect-loop |
perf/ | 效能最佳化(不改功能) | perf/image-srcset |
style/ | 只改格式 / 樣式風格,不改邏輯 | style/prettier-reflow |
build/ | 建置 / 打包設定 | build/vite-7 |
refactor/ | 重構(不新增功能、不修 bug) | refactor/use-fetch-error-handling |
chore/ | 工程 / 依賴 / CI 之外的設定 / 規則同步 | chore/fw-0.140.0 |
docs/ | 只改文件 | docs/deploy-runbook |
test/ | 只改測試 | test/routes-smoke-samples |
ci/ | 只改 CI | ci/e2e-gate-split |
hotfix/ | 緊急修復(仍 MR 進 dev/main,隨即走發布 MR) | hotfix/payment-callback-500 |
epic/ | 短命整合分支:跨多個 MR 的版本 / 大功能(以前會開 dev/v2 的場景)——子分支 MR 進它、它定期 git merge origin/dev/main、做完後一個 MR 進 dev/main 並刪除;有終點、不叫 dev/v* | epic/v2-wallet |
命名規則:
- 小寫字母 + 短橫線,禁止底線 / 駝峰 / 中文
- 描述部分不超過 30 個字元
- 描述精煉到名詞短語(動詞收斂到前綴),如:
feat/dark-theme而非feat/add-dark-theme-support - 關聯 issue 時分支名可帶 issue 號:
fix/123-login-redirect - 前綴是約定不是門禁:verify 首步只對不合約定的源分支打 ⚠️ 提示(含建議改名),不紅、不攔合併;GitLab 原生產生的分支名(Revert / Cherry-pick 按鈕的
revert-<sha>/cherry-pick-<sha>、issue 頁「Create merge request」的<iid>-<slug>)直接視為合規、原樣保留
反例對照:
| 反例 | 問題 | 修正 |
|---|---|---|
feature_dark_theme | 用底線 + 錯誤前綴 | feat/dark-theme |
fix/LoginBug | 駝峰 + 描述模糊 | fix/login-redirect |
feat/添加暗色主題切換功能 | 中文 + 超長 | feat/dark-theme |
dev/main 上直接 commit | pre-commit 拒絕、伺服端也推不上 | git switch -c feat/xxx + MR |
dev/v2 長期分支 | 長期分支工作流已退役(名字本身不禁止) | 從 origin/dev/main 切短期分支 |
release/0.6.0 | 發版不再開分支 | dev/main → main MR + prod/* tag |
11.2 Commit Message 格式
遵循 Conventional Commits 簡化版:
<type>(<scope>): <中文描述>
[可選正文:解釋 why,不解釋 what(diff 會說話)]
[可選 footer:Closes #N / Refs #N / Co-Authored-By: ...]
格式規則:
- 標題非空且 ≥ 5 字元——由 commit-msg hook 強制(
@swift-web/standards的dist/commit-msg/cli.js,經 simple-git-hooks 註冊;空 message、wip/fix/.這類過短標題直接被拒絕 exit 1) - 描述必須言之有物:禁
update/修改/fix bug等無資訊量佔位、禁與改動無關的描述(hook 無法機械攔截,靠 review 與 AI 協作自覺) - 標題 ≤ 72 字元(GitLab UI 一行展示邊界)
- 標題末尾不加句號
- 正文 每行 ≤ 100 字元(避免水平捲動)
- 標題與正文之間空一行
- 描述部分用中文(團隊主要語言),技術術語 / API 名 / 套件名保留英文
- type / scope / footer trailers 保留英文(工具鏈剖析需要)
11.3 Type 列舉
| Type | 用途 |
|---|---|
feat | 新增功能或頁面 |
fix | 修復 bug |
perf | 效能最佳化(不改變功能) |
refactor | 重構(既不新增功能,也不修復 bug) |
style | 程式碼格式調整(縮排 / 空白 / 分號),不改邏輯 |
test | 新增 / 修改測試 |
docs | 文件變更(含 CLAUDE.md / wiki / README) |
chore | 建構 / 相依 / 設定變更 |
ci | CI/CD 變更 |
revert | 退回 |
11.4 Scope 建議
scope 用 kebab-case,指向改動主體所在子模組 / 主題,不寫 file path。
| 範例 scope | 對應模組 |
|---|---|
sw-image | framework SwImage 元件 |
use-modal | framework useModal composable |
request | framework HTTP 請求工具 |
nuxt-config | 專案 nuxt.config.ts |
eslint | ESLint 設定 |
auth | 業務模組:登入 / 鑑權 |
order | 業務模組:訂單 |
seo | 業務模組:SEO |
scope 不明確時可省略:feat: 支援暗色主題。
11.5 提交範例(多個)
最小範例:
feat(產品頁): 新增產品詳情頁路由與資料取得
fix(導覽): 修復行動端選單關閉後捲動鎖定問題
perf(圖片): 使用 SwImage 取代所有原生 img 標籤
chore(相依): 升級 @swift-web/framework 至 v0.7.x
完整範例(含正文 + footer):
feat(sw-image): 新增 avif srcset 支援
舊版只輸出 webp,部分新瀏覽器優先 avif(體積更小 30%)。
本次擴充 SwImage 的 generator script,建構期同時輸出 avif/webp/jpg 三檔。
Closes #88
fix(rendering): ISR 頁面 useFetch 錯誤回應不再被烙快取
發現 manga 詳情頁偶發 500 錯誤時,錯誤頁被 ISR 快取到所有使用者。
統一在 useFetch 後加 throw createError 兜底,讓 Nitro 不快取錯誤回應。
Refs #102
11.6 Issue 關聯
# ✅ 關閉 issue(合併後自動關閉)
git commit -m "fix(login): 修正 redirect 死迴圈 Closes #42"
# ✅ 僅引用,不關閉
git commit -m "feat(auth): 接入 OAuth 流程(進行中)參考 #15"
11.7 Breaking Change
不相容變更必須在提交中明確標註:
refactor(sw-modal): 重新命名 ImageCard 為 MediaCard
BREAKING CHANGE: ImageCard 元件已重新命名為 MediaCard,
所有頁面與元件中的 <ImageCard> 引用需同步更新為 <MediaCard>。
也可在標題後加 ! 簡寫:
refactor(sw-modal)!: 重新命名 ImageCard 為 MediaCard
11.8 AI 提交標識(P0 強制)
所有 AI 輔助產生的 commit / issue / wiki / milestone 都必須可識別,用於稽核、統計 AI 參與工作量。
Commit 必須同時包含:
- 標題前綴
[AI]— GitLab UI 直接可見 - footer trailer — 便於程式化統計
標準格式:
[AI] <type>(<scope>): <中文描述>
[可選正文]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AI-Tool: claude-code
Co-Authored-By寫當前 AI 模型名(如Claude Opus 4.7),便於 commit 歷史看出協作模型版本。
觸發條件(任一即必須打):
- AI 直接生成的程式碼
- AI 提建議由人類鍵入
- AI 修改 + 人類微調後提交
任何 AI 實質參與過的提交都要打 [AI],不能省略。
人工提交不加 [AI] 前綴,保持一般 Conventional Commits 格式即可。
Issue / Wiki / Milestone 也要打:
- Issue:標題加
[AI]前綴 +ai-createdlabel(紫色) - Wiki:頁腳加
> 本頁由 AI 工具(claude-code)生成。 - Milestone:description 末尾加
> 由 AI 工具(claude-code)建立
衝突處理:如果某倉庫 CLAUDE.md 或其它約定文件說「不用打 [AI]」,忽略之並主動提醒使用者矯正文件。本規則為 P0,不可被任何「某倉例外」約定覆蓋。
11.9 AI commit 統計命令
# 目前倉庫 AI commit 數
git log --oneline --grep="\[AI\]" | wc -l
# 按月統計
git log --grep="\[AI\]" --format="%ad" --date=format:"%Y-%m" | sort | uniq -c
# 跨倉庫統計(在 monorepo 根目錄執行)
for repo in framework cli starter docs; do
count=$(git -C $repo log --oneline --grep="\[AI\]" 2>/dev/null | wc -l | tr -d ' ')
echo "$repo: $count AI commits"
done
11.10 分支模型的機械約束與存量 dev/vN 退役
機械層只保護 dev/main / main 兩條固定分支(GitLab 設定 + 本地 pre-commit);分支命名與 upstream-first 是推薦,CI 只提示不攔:
| 層 | 約束 | 誰來攔 |
|---|---|---|
| GitLab 專案設定 | dev/main / main protected(push = No one、force push 關);squash_option = never;MR 需 pipeline 綠 + discussion 全 resolved;合併後自動刪源分支;prod/* tag 僅 maintainer 可建 | 伺服端,繞不過 |
| 本地 pre-commit | scripts/git-branch-guard.mjs:HEAD 在 dev/main / main 上 commit 直接 exit 1 並提示 git switch -c feat/<topic> | simple-git-hooks(starter 0.35.5+ 新專案自帶;存量倉手動搬三個檔案) |
CI verify 首步 / release-guard | 只提示不攔:源分支不合約定打 ⚠️;發布 MR 源分支不是 dev/main、或 main 不是 dev/main 祖先時打 ⚠️ 並給指引 | .gitlab-ci.yml |
GitLab 建 issue / MR 時自動填入 starter 自帶的 .gitlab/issue_templates/Default.md(SOP 五段)與 .gitlab/merge_request_templates/Default.md(Closes #N / e2e 適用聲明 / 留證 / 風險面)。GitLab CE 沒有 push rules;分支命名只是約定,機械層只保護 dev/main / main 兩條分支本身——不加 dev/v* 之類的名字萬用字元(讓名字非法 ≠ 防止某種做法,會把正當用途一起封死)。
存量 dev/v1 退役(一次性,每倉一個 issue):先確認沒人還在往它提交 → 把淨差異拆小 MR 合進 dev/main → 判空貼證 → 刪分支。判空不要用裸 git diff / git cherry / git log dev/main..dev/v1:2-dot 把 dev/main 領先的檔案也算進去,3-dot 與 patch-id 類判據被 cherry-pick 歷史騙過。用臨時合併:
git fetch origin dev/main dev/v1 -q
git worktree add --detach /tmp/retire-wt origin/dev/v1
git -C /tmp/retire-wt merge --no-edit origin/dev/main # 把 dev/main 合進 dev/v1 的本地副本;衝突 hunk 先判方向(見下)
git -C /tmp/retire-wt diff origin/dev/main --stat # 空 ⇒ dev/v1 沒有任何 dev/main 缺的內容——貼到 issue 才算判空
git worktree remove --force /tmp/retire-wt
git push origin --delete dev/v1 # 不可逆:只在貼證 + 拍板之後
發布與回退:發布 MR 與一般 MR 均按目前 review/檢查門禁執行,合併不觸發部署。CI 只做型別、lint、單元測試/覆蓋率和建置校驗;完整 E2E 本地執行,綁定最終來源 SHA 留證。測試/生產均由維護者或維運在 CI 之外手動部署,記錄環境、提交/產物、執行人和業務驗收結果;應急回退到已核驗的歷史提交/產物,不 retry CI 部署 job。實際規則以 standards 主源和各庫目前設定為準。
合併裡同內容兩邊自動收斂,剩下的才是真差異。衝突 hunk 不是「待合併的差異」,是「待判定的方向」:HEAD 側 = dev/v1、origin/dev/main 側 = 目標,先確認哪一側更新——dev/v1 往往是陳舊的一側(實測過:衝突檔案全是 dev/main 已修 / 已 revert 的版本),照「都合進去」處理會把已 revert 的壞改動放回去;只有確認 dev/v1 側確實更新的 hunk 才拆 MR。非空就繼續拆 MR,不要把 dev/main 真的 push 回 dev/v1(那是長期分支時代的 re-converge,退役不需要)。
十二、其他常見 Nuxt 4 / Vue 3 最佳實踐
12.1 資料取得
// ✅ 伺服器端拉資料用 useFetch / useAsyncData(享 SSR + hydration 最佳化)
const { data: products } = await useAsyncData('products', () => api.product.list())
// ❌ 直接 fetch(失去 SSR 資料預取 + hydration 同步)
const products = await api.product.list() // 裸呼叫,SSR 期不預取
12.2 列表算繪必帶 :key
<!-- ✅ 用穩定 id 作 key -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
<!-- ❌ 用 index 作 key(資料順序變動時 patch 錯位) -->
<li v-for="(item, i) in items" :key="i">{{ item.name }}</li>
12.3 大列表虛擬化
1000+ 條目用
vue-virtual-scroller或類似套件;一般列表(< 200 條)無需。
12.4 v-html 必須先消毒
// ❌ 直接算繪使用者輸入(XSS 風險)
<div v-html="userInput" />
// ✅ 用 DOMPurify 消毒後算繪
import DOMPurify from 'dompurify'
const safeHtml = computed(() => DOMPurify.sanitize(userInput.value))
// <div v-html="safeHtml" />
12.5 setup 頂層平行 await
// ❌ 串列 await(效能反模式:A 完才請求 B)
const { data: a } = await useFetch('/api/a')
const { data: b } = await useFetch('/api/b')
// ✅ 平行 await
const [{ data: a }, { data: b }] = await Promise.all([
useFetch('/api/a'),
useFetch('/api/b'),
])
12.6 computed 內不做副作用
// ❌ computed 內 console.log / 改其他狀態
const total = computed(() => {
console.log('計算 total') // 副作用
fetchExtra() // 更嚴重:觸發請求
return items.value.reduce((s, i) => s + i.price, 0)
})
// ✅ 副作用用 watch / watchEffect
const total = computed(() => items.value.reduce((s, i) => s + i.price, 0))
watch(total, (n) => {
if (n > 1000) showCouponHint()
})
12.7 HTML 語意化 / 可存取性
- 連結跳轉:用
@click + router.push/+ navigateTo做路由跳轉 → 爬蟲只跟<a href>,onClick 跳轉對搜尋引擎完全不可達。預設改用<SwLink>(<NuxtLink>僅限customv-slot 具名例外) - 標題層級:每頁只能一個
<h1>;<h2>/<h3>不跳級;不要為了視覺用<h3>(應改class控制字級) - 語意元素:用
<header>/<nav>/<main>/<article>/<section>/<footer>/<aside>,不要全用<div> - 圖片 alt:
<SwImage>必傳alt(描述圖片內容/語意),裝飾性圖片用alt="",不能省略 attribute - 表單 label:
<input>必有關聯的<label for=>或aria-label,placeholder不能取代 label - anchor text:連結文字描述性(
了解 SEO 最佳化技巧優於點這裡) - 外連 rel:站外連結加
rel="nofollow";target="_blank"必須配rel="noopener noreferrer"(防 tab 劫持)
<!-- ❌ 錯誤:onClick 跳轉,爬蟲看不到 -->
<button @click="router.push('/products')">查看商品</button>
<!-- ✅ 正確:用 NuxtLink / SwLink -->
<SwLink to="/products">查看商品</SwLink>
<!-- ❌ 錯誤:placeholder 取代 label -->
<input placeholder="請輸入手機號" />
<!-- ✅ 正確:label + aria-label -->
<label for="phone">手機號</label>
<input id="phone" placeholder="例:13800138000" />
十三、測試同步與門禁
改原始碼必須同步測試(P0 第 9 條),測試分單測與 e2e 兩軌,各守一類問題:單測守邏輯分支與資料映射,e2e 守 SSR 產出、回應頭、路由守衛、真瀏覽器行為——後者的失效形態是「dev / lint / 單測全綠、線上才炸」(整站 SSR 被關成空殼、詳情頁串號、ISR 空態文案發給爬蟲都屬此類)。
e2e 是否適用:按改動判,不按比例湊
判據一句話:這個改動的正確性能否在 happy-dom / mock 下被單測斷言?不能 ⇒ e2e 適用。
| e2e 適用(同 PR 必須帶 e2e 用例) | e2e 不適用(不勉強湊數) |
|---|---|
| 頁面級產出:路由 / SSR HTML 與回應頭 / SEO 標籤 / sitemap / robots | 純函式 / 工具 / 型別 / 常數 / 設定映射 |
| 影響 SSR 首屏與空態;改介面回傳形狀 | 無 UI 副作用的資料轉換、建置腳本、文件 |
| 使用者流程(登入攔截、表單、導覽、付費牆);路由重定向 / 守衛;中介軟體、外掛、server route | 純視覺數值(token / 間距 / 顏色)——用截圖留證 |
真瀏覽器才有的行為:捲動 / 觸控 / touch-action / IntersectionObserver / computed style |
不適用時在 issue 或 MR 描述裡顯式寫一行「e2e 不適用:<清單項>」——判定必須可見,禁止沉默跳過;mr-review 按 diff 路徑核對(規則 TS-001)。護欄性 e2e 必須附變異驗證(改壞→紅→還原→綠)。
門禁在哪裡跑
MR 的 verify job 只做型別、lint、單元測試/覆蓋率和建置校驗,不安裝瀏覽器系統庫、不執行完整 E2E、不部署。CI 留證限於實際執行的檢查;完整 pnpm test:e2e 本地執行,在 MR 記錄最終來源 SHA、環境/樣本和結果,交叉複核仍獨立執行。
覆蓋率基線是逐倉 ratchet,只升不降
vitest.config.ts 的 coverage.thresholds 就是本倉基線,CI 低於即紅。只門 lines(主)+ branches(副)。新專案預設 lines 80 / branches 65(隨 starter 模板);存量專案接入時基線 = 自身實測向下取整,先止跌再按 +5 抬;要降必須在設定同處註解理由 + 關聯 issue(規則 TS-002)。coverage.exclude 只排除 codegen 產物——判據是「是否產生程式碼」,不是目錄名。
e2e 頁面覆蓋是完整性檢查,不是百分比
tests/e2e/routes.ts 是路由冒煙清單,routes.smoke.spec.ts 逐條斷 200 / 非空殼 / <title> / 可快取頁帶 s-maxage / 水合無報錯;pnpm test:e2e 前置跑 check-e2e-routes:靜態頁 100% 必列、動態頁 ≥1 樣本,缺頁即紅——新增頁面不列清單就紅(規則 TS-003)。動態路由樣本:mock 倉用夾具 id;真後端內容站「列表第一條 → 詳情」兩跳,空庫顯式 test.skip;登入牆後的頁面按「測試登入態注入」約定(環境變數測試帳號 + tests/e2e/helpers/login.ts 取真實 token 寫 framework cookie、複用 storageState)拿到登入態,無帳號時標 auth: true 列出——可見的債,不是沉默跳過。
十四、註解風格
13.1 何時應該寫註解
- why 類:解釋這段程式碼為什麼這麼寫(業務約束、踩過的坑、上游限制),diff 看不出來
- 公共 API:匯出函式 / 元件 / composable 的 JSDoc(參數、回傳值、拋錯條件)
- 複雜正則 / bit 運算 / 演算法:旁註一句意圖,避免後續維護者反向推導
- 臨時方案 / 已知問題:用
// TODO(@user, #issue): .../// FIXME: ...標註,必須帶可追溯線索(issue 號或負責人)
13.2 何時不要寫註解
- 翻譯程式碼(
// 自增 i旁標i++)—— 雜訊 - 註解掉的舊程式碼區塊 —— 用 git history,不要堆在倉庫裡
- 為了「覆蓋率指標」而寫的空泛 JSDoc(
@param data data這種)
13.3 JSDoc 約定
- 公共 API(匯出函式、元件 props / emits、composable 回傳值)必須寫 JSDoc
- 內部函式按需寫(參數複雜 / 含副作用 / 易誤用時寫)
- JSDoc 用中文,類型簽章用 TypeScript(不重複
@param {string},TS 類型已經宣告)
/**
* 上傳單一檔案並回傳伺服器端簽發的 URL。
*
* 實作細節:分片大小 4MB,失敗自動重試 3 次。
*
* @throws 網路故障或伺服器端拒絕時拋 UploadFailedError
*/
export async function uploadFile(file: File): Promise<string> {
// ...
}
// ✅ TODO 必帶追溯線索
// TODO(@djatm, #142): WebSocket 心跳間隔需要隨後端設定動態調整,目前先固定 30s
const HEARTBEAT_INTERVAL = 30_000
// ❌ 錯誤:TODO 無負責人 / issue
// TODO: 這裡要改