难度:★★☆(需要会一点点 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 失败,数据可能已经存下,不能简单提示“未保存”并盲目重复提交。
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 场景。
验收记录三次 Network 结果和页面状态,并解释错误发生在网络、服务器校验还是渲染阶段。若 Console 出现 Unexpected token '<',常见原因是请求错地址拿到了 HTML,却按 JSON 解析;先看原始响应,不要先改 JSON 代码。
原课防抖、Promise.all、SPA 和语音输入属于此链路上的进一步扩展。防抖减少请求数量,并不自动防止已发出的旧请求晚到后覆盖新结果;那需要请求编号、取消或其他明确策略。
后端(第 02、04 课学的)负责"存数据、给数据";前端负责"好看、好用"。
前端代码只做两件事:
网页由三种文件组成(就是"前端三件套"):
| 文件 | 角色 | 比喻 |
|---|---|---|
.html | 骨架(有哪些元素) | 房子的墙和房间 |
.css | 外观(长什么样) | 装修和家具 |
.js | 行为(能干什么) | 水电和开关 |
http://127.0.0.1:8000/| 标签 | 你能做什么 |
|---|---|
| Elements(元素) | 看到页面的 HTML 结构,鼠标悬停能高亮对应区域 |
| Console(控制台) | 输入 App 回车——能看到整个管理端的对象(作者留的后门) |
| 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 的"单一职责"是同一件事,只是换到前端。
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 看的参数"。
common.js 的 API 封装(第 4-28 行)—— 自己写一个迷你 axiosconst 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();
},
...
};
逐点讲解:
fetch(url) 是浏览器内置的"发请求"函数,返回一个 Promise(异步结果)await 表示"等它返回再继续";async 标记这个函数里有等待if (!r.ok) throw:.ok 在状态码 200-299 时为真;失败就抛错,调用方用 try/catch 接住(这就是错误处理,别忽略它)
method 声明动作、Content-Type 告诉后端"我发的是 JSON"JSON.stringify(body) 把 JS 对象变成 JSON 字符串(对照第 02 课后端 json.dumps)为什么要封装? 如果每次都手写 fetch + 头 + 错误处理,会有大量重复。
封装后,业务代码只需要写 await API.post("/api/cards", {...})。
真实世界的 axios 库就是做这件事的放大版——你已经理解它存在的理由了。
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 的温床,集中起来才好维护
init() 三件事的顺序:先能响应操作 → 再准备好配置 → 最后加载数据(似乎与第三课的顺序不同,为什么呢?可以问问大模型哦)
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");
}
}
?q=xxx&inbox=1——和你在第 02 课后端 parse_qs 收到的完全对应encodeURIComponent(q):中文和特殊符号必须编码,否则 URL 会坏(后端 urllib.parse.parse_qs 会自动解码,一来一回对上了)
细节一: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 毫秒后才真正搜索",打字过程中只发最后一次。
这是搜索框的标配技巧。
mdEscapecardHtml()(第 156 行)用模板字符串拼 HTML 再塞进页面。
把不可信输入交给 innerHTML 会引入 HTML 注入与 XSS 风险;例如事件属性可能执行代码,而通过 innerHTML 插入 script 标签本身通常不会直接执行——
这叫 XSS 攻击。所以 common.js 第 74-76 行有:
function mdEscape(s) {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
把 < > 换成"长得像但不会被执行"的字符。凡是用户输入要显示到页面上,都要转义。
(进阶:React 默认文本插值会转义,但危险 HTML 接口、URL 与第三方组件仍需检查,不能称为根治 XSS。)
mobile.html + mobile.js 是给手机用的界面,但请求的还是同一个后端。
两个亮点:
SpeechRecognition,把说的话变成文字填进输入框——不需要安装独立 App,但浏览器实现可能调用联网语音服务,兼容性与离线能力需要实测
localStorage,断网时还能翻看(呼应第 03 课:localStorage 适合本地缓存,不适合当主数据库)
| 教材项目 | axios 库 | jQuery(老时代) |
|---|---|---|
自己封装 API.get/post/del | 提供 axios.get() 等 | $.ajax({...}) |
手动检查 r.ok | 自动抛错 + 拦截器 | 回调函数地狱 |
原生 fetch | 基于 fetch/XHR 封装 | 自己实现 XHR |
作者的 API 对象虽然只有 20 多行,但结构和 axios 的设计思路一致。
看懂它,再去看任何请求库都不会陌生。
教材项目里,数据变了要手动调用 renderList() 重新拼 HTML。
React/Vue 的做法是"数据变了,界面自动重画":
| 教材项目 | React | |
|---|---|---|
| 数据变化 | 手动 renderList() | 自动重新渲染 |
| 更新方式 | 拼 HTML 字符串 + innerHTML | 组件 + 声明式描述界面 |
| 转义安全 | 自己记得 mdEscape | 框架默认转义 |
这不是说手写渲染的写法落后——恰恰相反,理解了"手动渲染",你才能真正理解
"框架帮你省了什么"。这就是第 03 课说的"手动版数据驱动"。
"一个 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)>'
看看效果(放心,只是弹个框)。
common.js 把"请求、提示、渲染、弹窗"抽成公共库,体现单一职责encodeURIComponent、防抖、Promise.all、mdEscape 是四个"专业感"细节| 术语 | 人话解释 |
|---|---|
| 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 项目该怎么组织",
也能理解为什么说它是你所有项目里"结构最成熟"的一个。