状态与渲染 · 前端:HTML、CSS 与 JavaScript
应用是怎么让屏幕和数据保持一致的?
课程目录为什么需要它
待办清单、购物车、聊天:数据一直在变,屏幕必须始终和它一致。一个元素一个元素地手动更新,很快就会乱成一团。React、Vue、Svelte 都基于同一个简单的想法来管住它,而你用普通函数就能学会。
直觉理解
屏幕是数据的函数:屏幕 = render(state)。
- 把所有数据放在一个地方:状态(state)。
- 写一个
render(state),把状态变成 HTML。 - 发生了什么事(点击、按键)时,用
update(state, action)算出一个新状态,然后重新渲染。
update 从不修改旧状态。它用 ... 展开和 map/filter 造出一个新状态返回。这样 bug 很好找:每个动作都把你从一个已知状态带到下一个状态。
在真实页面里,把它们接起来只要几行:
let state = [];
button.addEventListener("click", () => {
state = update(state, { type: "add", title: input.value });
list.innerHTML = render(state);
});框架帮你做最后一行,而且只更新页面上变了的部分。
render 有一条规矩:来自用户的文字,放进 HTML 之前必须转义。否则有人在评论里输入一个类似 <script> 的标签,就能在别人的浏览器里运行代码:这就是 XSS。
定义
状态
All the data the screen depends on, kept in one place.屏幕依赖的所有数据,放在一个地方。
渲染
Turning state into what's on screen: here, a function from state to an HTML string.把状态变成屏幕上的内容:这里是一个从状态到 HTML 字符串的函数。
不可变更新
Building a new object or array instead of changing the old one, with spread, map and filter.用展开、map、filter 造一个新对象或新数组,而不是修改旧的。
示例
渲染一个列表
动手试试一个函数把数据变成 HTML。
function render(todos) {
const items = todos.map(t => `<li>${t.done ? "[x]" : "[ ]"} ${t.title}</li>`);
return `<ul>${items.join("")}</ul>`;
}
const todos = [
{ title: "Buy milk", done: true },
{ title: "Call mum", done: false },
];
console.log(render(todos));<ul><li>[x] Buy milk</li><li>[ ] Call mum</li></ul>
- L2map 把每个待办变成一个 <li> 字符串;? : 选出勾选框的样子。
- L3join("") 把各项拼在一起,中间什么都不加。
用动作来更新
动手试试每次修改都是一个动作;update 返回下一个状态。
function update(todos, action) {
if (action.type === "add") {
return [...todos, { title: action.title, done: false }];
}
if (action.type === "toggle") {
return todos.map((t, i) => (i === action.index ? { ...t, done: !t.done } : t));
}
return todos;
}
const s0 = [];
const s1 = update(s0, { type: "add", title: "Buy milk" });
const s2 = update(s1, { type: "toggle", index: 0 });
console.log(s0.length, s1.length);
console.log(s1[0].done, s2[0].done);0 1 false true
- L3一个新数组:旧的各项,再加一项。
- L6一个新数组,只有被切换的那个待办是新对象。
- L14s0 还是空的:update 没有改它。
0 1 - L15s1 还是旧值;s2 是新值。
false true
转义用户输入
动手试试包含 HTML 的评论必须显示成文字,而不是被执行。
function escapeHtml(text) {
return text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
const comment = '<img src=x onerror="alert(1)">';
console.log(`<p>${comment}</p>`);
console.log(`<p>${escapeHtml(comment)}</p>`);<p><img src=x onerror="alert(1)"></p> <p><img src=x onerror="alert(1)"></p>
- L3先处理 &,这样 < 里的 & 就不会被再转义一次。
- L10原样插入:浏览器会加载那个坏图片并运行 alert(1)。
- L11转义后:浏览器把这些字符当普通文字显示。
常见错误
原地修改状态
state.push(item) 会连旧状态一起改掉,你就没法比较前后,框架也可能察觉不到变化。返回新数组:[...state, item]。
忘了 join
把数组直接放进模板字符串,会用逗号连接:<li>a</li>,<li>b</li>。要用 .join("")。
直接插入用户输入
用户输入的任何内容,放进 HTML 之前都要转义(或者用 textContent 代替 innerHTML)。XSS 攻击就是这样防住的。
小测验
1. 这段代码会输出什么?
const items = ["a", "b"].map(x => `<li>${x}</li>`);
console.log(`<ul>${items}</ul>`);查看答案
答案:B
数组放进 ${ } 会变成用逗号连接的各项。
2. 这段代码会输出什么?
const before = [1, 2];
const after = before;
after.push(3);
console.log(before.length);查看答案
答案:B
after = before 并没有复制:两个名字指向同一个数组,所以 push 也改了 before。
动手练习
3 道练习,分三层:先预测程序的输出,再按新需求改程序,最后从零写一个。代码在浏览器里运行,提交马上判题。
- 预测输出重放这些动作
准确写出它打印的内容。
- 改需求修好渲染函数
render(names)应该返回<ul><li>Ann</li><li>Bo</li></ul>这样的列表。它有两个 bug:各项之间出现了逗号;名字里的 HTML 没有被转义。把两个都修好(escapeHtml已经写好了)。 - 从零写一个购物车
给购物车写
update(cart, action):购物车是{ id, price, qty }的数组。{ type: "add", id, price }:商品已在购物车里就把qty加 1;否则在末尾加上{ id, price, qty: 1 }{ type: "remove", id }:把它的qty减 1,减到 0 就移除- 其他动作:原样返回购物车
不要修改传进来的购物车:返回一个新数组。
本课术语
| 术语 | 中文 | 英文定义 |
|---|---|---|
| State | 状态 | The data a screen is drawn from. |
| Reducer | reducer 更新函数 | A function (state, action) → new state. |
| Escaping | 转义 | Turning < > & " into entities so text can't become HTML. |