项目:html5-examples | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
架构决策记录(ADR)通常在决策发生时写作,而本项目没有留下正式的 ADR 文件——它的决策记录散落在三处"现场":源码注释(尤其是 vite.config.ts 与 src/utils/sandbox.ts 里那些记录事故现场的段落)、文件头说明(如 src/api/examples.ts 头注释对 2026-10-05 数据迁移的完整交代)、以及 AGENTS.md 第 10 节的八条半正式 ADR。本篇做的是一次"决策考古":从这三处现场出发,结合代码结构与历史痕迹,把 12 条影响全局的关键决策还原为"背景 → 备选 → 决策 → 源码证据 → 后果"的五段式记录。其中部分决策的"备选方案"是推测性重构(源码未明说当初考虑过什么),会明确标注;无法核实的时间点与动机一律标注 ⚠️ 待确认。
为什么值得为一个小项目做 12 条考古?因为这个项目的特殊性恰恰在于:它用极小的团队规模和极轻的依赖,完整回答了"内容型站点如何隔离执行不可信代码""数据应该放数据库还是放仓库""上千个动态 chunk 如何治理"这类普适问题,而且每条决策都有可运行的代码佐证而非纸上谈兵。读别人的决策最大的收益不是照抄结论,而是看清"约束条件如何推出结论"——本篇刻意把每条决策的约束条件写足,就是为了保留这条推理链。
条目按"内容域 → 渲染域 → 扩展机制 → 网络与状态 → 安全与认证"的大致顺序编排,编号不代表时间先后(多数决策无法考证精确时点,⚠️ 待确认);能确证的时点只有两个:数据本地化迁移为 2026-10-05(src/api/examples.ts 头注释),MDN 主题并入 CSS 主题为 2026-06(src/router/index.ts 74 行注释)。
import()(兼顾两者)。src/content/examples/*.json,按 sourceGroup 切成 8 个分组文件,src/api/examples.ts 用 8 个动态 import() 组成 groupLoaders 加载器表,Promise.all 并发拉取、模块级 cache 缓存,数据库退守用户行为域。src/api/examples.ts 头注释("示例内容自 2026-10-05 起从 PostgreSQL 迁移至 src/content/examples/*.json……本模块保持与原后端 API 完全一致的导出签名,消费方零改动");groupLoaders 表(26-53 行);loadAll() 的 cache/loadingPromise 双变量(55-56、96-117 行)。docs 与《06-部署运行》形态 A)、内容变更走 git 评审回滚、后端职责纯化。负面——内容更新必须发版(失去后台即时运营能力)、server/src/modules/examples/ 沦为冻结资产(《13-技术债务》第 2 条)、首次无筛选进入示例库仍需全量下载 8 个分组(《10-性能设计》§1 已立项按需分组优化)。getExamples/getExampleById/updateExample 三个函数的签名、PaginatedResponse 分页结构、404 异常语义全部原样保留,仅替换内部实现;create/delete 因无前端消费方直接移除。src/api/examples.ts 头注释明示该决策;getExamples 返回 { data, pagination: { total, page, limit, totalPages } }(15-23、168 行);getExampleById 未命中抛 ApiError(404, …, { message: '示例不存在或已被移除' })(174-183 行);分页边界计算复刻后端钳制语义(162-166 行)。src 指向服务端生成的页面(需要后端参与渲染,违背静态化方向);其四,srcdoc 内联文档(纯前端、零后端、文档级隔离)。src/utils/sandbox.ts 的 buildSrcDoc() 把示例三段源码拼装成完整 HTML 文档字符串,塞进 iframe 的 srcdoc 属性;宿主统一注入 reset CSS、viewport、<base> 与文档标题。src/utils/sandbox.ts 67-182 行 buildSrcDoc 全函数;ADR-4(AGENTS.md 第 10 节);消费链 src/composables/usePreview.ts 的 srcDoc computed → src/components/examples/PreviewPane.vue → src/components/common/DeviceFrame.vue。ExampleCodeSnapshot.vue)+ IntersectionObserver 可视区懒渲染 + 详情页才渲染"三层机制消化(《10-性能设计》§2)。另一个隐性代价是编辑态每次输入都重建 iframe 文档,liveExample 未做防抖是有意取舍(同上 §2)。public/shared-assets/、public/static-sites/ 下的同源素材(图片/字体),srcdoc 文档要借 <base href="{origin}/"> 加载它们,而 srcdoc 的 opaque origin 默认无法读取同源资源——需要 allow-same-origin。但把这个 flag 授予所有示例会无谓扩大攻击面。allow-same-origin(简单但攻击面最大化);全部不授予(引用素材的示例集体白屏);为每示例手工标注布尔位(元数据维护成本高、必然漂移);运行时按内容正则自动探测(零维护、随内容自洽)。needsSameOrigin(example) 用三组正则扫描示例三段源码,命中 /shared-assets/、/static-sites/、../assets/、./assets/、/images/ 等模式才追加 allow-same-origin;探测结果随示例数据走,无任何人工登记位。src/utils/sandbox.ts 184-196 行(正则组与注释"这些资源需要 allow-same-origin 才能在 iframe srcdoc 中通过 <base> 加载");消费方 PreviewPane.vue 以 needsSameOrigin(example) 决定传给 DeviceFrame 的属性。getSandboxAttributes 上方写下罕见坦诚的安全注释:声明风险、声明"示例代码半可信 + 入库校验"的缓解前提、列出三条后续加固路线(srcdoc 内 CSP meta、独立子域、postMessage 通信)。src/utils/sandbox.ts 198-221 行注释原文("allow-scripts + allow-same-origin 组合理论上存在沙箱逃逸风险……示例代码来自后端数据库(半可信)……后续如需进一步加固,可考虑 1) CSP meta 2) 独立子域 3) postMessage");默认属性集 ['allow-scripts','allow-forms','allow-modals','allow-popups'](216 行)。src/content/examples/*.json → groupLoaders → srcdoc 沙箱)、SFC 演示(src/components/examples/demos/ → defineAsyncComponent → 页面内组件渲染)、CSS 生成器(catalog.ts + generators/routes.ts → gen-:kind 动态路由 → BaseGenerator 骨架)、配置型数据(src/config/effects|tailwindTemplates|svgLessons* → 静态 import → 卡片网格)。src/views/PlaygroundPage.vue 的演示登记;src/config/css-lab/catalog.ts。src/config/css-lab/routes.ts 的 TOOL_ROUTE_PATHS 派生全部路径(toolPath() 供 catalog 引用,缺键即类型错误);src/router/routes/css-lab.ts 只留一条 css/gen-:kind 动态段由 CssGeneratorRouter.vue 二次分发;src/config/css-lab/catalog.ts 的 ToolItem 清单派生首页 Bento 卡、侧栏与搜索。新增生成器 = 组件 + generatorComponents 一行 + catalog 一条("两行登记")。src/views/css-lab/css/generators/routes.ts(64 个 kind 键的 defineAsyncComponent 映射);src/router/routes/css-lab.ts 65-71 行动态段;src/config/css-lab/catalog.ts 头部条目(link 全部经 toolPath 派生);机制详解见《07-模块详解》§2。server/src/app.module.ts 的 18 模块构成(活跃消费集中在 auth/topics/categories/favorites 与三个内容元数据模块);前端 api 资源模块中 8 个走 HTTP(auth/topics/categories/favorites/static-sites/ui-elements/canvas-examples/visual-demos),examples 一个走进程内本地数据(《15-接口全量参考》§2.2)。/example/:id 快速连点)、防抖搜索触发并发请求时,慢的旧响应可能后到并覆盖新结果——这是取数型 SPA 最经典的缺陷,且每个页面自己处理必然遗漏。@vueuse/core 的 useAsyncState 基础上自研一个带竞态保护的 composable。src/composables/useResourceFetch.ts——requestId 递增快照 + AbortController 取消前序双保险;三个出口(成功/失败/finally)都做序号比对,过期请求的结果、错误与 loading 复位一律丢弃;AbortError 视为竞态取消而非真实错误。src/composables/useResourceFetch.ts 41-76 行(头注释明示"设计参考 @vueuse/core useAsyncState,但增加 last-request-wins 竞态保护");典型消费 ExampleDetailPage.vue 的 watch: () => route.params.id。shallowRef 避免大数组深层代理(《10-性能设计》§5);错误不弹 toast 而是折叠进 error 状态由页面 EmptyState 消费(《03-接口设计》§4 错误哲学)。代价是 fetcher 必须接受并尊重 AbortSignal,否则取消只是"逻辑丢弃"而非真实中断(client.ts 的 linkSignals 已把信号合并进请求层兜底)。router.onError 捕获 message 匹配 Loading chunk|Failed to fetch dynamically imported module 的错误后整页 window.location.reload(),模块级 chunkReloadTriggered 标志位防止多个 chunk 连环失败触发多次刷新。src/router/index.ts 202-216 行(注释:"检测动态 import 失败(如部署后旧 chunk 失效),自动刷新页面")。defineAsyncComponent 的失败不经过 router.onError(《10-性能设计》§4 明示),生成器与演示的 chunk 失效不在自愈范围内;这是当前最值得补的尾巴(可挂全局 onerror/unhandledrejection 兜底,⚠️ 待确认 是否已有计划)。JWT_ACCESS_EXPIRES_IN 控制,未配置兜底 604800 秒)+ refresh token(非 JWT,crypto.randomBytes(64) hex 落 refresh_tokens 表,可吊销);其二,前端 authStore 以 REFRESH_AHEAD_SECONDS = 300 在过期前 5 分钟用模块级定时器静默换新(applyAuthResult/ensureHydrated 两处都重挂——localStorage 恢复会话后定时器必丢);其三,后端 refreshAccessToken 实现"旋转 + 重用检测":旧 token 用后即吊销,发现已吊销 token 再被使用即判定泄露,全量吊销该用户所有 refresh token。server/src/modules/auth/auth.service.ts 45-46 行 TTL 兜底、186 行 randomBytes(64)、336-364 行 refreshAccessToken(348 行"已吊销 → revokeAll"分支);src/stores/authStore.ts 11 行 REFRESH_AHEAD_SECONDS = 300、140-150 行 scheduleTokenRefresh、126 行注释"恢复会话后必须重新调度自动刷新"。expiresIn 字段回传——server/.env 若误配 TTL 为极小值,刷新风暴会放大(JWT_EXPIRES_IN 历史遗留键的语义澄清见《14-配置与环境详解》§2)。OVERRIDE_KEY = 'examples-overrides-v1' 覆盖层:updateExample 只摘取 html/css/js 三个内容字段(title/tags 等元数据不可改),写入 localStorage 并同步内存 cache;loadAll() 完成后 applyOverrides() 把覆盖合并回内存数组;隐私模式写入失败静默放弃。src/api/examples.ts 58-93 行(注释:"本地编辑覆盖层:SourcePane 的「保存」写入 localStorage……替代原 DB update 语义")、199-207 行 updateExample 的字段白名单。把 12 条决策并排放,能读出四条反复出现的模式,它们比任何单条决策都更接近这个项目的"设计宪法"。
模式一:用不可变的机制代替可被绕过的约定。 防抖封装进 useListFilter、竞态保护内建在 useResourceFetch、allow-same-origin 由正则推断而非人工标注、TOOL_ROUTE_PATHS 缺键直接类型报错——凡是有"人可能忘"的环节,项目都把正确做法固化进了机制,让人"想犯错都难"。这也是《01-系统架构》§5 收尾那句"修改基础 composable 改动半径远超表面行数"的原因:机制被所有人依赖。
模式二:签名冻结、实现自由。 ADR-02(数据层签名兼容)、路由重定向保全深链、/api/examples 端点保留——项目对"对外承诺"极其保守,对"内部实现"极其果断。存储介质换了三次,消费方零感知。做同类迁移时,先写清楚哪些东西永不变化,比急着动手更重要。
模式三:坦诚记录残余风险。 sandbox.ts 的逃逸风险注释、vite.config.ts 的三条事故注释、authStore 的定时器丢失注释——项目从不假装决策没有代价,而是把"知道什么没解决、将来怎么解决"写在离代码最近的地方。这让后来的权衡(比如是否开放用户提交)有据可依,而不是重新争论一遍。
模式四:每条简化决策都配套一条兜底机制。 8 分组懒加载配套 loadingPromise 失败重试;整页 reload 配套标志位防连环刷新;覆盖层配套隐私模式静默降级;全量 JSON 迁出配套 applyOverrides 保持编辑体验。简化不是砍掉防御,而是把防御移动到新的边界上——判断一个"简化"是否成立,就看它的兜底是否也跟着搬家了。
本篇完。下一篇:17-依赖全景.md