JS如何实现双向绑定?手写MVVM核心原理详解

来源:草根站长作者:缓存小熊猫头衔:程序员
导读:本期聚焦于缓存小熊猫创作的《JS如何实现双向绑定?手写MVVM核心原理详解》,敬请观看详情。双向绑定是前端框架的核心机制之一,Vue和Angular都依靠它实现了数据与视图的自动同步。本文从原理层面剖析双向绑定的实现思路,重点讲解通过Object.defineProperty进行数据劫持、结合发布订阅模式完成视图更新的完整流程,并提供可运行的手写代码示例。同时对比了脏检查、数据劫持与ES6 Proxy三种方案的优缺点,帮助读者理解为什么Vue2选择defineProperty而Vue3转向Proxy,真正掌握MVVM框架背后的底层逻辑。

双向绑定是现代前端框架的基石,无论是Vue中的v-model指令,还是Angular中的[(ngModel)]语法,背后都依赖一套完整的数据响应机制。简单来说,双向绑定就是:数据变了,视图跟着变;视图变了,数据也跟着变。很多开发者用了多年框架,却始终不清楚这套机制是怎么运转的。本文就用原生JavaScript手写一个简化版的MVVM,把双向绑定的底层原理彻底讲透。

JS如何实现双向绑定?手写MVVM核心原理详解

双向绑定的核心思路:数据劫持加发布订阅

要实现双向绑定,需要解决两个方向的问题。第一个方向是数据驱动视图:当JavaScript对象中的某个属性发生变化时,能够自动更新页面上对应的DOM节点。第二个方向是视图驱动数据:当用户在输入框中修改内容时,能够把最新的值同步回数据对象。

第一个方向的实现依赖两个关键技术。第一是数据劫持,也就是拦截对象属性的读取和修改操作。在ES5时代,我们使用Object.defineProperty把对象的普通属性改造成访问器属性,一旦有人给属性赋值,就会触发我们预先定义的setter函数。第二是发布订阅模式,它充当数据和视图之间的通信桥梁。每个被劫持的属性都维护一个订阅者列表,当属性变化时,遍历这个列表,通知所有订阅者去更新视图。

第二个方向反而简单得多,只需要监听输入框的input事件,在事件回调里把用户输入的值写回数据对象即可。写回的动作又会触发setter,进而更新其他绑定了同一数据的DOM节点,整个闭环就这样形成了。

手写核心代码:从Observer到Watcher

先看数据劫持部分。Observer的任务是遍历数据对象的所有属性,用Object.defineProperty逐一改写。注意这里要用一个局部变量保存原值,如果直接在getter里返回this.xxx,会造成无限递归调用。完整代码如下:

class MVVM {
  constructor(options) {
    this.$data = options.data;
    // 第一步:劫持数据
    new Observer(this.$data);
    // 第二步:编译模板
    new Compile(options.el, this);
  }
}

class Observer {
  constructor(data) {
    this.observe(data);
  }
  observe(data) {
    if (data && typeof data === 'object') {
      Object.keys(data).forEach(key => {
        this.defineReactive(data, key, data[key]);
      });
    }
  }
  defineReactive(obj, key, value) {
    // 每个属性对应一个依赖收集器
    let dep = new Dep();
    // 递归处理嵌套对象
    this.observe(value);
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        // 收集依赖:把当前正在计算的Watcher添加进列表
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
      set(newValue) {
        if (newValue === value) return;
        value = newValue;
        // 新值如果是对象也要劫持
        this.observe(newValue);
        // 通知所有订阅者更新
        dep.notify();
      }
    });
  }
}

Dep是发布订阅的中心,代码很简洁。Watcher则是订阅者,每个Watcher实例对应视图中一个绑定点。它的关键设计是在构造函数中主动把自己挂载到Dep.target上,然后触发一次getter完成依赖收集:

class Dep {
  constructor() {
    this.subs = [];
  }
  addSub(watcher) {
    this.subs.push(watcher);
  }
  notify() {
    this.subs.forEach(watcher => watcher.update());
  }
}

class Watcher {
  constructor(vm, expr, cb) {
    this.vm = vm;
    this.expr = expr;
    this.cb = cb;
    // 求值时触发getter,完成依赖收集
    this.value = this.get();
  }
  get() {
    Dep.target = this;
    let value = this.getVal(this.vm, this.expr);
    Dep.target = null;
    return value;
  }
  getVal(vm, expr) {
    // 支持取值路径,如 user.name
    return expr.split('.').reduce((data, key) => data[key], vm.$data);
  }
  update() {
    let newValue = this.getVal(this.vm, this.expr);
    let oldValue = this.value;
    if (newValue !== oldValue) {
      this.value = newValue;
      this.cb(newValue);
    }
  }
}

