← 代码课堂

第 05 课:前端三件套 —— 浏览器怎么和后端说话

难度:★★☆(需要会一点点 HTML 和 JS)
教材:查资料/web/(index.html / mobile.html + css + 3 个 js 文件)
预计时间:讲解 60 分钟 + 练习 40 分钟
学完你能:看懂网页是怎么"请求数据 → 显示数据"的,能给任何页面加一个按钮调用后端接口


主线实验:按钮成功,不等于数据已经显示

前置:能读 HTML 标签、JS 函数与对象;了解 02 的 JSON 请求和 04 的保存操作。没有基础时先用本节的小实验熟悉三处代码,再读原项目中的多个页面和公共工具。

在 assets/主线实验/ 启动 py -3 server.py,通过 http://127.0.0.1:8030 打开页面。这里必须通过 HTTP 打开,直接双击 index.html 不能得到同源的 /api/cards 接口。

从按键到屏幕,共有两次请求

submit
  → preventDefault 阻止表单默认导航
  → JSON.stringify 组织输入
  → POST /api/cards 保存
  → await 等本次结果
  → GET /api/cards 查询列表
  → 创建 li,通过 textContent 显示

打开 Network 后提交“阅读计划”,应该先看到 POST 的 201,再看到 GET 的 200。服务端保存成功与界面读取成功是两个阶段:如果 GET 失败,数据可能已经存下,不能简单提示“未保存”并盲目重复提交。

解释 async/await 时要带上等待对象

fetch 返回 Promise,表示将来得到 Response;await fetch 暂停当前 async 函数,页面的其他事件仍可处理。await response.json() 再等待正文读取和 JSON 解析。

HTTP 404/500 通常不会让 fetch 本身拒绝,所以 request 函数必须看 response.ok。网络失败、HTTP 失败、JSON 解析失败分别发生在不同位置,catch 最终把提示显示给用户。

做好四种界面状态

空列表显示“还没有卡片”;等待时显示“正在保存”并禁用按钮;成功后显示列表;失败时显示原因并在 finally 恢复按钮。finally 无论成功还是失败都会执行,适合清理“正在操作”的状态。

刷新逻辑用 textContent 显示标题,它把输入作为文本。如果改用 innerHTML,就必须处理 HTML 上下文与属性、URL 等不同边界;只转义三种字符并不能解决所有 XSS 场景。

练习、预期与排错

  1. 先把服务关闭,再点保存:应显示连接失败、按钮恢复,不能假装成功。
  2. 重启服务并提交一条有效卡片:列表出现新行,刷新后仍在。
  3. 输入空格标题:前端 required 不能阻止所有语义空值,后端应返回 400,列表数量不增加。

验收记录三次 Network 结果和页面状态,并解释错误发生在网络、服务器校验还是渲染阶段。若 Console 出现 Unexpected token '<',常见原因是请求错地址拿到了 HTML,却按 JSON 解析;先看原始响应,不要先改 JSON 代码。

原课防抖、Promise.all、SPA 和语音输入属于此链路上的进一步扩展。防抖减少请求数量,并不自动防止已发出的旧请求晚到后覆盖新结果;那需要请求编号、取消或其他明确策略。


课前须知:前端在干什么

后端(第 02、04 课学的)负责"存数据、给数据";前端负责"好看、好用"。
前端代码只做两件事:

  1. 发请求:向后端要数据,或者把数据交给后端
  2. 改页面:拿到数据后,更新页面上的内容

网页由三种文件组成(就是"前端三件套"):

文件角色比喻
.html骨架(有哪些元素)房子的墙和房间
.css外观(长什么样)装修和家具
.js行为(能干什么)水电和开关

第一步:用开发者工具"透视"网页

  1. 启动「查资料」,打开 http://127.0.0.1:8000/
  2. F12 打开开发者工具,依次玩这三个标签:
标签你能做什么
Elements(元素)看到页面的 HTML 结构,鼠标悬停能高亮对应区域
Console(控制台)输入 App 回车——能看到整个管理端的对象(作者留的后门)
Network(网络)刷新页面,看到每一个请求:谁发的、返回了什么
  1. 在 Network 里点开一条 /api/cards 请求,看 Response:

这就是第 04 课讲的数据库数据,被后端变成 JSON 发给了前端。

记住这条主线:点按钮 → 发请求 → 拿 JSON → 改页面。


第二步:模块地图

web/
├── index.html    电脑端页面(骨架)
│     左侧导航 / 搜索框 / 卡片网格 / 图谱 / 语义检索 / 各种弹窗
├── mobile.html   手机端页面(同一套后端的另一张脸)
├── css/style.css 公共外观
└── js/
    ├── common.js  公共工具箱(两个页面都用)
    │      API 请求封装 / Toast 提示 / 时间格式 / Markdown 渲染 / 弹窗 / 下载
    ├── web.js     电脑端逻辑(App 对象,609 行)
    └── mobile.js  手机端逻辑(Mobile 对象,437 行)

