实现一个双向绑定

手写实现数据与视图的双向绑定机制,理解 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.definePropertyProxy 拦截数据的读写操作,在 setter 中触发视图更新

  • 发布订阅模式:维护一个 bindings 对象,存储每个数据属性对应的所有 DOM 元素,数据变化时通知所有订阅者

  • 事件监听:为输入元素添加 input 事件监听器,实现视图到数据的单向绑定

  • 双向同步

    • 数据 → 视图:通过 setter 触发 notify 方法更新 DOM
    • 视图 → 数据:通过事件监听器更新数据对象
  • Proxy vs Object.defineProperty

    • Object.defineProperty 需要遍历对象的每个属性进行劫持
    • Proxy 可以直接代理整个对象,支持动态添加属性,是更现代的方案
  • 性能优化:实际应用中需要考虑批量更新、虚拟 DOM、依赖收集等优化策略

  • 扩展性:可以扩展支持计算属性、侦听器、数组变化检测等高级特性