最后是模板编译部分。Compile负责扫描DOM节点,找到带v-model属性的元素和双花括号插值,分别为它们创建Watcher。对于v-model元素,还要额外绑定input事件,实现视图到数据的回流:

class Compile {
  constructor(el, vm) {
    this.el = document.querySelector(el);
    this.vm = vm;
    this.fragment = this.nodeToFragment(this.el);
    this.compile(this.fragment);
    this.el.appendChild(this.fragment);
  }
  nodeToFragment(el) {
    // 移到内存碎片中操作,减少重排
    let fragment = document.createDocumentFragment();
    let child;
    while (child = el.firstChild) {
      fragment.appendChild(child);
    }
    return fragment;
  }
  compile(node) {
    Array.from(node.childNodes).forEach(child => {
      if (child.nodeType === 3) {
        // 文本节点:处理插值表达式
        this.compileText(child);
      } else if (child.nodeType === 1) {
        this.compileElement(child);
      }
      if (child.childNodes && child.childNodes.length) {
        this.compile(child);
      }
    });
  }
  compileText(node) {
    let reg = /\{\{(.+?)\}\}/;
    if (reg.test(node.textContent)) {
      let expr = RegExp.$1.trim();
      new Watcher(this.vm, expr, newValue => {
        node.textContent = newValue;
      });
      node.textContent = this.getVal(expr);
    }
  }
  compileElement(node) {
    Array.from(node.attributes).forEach(attr => {
      if (attr.name === 'v-model') {
        let expr = attr.value;
        // 数据驱动视图
        new Watcher(this.vm, expr, newValue => {
          node.value = newValue;
        });
        node.value = this.getVal(expr);
        // 视图驱动数据
        node.addEventListener('input', e => {
          expr.split('.').reduce((data, key, index, arr) => {
            if (index === arr.length - 1) {
              data[key] = e.target.value;
            } else {
              return data[key];
            }
          }, this.vm.$data);
        });
      }
    });
  }
  getVal(expr) {
    return expr.split('.').reduce((data, key) => data[key], this.vm.$data);
  }
}

在页面上定义一个输入框和一段文本,都用同一个数据字段绑定,就可以看到效果:修改输入框内容,文本和$data中的值同步变化;在控制台直接给数据赋值,视图也会立刻刷新。这正是Vue双向绑定的最小可运行模型。

三种主流方案对比:为什么Vue3放弃了defineProperty

除了数据劫持,业界还有其他实现双向绑定的思路,各有取舍。AngularJS早期采用的脏检查方案,会在特定时机(事件触发、定时器等)遍历所有被监听的数据,逐一对比新旧值。它的优点是不需要侵入数据对象,缺点是数据量大时性能开销明显,一次摘要循环可能执行多次比对。

Object.defineProperty方案是Vue2的选择,优点是兼容性好,能支持IE9及以上浏览器。但它有三个硬伤:一是无法监听属性的新增和删除,所以Vue2需要$set$delete这类补充API;二是无法直接监听数组下标的变化,Vue2只能重写七个数组变异方法来曲线救国;三是需要在初始化时递归遍历整个数据对象,对深层嵌套的大对象初始化成本较高。

Vue3改用ES6的Proxy,从根本上解决了这些问题。Proxy监听的是整个对象而不是单个属性,新增删除属性、数组下标赋值都能捕获,而且是惰性劫持,只有真正访问到嵌套对象时才会递归代理。来看一个简单的Proxy示例:

function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver);
      // 依赖收集逻辑
      track(target, key);
      // 嵌套对象递归处理(惰性)
      if (typeof result === 'object' && result !== null) {
        return reactive(result);
      }
      return result;
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      if (oldValue !== value) {
        // 派发更新逻辑
        trigger(target, key);
      }
      return result;
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key);
      trigger(target, key);
      return result;
    }
  });
}

三种方案的对比可以归纳为下表:

方案代表框架优点缺点
脏检查AngularJS 1.x不侵入数据,兼容性极好大量数据时性能差,时机不直观
definePropertyVue 2兼容IE9,实现简单无法监听增删属性和数组下标
ProxyVue 3全量拦截,惰性代理,性能更好不支持IE,无法被polyfill

理解了这些原理之后,再回头看框架文档中的响应式陷阱说明,比如Vue2中直接给数组某一项赋值不生效、Vue3中解构props会失去响应性等问题,就能明白它们不是框架的Bug,而是实现方案本身的特性边界。掌握了手写双向绑定的能力,面对框架中的异常行为时,也就有了独立分析和排查的底气。

双向绑定数据劫持发布订阅模式修改时间:2026-09-07 07:30:36

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260907/52061.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。