第7章 7.5 综合实战:响应式数据绑定迷你 Vue
🎯 开场:为什么你会被"双向绑定"折磨?
你有没有想过这个问题:为什么 Vue 写 message = 'hello' 后,页面自动就变了?这魔法一样的背后到底发生了什么?
上一章我们学了迭代器和生成器,知道 JavaScript 可以控制数据的"生产流程"。但光会控制数据还不够——数据变了,页面得跟着变,这才是真正让人头疼的问题。
今天我们要做的事:用 Proxy + 模板字符串,徒手撸一个迷你 Vue,真正理解"响应式"是怎么实现的。
学完这章,你将能:
- 手动实现一个简易的响应式数据系统
- 搞懂 Vue 3 响应式的核心原理(Proxy)
- 理解"数据驱动视图"这个听起来高大上的概念到底是怎么回事
🧱 基础:响应式数据绑定的三板斧
1. Proxy 是什么?
生活类比:Proxy 就像一个中介。房东(真实数据)把房子交给中介(Proxy)管理,租客(你的代码)不直接找房东,而是找中介。中介可以偷偷在中间做很多事—\n\n
\n\n
\n\n—记录谁来过、阻止某些操作、或者房东换家具了自动通知租客。
解决啥痛点:以前想监听对象属性变化,得用 Object.defineProperty,但这货功能有限,改不了不存在的属性。Proxy 完美解决了这个问题。
// 基础 Proxy 语法
const handler = {
get(target, prop) {
console.log(`有人读取了 ${prop}`);
return target[prop];
},
set(target, prop, value) {
console.log(`有人修改了 ${prop} 为 ${value}`);
target[prop] = value;
return true;
}
};
const data = new Proxy({ count: 0 }, handler);
// 现在试试操作 data
data.count; // 输出:有人读取了 count
data.count = 5; // 输出:有人修改了 count 为 5
2. 响应式系统的核心思想
生活类比:想象一下订报纸。你(视图)去报社订阅说"我以后都要看天气预报",报社(响应式系统)记下你的地址。每当天气(数据)变了,报社就自动把新报纸送到你手上。你不需要每天去问"今天天气变了吗"。
为什么要用:手动更新 DOM 是噩梦。100 个地方用了同一个数据,你得手动改 100 处。响应式让你只改数据,其他地方自动跟着变。
// 一个最简单的响应式系统雏形
class Reactive {
constructor(data, render) {
this.data = new Proxy(data, {
get: (target, prop) => target[prop],
set: (target, prop, value) => {
target[prop] = value;
// 数据变了,立刻执行渲染函数
render();
return true;
}
});
this.render = render;
}
}
// 使用起来超级简单
const state = new Reactive({ message: 'Hello' }, () => {
document.body.innerHTML = `<h1>${state.data.message}</h1>`;
});
// 改数据,页面自动更新!
state.data.message = 'World'; // 页面上的 h1 自动变成 "World"
3. 模板字符串:动态生成 HTML
生活类比:模板字符串就像填空题的卷子。Hello ${name} 是一道填空题,name 是变量,填进去就变成了完整的句子。
解决啥痛点:以前拼 HTML 字符串要 '<div>' + name + '</div>',一堆引号加号,看都看晕了。模板字符串让这个过程清晰多了。
const user = { name: '小明', age: 18 };
// 模板字符串渲染
const html = `
<div class="user-card">
<h2>${user.name}</h2>
<p>年龄:${user.age}岁</p>
</div>
`;
console.log(html);
// 输出完整的 HTML 字符串,变量被自动填进去了
🔥 实战:3 个递进小项目
项目 1:计数器(5 分钟)
跟着抄就能跑,理解最核心的响应式循环。
// 计数器 - 理解响应式的基本流程
class Vue {
constructor(options) {
this.data = new Proxy(options.data(), {
set: (target, key, value) => {
target[key] = value;
// 核心:数据变化时,自动重新渲染
this.render();
return true;
}
});
this.el = document.querySelector(options.el);
this.render(); // 初始渲染
}
render() {
// 把数据渲染到页面
this.el.innerHTML = `
<div>
<p>计数:<strong>${this.data.count}</strong></p>
<button onclick="app.data.count++">+1</button>
<button onclick="app.data.count--">-1</button>
</div>
`;
}
}
// 启动应用
const app = new Vue({
el: '#app',
data() {
return { count: 0 };
}
});
预期输出:页面上显示"计数:0",两个按钮可以加减数字。
一句话解释:数据变化触发 set -> set 触发 render() -> 页面更新。
项目 2:待办清单(15 分钟)
加入真实场景:从数组数据渲染列表,支持增删。
// 待办清单 - 处理数组的响应式
class TodoVue {
constructor(selector) {
this.el = document.querySelector(selector);
this.data = new Proxy({
todos: [],
inputValue: ''
}, {
set: (target, key, value) => {
target[key] = value;
this.render();
return true;
}
});
this.render();
}
addTodo() {
if (this.data.inputValue.trim()) {
this.data.todos.push({
id: Date.now(),
text: this.data.inputValue,
done: false
});
this.data.inputValue = ''; // 清空输入框
}
}
toggleTodo(id) {
const todo = this.data.todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}
deleteTodo(id) {
this.data.todos = this.data.todos.filter(t => t.id !== id);
}
render() {
const completed = this.data.todos.filter(t => t.done).length;
const total = this.data.todos.length;
this.el.innerHTML = `
<div style="font-family: sans-serif; max-width: 400px; margin: 20px auto;">
<h2>待办清单(${completed}/${total} 完成)</h2>
<div style="margin-bottom: 15px;">
<input
type="text"
value="${this.data.inputValue}"
oninput="app.data.inputValue = this.value"
onkeypress="if(event.key==='Enter') app.addTodo()"
placeholder="输入待办事项..."
style="padding: 8px; width: 70%;"
/>
<button onclick="app.addTodo()" style="padding: 8px 15px;">添加</button>
</div>
<ul style="list-style: none; padding: 0;">
${this.data.todos.map(todo => `
<li style="padding: 10px; border-bottom: 1px solid #eee; display: flex; align-items: center;">
<input
type="checkbox"
${todo.done ? 'checked' : ''}
onchange="app.toggleTodo(${todo.id})"
style="margin-right: 10px;"
/>
<span style="${todo.done ? 'text-decoration: line-through; color: #999;' : ''}">
${todo.text}
</span>
<button
onclick="app.deleteTodo(${todo.id})"
style="margin-left: auto; color: red; border: none; background: none; cursor: pointer;"
>删除</button>
</li>
`).join('')}
</ul>
${total === 0 ? '<p style="color: #999;">暂无待办事项</p>' : ''}
</div>
`;
}
}
// 启动
const app = new TodoVue('#app');
预期输出:
- 输入框输入文字,按回车或点"添加"增加待办
- 点击 checkbox 切换完成状态(删除线)
- 点"删除"移除待办
- 顶部显示完成进度
一句话解释:数组变化触发 set -> 重新渲染整个列表。
项目 3:数据仪表盘(15 分钟)
组合能力,做一个有点真实用的数据展示工具。
// 数据仪表盘 - 综合实战
class Dashboard {
constructor(selector) {
this.el = document.querySelector(selector);
this.data = new Proxy({
title: '销售数据仪表盘',
products: [
{ name: 'iPhone', sales: 120, price: 6999 },
{ name: 'iPad', sales: 85, price: 4999 },
{ name: 'MacBook', sales: 45, price: 9999 }
],
filter: ''
}, {
set: (target, key, value) => {
target[key] = value;
this.render();
return true;
}
});
this.render();
}
// 计算总销售额
get totalSales() {
return this.data.products.reduce((sum, p) => sum + p.sales * p.price, 0);
}
// 筛选后的数据
get filteredProducts() {
if (!this.data.filter) return this.data.products;
return this.data.products.filter(p =>
p.name.toLowerCase().includes(this.data.filter.toLowerCase())
);
}
render() {
const filtered = this.filteredProducts;
const total = filtered.reduce((sum, p) => sum + p.sales * p.price, 0);
this.el.innerHTML = `
<div style="font-family: -apple-system, sans-serif; padding: 20px;">
<h1 style="color: #333;">${this.data.title}</h1>
<div style="margin: 20px 0;">
<input
type="text"
value="${this.data.filter}"
oninput="dash.data.filter = this.value"
placeholder="搜索产品..."
style="padding: 10px; width: 200px; border: 1px solid #ddd; border-radius: 4px;"
/>
</div>
<table style="width: 100%; border-collapse: collapse; margin-top: 20px;">
<thead>
<tr style="background: #f5f5f5;">
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #ddd;">产品</th>
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #ddd;">销量</th>
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #ddd;">单价</th>
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #ddd;">销售额</th>
</tr>
</thead>
<tbody>
${filtered.map(p => `
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 12px;">${p.name}</td>
<td style="padding: 12px; text-align: right;">${p.sales}</td>
<td style="padding: 12px; text-align: right;">¥${p.price.toLocaleString()}</td>
<td style="padding: 12px; text-align: right; font-weight: bold;">
¥${(p.sales * p.price).toLocaleString()}
</td>
</tr>
`).join('')}
</tbody>
<tfoot>
<tr style="background: #e8f4e8; font-weight: bold;">
<td style="padding: 12px;">合计(${filtered.length} 个产品)</td>
<td style="padding: 12px; text-align: right;">${filtered.reduce((s, p) => s + p.sales, 0)}</td>
<td style="padding: 12px; text-align: right;">-</td>
<td style="padding: 12px; text-align: right; color: #2a5;">¥${total.toLocaleString()}</td>
</tr>
</tfoot>
</table>
${filtered.length === 0 ? '<p style="color: #999; margin-top: 20px;">没有找到匹配的产品</p>' : ''}
</div>
`;
}
}
// 启动
const dash = new Dashboard('#app');
预期输出:
- 显示产品列表,包含销量、单价、销售额
- 输入搜索词实时筛选
- 底部自动汇总筛选后的数据
一句话解释:一个 Proxy 搞定所有数据变化,搜索词一变,页面自动更新。
💪 进阶:常见坑 + 调试技巧
坑 1:Proxy 只能代理第一层
// ❌ 错误:嵌套对象不会被拦截
const data = new Proxy({ user: { name: '小明' } }, handler);
data.user.name = '小红'; // 不会触发 set!因为 user 本身没变
// ✅ 正确:需要递归代理,或者用 Vue 3 的方式(太复杂这里不展开)
const data = new Proxy({ user: { name: '小明' } }, {
get(target, prop) {
const value = target[prop];
if (typeof value === 'object' && value !== null) {
return new Proxy(value, this); // 递归代理
}
return value;
},
set(target, prop, value) {
target[prop] = value;
console.log(`设置了 ${prop} = ${value}`);
return true;
}
});
data.user.name = '小红'; // 现在能触发了
坑 2:数组 length 直接赋值不触发
// ❌ 错误:直接设置 length 不会触发响应式
data.todos.length = 0; // 不会清空!
// ✅ 正确:替换整个数组
data.todos = []; // 触发 set
坑 3:忘记 return true
// ❌ 错误:Proxy 的 set 必须返回 true
set(target, prop, value) {
target[prop] = value;
// 没 return,结果可能出问题
}
// ✅ 正确
set(target, prop, value) {
target[prop] = value;
return true;
}
坑 4:render 里面又改数据,死循环!
// ❌ 错误:render 里改 data 会触发又一次 render
render() {
this.el.innerHTML = `<span>${this.data.count}</span>`;
if (this.data.count > 10) {
this.data.count = 0; // 死循环警告!
}
}
// ✅ 正确:用个 flag 防止递归
let rendering = false;
set(target, key, value) {
target[key] = value;
if (!rendering) {
rendering = true;
this.render();
rendering = false;
}
return true;
}
调试技巧:给 Proxy 加日志
// 加了详细日志的 Proxy,方便排查问题
const debugProxy = new Proxy(data, {
get(target, prop) {
console.log(`📖 读取 ${prop}:`, target[prop]);
return target[prop];
},
set(target, prop, value) {
console.log(`✏️ 设置 ${prop}: ${value}(旧值: ${target[prop]})`);
target[prop] = value;
return true;
}
});
✏️ 练习题
练习 1(2 分钟):改颜色
- 输入:在项目 1 的计数器基础上
- 预期输出:当 count > 5 时,数字变成红色
- 提示:用模板字符串的条件渲染
<span style="color: ${count > 5 ? 'red' : 'black'}">
练习 2(2 分钟):加个重置按钮
- 输入:在项目 1 加一个重置功能
- 预期输出:点"重置"后 count 变回 0
- 提示:加个按钮调用
app.data.count = 0
练习 3(3 分钟):待办清单加时间戳
- 输入:在项目 2 的待办事项里加"创建时间"
- 预期输出:每个待办显示
HH:mm格式的时间 - 提示:存储时用
Date.now(),渲染时转换格式
练习 4(5 分钟):仪表盘排序
- 输入:给项目 3 加点击表头排序功能
- 预期输出:点击"销量"按销量排序,再点反序
- 提示:在 data 里加个
sortKey和sortOrder状态
练习 5(5 分钟):分析报错
- 输入:以下代码运行后为什么不工作?
const app = new Vue({
el: '#app',
data: { count: 0 } // 注意:这里是对象不是函数!
});
- 预期输出:说明原因并给出修正方法
- 提示:Vue 2 和 Vue 3 的 data写法不一样,Vue 3 要求 data 是返回对象的函数
作业:做一个「迷你 Weather App」
需求描述:用 Proxy 响应式系统做一个天气查看小工具。
功能点:
1. 输入城市名,点击查询(或按回车)
2. 显示该城市的当前天气(温度、天气状况、湿度)
3. 支持收藏城市,收藏列表本地持久化(localStorage)
加分项:
1. 添加 Loading 状态和错误提示
2. 温度单位可切换(摄氏度/华氏度)
验收标准:
- 能跑起来不报错
- 输入城市能显示模拟数据
- 刷新页面收藏的城市还在
提交方式:把代码贴到评论区,或者丢到 GitHub 贴链接!
📚 总结
今天我们学了:
1. Proxy 就像一个"中介",能在数据被读写时偷偷搞事
2. 响应式的核心就是:数据变 -> 自动执行渲染函数 -> 页面更新
3. 模板字符串让 HTML 拼串变得优雅多了
下章剧透:你可能会问——我们自己写的这个"响应式系统"功能太弱了,真实 Vue 里的 computed、watch、v-if/v-for 那些是怎么实现的?
下一章我们要学的 TypeScript,就是让大型 Vue 项目更易维护的秘密武器。它能把你在 JavaScript 里容易踩的坑,在写代码阶段就给你揪出来。
推荐资源:
- Vue 3 官方文档:https://vuejs.org (响应式部分写得超清楚)
- MDN Proxy 教程:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
- 《JavaScript 高级程序设计》:红宝书,第 10 章讲代理/反射讲得很透
互动钩子:你在做项目时有没有被"数据变了页面没更新"坑过?或者自己造过响应式系统的轮子?评论区聊聊,老粉优先回复!

评论(0)