实现一个双向绑定
手写实现数据与视图的双向绑定机制,理解 Vue 等框架的原理
问题
双向绑定是现代前端框架的特性之一,它实现了数据模型(Model)与视图(View)之间的自动同步:
- 当数据变化时,视图自动更新
- 当视图变化时(如用户输入),数据自动更新
需要实现一个简单的双向绑定系统,支持基本的数据监听和视图更新功能。
解答
class DataBinder {
constructor() {
this.data = {}; // 存储数据
this.bindings = {}; // 存储绑定关系
}
/**
* 定义响应式属性
* @param {string} key - 属性名
* @param {*} value - 初始值
*/
defineReactive(key, value) {
const self = this;
// 使用 Object.defineProperty 劫持属性
Object.defineProperty(this.data, key, {
get() {
return value;
},
set(newValue) {
if (newValue !== value) {
value = newValue;
// 数据变化时,通知所有绑定的元素更新
self.notify(key);
}
},
enumerable: true,
configurable: true
});
}
/**
* 绑定元素到数据
* @param {HTMLElement} element - DOM 元素
* @param {string} key - 数据属性名
*/
bind(element, key) {
// 初始化绑定数组
if (!this.bindings[key]) {
this.bindings[key] = [];
this.defineReactive(key, '');
}
// 添加元素到绑定列表
this.bindings[key].push(element);
// 监听输入事件,实现视图到数据的绑定
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
element.addEventListener('input', (e) => {
this.data[key] = e.target.value;
});
// 初始化元素值
element.value = this.data[key] || '';
} else {
// 初始化元素文本
element.textContent = this.data[key] || '';
}
}
/**
* 通知所有绑定的元素更新
* @param {string} key - 数据属性名
*/
notify(key) {
if (!this.bindings[key]) return;
this.bindings[key].forEach(element => {
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
element.value = this.data[key];
} else {
element.textContent = this.data[key];
}
});
}
/**
* 设置数据值
* @param {string} key - 属性名
* @param {*} value - 值
*/
set(key, value) {
this.data[key] = value;
}
/**
* 获取数据值
* @param {string} key - 属性名
*/
get(key) {
return this.data[key];
}
}
// 使用 Proxy 实现的版本(ES6+)
class ProxyDataBinder {
constructor() {
this.bindings = {};
// 使用 Proxy 代理数据对象
this.data = new Proxy({}, {
set: (target, key, value) => {
target[key] = value;
this.notify(key);
return true;
},
get: (target, key) => {
return target[key];
}
});
}
bind(element, key) {
if (!this.bindings[key]) {
this.bindings[key] = [];
}
this.bindings[key].push(element);
// 监听输入事件
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
element.addEventListener('input', (e) => {
this.data[key] = e.target.value;
});
element.value = this.data[key] || '';
} else {
element.textContent = this.data[key] || '';
}
}
notify(key) {
if (!this.bindings[key]) return;
this.bindings[key].forEach(element => {
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
element.value = this.data[key];
} else {
element.textContent = this.data[key];
}
});
}
}
使用示例
// HTML 结构
// <input type="text" id="input1" />
// <input type="text" id="input2" />
// <div id="display"></div>
// <button id="btn">设置值</button>
// 使用 Object.defineProperty 版本
const binder = new DataBinder();
// 绑定多个元素到同一个数据
const input1 = document.getElementById('input1');
const input2 = document.getElementById('input2');
const display = document.getElementById('display');
binder.bind(input1, 'username');
binder.bind(input2, 'username');
binder.bind(display, 'username');
// 在任一输入框输入,其他元素会自动同步更新
// 通过代码设置值
document.getElementById('btn').addEventListener('click', () => {
binder.set('username', 'Hello World');
});
// 使用 Proxy 版本
const proxyBinder = new ProxyDataBinder();
proxyBinder.bind(input1, 'email');
proxyBinder.bind(display, 'email');
// 直接修改 data 对象
proxyBinder.data.email = 'test@example.com';
// 完整示例:表单双向绑定
const formBinder = new ProxyDataBinder();
formBinder.bind(document.getElementById('name'), 'name');
formBinder.bind(document.getElementById('age'), 'age');
formBinder.bind(document.getElementById('preview'), 'name');
// 获取表单数据
console.log(formBinder.data); // { name: '...', age: '...' }
关键点
-
数据劫持:使用
Object.defineProperty或Proxy拦截数据的读写操作,在 setter 中触发视图更新 -
发布订阅模式:维护一个 bindings 对象,存储每个数据属性对应的所有 DOM 元素,数据变化时通知所有订阅者
-
事件监听:为输入元素添加
input事件监听器,实现视图到数据的单向绑定 -
双向同步:
- 数据 → 视图:通过 setter 触发 notify 方法更新 DOM
- 视图 → 数据:通过事件监听器更新数据对象
-
Proxy vs Object.defineProperty:
Object.defineProperty需要遍历对象的每个属性进行劫持Proxy可以直接代理整个对象,支持动态添加属性,是更现代的方案
-
性能优化:实际应用中需要考虑批量更新、虚拟 DOM、依赖收集等优化策略
-
扩展性:可以扩展支持计算属性、侦听器、数组变化检测等高级特性
目录