web-spa — Web SPA 开发模式与陷阱
1. 数据加载方案决策
| 方案 | 适用 | 坑 |
|---|---|---|
Supabase JS 客户端 @supabase/supabase-js@2 |
需要实时写入 | .select('*') 不加 .limit(1000) 可能分页截断;JSONB 字段偶发返回字符串而非数组 |
Supabase REST API fetch() 直调 |
只读、高性能 | 无自动重试,需手动 limit=1000 |
| OSS 预生成 JSON → fetch | 最可靠,零运行时风险 | 需 cron 定时同步,非实时 |
分离原因:题库是"慢数据"(数百题 x 完整选项),用预生成 JSON 最可靠;配置是"快数据"(几个数字),从 Supabase 直读实现零延迟同步。
CDN 缓存陷阱:OSS JSON 被 CDN 缓存后,即使设 Cache-Control: no-cache,浏览器仍可能用旧文件。修复:JSON 文件名版本化 quiz_data_{timestamp}.json,每次同步生成新文件名,display.html 中引用新 URL。
// 题库从 JSON → 配置从 Supabase REST
fetch('https://gzzhike.cn/funv-quiz/quiz_data_1784263695.json') // 版本化文件名
.then(r => r.json())
.then(d => { pool = d.required; })
.then(() => fetch(SUPABASE + '/rest/v1/exam_config?select=*&limit=1', { headers }))
.then(r => r.json())
.then(cfg => { config = cfg[0]; })
分离原因:题库是"慢数据"(数百题 x 完整选项),用预生成 JSON 最可靠;配置是"快数据"(几个数字),从 Supabase 直读实现零延迟同步。
Supabase JS 客户端正确用法(当必须使用时):
// ① 显式 limit(1000) 防止分页截断
db.from('questions').select('*').order('id').limit(1000)
// ② 防御性 JSONB 归一化
function normalizeOptions(opts) {
if (Array.isArray(opts) && opts.length > 0) return opts;
if (typeof opts === 'string') {
try { return JSON.parse(opts); } catch(e) {}
}
return null;
}
2. CSS 全屏居中 + 滚动的经典坑
❌ 绝对不能用的组合
.container {
display: flex;
align-items: center; /* ← 居中 + overflow = bug */
justify-content: center;
overflow-y: auto;
}
症状:内容超出视口时,顶部被裁切不可滚动,底部选项消失。
✅ 推荐方案 A:::before/::after 伪元素(最可靠,纯 CSS)
#main {
display: flex; flex-direction: column; align-items: center;
flex: 1; min-height: 0; /* ← min-height:0 是关键 */
overflow-y: auto; overflow-x: hidden;
}
#main::before, #main::after { content: ''; flex: 1; min-height: 0; }
#card { max-width: 960px; width: 100%; }
原理:::before 和 ::after 是 flex 弹性占位符,flex: 1 平分剩余空间→居中。内容溢出时 min-height: 0 让占位符缩到零,所有内容可滚动。
优点:纯 CSS、缩放/窗口变化自动响应、不依赖 JS 计算。
✅ 方案 B:JS 动态居中
function center() {
var c = document.getElementById('card');
var m = document.getElementById('main');
var ch = c.offsetHeight, mh = m.clientHeight;
c.style.marginTop = (ch < mh) ? Math.floor((mh - ch) / 2) + 'px' : '16px';
}
注意:页面缩放后不重新计算,可能导致导航被挤出视口。方案 A 更优。
3. JS 变量名与函数名冲突
JavaScript 函数声明提升优先级问题:
// ❌ 同名碰撞
var sel = null; // 全局变量
function sel(t, el) { // 函数声明
sel = t; // 首次执行后 sel 变成字符串,函数身份丢失
}
sel('required', el); // ✅ 第一次 OK
sel('quick', el); // ❌ TypeError: sel is not a function
修复:变量名与函数名必须不同。
var selType = null;
function sel(t, el) { selType = t; }
4. LLM 生成结构化数据的铁律(题库导入核心教训)
核心坑:LLM 重排选项导致答案字母错位——补全缺失选项时改变了 ABCD 排列顺序,但 correct_answer 字母未更新,导致指向错误选项。一次 session 中发现 38 处此类错误。
规避策略(按优先级)
- System Prompt 强制声明:
铁律:选项顺序与原文保持一致,不可重排!答案字母必须与选项位置对应。
选项固定4个,统一 "A.xxx" "B.xxx" "C.xxx" "D.xxx" 格式。
严禁使用 A、 A) A, 等分隔符。
导入后自动校验:每道题检查
correct_answer指向的选项文本与原始文档是否一致。答案字母错位修复脚本(比内容文本,非字母):
# 38 处字母错位 → 修复脚本:对每道题,匹配 docx 正确答案文本到 DB 选项位置
for q in db_questions:
dx_text = docx_correct_answer_text.get(q['question_text'][:80])
for idx, opt in enumerate(q['options']):
opt_clean = re.sub(r'^[A-D]\\.\\s*', '', str(opt)).strip()
if prefix_match(opt_clean, dx_text) > 0.5:
if chr(65+idx) != q['correct_answer']:
update_db(q['id'], chr(65+idx))
其他常见错误(本项目实际踩坑)
| 错误类型 | 现象 | 修复 |
|---|---|---|
| 选项合并 | "C.xxxD.xxx" 被当成一个选项 | 正则 ([A-D])\\\\. 拆分 |
| 缺 D 选项(58题) | 只有 3 个选项 | LLM 补全但保持前 3 个顺序不变 |
| 答案字母错位(38题) | 补选项后答案字母未更新 | 修复脚本比内容文本重新匹配位置 |
| 题号污染 | 选项文本中含 "B1-1 (√)" | re.sub(r'[BQ]\\\\d+-\\\\d+.*', '', opt) |
| 跨套重复 | 不同套同名题目 | 按 question_text 去重 |
| 近似重复 | "一个月内"/"1个月内" | Levenshtein ≤2 且答案相同→删一条 |
| JSON 非法 | 题目中引号未转义 | 导入前 json.dumps 校验 |
| 配置不实时 | admin 改完配置 display 页不变 | 配置从 Supabase 直读,不用 JSON 同步 |
5. 选项标准化管道
归一化所有选项为 A.xxx 格式,让前端渲染只需最简单正则:
后端归一化(Python):
def normalize_option(opt, letter):
stripped = re.sub(r'^[A-Da-d][.\u3001\uFF0C\s\)).::]+', '', str(opt)).strip()
return f'{letter}.{stripped}'
前端渲染(JS,格式统一后):
String(o).replace(/^[A-D]\.\s*/, '') // 只需处理 "A." 前缀
6. 响应式流体排版
用 clamp() 替代固定 px 和手动 @media:
.qtext { font-size: clamp(22px, 2.5vw, 38px); } /* 题目 */
.opt { font-size: clamp(14px, 1.4vw, 22px); } /* 选项 */
#card { max-width: clamp(600px, 70vw, 960px); } /* 卡牌 */
效果:一个声明覆盖 720p→4K 所有分辨率,UI 缩放 150% 时字体等比缩小。比手动 @media(max-height:800px) 更平滑。
7. 倒计时 + Web Audio 音效
场景:答题/竞赛类 SPA 需要在指定时间点发出音频提示。
Web Audio API 合成嘟声(无需外部音频文件):
var _actx = null;
function beep(n) {
try {
if (!_actx) _actx = new (window.AudioContext || window.webkitAudioContext)();
for (var k = 0; k < n; k++) {
(function(d) { setTimeout(function() {
var o = _actx.createOscillator(), g = _actx.createGain();
o.type = 'square'; o.frequency.value = 880; g.gain.value = 0.06;
o.connect(g); g.connect(_actx.destination);
o.start(); o.stop(_actx.currentTime + 0.1);
}, d); })(k * 350);
}
} catch(e) {}
}
// 高频警报"滴滴滴滴"(8声,两两分组)
function beepAlert() {
var count = 0;
var id = setInterval(function() {
if (count >= 8) { clearInterval(id); return; }
var o = _actx.createOscillator(), g = _actx.createGain();
o.type = 'square'; o.frequency.value = 1200; g.gain.value = 0.12;
o.connect(g); g.connect(_actx.destination);
o.start(); o.stop(_actx.currentTime + 0.15);
count++;
}, 300);
}
时间点触发模式:
// 1分钟时:2声嘟
if (left === 60 && lastAlert > 60) { beep(2); }
// 10→1秒:红色脉冲(可配合每秒 beep(1))
if (left >= 1 && left <= 10) { el.classList.add('warn'); }
// 归零
if (left <= 0) { stopTimer(); beepAlert(); }
显示:大号 MM:SS 格式 + 缩放适配:
function fmtTime(s) {
var m = Math.floor(s / 60), sec = s % 60;
return (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
}
8. 导航条自动隐藏(沉浸式全屏)
场景:大屏投影答题时,顶栏和底栏默认隐藏,鼠标移到触发区时淡入。
CSS 实现:
/* 导航条:默认滑出隐藏 */
#topbar {
opacity: 0; transform: translateY(-100%);
transition: opacity .35s ease, transform .35s ease;
backdrop-filter: blur(20px); /* Apple 毛玻璃 */
}
#topbar:hover, #topbar.show { opacity: 1; transform: translateY(0); }
/* 透明触发区(扩大 hover 范围) */
.top-zone { position: fixed; top: 0; left: 0; right: 0; height: 44px; z-index: 21; }
底栏同理,用 translateY(100%)。首次进入时给导航条加 .show class 亮相 3 秒后自动移除作为提示。
9. Apple 风格 UI(关联技能)
需要 Apple 设计语言时加载 apple-design 技能。在实际项目中应用的组件:
- 毛玻璃:
backdrop-filter: blur(20px) saturate(180%) - 弹簧动画:
cubic-bezier(0.32,0.72,0,1)/cubic-bezier(0.34,1.56,0.64,1) - 胶囊按钮 +
:active { transform: scale(0.96) }按压反馈 - SF 风格字体:
letter-spacing: -0.022em(大标题收紧)
10. PPT 式横向翻页 Pager(单文件手册/课件)
做「横向翻页单文件 HTML」(学员手册、课件 deck、landing 翻页页)时,直接套用 references/pager-carousel.md 的完整配方:
- 物理:damping-ratio/response 双参数弹簧(response 0.4s),释放速度直接作弹簧初速;
project()动量投射(d=0.998)定落页;甩动 |v|>600px/s 才用 damping 0.8,默认 1.0 无过冲;飞行中 pointerdown 即cancelAnimationFrame可中断。 - 手势:viewport
touch-action: pan-y(纵滚交给原生),10px 阈值横向优先判定,判定后才setPointerCapture;首末页橡皮筋(c=0.55);拖拽 >10px 后 suppress 一次 click。 - UI:进度条从弹簧实时 x 渲染(非 idx);入场
.rv元素--d错峰 +page:not(.active)无过渡即隐 → 重进重播;目录从data-g/data-t属性两处生成(浮层 + 内嵌目录页)。 - reduced-motion:track 交叉淡入 + 瞬时吸附,CSS 强制
.rv全可见。 - 演讲者备注(售前/授课 deck):每页
<div class="notes">(display:none,说明性文字只进 notes 不进可见区);底部#drawer(fixed +translateY(105%)隐藏),N 键切换,打开时注入当前页 notes 的 innerHTML,翻页时同步刷新,Esc 关闭。备注每页 100-200 字口语提示(加粗关键词+过渡句独立成段)。 - F 键全屏:
document.documentElement.requestFullscreen()切换——售前投影/授课场景必备,与 N 键备注配套。 - 对客 deck 红线扫描:内容派生自内部文档时,交付前 grep 内部专属词(成本线/内部机制/其他客户名/内部术语),零命中才交付(详见 multi-doc-consistency Workflow C)。
- 验收:
scripts/verify-pager.js(playwright-core + 系统 Chrome)——页数/键盘/目录跳页/拖拽/reduced-motion/移动端 resize/控制台零报错,一条命令出 PASS/FAIL。托管 browser 工具拒绝 file:// 与 localhost 时走此路径。 - Playwright 两大陷阱(60 页 deck 实测踩出):①
page.goto()仅 hash 变化是同文档导航不重载——hash 深链测试/截图前必须先goto("about:blank")强制真实加载,否则静默拍错页;②page.evaluate()返回序列化值不是元素句柄,.click()会报 AttributeError——点击用page.locator(sel).nth(i).click()。 - DOM 对比度审计:浅色强调底(#5ac8fa 类,亮度≈0.5)+白字对比度仅 1.9 不可读——白字彩块背景亮度需 ≤0.28(#3d8fd1≈3.47 过线)。交付前在页面上下文跑有效背景色+亮度比全量扫描(<3.2 报警),比抽页目视可靠。
11. 调试技巧
// 全局错误捕获
window.onerror = function(msg, src, line) { /* 显示在固定底部面板 */ };
// 渲染关键节点打印数据结构
console.log('q:', q[i], 'opts type:', typeof q[i].options, 'len:', q[i].options?.length);
在页面底部放红色调试面板,按 D 键切换显示。加载完成后自动隐藏。
12. 单文件练习/考试 SPA(零依赖 + 内联题库)
无后端、无 OSS、双击即开的练习系统形态(练习/测验/错题本四视图):维护 _build_template.html(脚本内 const BANK = /*__BANK_JSON__*/; 占位)+ _build.js(读题库 JSON → 剥选项字母前缀 → 内联)。完整配方见 references/single-file-quiz-spa.md,要点:
- JSON 内联防闭合:
JSON.stringify()后必须.replace(/</g,'\\u003c'),防题目文本里的</提前闭合<script>。 - 选项乱序 = 对象级重映射:选项做成
{text,isCorrect}整体 shuffle,渲染标签按新下标重分配 A/B/C/D,判分只查isCorrect标志——绝不拿用户选的新字母和原答案字母比(与 §4 的 LLM 重排坑同源,一个在数据期一个在运行期)。多选判分=集合相等。 - localStorage 在 file:// 下抛异常(opaque origin):try/catch 探测 + 内存降级 + 页面可见提示条,否则整个 SPA 白屏。
- 错题闭环:答错自动入册(练习和测验都入),重练连对 2 次自动移出;错题条目存
{streak,ts}。 - 测验抽题:按题型分组→各组 shuffle→slice 配额→合并再 shuffle;选项在抽题时一次 prepare 存 session,翻题导航回去不重洗。
- 验收:逻辑态用 jsdom 无头测(判分/localStorage/抽题配比/倒计时/降级),配方见
references/jsdom-headless-testing.md;视觉/手势仍走scripts/verify-pager.js的 playwright 路径。
参考文件
references/css-flex-overflow-bug.md— CSS flex 居中+overflow bug 详细复现与解决方案references/llm-import-guardrails.md— LLM 批量生成题库的导入规范、System Prompt 模板、校验清单references/pager-carousel.md— PPT 式横向翻页 Pager 完整配方:弹簧物理/手势状态机/橡皮筋/reduced-motion 交叉淡入/入场错峰references/single-file-quiz-spa.md— 单文件零依赖练习/考试 SPA 配方:题库内联构建管线、选项乱序对象级重映射、file:// localStorage 降级、错题本 streak 闭环、测验抽题/倒计时/题号网格references/jsdom-headless-testing.md— jsdom 无头验收单文件 SPA:JSDOM 配置骨架、七个坑(opaque origin/词法绑定 eval/confirm 覆盖/缺失 API 防御/真定时器/计数断言枚举写入路径)references/playwright-headless-verification.md— Playwright 无头验证全屏 HTML 交付物:hash 同文档不重载陷阱(about:blank 插页)、evaluate 非句柄、DOM 对比度全量审计、溢出审计、无 VLM 像素健全性、浅色不做白字底、超大 deck 分块构建( 标记法)scripts/verify-pager.js— 翻页 deck 无头验收探针(playwright-core + 系统 Chrome),交付前跑 PASS/FAILweb-quiz-system— 问答系统专用技能:Supabase题库+大屏展示+LLM导入+OSS部署(本项目实际应用)