@vobs/cli 1.8.5 → 1.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ /*
2
+ * `vobs agent-doc` —— 把框架契约**生成**到项目里,供任何 LLM / agent 读取。
3
+ *
4
+ * ## 为什么是"生成"而不是"手写"
5
+ *
6
+ * 契约随框架演进(1.8.0 加 `Show`/`ClientOnly`、1.8.3 加 `on()`/`VOBS_C106`、
7
+ * 1.8.4 加 `VOBS_C107`、1.8.5 改 fix 文案)。手写的话每个项目一份副本,**必然漂移**。
8
+ * 生成则与版本绑定 —— 升级框架后重新生成即可。
9
+ *
10
+ * ## 要锁住的四条行为
11
+ *
12
+ * 1. **标记块**:只替换 `vobs:begin`/`vobs:end` 之间,块外是项目自有约定
13
+ * 2. **不覆盖手写的 `AGENTS.md`**:没有标记块时**追加**生成块,原有内容全保留
14
+ * (丢掉别人的规则比不生成更糟)
15
+ * 3. **`CLAUDE.md` 只写一行 `@AGENTS.md`**:内容只有一份,避免两份副本漂移
16
+ * 4. **`--check`**:不一致时 exit 1,可进 CI —— 把"文档漂移"从"靠人记得"变成"机器拦"
17
+ */
18
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import { afterEach, describe, expect, it, vi } from 'vitest'
22
+ import { agentDocCommand } from './agent-doc'
23
+
24
+ const created: string[] = []
25
+ function makeProject(): string {
26
+ const dir = mkdtempSync(join(tmpdir(), 'vobs-agent-doc-'))
27
+ created.push(dir)
28
+ return dir
29
+ }
30
+ afterEach(() => {
31
+ while (created.length > 0) rmSync(created.pop()!, { recursive: true, force: true })
32
+ })
33
+
34
+ /** 跑一次命令并吃掉它的 stdout(测试只关心退出码与文件)。 */
35
+ async function run(dir: string, options: Record<string, boolean>): Promise<void> {
36
+ const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
37
+ const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
38
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
39
+ try {
40
+ await agentDocCommand({ dir, ...options })
41
+ } finally {
42
+ write.mockRestore(); log.mockRestore(); vi.restoreAllMocks()
43
+ }
44
+ }
45
+
46
+ describe('vobs agent-doc', () => {
47
+ it('--write 生成 AGENTS.md 与 CLAUDE.md,契约要点都在', async () => {
48
+ const dir = makeProject()
49
+ await run(dir, { write: true })
50
+
51
+ const agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8')
52
+ // 契约必须覆盖这轮落地的关键约束 —— 缺一条就等于没同步
53
+ for (const key of ['run-once', 'RouterView', 'on(deps', 'VOBS_C107', 'VOBS_C210', 'parseNumber', 'ClientOnly']) {
54
+ expect(agents, `契约里缺 ${key}`).toContain(key)
55
+ }
56
+ // 标记块存在(--check 靠它比对)
57
+ expect(agents).toContain('vobs:begin')
58
+ expect(agents).toContain('vobs:end')
59
+ // 内容只有一份:CLAUDE.md 是指针,不是副本
60
+ expect(readFileSync(join(dir, 'CLAUDE.md'), 'utf8').trim()).toBe('@AGENTS.md')
61
+ })
62
+
63
+ it('**手写的 AGENTS.md 不被覆盖** —— 生成块追加在后', async () => {
64
+ const dir = makeProject()
65
+ writeFileSync(join(dir, 'AGENTS.md'), '# 我的项目约定\n- 目录:src/pages\n', 'utf8')
66
+ await run(dir, { write: true })
67
+
68
+ const agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8')
69
+ expect(agents, '原有的项目约定被覆盖了').toContain('我的项目约定')
70
+ expect(agents).toContain('目录:src/pages')
71
+ expect(agents).toContain('vobs:begin')
72
+ })
73
+
74
+ it('再次 --write 只替换契约块,块外的新内容保留', async () => {
75
+ const dir = makeProject()
76
+ await run(dir, { write: true })
77
+ // 用户在块外补自己的内容
78
+ const first = readFileSync(join(dir, 'AGENTS.md'), 'utf8')
79
+ writeFileSync(join(dir, 'AGENTS.md'), `${first}\n- 我的新增规则\n`, 'utf8')
80
+
81
+ await run(dir, { write: true })
82
+ const second = readFileSync(join(dir, 'AGENTS.md'), 'utf8')
83
+ expect(second, '块外的用户内容被清掉了').toContain('我的新增规则')
84
+ // 契约块仍然只有一份(不会重复追加)
85
+ expect(second.match(/vobs:begin/gu)?.length).toBe(1)
86
+ })
87
+
88
+ it('--check:未生成时 exit 1,生成后通过', async () => {
89
+ const dir = makeProject()
90
+ process.exitCode = 0
91
+ await run(dir, { check: true })
92
+ expect(process.exitCode, '没生成却通过了检查').toBe(1)
93
+
94
+ process.exitCode = 0
95
+ await run(dir, { write: true })
96
+ await run(dir, { check: true })
97
+ expect(process.exitCode, '生成之后仍然检查失败').toBe(0)
98
+ process.exitCode = 0
99
+ })
100
+
101
+ it('--check:契约块被手改后报不一致', async () => {
102
+ const dir = makeProject()
103
+ await run(dir, { write: true })
104
+ const agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8')
105
+ // 手改块内内容 —— 模拟框架升级后项目里那份没同步
106
+ writeFileSync(join(dir, 'AGENTS.md'), agents.replace('run-once', 'run-once-手改过'), 'utf8')
107
+
108
+ process.exitCode = 0
109
+ await run(dir, { check: true })
110
+ expect(process.exitCode, '块被改动了却通过检查').toBe(1)
111
+ process.exitCode = 0
112
+ })
113
+
114
+ it('--check:CLAUDE.md 被改成副本而非指针时也有提示', async () => {
115
+ const dir = makeProject()
116
+ await run(dir, { write: true })
117
+ writeFileSync(join(dir, 'CLAUDE.md'), '# 我把内容抄了一份\n', 'utf8')
118
+
119
+ process.exitCode = 0
120
+ await run(dir, { check: true })
121
+ expect(process.exitCode, 'CLAUDE.md 没指向 AGENTS.md 却通过了').toBe(1)
122
+ process.exitCode = 0
123
+ })
124
+
125
+ it('--body 只输出契约正文(无标记块)', async () => {
126
+ const dir = makeProject()
127
+ let captured = ''
128
+ const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
129
+ captured += String(chunk)
130
+ return true
131
+ })
132
+ try {
133
+ await agentDocCommand({ dir, body: true })
134
+ } finally {
135
+ write.mockRestore()
136
+ }
137
+ expect(captured).toContain('run-once')
138
+ expect(captured, '--body 不该带标记块').not.toContain('vobs:begin')
139
+ })
140
+
141
+ it('不加任何开关时打到 stdout、不写文件', async () => {
142
+ const dir = makeProject()
143
+ const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
144
+ try {
145
+ await agentDocCommand({ dir })
146
+ } finally {
147
+ write.mockRestore()
148
+ }
149
+ expect(existsSync(join(dir, 'AGENTS.md')), '没加 --write 却写了文件').toBe(false)
150
+ })
151
+ })
@@ -0,0 +1,142 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { AGENT_CONTRACT } from '../templates/agent-contract.js'
4
+
5
+ /**
6
+ * `vobs agent-doc` —— 把框架契约**生成**到当前项目,供任何 LLM / agent 读取。
7
+ *
8
+ * ## 为什么是"生成"而不是"手写"
9
+ *
10
+ * 契约随框架演进(1.8.0 加 `Show`/`ClientOnly`、1.8.3 加 `on()` 与 `VOBS_C106`、
11
+ * 1.8.4 加 `VOBS_C107`、1.8.5 改 fix 文案)。手写的话,每个项目一份副本,
12
+ * **必然各自漂移**;而且写的人未必知道最新契约。
13
+ *
14
+ * 生成则天然与版本绑定:`@vobs/cli@1.8.x` 产出 1.8.x 的契约。
15
+ *
16
+ * ## 设计要点
17
+ *
18
+ * 1. **标记块**:只替换 `<!-- vobs:begin -->` 与 `<!-- vobs:end -->` 之间的内容,
19
+ * 块外的**项目自有约定原样保留** —— 重新生成不会覆盖你自己的东西。
20
+ * 2. **`--check`**:校验项目里那份是否与当前框架版本一致,不一致 exit 1。
21
+ * 可以进 CI —— 这样"文档漂移"从"靠人记得"变成"机器拦"。
22
+ * 3. **`CLAUDE.md` 只写一行 `@AGENTS.md`**:内容只有一份,避免两份副本漂移。
23
+ * 4. 由**框架**产出,不由用户手抄;工具无关(内容里不提任何特定 LLM)。
24
+ */
25
+
26
+ const BEGIN = '<!-- vobs:begin 由 `vobs agent-doc --write` 生成,请勿手改 -->'
27
+ const END = '<!-- vobs:end -->'
28
+
29
+ export interface AgentDocOptions {
30
+ /** 目标目录,默认当前工作目录。 */
31
+ readonly dir?: string
32
+ /** 写入文件(否则打到 stdout)。 */
33
+ readonly write?: boolean
34
+ /** 校验项目里那份是否与当前框架版本一致;不一致 exit 1。 */
35
+ readonly check?: boolean
36
+ /** 只输出框架契约正文(不含标记块外的说明)。 */
37
+ readonly body?: boolean
38
+ }
39
+
40
+ /** 契约正文内联在 `templates/agent-contract.ts`(见那里的注释:读文件在全量测试下会失败)。 */
41
+ function readContract(): string {
42
+ return AGENT_CONTRACT.trim()
43
+ }
44
+
45
+ /** 把契约包进标记块 —— 块外是项目自己的内容,重新生成不会动它。 */
46
+ function buildAgentsDoc(contract: string): string {
47
+ return [
48
+ BEGIN,
49
+ '',
50
+ contract,
51
+ '',
52
+ END,
53
+ '',
54
+ '<!-- 以下是本项目的自有约定,`vobs agent-doc --write` 不会覆盖。 -->',
55
+ '',
56
+ '## 本项目约定',
57
+ '',
58
+ '(在这里写你自己的目录结构、命名、业务流程等规则。)',
59
+ ''
60
+ ].join('\n')
61
+ }
62
+
63
+ /** 取出文件里的标记块内容(没有标记块时返回 null)。 */
64
+ function extractBlock(text: string): string | null {
65
+ const start = text.indexOf(BEGIN)
66
+ const end = text.indexOf(END)
67
+ if (start < 0 || end < 0 || end < start) return null
68
+ return text.slice(start, end + END.length)
69
+ }
70
+
71
+ /**
72
+ * 合并:保留块外内容,只替换块。
73
+ *
74
+ * 没有标记块的既有 `AGENTS.md`(用户手写的)**不直接覆盖** ——
75
+ * 把生成块**追加在后**,用户原有内容全部保留。丢掉别人的规则比不生成更糟。
76
+ */
77
+ function mergeAgentsDoc(existing: string | null, generated: string): string {
78
+ if (existing === null) return generated
79
+ const block = extractBlock(generated)
80
+ if (block === null) return generated
81
+ if (extractBlock(existing) === null) return `${existing.trimEnd()}\n\n${block}\n`
82
+ return existing.replace(extractBlock(existing)!, block)
83
+ }
84
+
85
+ export async function agentDocCommand(options: AgentDocOptions = {}): Promise<void> {
86
+ const root = resolve(options.dir ?? process.cwd())
87
+ const contract = readContract()
88
+
89
+ if (options.body === true) {
90
+ process.stdout.write(`${contract}\n`)
91
+ return
92
+ }
93
+
94
+ const generated = buildAgentsDoc(contract)
95
+ const agentsPath = resolve(root, 'AGENTS.md')
96
+ const claudePath = resolve(root, 'CLAUDE.md')
97
+ // 内容只有一份:CLAUDE.md 只做入口,避免两份副本漂移
98
+ const claudeDoc = '@AGENTS.md\n'
99
+
100
+ if (options.check === true) {
101
+ const problems: string[] = []
102
+ if (!existsSync(agentsPath)) {
103
+ problems.push('AGENTS.md 不存在 —— 跑 `vobs agent-doc --write` 生成')
104
+ } else {
105
+ const existing = readFileSync(agentsPath, 'utf8')
106
+ const block = extractBlock(existing)
107
+ if (block === null) {
108
+ problems.push('AGENTS.md 里找不到 vobs 标记块 —— 跑 `vobs agent-doc --write` 补上')
109
+ } else if (block !== extractBlock(generated)) {
110
+ problems.push('AGENTS.md 的 vobs 契约块与当前框架版本不一致 —— 跑 `vobs agent-doc --write` 同步')
111
+ }
112
+ }
113
+ if (!existsSync(claudePath)) {
114
+ problems.push('CLAUDE.md 不存在 —— 跑 `vobs agent-doc --write` 生成(内容只需一行 `@AGENTS.md`)')
115
+ } else if (!readFileSync(claudePath, 'utf8').includes('@AGENTS.md')) {
116
+ problems.push('CLAUDE.md 没有指向 @AGENTS.md')
117
+ }
118
+
119
+ if (problems.length > 0) {
120
+ console.error('[agent-doc] 契约与框架版本不同步:')
121
+ for (const problem of problems) console.error(` - ${problem}`)
122
+ process.exitCode = 1
123
+ return
124
+ }
125
+ console.log('[agent-doc] 契约与当前框架版本一致 ✓')
126
+ return
127
+ }
128
+
129
+ if (options.write !== true) {
130
+ process.stdout.write(generated)
131
+ return
132
+ }
133
+
134
+ const existingAgents = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : null
135
+ writeFileSync(agentsPath, mergeAgentsDoc(existingAgents, generated), 'utf8')
136
+ writeFileSync(claudePath, claudeDoc, 'utf8')
137
+ console.log(`[agent-doc] 已写入 ${agentsPath}`)
138
+ console.log(`[agent-doc] 已写入 ${claudePath}(内容为 @AGENTS.md)`)
139
+ if (existingAgents !== null && extractBlock(existingAgents) === null) {
140
+ console.log('[agent-doc] 你原有的 AGENTS.md 内容已保留,vobs 契约块追加在后。')
141
+ }
142
+ }
@@ -0,0 +1,107 @@
1
+ # vobs 框架契约(写代码前必读)
2
+
3
+ vobs 是**编译型 / 细粒度 / 响应式**框架,与 React 的心智模型有几处**根本不同**。
4
+ 下面的每一条都来自真实事故,**违反时通常编译通过、运行期才错**。
5
+
6
+ > 本文档由 `vobs agent-doc` 从框架版本生成 —— 不要手改生成的块。
7
+ > 每条后面括号里是**违反时框架会报的诊断码**。
8
+
9
+ ## 一、组件体只执行一次(run-once)
10
+
11
+ **组件函数体只在创建时跑一次**,之后只有 JSX 里订阅的位置更新。
12
+
13
+ - ❌ `const filtered = list.value.filter(...)` —— 一次快照,之后永不更新
14
+ - ✅ 派生放进 JSX:`<div>{list.value.filter(...).map(render)}</div>`
15
+ - ❌ 组件体里取快照给回调用:`const v = name.value; onClick={() => save(v)}`
16
+ - ✅ 回调内重读:`onClick={() => save(name.value)}`
17
+ - 动态 props 用 getter 形态,不要传求值后的值
18
+
19
+ ## 二、条件渲染:**不要在组件体里 `return` 分支**(`VOBS_C107` / `VOBS_C104`)
20
+
21
+ 组件体里读信号的 `return` 在挂载时固化,**三种写法坏得一模一样**:
22
+
23
+ ```tsx
24
+ return cond.value ? <A/> : <B/> // ❌ 冻结
25
+ if (cond.value) return <A/> // ❌ 冻结
26
+ return cond.value ? <A/> : null // ❌ 冻结
27
+ ```
28
+
29
+ 顶层 `return` 处**没有 parent/anchor**,所以两支都是 JSX 也一样不会重分支。
30
+
31
+ | 场景 | 用什么 |
32
+ |---|---|
33
+ | **路由分支** | **`<RouterView/>`**(声明式,内部 `insertDynamic` + `resetKey`) |
34
+ | 保留挂载、只切显隐 | **`<Show when={cond}>`**(切 `hidden` + `inert`,焦点/滚动/内部状态不丢) |
35
+ | 只切类名 | `class="base" classList={{ 'is-on': cond.value }}` |
36
+ | 普通条件渲染 | 放进 **JSX 子节点位置**:`<div>{cond.value ? <A/> : <B/>}</div>` |
37
+
38
+ ## 三、effect 里不要写自己读过的信号(`VOBS_C210` / `VOBS_C211`)
39
+
40
+ **依赖是自动收集的**:effect 运行期间读到的**任何**信号都会变成依赖。
41
+
42
+ - ❌ `effect(() => { count.value++ })` —— 读+写同一个信号 = 自订阅循环
43
+ - ❌ `effect(() => { if (session.value) void sync() })` —— **「只调了个函数」不等于没依赖**:
44
+ 被调函数在**首个 `await` 之前**的代码是**同步执行**的,它读的信号算在 effect 头上
45
+ - ✅ 只想声明依赖:**`effect(on(deps, () => { … }))`** —— 回调在 untrack 作用域里跑,
46
+ 它调用的函数碰什么信号都不会反向订阅
47
+ - ✅ 派生值用 `memo`,不要"读 A 写 B"
48
+ - 兜底才是 `untrack(() => { X.value = next })`
49
+
50
+ **别在 effect 里调 async 函数**(`VOBS_C106`):`effect` 不等它,
51
+ 且首个 `await` 之前的部分是同步的。异步取数用 **`@vobs/resource`**。
52
+
53
+ ## 四、客户端副作用与 SSR
54
+
55
+ - 定时器 / 监听 / `matchMedia` / `localStorage` → **`onMount` 启动、`onDestroy` 清理**
56
+ (**不要手写 `typeof window` 守卫**)
57
+ - **渲染输出本身**依赖浏览器(窗口尺寸 / `localStorage` 回填 / `Date.now` / 随机值)
58
+ → **`<ClientOnly fallback={…}>`**(首轮两侧都渲染 fallback,水合对得上)
59
+ - 模块顶层**禁止** JSX(`VOBS_C105`):import 求值早于渲染器安装
60
+
61
+ ## 五、列表与数据
62
+
63
+ - **数组更新必须换引用**:`list.value = [...list.value, item]`;
64
+ 原地 `push` / 改字段**不触发更新**
65
+ - JSX **子节点位置**只放四种形态:组件标签 / 元素 / 两分支三元 / `.map()`
66
+ - 数据结构里**只存纯描述**(字符串/样式/结构字段),**节点对象别进数据常量**
67
+ (SSG 序列化会炸,产物出现 `[object Xxx]` 时框架会直接报错)
68
+
69
+ ## 六、输入
70
+
71
+ - **数字输入走 `parseNumber`**(`<Field type="number">` 已内置):
72
+ 空串 / 非法 / 超界**不提交**,保持原值
73
+ —— `Number('') === 0` 会把输入清成 0 并沿联动链路清零兄弟维度
74
+ - `<select>` 的 value 直接绑,**不要写 ref 兜底**(1.5.1+ 已修时序)
75
+
76
+ ## 七、图标与 SVG
77
+
78
+ - **SVG 直接写 JSX**(`<svg><path/></svg>`,1.7.4+ 按 namespace 创建与水合)
79
+ - 图标要**登记进白名单**;查表 miss 时 `@vobs/icon-core` 会警告点名(但仍应登记)
80
+
81
+ ## 八、提交前自测
82
+
83
+ ```bash
84
+ pnpm run check:source # = vobs check,全仓一次列出全部诊断
85
+ pnpm run check:runtime # 真实浏览器逐路由跑护栏(需 Chrome)
86
+ pnpm run check:runtime:interact # 再点所有按钮、触发所有输入
87
+ ```
88
+
89
+ `vite build` **会**打印编译期警告(`C104`/`C105`/`C106`/`C107`),但只覆盖它编译到的文件;
90
+ `check:source` 才是全仓入口。
91
+
92
+ ---
93
+
94
+ ## 一句话速记
95
+
96
+ | 主题 | 一句话 |
97
+ |---|---|
98
+ | 组件体 | 只跑一次,派生进 JSX |
99
+ | 回调 | 触发时重读 `.value` |
100
+ | 条件渲染 | 路由用 `RouterView`,显隐用 `Show`,别在组件体 return 分支 |
101
+ | effect | 别写自己读的信号,用 `on(deps, fn)` |
102
+ | async | 别塞进 effect,用 `@vobs/resource` |
103
+ | 客户端 | `onMount`/`onDestroy`/`ClientOnly`,别手写 `typeof window` |
104
+ | 列表 | 换引用 |
105
+ | 数字 | `parseNumber` |
106
+ | SVG | 直接写 |
107
+ | 自测 | `check:source` + `check:runtime` |
@@ -0,0 +1,119 @@
1
+ /**
2
+ * vobs 框架契约正文 —— `vobs agent-doc` 的输出来源。
3
+ *
4
+ * 为什么是 TS 模块而不是 .md 文件:读文件要靠 `import.meta.url` 解析相对路径,
5
+ * 而**全量测试时 vitest 会转换模块**,`import.meta.url` 指向虚拟路径,
6
+ * `new URL('../../src/templates/…')` 就找不到文件(实测:单独跑 8 条全过、
7
+ * 全量跑 8 条全红)。内联成字符串**免疫打包与转换**,也省掉发布时拷模板。
8
+ *
9
+ * 内容由 `packages/cli/src/templates/agent-contract.md` 生成(保留 .md 作可读源);
10
+ * 改契约请改 .md 后重新生成,或直接改这里的字符串。
11
+ */
12
+ export const AGENT_CONTRACT = `# vobs 框架契约(写代码前必读)
13
+
14
+ vobs 是**编译型 / 细粒度 / 响应式**框架,与 React 的心智模型有几处**根本不同**。
15
+ 下面的每一条都来自真实事故,**违反时通常编译通过、运行期才错**。
16
+
17
+ > 本文档由 \`vobs agent-doc\` 从框架版本生成 —— 不要手改生成的块。
18
+ > 每条后面括号里是**违反时框架会报的诊断码**。
19
+
20
+ ## 一、组件体只执行一次(run-once)
21
+
22
+ **组件函数体只在创建时跑一次**,之后只有 JSX 里订阅的位置更新。
23
+
24
+ - ❌ \`const filtered = list.value.filter(...)\` —— 一次快照,之后永不更新
25
+ - ✅ 派生放进 JSX:\`<div>{list.value.filter(...).map(render)}</div>\`
26
+ - ❌ 组件体里取快照给回调用:\`const v = name.value; onClick={() => save(v)}\`
27
+ - ✅ 回调内重读:\`onClick={() => save(name.value)}\`
28
+ - 动态 props 用 getter 形态,不要传求值后的值
29
+
30
+ ## 二、条件渲染:**不要在组件体里 \`return\` 分支**(\`VOBS_C107\` / \`VOBS_C104\`)
31
+
32
+ 组件体里读信号的 \`return\` 在挂载时固化,**三种写法坏得一模一样**:
33
+
34
+ \`\`\`tsx
35
+ return cond.value ? <A/> : <B/> // ❌ 冻结
36
+ if (cond.value) return <A/> // ❌ 冻结
37
+ return cond.value ? <A/> : null // ❌ 冻结
38
+ \`\`\`
39
+
40
+ 顶层 \`return\` 处**没有 parent/anchor**,所以两支都是 JSX 也一样不会重分支。
41
+
42
+ | 场景 | 用什么 |
43
+ |---|---|
44
+ | **路由分支** | **\`<RouterView/>\`**(声明式,内部 \`insertDynamic\` + \`resetKey\`) |
45
+ | 保留挂载、只切显隐 | **\`<Show when={cond}>\`**(切 \`hidden\` + \`inert\`,焦点/滚动/内部状态不丢) |
46
+ | 只切类名 | \`class="base" classList={{ 'is-on': cond.value }}\` |
47
+ | 普通条件渲染 | 放进 **JSX 子节点位置**:\`<div>{cond.value ? <A/> : <B/>}</div>\` |
48
+
49
+ ## 三、effect 里不要写自己读过的信号(\`VOBS_C210\` / \`VOBS_C211\`)
50
+
51
+ **依赖是自动收集的**:effect 运行期间读到的**任何**信号都会变成依赖。
52
+
53
+ - ❌ \`effect(() => { count.value++ })\` —— 读+写同一个信号 = 自订阅循环
54
+ - ❌ \`effect(() => { if (session.value) void sync() })\` —— **「只调了个函数」不等于没依赖**:
55
+ 被调函数在**首个 \`await\` 之前**的代码是**同步执行**的,它读的信号算在 effect 头上
56
+ - ✅ 只想声明依赖:**\`effect(on(deps, () => { … }))\`** —— 回调在 untrack 作用域里跑,
57
+ 它调用的函数碰什么信号都不会反向订阅
58
+ - ✅ 派生值用 \`memo\`,不要"读 A 写 B"
59
+ - 兜底才是 \`untrack(() => { X.value = next })\`
60
+
61
+ **别在 effect 里调 async 函数**(\`VOBS_C106\`):\`effect\` 不等它,
62
+ 且首个 \`await\` 之前的部分是同步的。异步取数用 **\`@vobs/resource\`**。
63
+
64
+ ## 四、客户端副作用与 SSR
65
+
66
+ - 定时器 / 监听 / \`matchMedia\` / \`localStorage\` → **\`onMount\` 启动、\`onDestroy\` 清理**
67
+ (**不要手写 \`typeof window\` 守卫**)
68
+ - **渲染输出本身**依赖浏览器(窗口尺寸 / \`localStorage\` 回填 / \`Date.now\` / 随机值)
69
+ → **\`<ClientOnly fallback={…}>\`**(首轮两侧都渲染 fallback,水合对得上)
70
+ - 模块顶层**禁止** JSX(\`VOBS_C105\`):import 求值早于渲染器安装
71
+
72
+ ## 五、列表与数据
73
+
74
+ - **数组更新必须换引用**:\`list.value = [...list.value, item]\`;
75
+ 原地 \`push\` / 改字段**不触发更新**
76
+ - JSX **子节点位置**只放四种形态:组件标签 / 元素 / 两分支三元 / \`.map()\`
77
+ - 数据结构里**只存纯描述**(字符串/样式/结构字段),**节点对象别进数据常量**
78
+ (SSG 序列化会炸,产物出现 \`[object Xxx]\` 时框架会直接报错)
79
+
80
+ ## 六、输入
81
+
82
+ - **数字输入走 \`parseNumber\`**(\`<Field type="number">\` 已内置):
83
+ 空串 / 非法 / 超界**不提交**,保持原值
84
+ —— \`Number('') === 0\` 会把输入清成 0 并沿联动链路清零兄弟维度
85
+ - \`<select>\` 的 value 直接绑,**不要写 ref 兜底**(1.5.1+ 已修时序)
86
+
87
+ ## 七、图标与 SVG
88
+
89
+ - **SVG 直接写 JSX**(\`<svg><path/></svg>\`,1.7.4+ 按 namespace 创建与水合)
90
+ - 图标要**登记进白名单**;查表 miss 时 \`@vobs/icon-core\` 会警告点名(但仍应登记)
91
+
92
+ ## 八、提交前自测
93
+
94
+ \`\`\`bash
95
+ pnpm run check:source # = vobs check,全仓一次列出全部诊断
96
+ pnpm run check:runtime # 真实浏览器逐路由跑护栏(需 Chrome)
97
+ pnpm run check:runtime:interact # 再点所有按钮、触发所有输入
98
+ \`\`\`
99
+
100
+ \`vite build\` **会**打印编译期警告(\`C104\`/\`C105\`/\`C106\`/\`C107\`),但只覆盖它编译到的文件;
101
+ \`check:source\` 才是全仓入口。
102
+
103
+ ---
104
+
105
+ ## 一句话速记
106
+
107
+ | 主题 | 一句话 |
108
+ |---|---|
109
+ | 组件体 | 只跑一次,派生进 JSX |
110
+ | 回调 | 触发时重读 \`.value\` |
111
+ | 条件渲染 | 路由用 \`RouterView\`,显隐用 \`Show\`,别在组件体 return 分支 |
112
+ | effect | 别写自己读的信号,用 \`on(deps, fn)\` |
113
+ | async | 别塞进 effect,用 \`@vobs/resource\` |
114
+ | 客户端 | \`onMount\`/\`onDestroy\`/\`ClientOnly\`,别手写 \`typeof window\` |
115
+ | 列表 | 换引用 |
116
+ | 数字 | \`parseNumber\` |
117
+ | SVG | 直接写 |
118
+ | 自测 | \`check:source\` + \`check:runtime\` |
119
+ `