第7章 7.5 综合实战:响应式数据绑定迷你 Vue

🎯 开场:为什么你会被"双向绑定"折磨?

你有没有想过这个问题:为什么 Vue 写 message = 'hello' 后,页面自动就变了?这魔法一样的背后到底发生了什么?

上一章我们学了迭代器和生成器,知道 JavaScript 可以控制数据的"生产流程"。但光会控制数据还不够——数据变了,页面得跟着变,这才是真正让人头疼的问题。

今天我们要做的事:用 Proxy + 模板字符串,徒手撸一个迷你 Vue,真正理解"响应式"是怎么实现的。

学完这章,你将能:
- 手动实现一个简易的响应式数据系统
- 搞懂 Vue 3 响应式的核心原理(Proxy)
- 理解"数据驱动视图"这个听起来高大上的概念到底是怎么回事


🧱 基础:响应式数据绑定的三板斧

1. Proxy 是什么?

生活类比:Proxy 就像一个中介。房东(真实数据)把房子交给中介(Proxy)管理,租客(你的代码)不直接找房东,而是找中介。中介可以偷偷在中间做很多事—\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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 里加个 sortKeysortOrder 状态

练习 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 里的 computedwatchv-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 章讲代理/反射讲得很透


互动钩子:你在做项目时有没有被"数据变了页面没更新"坑过?或者自己造过响应式系统的轮子?评论区聊聊,老粉优先回复!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。