📑 本页目录(点开跳转)
09 · 最小可用前端
⏱ 80 分钟 | ⭐ 这一章不教前端框架,教你怎么做出一个不丢人的界面
🎯 一句话
一个聊天界面,原生 JS 三百行就够了 —— 而且这三百行你能全部看懂、随时改、部署时不需要构建。
⭐ 第五节有完整的一份(HTML + CSS + JS 一共 163 行):复制进一个 .html 文件,双击就能打开用,不需要后端。
⚠️ 这一章的读者假设是:你是个后端,前端只想「够用就行」。 如果你本来就写前端,⭐ 直接跳到第 10 章 —— 那一章讲的流式接收、 边流边渲染、滚动跟随,才是 AI 应用真正特殊的地方。
🚫 一、为什么这里不上 React
这不是「框架不好」,是这个阶段的成本收益不划算。
上一个前端框架,你就同时买进了:
| 买进的 | 具体是什么 |
|---|---|
| 构建工具链 | Vite / webpack、配置文件、node_modules |
| 一个新的部署产物 | 静态文件要单独构建、单独托管、和后端的 CORS 要配 |
| 一套新的调试方式 | 出问题要分清是构建的问题还是代码的问题 |
| ⚠️ 一个新的生态更新节奏 | 它和你的后端各有各的破坏性升级 |
而你换来的是什么?对一个聊天界面来说,主要是组件复用和状态管理 —— 但聊天界面只有一个页面、一个列表、一个输入框。
⭐ 一句话判据:当你的前端开始有「第二个页面」和「跨页面共享的状态」时,才需要框架。 在那之前,框架解决的问题你还没有。
⚠️ 但要说清什么时候该上
| 信号 | 说明 |
|---|---|
| 多个页面 + 路由 | 手写路由到第三个页面就开始难受 |
| 复杂的交互状态 | 比如多标签页对话、拖拽排序、撤销重做 |
| ⭐ 团队协作 | 别人接手时,「一个自定义的三百行 JS」比「一个标准 React 项目」难上手得多 |
| 需要成熟组件库 | 表格、日期选择器、复杂表单 —— 自己写这些不划算 |
⭐ 迁移不难:如果你按下面的结构写(数据和渲染分开),将来搬到 React 基本是把渲染函数换成组件。
🧱 二、一个聊天界面需要什么
拆开只有五件事:
┌─ 消息列表 ← 历史消息,能滚动
├─ 输入框 ← 回车发送,Shift+回车换行
├─ 发送按钮 ← 发送中要禁用,否则用户会连点
├─ 加载态 ← ⭐ 不能只是转圈,见第三节
└─ 错误态 ← ⚠️ 要能重试,不能只是消失
⭐ 核心的数据结构就一个数组:
// 整个应用的状态,就这些
let messages = []; // [{role: 'user'|'assistant', content: '...', error?: true}]
let isStreaming = false; // 正在生成中
let controller = null; // AbortController,用于「停止生成」
⚠️ 别把 DOM 当状态。 一个新手常见错误是「从页面上读出当前有几条消息」—— 一旦要做重试、编辑、删除,你会发现读不回来。 ⭐ 状态在数组里,页面只是它的一个投影 —— 这条原则和 React 是一样的, 只是你手动做那次渲染。
🔍 一个就在手边的实例:第 1 章那个前端正是这么写的 ——
out.textContent += JSON.parse(payload).text,字直接往 DOM 里塞,全程没有任何数组。 ⚠️ 在那一章那是对的选择(目标是最短路径:只问一次、只显示一次,历史都不留)。 ⭐ 但它也因此一条都做不了:翻不了历史、重试不了、多轮上下文压根发不出去。 这一章就是来把它换掉的 —— 第五节那份文件就是换完的样子。
最小渲染函数:
🗓️ 本章正文里的代码块全部未实跑 —— 它们是浏览器代码,而且都是片段(拿来说明形状用的)。 ⭐ 完整、语法核验过、能直接打开就用的那一份在第五节。
function render() {
const box = document.getElementById('messages');
box.innerHTML = ''; // ⚠️ 简单但粗暴,见下面的注意
for (const m of messages) {
const div = document.createElement('div');
div.className = 'msg ' + m.role + (m.error ? ' error' : '');
div.textContent = m.content; // ⭐ 用 textContent 不用 innerHTML
box.appendChild(div);
}
box.scrollTop = box.scrollHeight; // ⚠️ 先这么写,第三节 ② 会说明它错在哪、怎么修
}
⚠️⚠️ 两个必须注意的点: 1.
textContent而不是innerHTML—— 模型的输出里可能有<script>。 ⭐ 模型输出是不可信输入,和用户输入一样要当心。要渲染 Markdown 的话见第 10 章。 2.innerHTML = ''全量重绘在流式下会出问题 —— 一秒重绘几十次, 而且会打断用户选中文本。第 10 章讲怎么只更新最后一条。
⏳ 三、⭐ AI 应用前端的三个特殊点(外加一条花钱的)
这三条是普通 Web 前端教程不会讲的,因为只有 AI 应用会同时撞上。 (💸 第四条讲的不是体验而是成本,放在这一节末尾。)
① 响应很慢 —— 加载态不能只是转圈
普通 API 量级上是几百毫秒返回,转个圈没人在意。 ⭐ LLM 要慢一到两个数量级:首字以秒计,整段答完几十秒是常态。 (🗓️ 具体数字随模型、输出长度、要不要先检索而变,⭐ 但「和普通 API 差一两个数量级」这件事不会变 —— 那才是界面要为之改设计的原因。要量你自己那套的真实数字,看第 15 章的 TTFT。)
⚠️ 纯转圈的问题:用户分不清「在想」和「卡死了」。等上几秒还没有任何反馈,人就会去点刷新 —— 💸 而第 5 章第一节说过:重刷会再烧一次钱。
⭐ 正确做法:让首字尽快出现,用内容本身当加载态。
// ❌ 等全部返回再显示
const res = await fetch('/api/chat', {...});
const data = await res.json();
messages.push({role: 'assistant', content: data.text});
// ✅ 先插一条空的,边流边填(完整实现见第 10 章)
messages.push({role: 'assistant', content: ''});
for await (const chunk of stream) {
messages[messages.length - 1].content += chunk;
render();
}
⭐ 如果实在做不了流式,退而求其次:给一个有信息量的等待提示 (「正在检索你的文档…」→「正在生成回答…」),比一个转圈强得多。
② 响应很长 —— 滚动要跟随,但别跟死
生成中要自动滚到底部,否则用户看不到新内容。但是:
⚠️ 用户往回翻看历史时,绝不能把他强行拽回底部。 这是 AI 聊天界面最常见的体验 bug —— 用户想复制上面一段代码, 结果每来一个 token 就被弹回底部,根本选不中。
⭐ 判据:只有当用户本来就在底部附近时,才自动滚。
function shouldAutoScroll(box) {
// 距离底部小于 80px 就认为「用户在跟读」
return box.scrollHeight - box.scrollTop - box.clientHeight < 80;
}
⚠️ 回头看第二节那个 render() —— 它结尾那行无条件的 box.scrollTop = box.scrollHeight
正是这里说的错误写法。修法不只是加个 if,⭐ 顺序也很关键:必须在重绘之前量,
因为重绘之后 scrollHeight 已经变了,再量就永远判成「不在底部」。
function render() {
const stick = shouldAutoScroll(box); // ⭐ 先量,再动 DOM
// ……清空、重新 append 每一条……
if (stick) box.scrollTop = box.scrollHeight; // ⭐ 只有本来就在底部才滚
}
③ 响应可能失败到一半
⚠️ 这是 AI 应用独有的:普通请求要么成功要么失败,流式请求可以「成功了一半」 —— 用户已经看到三行字,然后断了。
| 界面上该怎么办 | 说明 |
|---|---|
| ⭐ 保留已经收到的内容 | 别清空,那三行字是有价值的 |
| 明确标出「这条不完整」 | 加个标记或淡色提示,别让用户以为模型就说了这么多 |
| ⭐ 给重试按钮 | 而且重试要能接着说或重新说,让用户选 |
⚠️ 最糟的处理是弹一个「网络错误」的 alert 然后什么都不留 —— 用户既看不到已生成的内容,也不知道该做什么。
💸 还有第四条,属于「成本」那条主线
前三条讲的是体验,但前端的每一个疏忽在 AI 应用里还会多花一笔:这里花的是真钱。
⚠️ 最典型的一处:用户点了「停止」,而你的前端没有真的 abort()(或者用户干脆直接关了页面)——
⭐ 后端会一直把剩下那几百个 token 生成完,没有任何人看到,而这笔钱你照付。
两边都要做才算真的停止:前端 AbortController 在第 10 章,
后端感知断连在第 5 章。⚠️ 只做一边等于没做。
⭐ 这就是为什么第二节那三个状态变量里,
controller和messages一样是一等公民 —— 它不是「顺手加的功能」,它是关掉水龙头的那只手。
♿ 四、可访问性与移动端(各一小段)
不用做全套,但这几条成本极低、收益很高:
| 做什么 | 一行代码的事 |
|---|---|
输入框有 <label> 或 aria-label |
屏幕阅读器能念出来 |
发送按钮是 <button> 不是 <div onclick> |
键盘能 Tab 到、回车能触发 |
⭐ 消息区加 aria-live="polite" |
新消息会被屏幕阅读器读出来 |
| 焦点管理 | 发送后焦点回到输入框,用户能连续输入 |
移动端:
- ⚠️
100vh在移动浏览器上不等于可视高度(地址栏会伸缩),用100dvh或 JS 兜底 - ⭐ 输入框聚焦时键盘弹出会遮住内容 —— 发送后要把最新消息滚进视野
- 触摸目标至少 44×44 像素
🛑 读到这里可以停 —— ⭐ 这里是「概念」和「动手」的分界,不是时间的中点(前半约 22 分钟)。 前四节该讲的判据已经讲完了,只读到这里也是完整的一份收获。 后半章还有:把前面四节拼起来:一个能直接打开的文件 · 代码放哪:什么时候该拆文件 · 换个栈怎么对应 ⚠️ 后半章标称时间偏长,是因为那一节主体是一份 160 行的完整文件 —— 它是拿来复制运行的,不是拿来逐行读的,实际花的时间远少于徽章上的数字。 回来的时候不用重读,直接从下一节接着看就行。
🔨 五、把前面四节拼起来:一个能直接打开的文件
前面四节全是碎片和判据。这一节把它们装进一个文件 —— ⭐ 前面点过名的每一件事,这里都真的实现了,不是表格里的一句描述:
| 前面说过什么 | 在这份文件里是哪一句 |
|---|---|
| 回车发送 / Shift+回车换行(第二节) | keydown 里判 e.key === 'Enter' && !e.shiftKey |
| 发送中按钮禁用(第二节) | sendBtn.disabled = isSending —— ⭐ 写在 render() 里,跟着状态走 |
| 错误态能重试(第三节 ③) | 失败只加一个 slot.error,已收到的内容一个字不删,旁边长出「重试」按钮 |
| aria 四条(第四节) | aria-live="polite" + aria-label + 真 <button> + 发完 ta.focus() |
| 滚动跟随(第三节 ②) | shouldAutoScroll() 先量 → 重绘 → 再决定滚不滚 |
100dvh 和 44px 触摸目标(第四节) |
CSS 里各一行 |
⚠️ 这一版故意不做流式:发一次、收一次完整回答。
⭐ 而接流式只需要换掉一个函数 —— 整个文件里只有 callModel() 和后端打交道。
第 10 章要动的就是它(外加「只更新最后一条」这个渲染优化),
骨架、状态结构、事件绑定一行都不用改。这就是「数据和渲染分开」的回报。
🗓️ 未实跑 —— 浏览器代码,需要 DOM,本项目的环境跑不了。 ⭐ 做过的核验:把
<script>里的 JS 整段抽出来过了一遍node --check,语法无误。 它默认走一个假模型(setTimeout+ 回声),所以双击就能打开用,不需要后端; 💡 输入里带「报错」两个字会触发一次失败,用来看错误态和重试按钮长什么样。
📄 完整文件(163 行)—— 复制进 chat.html,双击打开
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>最小聊天界面</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; height: 100dvh; /* ⭐ 不是 100vh:移动端地址栏会伸缩 */
display: flex; flex-direction: column;
font: 16px/1.6 system-ui, sans-serif; }
#messages { flex: 1; overflow-y: auto; padding: 12px; }
.msg { max-width: 40em; margin: 0 auto 12px; padding: 10px 14px; border-radius: 10px;
white-space: pre-wrap; /* 模型输出里的换行要保留 */
overflow-wrap: anywhere; }
.msg.user { background: #e8f0fe; }
.msg.assistant { background: #f1f3f4; }
.msg.error { background: #fce8e6; }
.msg.pending::after { content: '▍'; animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.note { font-size: 14px; opacity: .8; margin-top: 6px; }
form { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #ddd; }
textarea { flex: 1; resize: none; font: inherit; padding: 8px;
border: 1px solid #ccc; border-radius: 8px; }
button { min-width: 44px; min-height: 44px; /* ⭐ 触摸目标不小于 44×44 */
font: inherit; padding: 0 16px; border: 0; border-radius: 8px;
background: #1a73e8; color: #fff; cursor: pointer; }
button[disabled] { opacity: .5; cursor: default; }
.retry { background: #5f6368; min-height: 36px; margin-top: 6px; }
</style>
</head>
<body>
<!-- ⭐ aria-live="polite":新消息会被屏幕阅读器读出来 -->
<div id="messages" role="log" aria-live="polite" aria-label="对话记录"></div>
<form id="composer">
<!-- ⭐ 有 aria-label,屏幕阅读器才念得出这是干什么的 -->
<textarea id="q" rows="2" aria-label="要问什么"
placeholder="回车发送,Shift+回车换行"></textarea>
<!-- ⭐ 是 <button> 不是 <div onclick>:键盘能 Tab 到、回车能触发 -->
<button id="send" type="submit">发送</button>
</form>
<script>
// ── 状态:全部真相都在这三个变量里,页面只是它们的投影 ──────────────
let messages = []; // [{role:'user'|'assistant', content, error?, pending?}]
let isSending = false;
const box = document.getElementById('messages');
const form = document.getElementById('composer');
const ta = document.getElementById('q');
const sendBtn = document.getElementById('send');
// ── 渲染 ────────────────────────────────────────────────────────
const NEAR_BOTTOM = 80; // 像素
function shouldAutoScroll() {
return box.scrollHeight - box.scrollTop - box.clientHeight < NEAR_BOTTOM;
}
function render() {
const stick = shouldAutoScroll(); // ⭐ 先量:重绘之后 scrollHeight 就变了
box.textContent = ''; // 非流式下全量重绘够用;流式见第 10 章
messages.forEach((m, i) => {
const div = document.createElement('div');
div.className = 'msg ' + m.role
+ (m.error ? ' error' : '')
+ (m.pending ? ' pending' : '');
div.textContent = m.content; // ⭐⭐ 永远 textContent,绝不 innerHTML
if (m.error) {
const note = document.createElement('div');
note.className = 'note';
note.textContent = '⚠️ 这条没说完:' + m.error; // 标出「不完整」,内容一个字不删
div.appendChild(note);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'retry';
btn.textContent = '重试';
btn.addEventListener('click', () => retry(i));
div.appendChild(btn);
}
box.appendChild(div);
});
if (stick) box.scrollTop = box.scrollHeight; // ⭐ 用户在底部才滚,否则别拽他
sendBtn.disabled = isSending; // ⭐ 发送中禁用,否则用户会连点
sendBtn.textContent = isSending ? '生成中…' : '发送';
}
// ── 发送 ────────────────────────────────────────────────────────
async function send(text) {
messages.push({ role: 'user', content: text });
messages.push({ role: 'assistant', content: '', pending: true });
isSending = true;
render();
const slot = messages[messages.length - 1];
try {
slot.content = await callModel(messages.slice(0, -1));
} catch (e) {
slot.error = e.message; // ⭐ 只加错误标记,已收到的 content 一个字都不删
} finally {
delete slot.pending;
isSending = false;
render();
ta.focus(); // ⭐ 焦点回输入框,用户能连着往下问
}
}
function retry(i) {
if (isSending) return;
const question = messages[i - 1].content; // 失败那条的前一条就是用户的问题
messages.length = i - 1; // 把这一问一答一起撤掉
send(question);
}
// ── 事件 ────────────────────────────────────────────────────────
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = ta.value.trim();
if (!text || isSending) return;
ta.value = '';
send(text);
});
ta.addEventListener('keydown', (e) => {
// ⭐ 回车发送、Shift+回车换行
// ⚠️ e.isComposing 不能少:中文输入法选字时那次回车是「确认候选词」,不是发送
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
form.requestSubmit();
}
});
// ── 模型调用:整个文件里唯一和后端打交道的地方 ────────────────────
// 默认是个假模型,所以这个文件【双击就能打开用】,不需要后端。
async function callModel(history) {
const last = history[history.length - 1].content;
await new Promise((r) => setTimeout(r, 800));
if (last.includes('报错')) throw new Error('模拟的上游错误'); // 打「报错」看错误态
return '(假模型)我收到了:' + last;
}
// 接真后端时把上面那个换成这个(非流式:一次拿一整段回答):
// async function callModel(history) {
// const res = await fetch('/api/chat', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ messages: history }),
// });
// if (!res.ok) throw new Error('HTTP ' + res.status);
// return (await res.json()).text;
// }
render();
ta.focus();
</script>
</body>
</html>
⭐ 三处值得单独回看一眼的地方:
- ⚠️⚠️
e.isComposing不能少。 中文输入法选字时按的那次回车是「确认候选词」, 不是「发送」。少了这一句,中文用户每选一次词就发出去一条半截消息 —— 而 ⭐ 英文测试永远发现不了(和第 1 章那个{stream: true}是同一类 bug: 只有非 ASCII 输入才会触发)。 - ⭐ 按钮的禁用状态写在
render()里,不在点击处理器里手动开关。 按钮也是状态的投影 —— 一旦有第二条路径能改它(比如重试、超时),手动开关就迟早会漏掉一条, 症状是「按钮永远灰着」或者「能连点两次」。 - ⭐
retry(i)是把「一问一答」整对撤掉再重发(messages.length = i - 1),不是原地重来。 因为状态在数组里,撤销就只是截断一个数组这么简单。 ⚠️ 这正是第二节那条原则的兑现:换成「从 DOM 里删两个节点」,你就得同时维护两份真相。
🗂 六、代码放哪:什么时候该拆文件
一开始一个 HTML 文件就够(上一节那一份)。
⭐ 拆分的时机不是「文件太长」,是「你开始来回滚动找东西」。真到那一步,最小的拆法:
static/
index.html ← 结构
app.js ← 状态 + 渲染 + 事件
style.css ← 样式
⚠️ 别为了拆而引入打包工具。现代浏览器原生支持 ES 模块:
<script type="module" src="/static/app.js"></script>
⭐ 这就够了,不需要 webpack。 后端直接把 static/ 当静态目录挂出去
(FastAPI 的 StaticFiles),部署时也少一个产物。
🔄 七、换个栈怎么对应
| 概念 | 原生 JS(本章) | React | Vue |
|---|---|---|---|
| 状态 | 一个数组 + 手动 render() |
useState / useReducer |
ref / reactive |
| 渲染 | 手写 createElement |
JSX,框架自动 diff | 模板,框架自动 diff |
| ⭐ 只更新最后一条 | 要自己写(第 10 章) | key 相同的组件自动局部更新 | 同左 |
| 停止生成 | AbortController |
一样是 AbortController |
同左 |
| 滚动跟随 | 手写判断 + scrollTop |
⚠️ 一样要手写,框架不管这个 | 同左 |
⭐⭐ 注意最后两行:流式相关的难点,换成任何框架都还在。
AbortController、滚动跟随、边流边渲染、半截失败 —— 这些是浏览器和 HTTP 层的问题, 不是框架能替你解决的。这也是为什么第 10 章值得单独一章。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| ⭐⭐ 10 · 把流式接到界面上 | 本章搭了骨架,那一章才是 AI 前端真正难的部分:怎么收流、怎么边流边渲染 Markdown、怎么处理半截失败 |
| 01 · 第一天:两小时上线 | ⚠️ 反面对照,不是最小版:那一章为了走最短路径,直接 out.textContent += ... 往 DOM 里塞 —— 正是本章第二节那个「把 DOM 当状态」。⭐ 这一章就是来把它换掉的:只有先有了 messages 数组,重试、编辑、删除、多轮上下文才谈得上 |
| 05 · 流式输出 | 后端那一半。⭐ 前后端的分帧格式要对齐 —— 那一章定的 SSE 帧格式,本章和第 10 章按它来收 |
| 08 · 认证、会话与多租户 | ⚠️ 前端怎么带 token —— 这里有个坑:EventSource 带不了自定义 header,第 10 章会讲 |
✅ 检查点
- 为什么这一章不上 React?给出「什么时候该上」的一句话判据。
- 聊天界面的核心状态是什么?为什么说「别把 DOM 当状态」?
- 渲染消息为什么要用
textContent而不是innerHTML? - LLM 应用的加载态为什么不能只是转圈?正确做法是什么?
- ⭐ 滚动跟随的判据是什么?不做这个判断会出什么体验问题?
- 「响应失败到一半」为什么是 AI 应用独有的?界面上该怎么处理?
- 什么时候该把单个 HTML 文件拆开?拆的时候需要引入打包工具吗?
- ⭐ 换成 React 之后,哪些流式相关的难点仍然存在?
- 💸 这一章和「成本」那条主线的连接点在哪?前端漏掉什么会直接烧钱?
- ⭐ 「回车发送」的判断里为什么必须加
e.isComposing?这个 bug 为什么英文测试发现不了?
👀 答案
- 因为这个阶段成本收益不划算:上框架就买进了构建工具链、一个新的部署产物、一套新的调试方式、⚠️ 以及一个和后端各自升级的生态;而换来的组件复用和状态管理,对「一个页面、一个列表、一个输入框」的聊天界面用不上。⭐ 判据:当你的前端开始有「第二个页面」和「跨页面共享的状态」时,才需要框架。(另外三个信号:复杂交互状态、团队协作、需要成熟组件库。)
- 一个
messages数组 +isStreaming+controller。⭐ 别把 DOM 当状态是因为一旦要做重试、编辑、删除,你会发现从页面上读不回来。状态在数组里,页面只是它的一个投影 —— 这条原则和 React 一样,只是你手动做那次渲染。 - ⚠️ 因为模型的输出里可能有
<script>。⭐ 模型输出是不可信输入,和用户输入一样要当心。要渲染 Markdown 得另外处理(第 10 章)。 - 因为 LLM 比普通 API 慢一到两个数量级 —— 普通 API 量级上几百毫秒返回,而 LLM 首字以秒计、整段答完几十秒是常态。⚠️ 纯转圈让用户分不清「在想」和「卡死了」,等上几秒还没有任何反馈,人就会去点刷新,💸 而重刷会再烧一次钱。⭐ 正确做法:让首字尽快出现,用内容本身当加载态(先插一条空消息,边流边填)。做不了流式时退而求其次给有信息量的等待提示(「正在检索你的文档…」)。
- ⭐ 只有当用户本来就在底部附近(比如距底部 < 80px)时才自动滚。 ⚠️ 不做这个判断,用户往回翻看历史、想复制上面一段代码时,每来一个 token 就被弹回底部,根本选不中 —— 这是 AI 聊天界面最常见的体验 bug。
- 因为普通请求要么成功要么失败,流式请求可以「成功了一半」 —— 用户已经看到三行字然后断了。处理:⭐ 保留已收到的内容(那三行有价值)、明确标出「这条不完整」、⭐ 给重试按钮(并让用户选「接着说」还是「重新说」)。⚠️ 最糟的是弹个 alert 然后什么都不留。
- ⭐ 时机不是「文件太长」,是「你开始来回滚动找东西」。 拆成
index.html/app.js/style.css即可,⚠️ 不需要打包工具 —— 现代浏览器原生支持<script type="module">,后端把static/挂出去就行,部署还少一个产物。 - ⭐⭐
AbortController、滚动跟随、边流边渲染、半截失败 —— 全都还在。 因为这些是浏览器和 HTTP 层的问题,不是框架能替你解决的。框架能替你做的只有「自动 diff 局部更新」这一件。这也是第 10 章值得单独一章的原因。 - 💸 用户点了「停止」而前端没有真的
abort()(或者用户干脆关了页面),⭐ 后端会一直把剩下那几百个 token 生成完,没有任何人看到,而这笔钱你照付。前端那一半(AbortController)在第 10 章,后端感知断连在第 5 章,⚠️ 只做一边等于没做。同理,用户因为等太久去点刷新,也是重烧一次钱。这就是为什么controller和messages一样是一等公民状态。 - ⚠️ 因为中文输入法选字时按的那次回车是「确认候选词」,不是发送。少了这一句,中文用户每选一次词就发出去一条半截消息。⭐ 英文输入不走候选词流程,
isComposing永远是false,所以英文测试全绿 —— 和第 1 章那个{stream: true}是同一类 bug:只有非 ASCII 输入才会触发。
🛑 可以停在这里
⚡ 走神救援
⭐ 这一章的立场:不教前端框架,只让后端做出一个不丢人的界面。 为什么不上 React —— 不是框架不好,是这个阶段成本收益不划算:你会同时买进构建工具链、一个新的部署产物、一套新的调试方式、⚠️ 以及一个和后端各自破坏性升级的生态;而换来的组件复用和状态管理,对「一个页面、一个列表、一个输入框」根本用不上。⭐ 判据一句话:当前端开始有「第二个页面」和「跨页面共享的状态」时才需要框架(另外三个信号:复杂交互、团队协作、需要成熟组件库)。⭐ 按「数据和渲染分开」写,将来搬到 React 基本就是把渲染函数换成组件。界面只有五件事:消息列表、输入框(回车发送/Shift+回车换行)、发送按钮(发送中禁用,否则用户连点)、加载态、错误态。⭐ 核心状态就一个
messages数组 +isStreaming+controller;⚠️ 别把 DOM 当状态 —— 一旦要做重试/编辑/删除就读不回来,状态在数组里、页面只是它的投影(和 React 是同一条原则,只是你手动渲染)。🔍 第 1 章那个前端正是反面实例:out.textContent += ...直接往 DOM 里塞、全程没有数组,所以它翻不了历史、重试不了、多轮上下文也发不出去 —— 这一章就是来把它换掉的。⚠️⚠️ 渲染两个必须注意:用textContent不用innerHTML(⭐ 模型输出是不可信输入,里面可能有<script>);全量重绘在流式下会出问题(一秒几十次,还会打断用户选中文本)。⭐⭐ AI 前端的三个特殊点(普通前端教程不讲的):① 响应很慢 —— LLM 比普通 API 慢一到两个数量级(普通 API 量级上几百毫秒,LLM 首字以秒计、整段答完几十秒是常态),⚠️ 纯转圈让用户分不清「在想」和「卡死」,等上几秒没反馈人就去点刷新,而 💸 重刷会再烧一次钱;正确做法是先插一条空消息边流边填,用内容本身当加载态。② 响应很长 —— 要自动滚到底,但 ⚠️⚠️ 用户往回翻时绝不能强行拽回底部(想复制上面一段代码,每来一个 token 就被弹回去,根本选不中,这是最常见的体验 bug);⭐ 判据是距底部小于 80px 才认为用户在跟读。③ 可能失败到一半 —— 这是 AI 独有的(普通请求要么成功要么失败,流式能「成功一半」):⭐ 保留已收到的内容、标出这条不完整、给重试按钮(让用户选接着说还是重新说);⚠️ 最糟是弹个 alert 什么都不留。💸 还有第四条,属于「成本」那条主线:前端的疏忽在 AI 应用里会直接变成钱 —— 用户点了停止而前端没真的abort()(或者直接关了页面),后端会把剩下那几百个 token 生成完,没人看到,钱照付;前端那半(AbortController)在第 10 章、后端感知断连在第 5 章,⚠️ 只做一边等于没做。可访问性四条成本极低:输入框有 label、按钮用<button>、⭐ 消息区加aria-live="polite"、发送后焦点回输入框。移动端:⚠️100vh不等于可视高度(用100dvh)、键盘弹出会遮内容、触摸目标 ≥44px。⭐ 第五节把前面四节拼成了一个 163 行、双击就能打开用的文件(默认走假模型,不需要后端;⚠️ 未实跑但语法核验过),⭐ 接流式只要换掉callModel()这一个函数 —— 骨架、状态、事件一行不动,这就是「数据和渲染分开」的回报。里面三个细节值得记:⚠️⚠️e.isComposing不能少(中文选字时那次回车是「确认候选词」不是发送,少了就每选一次词发一条半截消息,而英文测试永远发现不了)、⭐ 按钮禁用状态写在render()里(按钮也是状态的投影,手动开关迟早漏)、⭐ 重试是把「一问一答」整对截掉再重发(messages.length = i - 1,因为状态在数组里,撤销就只是截断数组)。拆文件的时机不是「太长」是「开始来回滚动找东西」,而且 ⚠️ 不需要打包工具 —— 浏览器原生支持<script type="module">。⭐⭐ 最后一条最重要:换成 React 之后,AbortController、滚动跟随、边流边渲染、半截失败这些难点全都还在 —— 它们是浏览器和 HTTP 层的问题,框架替你做的只有「自动 diff 局部更新」一件。这就是下一章值得单独存在的原因。
下一节 👉 10-把流式接到界面上.md