開發規範

本規範約束所有 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。本頁覆蓋主源的子集,完整規範以 standards rules/ 為準——主源近期新增的「全域 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/PascalCaseLoginForm.vue / AdSwiper.vue / PaginationBar.vue
app/pages/kebab-caseabout-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 + PascalCaseuseModal.ts / useToast.ts / useLoginCheck.ts
app/store/kebab-caseuser-info.ts / system-info.ts
app/plugins/kebab-caseanalytics.client.ts / error-handler.ts
app/middleware/kebab-case + .global 後綴login.global.ts / guest.global.ts
app/utils/kebab-caseaes-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 速查對照表

物件類別風格範例
業務元件檔案PascalCaseLoginForm.vue
核心元件檔案Sw + PascalCase(framework)SwAsyncText.vue
頁面檔案kebab-caseproduct-detail.vue
版面檔案全小寫default.vue
Composable 檔案use + PascalCaseuseToast.ts
Utils 檔案kebab-caseaes-dynamic.ts
Store 檔案kebab-caseuser-info.ts
Middleware 檔案kebab-case + .globallogin.global.ts
Plugin 檔案kebab-case + .client/.serveranalytics.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 種邊緣情形:

  1. 多根元件(Portal / Teleport 產生兄弟根):每個根獨立判定——寫了 class 就必須帶 sw-<元件名>-<部位> 複合前綴,沒寫則放過
    <template>
      <Teleport to="body">
        <div class="sw-toast-overlay">
          <div class="sw-toast-content">{{ message }}</div>
        </div>
      </Teleport>
    </template>
    
  2. Pass-through wrapper(無自身 DOM):class 附到實際算繪的 DOM 上
    <!-- 包裝 SwImage 但自己不算繪 wrapper -->
    <template>
      <SwImage class="sw-product-cover" v-bind="$attrs" />
    </template>
    
  3. 純邏輯元件(零 DOM 產出,例如只 emit 全域事件):自然 no-op,無需任何標註;如需明確表達可選加 @no-root-class 檔頭註解
    <script setup lang="ts">
    /**
     * GlobalEventBus — 純邏輯元件,零 DOM 產出
     * @no-root-class(可選標註,明確表達「本元件不適用根 class 規則」)
     */
    onMounted(() => { /* ... */ })
    </script>
    <template></template>
    
  4. 條件算繪多分支(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 前綴不能取代 Vue multi-word-component-names 規則。前者防 CSS class 衝突,後者防元件名與 HTML 原生標籤衝突。兩者並存不冗餘。

Lint / Format 兜底:

  • @swift-web/standards/eslint 已發布 root-class-sw-prefix 規則(v2 語意:寫了 class 才檢查前綴),預設 severity warn;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-indentrem(a11y 友善)
line-height無單位數(如 1.5),禁止 px / rem
padding / margin / gap / width / height / top/right/bottom/leftrem
border-radius ≥ 0.5rem(8px)rem
border-radius < 8px 小圓角px 例外(半像素算繪穩定)
border-width / box-shadow / text-shadow offsetpx
媒體查詢 @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 範例理由
路由 / pathnameroutes.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
錯誤頁 / 404SSR + 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']) 讀使用者態 header
  • event.context.user(server middleware 注入的使用者物件)
  • 私有頁面(個人中心 / 訂單 / 後台)忘標 ssr: false

四道防線(按優先順序):

  1. 使用者態頁面 ssr: false(最簡單)
  2. Public / Private API 分離(判據是後端標註,不是前端目錄)+ 混合頁面強制拆分(見第八章)
  3. Nitro cache.varies 限定場景
  4. 把變數提到 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 欄位長度建議

欄位推薦長度備註
title10–70 字元偏短:搜尋結果不顯眼;偏長:被搜尋引擎截斷
description50–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 | privategenerate-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> / 自封裝 imageSwImage(自帶 ratio + 懶載入 + avif/webp srcset)
原生 <button> / 自封裝按鈕SwButton(標準按鈕,配 防 double-submit)
原生 <a> / 路由跳轉SwLink(預設一律 SwLink;<NuxtLink> 僅限 custom v-slot 具名例外)
自實作 modal / dialogSwModal
自實作 toast / messageuseToast()
自實作彈窗確認useModal()
主題切換useTheme()
AES 加解密decryptBinary / decryptBase64 / encryptDynamic
MD5 / 檔案 hashmd5 / 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/只改 CIci/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 上直接 commitpre-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建構 / 相依 / 設定變更
ciCI/CD 變更
revert退回

11.4 Scope 建議

scope 用 kebab-case,指向改動主體所在子模組 / 主題,不寫 file path。

範例 scope對應模組
sw-imageframework SwImage 元件
use-modalframework useModal composable
requestframework HTTP 請求工具
nuxt-config專案 nuxt.config.ts
eslintESLint 設定
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 必須同時包含:

  1. 標題前綴 [AI] — GitLab UI 直接可見
  2. 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-created label(紫色)
  • 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-commitscripts/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> 僅限 custom v-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: 這裡要改

十五、相關連結