最值得学的思想:common.js 是"公共库"——两个页面共用的功能只写一次。
这和第 03 课 MVC 的"单一职责"是同一件事,只是换到前端。


第三步:逐块精讲

块 1:index.html —— 一个页面装下多个"视图"(第 66-67 行、205-219 行)

<style>
.view { display: none; }          /* 默认所有视图隐藏 */
.view.active { display: block; }  /* 加了 active 的才显示 */
</style>
...
<section class="view active" id="view-inbox"> ... </section>
<section class="view" id="view-graph"> ... </section>
<section class="view" id="view-semantic"> ... </section>

整个应用只有一个页面,但通过"给不同的 section 加 .active 类"来切换显示内容
(收件箱 / 图谱 / 语义检索)。这是单页应用(SPA)的雏形:
不刷新页面,只换内容,所以切换丝滑。

另外注意 HTML 里的"路标":id="top-search-input"、data-view="graph"。
JS 就是靠这些名字找到元素的——data-* 属性用来存"给 JS 看的参数"。

块 2:common.js 的 API 封装(第 4-28 行)—— 自己写一个迷你 axios

const API = {
  async get(url) {
    const r = await fetch(url);
    if (!r.ok) throw new Error("请求失败 " + r.status);
    return r.json();
  },
  async post(url, body) {
    const r = await fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body || {}),
    });
    if (!r.ok) throw new Error("请求失败 " + r.status);
    return r.json();
  },
  ...
};

逐点讲解:

失败就抛错,调用方用 try/catch 接住(这就是错误处理,别忽略它)

为什么要封装? 如果每次都手写 fetch + 头 + 错误处理,会有大量重复。
封装后,业务代码只需要写 await API.post("/api/cards", {...})。
真实世界的 axios 库就是做这件事的放大版——你已经理解它存在的理由了。

块 3:web.js 的 App 对象 —— 状态集中管理(第 4-20 行)

