🏠 总目录📚 本教程 09 · 最小可用前端
📑 本页目录(点开跳转)

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 章。⚠️ 只做一边等于没做。

⭐ 这就是为什么第二节那三个状态变量里,controllermessages 一样是一等公民 —— 它不是「顺手加的功能」,它是关掉水龙头的那只手


♿ 四、可访问性与移动端(各一小段)

不用做全套,但这几条成本极低、收益很高

做什么 一行代码的事
输入框有 <label>aria-label 屏幕阅读器能念出来
发送按钮是 <button> 不是 <div onclick> 键盘能 Tab 到、回车能触发
⭐ 消息区加 aria-live="polite" 新消息会被屏幕阅读器读出来
焦点管理 发送后焦点回到输入框,用户能连续输入

移动端


🛑 读到这里可以停 —— ⭐ 这里是「概念」和「动手」的分界,不是时间的中点(前半约 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>

三处值得单独回看一眼的地方

  1. ⚠️⚠️ e.isComposing 不能少。 中文输入法选字时按的那次回车是「确认候选词」, 不是「发送」。少了这一句,中文用户每选一次词就发出去一条半截消息 —— 而 ⭐ 英文测试永远发现不了(和第 1 章那个 {stream: true} 是同一类 bug: 只有非 ASCII 输入才会触发)。
  2. 按钮的禁用状态写在 render() 里,不在点击处理器里手动开关。 按钮也是状态的投影 —— 一旦有第二条路径能改它(比如重试、超时),手动开关就迟早会漏掉一条, 症状是「按钮永远灰着」或者「能连点两次」。
  3. 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 章会讲

✅ 检查点

  1. 为什么这一章不上 React?给出「什么时候该上」的一句话判据。
  2. 聊天界面的核心状态是什么?为什么说「别把 DOM 当状态」?
  3. 渲染消息为什么要用 textContent 而不是 innerHTML
  4. LLM 应用的加载态为什么不能只是转圈?正确做法是什么?
  5. ⭐ 滚动跟随的判据是什么?不做这个判断会出什么体验问题?
  6. 「响应失败到一半」为什么是 AI 应用独有的?界面上该怎么处理?
  7. 什么时候该把单个 HTML 文件拆开?拆的时候需要引入打包工具吗?
  8. ⭐ 换成 React 之后,哪些流式相关的难点仍然存在?
  9. 💸 这一章和「成本」那条主线的连接点在哪?前端漏掉什么会直接烧钱?
  10. ⭐ 「回车发送」的判断里为什么必须加 e.isComposing?这个 bug 为什么英文测试发现不了?
👀 答案
  1. 因为这个阶段成本收益不划算:上框架就买进了构建工具链、一个新的部署产物、一套新的调试方式、⚠️ 以及一个和后端各自升级的生态;而换来的组件复用和状态管理,对「一个页面、一个列表、一个输入框」的聊天界面用不上。⭐ 判据:当你的前端开始有「第二个页面」和「跨页面共享的状态」时,才需要框架。(另外三个信号:复杂交互状态、团队协作、需要成熟组件库。)
  2. 一个 messages 数组 + isStreaming + controller。⭐ 别把 DOM 当状态是因为一旦要做重试、编辑、删除,你会发现从页面上读不回来。状态在数组里,页面只是它的一个投影 —— 这条原则和 React 一样,只是你手动做那次渲染。
  3. ⚠️ 因为模型的输出里可能有 <script>。⭐ 模型输出是不可信输入,和用户输入一样要当心。要渲染 Markdown 得另外处理(第 10 章)。
  4. 因为 LLM 比普通 API 慢一到两个数量级 —— 普通 API 量级上几百毫秒返回,而 LLM 首字以秒计、整段答完几十秒是常态。⚠️ 纯转圈让用户分不清「在想」和「卡死了」,等上几秒还没有任何反馈,人就会去点刷新,💸 而重刷会再烧一次钱。⭐ 正确做法:让首字尽快出现,用内容本身当加载态(先插一条空消息,边流边填)。做不了流式时退而求其次给有信息量的等待提示(「正在检索你的文档…」)。
  5. 只有当用户本来就在底部附近(比如距底部 < 80px)时才自动滚。 ⚠️ 不做这个判断,用户往回翻看历史、想复制上面一段代码时,每来一个 token 就被弹回底部,根本选不中 —— 这是 AI 聊天界面最常见的体验 bug。
  6. 因为普通请求要么成功要么失败,流式请求可以「成功了一半」 —— 用户已经看到三行字然后断了。处理:⭐ 保留已收到的内容(那三行有价值)、明确标出「这条不完整」、⭐ 给重试按钮(并让用户选「接着说」还是「重新说」)。⚠️ 最糟的是弹个 alert 然后什么都不留。
  7. 时机不是「文件太长」,是「你开始来回滚动找东西」。 拆成 index.html / app.js / style.css 即可,⚠️ 不需要打包工具 —— 现代浏览器原生支持 <script type="module">,后端把 static/ 挂出去就行,部署还少一个产物。
  8. ⭐⭐ AbortController、滚动跟随、边流边渲染、半截失败 —— 全都还在。 因为这些是浏览器和 HTTP 层的问题,不是框架能替你解决的。框架能替你做的只有「自动 diff 局部更新」这一件。这也是第 10 章值得单独一章的原因。
  9. 💸 用户点了「停止」而前端没有真的 abort()(或者用户干脆关了页面),⭐ 后端会一直把剩下那几百个 token 生成完,没有任何人看到,而这笔钱你照付。前端那一半(AbortController)在第 10 章,后端感知断连在第 5 章,⚠️ 只做一边等于没做。同理,用户因为等太久去点刷新,也是重烧一次钱。这就是为什么 controllermessages 一样是一等公民状态
  10. ⚠️ 因为中文输入法选字时按的那次回车是「确认候选词」,不是发送。少了这一句,中文用户每选一次词就发出去一条半截消息。⭐ 英文输入不走候选词流程,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

打卡记录保存在你的浏览器里,首页能看到总进度