开发规范
本规范约束所有 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 源靠约定 + review) 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 —— 靠约定 + review 把关 |
visibility.json 只有 OpenAPI 源的项目才有。公司旧后端走 YAPI,不支持该标注 ——
那类项目下面 8.5 的 isr-no-private-api 机检会静默 no-op,只能靠约定 + review。
别以为装了 framework 就自动有这道机检。
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。
按路由路径判定,与下面 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.*),别绕开。
另有两条同属 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 期跑。
九、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 } = 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: 这里要改