const App = {
  state: {
    view: "inbox",
    selected: new Set(),
    searchMode: "fulltext",
    allCards: [],
    ...
  },
  init() {
    this.bindEvents();     // 1. 先绑事件
    this.loadConfig();     // 2. 加载配置
    this.refreshAll();     // 3. 再拉数据
  },

和第 03 课 MVC 的 Controller 一模一样的思想:

状态散落到各处是 bug 的温床,集中起来才好维护

(似乎与第三课的顺序不同,为什么呢?可以问问大模型哦)

块 4:一次完整的数据加载(refreshCards,第 81-94 行)

async refreshCards() {
  const q = document.getElementById("top-search-input").value.trim();
  let url = "/api/cards";
  if (q && this.state.searchMode === "fulltext") url += "?q=" + encodeURIComponent(q);
  if (this.state.view === "inbox") url += (url.includes("?") ? "&" : "?") + "inbox=1";
  try {
    const cards = await API.get(url);
    this.state.allCards = cards;
    this.renderList(cards);
    this.renderTags(cards);
  } catch (e) {
    toast("加载失败:" + e.message, "err");
  }
}

(后端 urllib.parse.parse_qs 会自动解码,一来一回对上了)

块 5:两个"专业感"细节

细节一:Promise.all 并行请求(第 78、98 行)

const [all, inbox] = await Promise.all([
  API.get("/api/cards"),
  API.get("/api/cards?inbox=1"),
]);

两个请求同时发,而不是"等第一个完再发第二个"——响应更快。
Promise.all 是前端性能优化的日常操作。

细节二:输入防抖(debounce,第 39-46 行)

let t;
document.getElementById("top-search-input").addEventListener("input", (e) => {
  clearTimeout(t);
  t = setTimeout(() => { ...搜索... }, 350);
});

如果每敲一个字母就搜一次,敲"天文"两个字会发两次请求;
防抖的意思是"停手 350 毫秒后才真正搜索",打字过程中只发最后一次。
这是搜索框的标配技巧。

块 6:渲染与 XSS —— 为什么要有 mdEscape

cardHtml()(第 156 行)用模板字符串拼 HTML 再塞进页面。
把不可信输入交给 innerHTML 会引入 HTML 注入与 XSS 风险;例如事件属性可能执行代码,而通过 innerHTML 插入 script 标签本身通常不会直接执行——
这叫 XSS 攻击。所以 common.js 第 74-76 行有:

function mdEscape(s) {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

把 < > 换成"长得像但不会被执行"的字符。凡是用户输入要显示到页面上,都要转义。
(进阶:React 默认文本插值会转义,但危险 HTML 接口、URL 与第三方组件仍需检查,不能称为根治 XSS。)

块 7:手机端——同一后端,两套界面

mobile.html + mobile.js 是给手机用的界面,但请求的还是同一个后端。
两个亮点:

  1. 语音输入(第 88-110 行):用浏览器内置的 SpeechRecognition,

把说的话变成文字填进输入框——不需要安装独立 App,但浏览器实现可能调用联网语音服务,兼容性与离线能力需要实测

  1. 离线缓存(第 424-429 行):把卡片列表存进 localStorage,

断网时还能翻看(呼应第 03 课:localStorage 适合本地缓存,不适合当主数据库)


第四步:对照开源,别人怎么写前端

对照:手写 fetch vs 框架/库

教材项目axios 库jQuery(老时代)
自己封装 API.get/post/del提供 axios.get() 等$.ajax({...})
手动检查 r.ok自动抛错 + 拦截器回调函数地狱
原生 fetch基于 fetch/XHR 封装自己实现 XHR

作者的 API 对象虽然只有 20 多行,但结构和 axios 的设计思路一致。
看懂它,再去看任何请求库都不会陌生。

对照:手动渲染 vs React/Vue

教材项目里,数据变了要手动调用 renderList() 重新拼 HTML。
React/Vue 的做法是"数据变了,界面自动重画":

教材项目React
数据变化手动 renderList()自动重新渲染
更新方式拼 HTML 字符串 + innerHTML组件 + 声明式描述界面
转义安全自己记得 mdEscape框架默认转义

这不是说手写渲染的写法落后——恰恰相反,理解了"手动渲染",你才能真正理解
"框架帮你省了什么"。这就是第 03 课说的"手动版数据驱动"。

对照:单页应用(SPA)

"一个 HTML + 切换 .view"是 SPA 的最小实现。
现在流行的 Next.js / React Router,本质也是"不刷新页面,换内容",
只是做得更自动、更强大。两者在用同一个思想。


动手练习(做完才算过关)

练习 1(热身):用 Network 面板做侦探
打开 F12 的 Network,在搜索框输入"天文",观察:
(1)观察实际请求地址:关键词模式可能请求 /api/cards?q=...,语义模式才使用不同接口;快速连续输入时发了几条请求,停顿后是否触发防抖?

看答案

防抖

(2)点开响应,找到返回的 JSON 里的 tags 字段。

练习 2(必做):加一个"关于"按钮
在第 02 课练习 1 里,你给后端加了 /api/about 接口。
现在在网页上加一个按钮:点击后 GET /api/about,用 toast() 把结果弹出来。
提示:在 index.html 找个地方加 <button id="btn-about">,
在 web.js 的 bindEvents() 里绑事件,用 await API.get("/api/about")。

练习 3(体会异步):
把 refreshAll()(第 77 行)的 Promise.all 改成先 await refreshCards() 再
await refreshCounts(),用 Network 面板对比两种写法的请求时间线,
说说看"并行"和"串行"的区别。

练习 4(防抖实验):
把 web.js 第 45 行的 350 改成 2000,在搜索框快速打字,观察请求什么时候发出;
再改成 0,观察有什么变化。说说看"防抖时间"设长设短各有什么利弊。

练习 5(安全思考题):
假设有恶意用户把卡片标题存成 <img src=x onerror=alert(1)>,
在你的页面上会发生什么?mdEscape 是怎么挡住它的?
再用 F12 控制台手动执行 document.body.innerHTML = '<img src=x onerror=alert(1)>'
看看效果(放心,只是弹个框)。


本课小结

术语表

术语人话解释
fetch浏览器内置的发请求函数
Promise / async / await处理"异步"(要等一会儿才有结果)的现代写法
state应用当前的"状态"数据(当前视图、选中项等)
渲染(render)把数据变成页面上的内容
防抖(debounce)连续触发时,只在停下来之后执行一次
XSS把恶意脚本混进页面执行的攻击
转义(escape)把危险字符变成安全的显示字符
SPA单页应用:不刷新页面切换内容
localStorage浏览器本地小仓库(缓存用)

下节预告

第 06 课:Python 包结构:core/cli/ui 分层。
我们离开「查资料」,去看 resource_manager_v2——一个被认真重构过的项目。
它把代码拆成了 core(逻辑)/ cli(命令行)/ ui(界面)/ tests(测试)几个文件夹,
还有 pyproject.toml 定义依赖。学完这课,你就知道"一个 Python 项目该怎么组织",
也能理解为什么说它是你所有项目里"结构最成熟"的一个。