1. 为什么要在单页 HTML 里做「决策点」这件事第一次看到answer-me-with-html这个思路时我脑子里蹦出来的不是某个具体框架而是一个很朴素的场景你手上有一份说明文档、一份操作手册、一份选型指南读者读完之后往往卡在「那我到底该选哪个」这一步。传统做法是写一堆「如果……那么……」的段落读者自己在大脑里做条件判断读起来累还容易漏掉分支。ask组件想解决的就是这个问题——把决策点从文字里拎出来变成一个可以点、可以选、可以立刻看到结果的交互块而且整个东西就塞在一页 HTML 里不需要后端、不需要构建工具、不需要装任何依赖。这个定位很关键。它不是要做一个完整的问卷系统也不是要替代正经的表单引擎而是服务于「单页、静态、可分发」这个约束。你可以把这一页 HTML 直接发给同事、丢进内网共享盘、塞进邮件附件对方双击就能用。ask组件承担的角色就是页面里那些「由你决定」的岔路口用户点一个选项页面就地给出对应的下一步内容而不是跳转、不是刷新、不是等接口返回。适合谁来参考三类人最对口。第一类是经常写内部文档、操作手册的人想让文档从「读」变成「用」第二类是做轻量工具的前端手头没有后端资源但又想交付带交互的东西第三类是对「渐进增强」这套理念感兴趣、想看看纯静态页面能把交互做到什么程度的开发者。哪怕你只会写最基础的 HTML 和一点点 JavaScript这篇里的思路和代码都能直接抄。我先把核心结论摆出来ask组件的本质是一个声明式的分支容器。你用一段结构化的数据描述「问题是什么、有哪些选项、每个选项通向哪段内容」组件负责渲染、负责状态管理、负责把选中的结果反馈到页面上。它不发明新语法而是尽量贴着 HTML 的原生能力走这样即使脚本没跑起来页面也不至于变成一片空白。下面我从设计取舍讲起一路讲到能跑起来的实现和踩过的坑。2. 整体设计与思路拆解2.1 为什么是「单页 内嵌数据」而不是多页跳转多页方案听起来更「正规」每个分支一个页面用链接串起来。但我实测下来单页内嵌数据在这个场景里优势明显。原因有三条都是实打实的工程考量。第一是分发成本。多页意味着多个文件接收方要保证目录结构不乱、相对路径不断。一旦有人把其中一页单独转发出去链接就废了。单页 HTML 把样式、脚本、数据全打包在一个文件里天然是「一个文件即一个完整应用」转发、归档、版本管理都简单。第二是状态连续性。决策往往不是一次性的用户选了 A 之后可能想回头改成 B看看结果差多少。单页方案里历史选择可以留在内存甚至留在 URL 的 hash 里回退和对比都很自然多页方案要么靠浏览器前进后退要么靠服务端会话前者体验割裂后者直接违背了「无后端」的前提。第三是渲染时机。单页里所有分支内容在初始加载时就已存在于 DOM 或数据里切换只是显示与隐藏没有网络往返点下去是即时的。这种「零延迟」的反馈感是让读者愿意去点、去试的关键。人对等待的容忍度很低哪怕只有 200 毫秒的加载态也会打断「我在探索」的心流。当然单页也有代价所有分支内容都堆在一个文件里文件体积会随分支数量线性增长。这就引出下一个取舍——数据怎么组织。2.2 数据驱动的声明式结构把「分支逻辑」和「渲染逻辑」分开我见过不少同类实现是把分支逻辑直接写死在事件回调里点按钮 A就document.getElementById(resultA).style.display block。这种写法在分支少的时候没问题一旦分支超过五六个回调里全是 DOM 操作改一个文案要翻半天加一个分支要复制粘贴一大段维护性很差。ask组件采用的是数据驱动分支关系用一份纯数据描述渲染和交互由通用代码处理。数据结构大致长这样——一个问题对象带id、prompt问题文案、options选项数组每个选项带label显示文字、value内部值、next选中后要展示的内容标识或下一问的 id。这份数据可以是页面里一段script typeapplication/json也可以是 JS 里一个常量对象。这么拆的好处很直接。改文案不用碰逻辑你只动数据里的字符串加分支不用改渲染代码往options里追加一项、在内容区补一段对应标识的 HTML 就行测试也容易数据是纯对象可以单独校验有没有悬空引用某个next指向了不存在的内容。这就是「声明式」相对「命令式」的典型收益描述「是什么」而不是「怎么做」。提示数据里所有next引用建议在初始化时做一次完整性校验发现指向不存在的内容标识就抛错并在控制台打印出来。这个校验只花几行代码却能挡掉绝大多数「点了没反应」的低级问题。2.3 渐进增强的底线脚本挂了页面还得能读这是我在实际项目里最看重的一条。单页交互组件最容易犯的错是把所有内容都藏在 JS 里脚本一报错用户看到的就是一片空白。ask组件的设计原则是分支内容默认就在 HTML 里只是被折叠脚本的作用是「让折叠可交互」而不是「凭空生成内容」。具体做法是每个分支结果块用原生details或者带hidden属性的容器承载初始状态是收起或隐藏。脚本加载成功后接管这些容器的显隐控制并给选项按钮绑定事件。如果脚本因为某种原因没执行比如被安全策略拦了、语法写错了用户至少还能看到问题文案和所有分支的原始内容虽然体验降级但信息没丢。这个底线思维还带来一个附带好处对搜索引擎和读屏软件更友好。内容在 DOM 里是真实存在的不是运行时拼出来的可访问性和可索引性都更好。做静态页面的人容易忽略这点但一旦你的页面要被归档、被检索、被辅助技术读取这个差别就出来了。2.4 状态模型单选、多选与「可回退」ask组件支持两种基本模式单选radio 语义和多选checkbox 语义。单选适合「二选一、三选一」的排他决策多选适合「勾选你需要的功能我告诉你组合结果」这类场景。状态用一个普通对象维护键是问题 id值是选中的 value单选或 value 数组多选。「可回退」是我特意加的设计。用户改主意时不应该被迫刷新页面重来。实现上每次选择变化都触发一次「重算」清空当前问题下游的所有结果块显隐状态再根据新选择重新展示。这里有个细节——下游的连锁问题也要一并重置否则会出现「上一问改了下一问还停留在旧答案」的错位。我的做法是给每个问题标记它属于哪条链路重算时按链路顺序清理。3. 核心细节解析与实操要点3.1 一份能直接用的数据结构长什么样先看数据。我把一个完整的ask配置写成下面这样字段命名尽量直白不搞缩写const askConfig { questions: [ { id: q-deploy, prompt: 你的部署环境更接近哪种, mode: single, options: [ { label: 完全离线不能连外网, value: offline, next: r-offline }, { label: 可以访问公网, value: online, next: r-online }, { label: 不确定需要先确认, value: unknown, next: r-unknown } ] }, { id: q-scale, prompt: 预计同时使用的人数规模, mode: single, options: [ { label: 10 人以内, value: small, next: r-small }, { label: 10 到 100 人, value: medium, next: r-medium }, { label: 100 人以上, value: large, next: r-large } ] } ] };几个字段的用意值得说清楚。mode决定渲染成单选还是多选控件next指向结果块的标识结果块本身在 HTML 里用>.ask-option input { position: absolute; opacity: 0; width: 1px; height: 1px; } .ask-option label { display: block; padding: 12px 16px; border: 1px solid #d0d5dd; border-radius: 8px; cursor: pointer; } .ask-option input:checked label { border-color: #2563eb; background: #eff6ff; } .ask-option input:focus-visible label { outline: 2px solid #2563eb; outline-offset: 2px; }这段 CSS 里:focus-visible那一条别省。很多人做自定义控件时把焦点样式去掉了键盘用户按 Tab 键完全不知道焦点在哪这是实打实的可用性缺陷。3.3 结果块的显隐控制与过渡结果块我建议用hidden属性控制显隐而不是display:none内联样式。原因是hidden是语义化的读屏软件会跳过它而且用 JS 切换时只需el.hidden true/false比操作style干净。如果要做淡入过渡可以在hidden切换后加一个 class 触发 CSS 动画但注意hidden元素本身不参与布局动画要配合starting-style或者先移除hidden再加 class 的两步走。实测下来最稳的过渡方案是移除hidden的同时加.is-entering类下一帧移除该类CSS 里.is-entering { opacity: 0; transform: translateY(4px); }配合transition。这样既有动画又不会因为hidden和display的冲突导致动画不触发。3.4 事件委托别给每个选项单独绑事件分支一多选项按钮可能几十个。给每个都addEventListener不是不行但没必要而且动态增删选项时还要记得解绑。更稳的做法是在问题容器上做事件委托监听change事件原生控件会冒泡从event.target往上找最近的.ask-option读出它的>container.addEventListener(change, (e) { const input e.target.closest(input[data-question]); if (!input) return; handleSelect(input.dataset.question, input.value, input.type checkbox); });事件委托还有个隐性好处新增的问题块自动生效不用重新绑定。如果你的页面是分步加载问题比如先展示第一问答完再展示第二问这个特性省掉不少初始化代码。4. 实操过程与核心环节实现4.1 从零搭一个可运行的最小版本我把完整流程拆成五步照着做半小时能跑起来。第一步建 HTML 骨架。一个main容器里面放问题区div idask-questions和结果区div idask-results。结果区里预先写好所有分支内容块每个带>main div idask-questions/div div idask-results section>function syncHash(state) { const params new URLSearchParams(); Object.entries(state).forEach(([k, v]) { params.set(k, Array.isArray(v) ? v.join(,) : v); }); history.replaceState(null, , # params.toString()); }这个技巧的价值在于零成本的可分享性。对方打开链接直接看到你选的那条路径的结果不用重新点一遍。注意多选值用逗号连接时要确保 value 里不含逗号前面说的「value 用英文短横线命名」在这里又派上用场。4.4 实操现场一次真实的分支调试记录说个我实际调试时遇到的场景。当时配了一个三问的决策链第二问的选项next指向了第三问的问题 id而不是结果块标识。结果就是用户答完第二问第三问没出现页面卡住。问题出在数据里——我把「指向下一问」和「指向结果」用了同一个next字段但渲染逻辑只处理了结果块的情况。修复方案是给next加类型区分值以q-开头视为下一问以r-开头视为结果块。渲染时先判断前缀再决定是展开问题还是显示结果。这个约定简单但有效比在数据里加nextType字段更省事也更好记。教训是数据契约里的隐式约定要写进注释否则过两周自己都忘了q-和r-是什么意思。5. 常见问题与排查技巧实录5.1 点了选项没反应从三个方向排查这是最高频的问题。我的排查顺序是固定的基本能在两分钟内定位。先看事件有没有绑上。在change监听里打一行console.log点选项看有没有输出。没有输出说明监听没生效常见原因是容器选择器写错或者脚本在 DOM 就绪前执行了脚本放在head里又没加defer。再看状态有没有更新。有输出但页面没变就在handleSelect里打印state看值有没有进去。没进去多半是dataset读错了比如 HTML 里写>:root { --ask-border: #d0d5dd; --ask-bg-checked: #eff6ff; --ask-accent: #2563eb; } media (prefers-color-scheme: dark) { :root { --ask-border: #3a3f4a; --ask-bg-checked: #1e2a44; --ask-accent: #60a5fa; } }5.4 独家避坑别在结果块里再嵌ask组件我试过在结果块内部再放一个ask问题想做「答完这问还有下一问」的嵌套。技术上能跑但状态管理会变得很绕——外层重算时要不要清空内层内层的选择要不要参与 URL 序列化用户回退时内层状态怎么恢复这些问题叠加起来代码复杂度陡增。后来我改成扁平化链路所有问题平铺在一个问题区用next指向下一问的 id 来表达顺序结果块只放最终结论不再嵌套问题。这样状态是一维的重算逻辑简单URL 序列化也直观。嵌套看起来优雅实际是给自己挖坑除非你的决策树真的需要「不同分支走完全不同的子问题集」否则别碰。提示如果你的分支确实需要不同的子问题用「问题可见性」来控制——所有问题都在 DOM 里但只有当前链路激活的问题才显示。这比嵌套容器好维护得多。6. 组件之外这套思路还能怎么延展ask组件跑通之后我发现它的核心模式——「声明式数据 原生控件 结果块显隐」——可以迁移到不少相邻场景。比如做一个配置推荐器用户勾选自己的使用场景页面实时拼出一段推荐配置文本或者做一个故障自查向导按现象逐层排除最后定位到具体原因和处置步骤。这些场景的共同点是「输入是有限选项、输出是预置内容」正好落在ask的能力圈里。延展时唯一要守住的是那条渐进增强的底线结果内容始终在 HTML 里脚本只负责显隐和状态。守住这条你的单页工具就能在任何环境下至少「可读」而不是把全部价值押在一段可能出错的脚本上。我在实际交付里越来越觉得静态页面的可靠性不来自代码多精巧而来自「最坏情况下还剩多少可用信息」这个冗余设计。ask组件把冗余留在了 DOM 里这是我愿意在多个项目里复用它最主要的原因。
阅读完成 · 觉得有帮助?