项目:html5-examples | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本篇做前三篇没做的事:把代码原文摊开,逐段读。《07-模块详解》用表格与要点概括了模块职责,《05-核心流程》用图还原了执行顺序,但"这段代码为什么这样写、换一种写法会怎样"必须回到原文才能看清。选段标准有三条:一是机制的完整标本——一段函数内含了该项目某个标志性决策的全部要素;二是可迁移性——读它学到的东西能直接搬去别的项目;三是有注释考古价值——代码本身或旁边留着决策痕迹。按此选出三组四处:src/utils/sandbox.ts 的 buildSrcDoc(沙箱内核的拼装管线)、src/composables/useResourceFetch.ts 与 src/composables/useListFilter.ts(两个"底座中的底座"composable,分别解决竞态与状态同步)、src/views/css-lab/css/generators/BaseGenerator.vue 搭配一个真实生成器 RgbaColorGenerator.vue(配置驱动扩展的骨架与肉)。
走读约定:代码原文用四反引号围栏包裹并标注文件与行号(行号以 2026-10-06 实测为准,后续提交可能漂移);每段走读以「这段代码教会我们什么」收尾——它是本篇真正的产出,正文段落里的行文都可以跳读,这四段小结建议逐字读。
buildSrcDoc:把三段源码拼成一个受控文档文件:src/utils/sandbox.ts(222 行,纯函数、零 Vue 依赖——工具层的"无框架污染"纪律在此得到贯彻)。调用链:usePreview 的 srcDoc computed → PreviewPane → DeviceFrame 的 <iframe :sandbox>(机制总览见《07-模块详解》§1.1,安全分析见《08-安全与权限设计》§1)。
src/utils/sandbox.ts 67-73 行
export function buildSrcDoc(example: CodeExample, snippet?: Snippet): string {
const baseCss = example.css ?? ''
const baseJs = example.js ?? ''
const css = snippet?.css ? `${baseCss}\n${snippet.css}` : baseCss
const html = snippet?.html ?? example.html
const js = snippet?.js ? `${baseJs}\n${snippet.js}` : baseJs开篇五行就是一个精巧的契约设计。snippet 是详情页「变体」切换的载体(DemoSnippet,见《04-数据模型》§2.3),它与基础示例的合并语义是非对称的:css 与 js 用换行追加,html 却是整体替换。为什么?因为三类源码的可分割性不同——一个变体通常只想改一段样式(追加的 CSS 靠层叠自然生效)或加一段脚本(追加的 JS 靠执行顺序生效),而 HTML 是结构性的,"追加"一段 HTML 会产生两个并列的页面根,语义上不成立,所以变体里只要声明了 html 就意味着"整体换掉"。这个设计让 DemoSnippet 类型可以只携带改动的那一段(snippets: [{ label: '深色版', css: '...' }]),数据量最小、意图最清晰。若反过来要求变体提供全量三段,每个变体都会携带大量与基础示例重复的代码——数据层立刻膨胀。
src/utils/sandbox.ts 75-92 行
// 为 html5 主题示例提供统一的舞台容器,补齐被清洗掉的 body 居中/全高等布局样式
const useExampleStage = example.topicSlug === 'html5'
const stagedHtml = useExampleStage ? `<div class="example-stage">${html}</div>` : html
const exampleStageCss = useExampleStage
? `.example-stage {
width: 100%;
min-height: 100vh;
height: 100vh;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.example-stage > * {
flex-shrink: 0;
}`
: ''注释里"补齐被清洗掉的 body 居中/全高等布局样式"是一个浓缩的历史事件:示例从 Markdown 等来源导入时,body 上的布局样式曾被批量清洗(《11-扩展开发指南》场景一第 4 步要求 html5 主题示例把 body 选择器改写为 .example-stage),清洗的理由是宿主要统一控制文档结构、不能让示例的 body 样式与拼装层冲突。清洗之后示例就失去了"居中、占满视口"这类布局默认值,于是拼装层用一个 div.example-stage 把 body 的角色接了过来——flex 居中、100vh、子元素不收缩,五行 CSS 精确复刻了被清洗掉的语义。注意这里的判断键是 topicSlug === 'html5' 而非"有没有缺样式":内容域的类别决定了渲染层的处理分支,这是"渲染机制服务于内容形态"的又一例。CSS 实验室与 CSS 主题示例不需要这个舞台(它们的示例以区块流式排布为常态),所以分支是二值的而不是可配置的——没有为"假如未来第三主题需要另一种舞台"留口子,YAGNI 纪律可嘉。
src/utils/sandbox.ts 94-95 行
// 防止 JS 中的 </script> 字符串提前闭合 script 标签
const safeJs = deduplicateRepeatedJs(js).replace(/<\/script/gi, '<\\/script')一行代码里叠了两层处理,顺序不能颠倒。deduplicateRepeatedJs(21-50 行)修复的是导入数据的历史病:部分示例的 JS 被重复拼接,产生顶级 const/let 重复声明而直接语法报错。它的算法很克制——先走"长度为偶数且前后半完全相等"的快路径,再按顶级声明语句(const|let|var|function|class|import|export 开头的换行分界)切分,仅当语句序列前后两半逐条一致时才保留前半。注释明确声明"此函数仅处理'完全相同的两段拼接',不做语义分析"——这是把修复范围锁死在已知病症上,不做泛化的"智能去重"(那会引入误杀风险)。replace(/<\/script/gi, '<\\/script') 则是防未来病:示例 JS 字符串里若含 </script> 字面量(比如动态创建脚本标签的示例),会提前闭合拼装层的 <script> 标签导致整个文档结构崩坏;转义成 <\/script 在 JS 字符串与正则里都与原文等价(\/ 就是 /),但在 HTML 解析器眼里不再是闭合标签。先去重再转义,是因为去重的逐条比对依赖原文一致性,转义后的文本会让"前后半相等"的判断失真。最终脚本块用 '<' + '/script>' 的拼接写法输出(179 行),让拼装层自身的模板字符串也不会被宿主 HTML 解析器误读——防御做到了自己头上。
<base> 与 reset 的用意src/utils/sandbox.ts 97-104 行(节选)
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="${window.location.origin}/">
<title>${escapeHtml(example.title)}</title>文档骨架里最关键的一行是 <base href="${window.location.origin}/">。srcdoc 文档的默认 base 是 about:srcdoc,示例里任何 assets/foo.png 这样的相对路径都会解析失败;<base> 把解析锚点移到站点 origin,/shared-assets/、/static-sites/ 这些绝对路径才能命中 public/ 下的真实文件——而这也正是 needsSameOrigin(184-196 行)存在的理由:资源要跨到真实 origin 去拿,iframe 就必须有 allow-same-origin。一个 <base> 标签串起了"资源加载"与"沙箱属性"两个机制,读 needsSameOrigin 的正则之前必须先看到这一行才能理解其必要性。escapeHtml(52-60 行)对标题做五字符实体转义,防止示例标题里的引号或尖括号破坏文档结构——标题是唯一被拼进 HTML 的元数据字段,转义面控制得极小、极精确。骨架中的 .demo-layout/.snippet-panel/.snippet-btn/.preview-panel 辅助样式(115-172 行)源自历史导入期"侧栏预览布局",但不是可删的活化石而是活依赖:2026-10-06 全量 JSON grep 验证,六个分组共 2266 处引用(css-visual 1361、css-layout 609、css-animation 161、css-basics 132、html5-advanced 2、html5-forms-media 1),且 16 个 Vue/TS 文件仍在使用——含 14 个 m-* 合集组件(MBackgroundLab.vue 等)、CssPropertyDemo/StickyFooterDemo 演示与 GradientPage.vue、CanvasExampleDetail.vue——删除前必须先清理引用它的示例数据与组件,否则会出现大面积裸结构。
这段代码教会我们什么:沙箱的本质不是"隔离别人的代码",而是"用一个完全受控的文档模板去承载不可控的内容"——模板里每一个固定字符(charset、base、reset、stage)都是拼装者主权,模板之外(三段源码)全部是访客。好的拼装器会精确划定两者的边界:访客可以改 CSS/JS/HTML 片段,但文档结构、编码声明、资源锚点永远在宿主手里。转义与去重这种"小手术"放在拼装层而不是内容层,意味着同一类修复对全量内容一次性生效——在边界上做一次,好过在每条数据上做一千次。
useResourceFetch:二十行代码的竞态教科书文件:src/composables/useResourceFetch.ts(95 行)。设计声明写在头注释:"设计参考 @vueuse/core useAsyncState,但增加 last-request-wins 竞态保护"。它解决的问题是:/example/:id 路由参数快速切换、防抖搜索连发时,慢的旧响应后到会覆盖新结果。
src/composables/useResourceFetch.ts 38-56 行
let requestId = 0
let abortController: AbortController | null = null
async function run(): Promise<void> {
// 取消前一个请求
if (abortController) {
abortController.abort()
}
abortController = new AbortController()
const currentRequestId = ++requestId
loading.value = true
error.value = null
try {
const result = await fetcher(abortController.signal)
// 竞态保护:仅最新请求的结果才会更新 state
if (currentRequestId !== requestId) return
data.value = result
options.onSuccess?.(result)这里有两套互为冗余的防线。第一套是 AbortController:每次 run() 先 abort 前序并把信号传给 fetcher——这是"物理取消",理想情况下旧请求的连接直接断掉。但物理取消不可依赖:fetcher 可能忽略信号(项目自己的 api/examples.ts 本地数据层就没有任何可取消的 I/O)、可能已经拿到响应正在解析、可能同步抛出。所以第二套防线 requestId 快照必须独立存在:发起前 ++requestId 并把当时的值快照进 currentRequestId,await 返回后比对"我还是不是最新的"。两套防线的语义不同——abort 尽力省资源,序号保证正确性。只实现后者功能也正确(结果照样丢弃),只实现前者则会出 bug。凡写竞态保护,物理取消是优化,序号比对才是契约,这句判断值得抄在任何取数代码的注释里。
src/composables/useResourceFetch.ts 60-76 行
} catch (e) {
// 竞态保护:过期请求的错误不更新 state
if (currentRequestId !== requestId) return
// AbortError 通常由竞态取消触发,不视为真实错误
if (e instanceof DOMException && e.name === 'AbortError') return
const err = e instanceof Error ? e : new Error(String(e))
error.value = err
options.onError?.(err)
} finally {
// 仅最新请求才会清除 loading
if (currentRequestId === requestId) {
loading.value = false
}
}错误分支与 finally 分支重复了同一个比对,这不是冗余而是必要:过期的失败会抛出真实的 Error 对象,若不比对就 error.value = err,新请求进行中时界面会闪现旧请求的错误——这正是"错误也参与竞态"的常见疏漏。AbortError 的特判把"被取消"与"真失败"分开:取消是正常业务流程(用户切走了页面),不该渲染错误态。最容易被忽略的是 finally 里的第三处比对:如果旧请求在 finally 里无条件 loading.value = false,会出现"新请求还在路上,loading 却提前熄灭"的界面撒谎。三个出口、同一判定——这正是头注释里 last-request-wins 的完整含义:结果、错误、加载态三者全部只认最新请求。data 用 shallowRef(34 行)是配套的性能决策:列表场景下大数组被深层响应式代理的开销不值得,反正消费方总是整体替换引用。
这段代码教会我们什么:竞态保护的难点从来不是"想到要用序号",而是枚举所有会写状态的出口并逐一设卡。这段 95 行的代码之所以值得作为范本,是因为它把成功、失败、取消、finally 四条路径全部覆盖且判定一致;同时它诚实地承认"abort 可能无效"而保留了序号这层逻辑兜底。把"正确性依赖的机制"与"性能优化的机制"分开设计,前者才不会因为后者的失效而崩塌。
useListFilter:300ms 防抖与 URL 即状态文件:src/composables/useListFilter.ts(123 行)。它管理列表页筛选状态,输出"实时值 + 300ms 防抖值"双层结构,并把防抖值镜像到 URL query(机制总览见《07-模块详解》§1.5,页面消费见《02-页面设计》§2.2)。
src/composables/useListFilter.ts 48-64 行
// 初始化时优先从 URL 恢复
function resolveInitial(): T {
if (!syncUrl || !urlKeys) return { ...initial }
const params = new URLSearchParams(window.location.search)
const merged = { ...initial }
for (const field of Object.keys(urlKeys) as (keyof T)[]) {
const urlKey = urlKeys[field]
if (!urlKey) continue
const urlValue = params.get(urlKey)
if (urlValue !== null) {
;(merged as Record<string, unknown>)[field as string] = urlValue
}
}
return merged
}
const filters = reactive(resolveInitial()) as T
const debouncedFilters = ref({ ...filters }) as Ref<T>"URL 即状态"的落地顺序在这里看得非常清楚:先读 URL、后定初值——reactive(resolveInitial()) 让初始状态本身就是 URL 的投影,刷新、分享链接、后退三种场景的恢复不依赖任何额外同步逻辑。注意 urlKeys 的角色:它是一个显式的字段名 → query key 映射,未映射的字段不同步——这个白名单设计让"哪些筛选值得进 URL"成为每个页面自己声明的决策(比如视图模式 grid/list 进了 /examples 的 URL,而某些临时态没有),而不是 composable 替页面拍板。debouncedFilters 初始化为 { ...filters } 的浅拷贝,保证"实时层"与"防抖层"从第一帧开始就是两个独立对象,后续防抖写入不会污染实时层。
src/composables/useListFilter.ts 66-99 行(节选)
// watchDebounced:监听 filters 所有字段变化,防抖后写入 debouncedFilters
watchDebounced(
() => ({ ...filters }) as T,
(val) => {
debouncedFilters.value = val
},
{ debounce, deep: true }
)
// URL 同步:debouncedFilters 变化时写回 URL
if (syncUrl && urlKeys) {
watch(
debouncedFilters,
(val) => {
const params = new URLSearchParams(window.location.search)
...
const newSearch = params.toString()
const currentSearch = window.location.search.replace(/^\?/, '')
if (newSearch !== currentSearch) {
const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ''}${window.location.hash}`
window.history.replaceState(null, '', newUrl)
}
},
{ deep: true }
)
}数据流向是一条严格的单向管道:用户输入 → filters(实时)→ watchDebounced 300ms → debouncedFilters(查询触发)→ watcher 回写 URL。三个细节体现了"别惹恼浏览器历史"的克制。其一,回写用 history.replaceState 而非 router push——筛选是"同一视图的参数微调",不产生历史条目,后退键的语义留给真正的页面导航;其二,回写前先比对 newSearch !== currentSearch,URL 没变就不动(避免无谓的 replaceState 噪音);其三,空值字段从 URL 里 delete 而不是留 ?search=,保持分享链接的干净。回写保留了 window.location.hash 与非映射字段的 query(new URLSearchParams(window.location.search) 从现状出发),说明它对自己"只管理映射字段"的边界有清醒认知。
composable 只写 URL、不读路由变化——外部导航(如首页主题卡带 ?topicSlug=css 跳入)不会经过 useListFilter 的任何 watcher,而是由页面层自己的 route.query watcher 单向同步进 filters(《02-页面设计》§2.2,ExamplesLibraryPage.vue 的注释专门解释了这个分工)。为什么这么拧巴?因为双向同步(URL 变 → 状态变,状态变 → URL 变)必然引入回环:composable 刚把状态写进 URL,自己的路由 watcher 又把 URL 读回状态,触发第二次写入。项目用"分层单向"解开了这个环:composable 管状态 → URL 方向,页面管 URL → 状态方向,两个方向的触发源(防抖值 vs 路由事件)天然不同时活跃。代价是接手者必须理解这个约定才不会"补上"那个看起来缺失的回读——这也是它值得写进走读实录的原因:有些代码的"没写什么"比"写了什么"更接近设计本质。
这段代码教会我们什么:与 URL 同步状态,最难的不是读写 API 而是防回环的方向设计。useListFilter 的答案是切分所有权:一个模块只负责一个方向,回环在结构上不存在而不是被 guard 挡住。另外,防抖层与实时层的双层暴露(filters/debouncedFilters)是"把节流决策交给消费方"的正确形态——列表页用防抖值发查询、输入框绑实时值,同一个状态的两种时间性并存,谁也不绑架谁。
BaseGenerator + RgbaColorGenerator:骨架与肉文件:src/views/css-lab/css/generators/BaseGenerator.vue(约 600 行含样式)与 src/views/css-lab/css/generators/RgbaColorGenerator.vue(180 行)。前者是 63 个生成器共享的布局骨架("方案 B 布局",见《07-模块详解》§2.4),后者是最小的真实生成器之一,两者放在一起正好展示"模板化如何把新增成本压到两行登记"(《11-扩展开发指南》场景二)。
src/views/css-lab/css/generators/BaseGenerator.vue 35-63 行(节选)
const props = withDefaults(
defineProps<{
title: string
description?: string
icon?: string
iconColor?: string
config?: Record<string, unknown>
presets?: GeneratorPreset[]
sections?: GeneratorSection[]
previewStyles?: Record<string, string | number>
codeOutput?: string
/** 源码预览模式:非空时预览区展示任意 html+css,而非参数化预览 */
customPreview?: CustomPreview | null
}>(),
{ ... }
)
const emit = defineEmits<{
'update:config': [key: string, value: unknown]
reset: []
'apply-preset': [preset: GeneratorPreset]
'exit-custom-preview': []
}>()骨架的全部可变性都收敛在 9 个 props 与 4 个 emits 里。设计的关键在于骨架对内容保持无知:它不知道也不关心 config 里有什么参数,参数的"长什么样"由 sections(GeneratorSection[],内含 GeneratorParam[])声明式描述,参数的"算出什么"由子组件自己的 computed 完成,最终产出的代码字符串经 codeOutput prop 送回来展示。布局(Header/Action Bar/双列 Body/代码 Modal)、控件渲染(slider/color/select/text/number/toggle 六类,isFullWidth 决定 text/number 独占整行)、预设 pill 交互全部继承——子组件唯一必须提供的是"参数模型"和"从参数到 CSS 的纯函数"。customPreview 是一个重要的逃生舱:当生成器需要展示"非参数化的真实源码渲染"(迁移来的卡片效果)时,可以绕过参数化预览区,这就是《07-模块详解》§2.3 里卡片墙回退路径的类型基础。类型定义不在 .vue 文件里而在 generatorTypes.ts,文件头注释解释了原因:.vue 模块经 declare module '*.vue' shim 处理后,其命名导出无法被 import type 引用(TS2614)——一个类型工程细节反过来影响了模块划分。
src/views/css-lab/css/generators/RgbaColorGenerator.vue 8-24 行(节选)
const createDefaults = () => ({
color: '#2563EB',
alpha: 0.35,
})
const config = reactive(createDefaults())
const baseColor = computed(() => parseColor(config.color, '#2563EB'))
const rgbaValue = computed(() => toRgba(baseColor.value, clamp(config.alpha, 0, 1)))
const codeOutput = computed(() => {
const { r, g, b, a } = rgbValues.value
return `/* RGBA 颜色值 */
.overlay {
background: rgba(${r}, ${g}, ${b}, ${a.toFixed(2)});
}`
})
const sections = [
{
id: 'color',
label: '颜色设置',
params: [
{ key: 'color', label: '颜色', type: 'color' as const },
{ key: 'alpha', label: '透明度', type: 'slider' as const, min: 0, max: 1, step: 0.01, unit: '' },
],
},
]这 30 来行就是一个生成器的全部"业务逻辑":createDefaults 工厂保证 reset 语义(handleReset 里 Object.assign(config, createDefaults()),不共享引用);computed 链从 config 纯函数式地推出颜色值、预览样式与代码文本——没有一处命令式 DOM 操作,参数变化到界面更新完全交给 Vue 的响应式推导;sections 用数据描述参数区,presets 用 { id, label, apply() } 描述一键预设(apply 直接改 config)。对照骨架的 props 清单可以发现每个 prop 的落点:sections 进配置面板、presets 进 Action Bar、previewStyles 决定预览色块的渲染、codeOutput 进代码 Modal。子组件的 template 因此只剩一件事——把 BaseGenerator 摆进页面。这正是"63 个生成器共用一条路由、一个骨架、一份目录"的实现论:《05-核心流程》流程五说的"参数化程度越高、模板化越彻底的内容形态,扩展成本越低",落到代码上就是这 30 行。
这段代码教会我们什么:模板化的本质是把"变的"从"不变的"里彻底析出,并用类型把"变的"约束住。BaseGenerator 把不变的东西(布局、控件渲染、交互范式)固化成骨架,把变的东西(参数模型、计算逻辑、预设)声明成类型化契约,于是一个新生成器的实现退化成"填一张类型安全的表"。反面教训也藏在同一族代码里:《13-技术债务》第 3 条的五处口径漂移说明,模板化解决了"怎么写"却没解决"登记了几份"——骨架之外还需要那个尚未诞生的校验脚本。
把三组走读并置,能看到同一种工程气质的三种表现:buildSrcDoc 把安全与修复收敛在边界上;useResourceFetch 把正确性建立在全出口一致的判定上;useListFilter 把复杂度化解在方向的所有权上;BaseGenerator 把扩展成本压缩到类型化的声明上。四者的共同点是:核心逻辑都短(20-90 行),但边界考虑密——真正的工程质量不体现在主路径多聪明,而体现在"别人会忘的每一种情况"都被放在了离代码最近的位置。这也是本篇选择"逐段带码走读"而非"概括机制"的原因:那些注释里的犹豫、比对的三次重复、as const 的坚持,只有在原文里才读得到。
本篇完。下一篇:19-数据契约与示例集.md