answer-me-with-html:零依赖内嵌式交互提问组件实践
1. 这个组件到底在解决什么问题第一次看到 answer-me-with-html 这个名字我脑子里蹦出来的第一反应是又是一个把 Markdown 转成网页的轮子但翻完它的设计思路之后我发现它真正有意思的地方不在渲染而在那个叫ask的组件——它把决策点直接嵌进了一页静态 HTML 里。先说清楚它是什么。ask组件本质上是一个内嵌在 HTML 页面中的交互式提问单元页面渲染到某个位置时会抛出一个问题附带若干选项读者点选之后页面根据选择走不同的分支——可能是展开一段补充说明可能是跳转到另一个锚点也可能是把选择结果记录下来供后续环节使用。整个交互过程不需要后端、不需要框架、不需要构建工具一个.html文件双击打开就能跑。它能做什么举几个我实际用过的场景。写一份技术选型文档里面有个你团队规模多大的分叉小团队和大团队看到的推荐方案不一样以前只能写成两段并列文字让读者自己找现在用ask组件做成一个选择题选完直接展示对应内容。再比如做一份产品需求说明里面有几个待定的决策点把ask组件嵌进去评审会上大家当场点选选完页面状态一目了然比来回问你倾向哪个高效得多。它解决的核心痛点是静态文档天生缺乏分叉能力。传统 HTML 或 Markdown 文档是线性的所有读者看到同样的内容遇到如果 A 则……如果 B 则……这种结构只能靠文字描述硬撑读者得自己在脑子里做条件判断。ask组件把这种隐式的条件判断变成了显式的交互动作读者点一下页面替他做判断。适合谁参考三类人最对口。第一类是经常写技术文档、方案文档的工程师想让文档从死变活第二类是做内部工具、原型演示的人需要一个零依赖的交互载体第三类是对前端感兴趣但不想碰框架的爱好者ask组件的实现思路本身就是个很好的学习样本——它把状态管理、事件委托、DOM 操作这些基础概念用最朴素的方式串了一遍。我特别想强调一点这个组件的价值不在于技术多复杂而在于它把交互这件事的门槛降到了极低。你不需要懂 React 的 useState不需要配 webpack甚至不需要起一个本地服务器写几行 HTML 加一段脚本交互就成立了。这种低门槛高回报的特性恰恰是很多重型方案做不到的。2. 整体设计思路与方案选型拆解2.1 为什么是一页 HTML而不是一个应用设计ask组件时第一个要拍板的决策就是做成一个独立应用还是做成嵌入页面的组件我见过太多项目一上来就create-react-app为了一个选择题搞出一整套构建链路最后交付物是一个需要npm install才能跑起来的目录。这对文档场景来说是灾难——文档的读者可能只是想快速看一眼你让他先装 Node 环境所以ask组件选择了一页 HTML路线背后的逻辑很清晰交付物即产物。一个.html文件包含了结构、样式、脚本、数据拷给别人就能用扔到任何静态托管上就能访问甚至可以直接作为邮件附件发出去。这种零部署成本是它最大的竞争力。代价是什么没有模块系统没有类型检查没有热更新。但对于一个交互式决策点来说这些代价完全可以接受——ask组件的代码量控制在几百行以内用原生 JS 写完全 hold 得住反而省去了构建配置的心智负担。2.2 状态放在哪里DOM 即状态第二个关键决策是状态管理。常规思路是维护一个 JS 对象存所有选择结果但ask组件走了一条更土也更稳的路把状态直接写在 DOM 上。具体做法是每个ask实例渲染成一个带>div classask>div classask>!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title决策文档/title style .ask { border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin: 24px 0; } .ask-question { font-weight: 600; margin-bottom: 12px; } .ask-option { display: block; width: 100%; text-align: left; padding: 10px 12px; margin: 6px 0; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer; } .ask-option[data-selectedtrue] { border-color: #2b6cb0; background: #ebf4ff; } .ask[hidden] { display: none; } /style /head body main idcontent/main script srcask.js/script /body /html样式部分我刻意保持克制只做必要的视觉区分。>function escapeHtml(str) { return String(str).replace(/[]/g, ch ({ : amp;, : lt;, : gt;, : quot;, : #39; }[ch])); } function parseAction(actionStr) { if (!actionStr) return { type: record }; const [type, target] actionStr.split(:); return { type, target }; }escapeHtml是安全底线所有来自>function renderAsk(container) { const id container.dataset.askId; const question container.dataset.askQuestion || ; const options [...container.querySelectorAll([data-ask-option])]; if (!container.querySelector(.ask-question)) { const qEl document.createElement(div); qEl.className ask-question; qEl.textContent question; container.prepend(qEl); } options.forEach(btn { btn.classList.add(ask-option); btn.type button; }); }注意这里用textContent而不是innerHTML设置问题文本天然免疫 XSS比转义更省心。选项按钮只加类名和 type内容保持原样。事件部分用委托document.addEventListener(click, e { const btn e.target.closest([data-ask-option]); if (!btn) return; const container btn.closest(.ask); if (!container) return; handleSelect(container, btn.dataset.askOption); }); function handleSelect(container, value) { const id container.dataset.askId; container.dataset.selected value; container.querySelectorAll([data-ask-option]).forEach(b { b.dataset.selected String(b.dataset.askOption value); }); persist(id, value); const action parseAction(container.dataset.askAction); runAction(action, value); refreshDependencies(); }handleSelect是核心更新选中态、持久化、执行动作、刷新依赖四步走完。refreshDependencies遍历所有带>function refreshDependencies() { document.querySelectorAll([data-ask-depends]).forEach(el { const expr el.dataset.askDepends; const [depId, depValue] expr.split(:); const depEl document.querySelector([data-ask-id${depId}]); const current depEl ? depEl.dataset.selected : null; el.hidden current ! depValue; }); }这段代码短但覆盖了绝大多数依赖场景。如果要支持或关系q1 选 a 或 b 都显示把表达式扩展成q1:a|bsplit 之后用includes判断即可。我建议一开始别做太复杂等真遇到需求再扩展避免过度设计。4.4 持久化与恢复的完整链路function persist(id, value) { try { localStorage.setItem(ask:${id}, value); } catch (e) { // 静默失败不影响主流程 } } function restore() { document.querySelectorAll(.ask).forEach(container { const id container.dataset.askId; let saved null; try { saved localStorage.getItem(ask:${id}); } catch (e) {} if (saved) handleSelect(container, saved); }); }restore在初始化最后调用把保存的选择重新应用一遍。注意handleSelect会触发动作和依赖刷新所以恢复后页面状态是完整的不需要额外处理。4.5 参数选择背后的计算逻辑有个参数值得单独说依赖求值的触发频率。如果每次选择都全量求值页面里 20 个ask实例、每个 3 个选项一次点击要遍历 60 个节点虽然现代浏览器扛得住但快速连点时会有可感知的卡顿。我的处理是加一个简单的节流let rafPending false; function scheduleRefresh() { if (rafPending) return; rafPending true; requestAnimationFrame(() { rafPending false; refreshDependencies(); }); }用requestAnimationFrame而不是setTimeout是因为前者和浏览器渲染节奏对齐视觉上最顺滑。这个优化在实例少的时候看不出差别实例一多就是质变。5. 常见问题与排查技巧实录5.1 选项点了没反应怎么定位这是最高频的问题。排查顺序我总结成三步看控制台有没有报错。最常见的是ask.js加载失败或语法错误脚本根本没跑起来。检查>const ASK_CONFIG { q1: { question: 你倾向哪种部署方式, options: [ { value: a, label: 容器化部署, action: reveal:#block-a }, { value: b, label: 直接跑在主机上, action: reveal:#block-b } ] } };这样内容和结构彻底解耦改文案只动 JSON改布局只动 HTML维护成本直线下降。而且 JSON 可以被脚本生成——如果决策点是从某个表格或文档里批量导出的直接生成 JSON 塞进去就行不用手写 HTML。这个模式我在三个项目里复用每次都能省下大量调整时间。提示JSON 里的文本同样要过escapeHtml再插入 DOM别因为配置是可信的就跳过转义配置来源一旦变复杂这个假设就不成立了。6. 扩展方向与个人实践体会ask组件跑通之后我陆续加了几个小扩展都是实际用出来的需求。一个是多选模式>