导读:本期聚焦于公主创作的《JavaScript双向数据绑定的实现原理是什么?一文搞懂数据劫持与发布订阅模式》,敬请观看详情。为什么修改一个JavaScript对象属性,页面就自动更新了?双向数据绑定听起来神奇,拆开看核心就两件事:如何侦测到数据变化,以及如何通知视图刷新。本文从Object.defineProperty的getter和setter讲起,一步步手写简易版响应式系统,再对比Proxy方案的优劣,并分析Vue中编译、订阅、更新视图的完整链路,帮你真正理解数据驱动的底层逻辑,而不是停留在会用的层面。

数据绑定这个词几乎天天挂在嘴边,但真正动手实现过的人不多。所谓绑定,本质上就是建立数据和视图之间的自动同步机制:数据变了,视图跟着变;视图上用户输入了,数据也跟着变。听起来像魔法,其实拆开之后就是两个独立的问题——怎么知道数据变了(侦测变化),以及数据变了之后告诉谁(依赖收集与派发更新)。本文围绕这两条主线,用原生JavaScript把双向绑定的核心逻辑完整实现一遍。

JavaScript双向数据绑定的实现原理是什么?一文搞懂数据劫持与发布订阅模式

一、数据侦测:Object.defineProperty的getter与setter

要感知一个对象属性的变化,最经典的思路是用Object.defineProperty把属性改造成访问器属性。改造之后,读取属性会触发getter,写入属性会触发setter,我们就在setter里埋点,任何赋值动作都逃不过去。

先看一个最小化的示例,只对一个属性做劫持:

// 定义一个普通的观察函数
function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      console.log('属性被读取了:' + key);
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      console.log('属性被修改了:' + key + ',旧值:' + val + ',新值:' + newVal);
      val = newVal;
    }
  });
}

var data = { message: 'hello' };
defineReactive(data, 'message', data.message);
data.message;       // 触发getter
data.message = 'hi'; // 触发setter

这段代码就是Vue 2响应式系统的雏形。getter里除了返回值,更重要的职责是“收集依赖”——把当前正在使用这个属性的观察者记下来;setter里除了更新值,还要“派发更新”——通知所有记下来的观察者去执行各自的更新逻辑。

不过Object.defineProperty有几个绕不开的缺陷:一是无法监测对象属性的新增和删除,所以Vue 2提供了$set$delete来补位;二是数组的变化通过索引赋值和修改length它也感知不到,Vue 2采取的办法是重写数组原型上的七个变异方法(push、pop、shift、unshift、splice、sort、reverse);三是初始化时需要递归遍历整个对象,数据量大时性能开销不小。

二、手写一个完整的双向绑定:Observer、Watcher与Dep

只劫持数据还不够,得有人订阅、有人通知。经典的三件套分工很明确:Observer负责把数据全部变成响应式的;Dep是一个依赖收集容器,每个属性对应一个Dep实例;Watcher是订阅者,数据一变它就执行回调。而视图层需要一个编译器,扫描DOM里的指令,为每个绑定的属性创建Watcher,同时在getter里完成订阅。

下面是精简后的完整实现,可以直观看到整个链路:

// 1. Dep:依赖收集器
class Dep {
  constructor() {
    this.subs = []; // 存放所有Watcher
  }
  addSub(w) { this.subs.push(w); }
  notify() { this.subs.forEach(w => w.update()); }
}
Dep.target = null; // 当前正在收集的Watcher

// 2. Observer:递归劫持所有属性
function observe(obj) {
  if (!obj || typeof obj !== 'object') return;
  Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]));
}

function defineReactive(obj, key, val) {
  const dep = new Dep();
  observe(val); // 递归处理嵌套对象
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) dep.addSub(Dep.target); // 收集依赖
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      val = newVal;
      observe(newVal);
      dep.notify(); // 派发更新
    }
  });
}

// 3. Watcher:订阅者
class Watcher {
  constructor(obj, key, cb) {
    this.obj = obj;
    this.key = key;
    this.cb = cb;
    this.value = this.get();
  }
  get() {
    Dep.target = this; // 把自己挂到全局
    const value = this.obj[this.key]; // 触发getter完成订阅
    Dep.target = null;
    return value;
  }
  update() {
    const oldVal = this.value;
    const newVal = this.obj[this.key];
    if (newVal !== oldVal) {
      this.value = newVal;
      this.cb(newVal, oldVal);
    }
  }
}

// 4. 使用示例:数据变了,视图回调自动执行
const data = { text: '初始内容' };
observe(data);
new Watcher(data, 'text', (newVal) => {
  console.log('视图更新为:' + newVal);
});
data.text = '我变了'; // 控制台输出:视图更新为:我变了

这段代码的关键技巧在于Dep.target这个全局变量。Watcher初始化时先把自己挂上去,再去读取一次目标属性,读取动作会触发getter,getter发现Dep.target存在,就把这个Watcher存进Dep。这一步俗称“依赖收集”,本质上就是发布订阅模式在getter里的落地。

把这个Watcher的回调改成操作DOM,比如document.querySelector('#app').textContent = newVal,再把输入框的input事件监听里写入数据,双向绑定就成型了:用户输入触发事件更新数据,数据的setter触发notify更新视图,形成闭环。

三、Proxy方案:Vue 3为什么换了思路

ES6的Proxy可以代理整个对象而不是某个属性,新增删除属性、数组索引赋值都能拦截到,天然解决了Object.defineProperty的短板,所以Vue 3全面转向了Proxy。

const data = { list: ['a', 'b'] };

const proxy = new Proxy(data, {
  get(target, key, receiver) {
    console.log('读取:' + String(key));
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log('写入:' + String(key) + ' = ' + value);
    const result = Reflect.set(target, key, value, receiver);
    // 这里可以触发更新
    return result;
  },
  deleteProperty(target, key) {
    console.log('删除:' + String(key));
    return Reflect.deleteProperty(target, key);
  }
});

proxy.list[0] = 'x';  // 索引赋值也能拦截
proxy.newProp = 123;  // 新增属性也能拦截
delete proxy.newProp; // 删除属性同样能拦截

Proxy的优势还包括惰性代理:访问到嵌套对象时才递归代理,初始化成本比Vue 2低很多。但它也不是完美无缺,比如Proxy代理不了内部this指向原始对象的场景,一个典型例子是WeakMap与Vue响应式配合时的边界情况;另外Proxy在部分老旧浏览器不可用且无法被polyfill,这是框架选型时需要权衡的点。

四、双向绑定的另一半:视图到数据的同步

上面解决的都是数据到视图这一半,反过来视图到数据其实简单得多,靠的就是事件监听。框架的做法是编译模板时发现某个指令(比如Vue里的v-model),就会给对应的输入元素绑定input事件,事件触发时把新值写回数据,数据的setter再触发视图更新,两个方向就串起来了。

// 极简版v-model的实现思路
function modelBind(inputEl, data, key) {
  // 数据到视图
  new Watcher(data, key, (newVal) => {
    inputEl.value = newVal;
  });
  inputEl.value = data[key];
  // 视图到数据
  inputEl.addEventListener('input', function () {
    data[key] = inputEl.value;
  });
}

值得一提的是,v-model在不同元素上是语法糖:在原生input上相当于绑定value属性加input事件;在组件上相当于props传值加监听一个update事件。理解了这一点,就能明白为什么自定义组件上使用v-model时需要声明对应的props和emit,而不是以为框架替你做了什么神秘操作。

总结一下,双向数据绑定并不神秘:数据到视图靠劫持加发布订阅,视图到数据靠事件监听。搞懂了defineReactive里getter收集依赖、setter派发更新这套机制,再去读Vue、MobX甚至任何响应式库的源码,都会顺畅很多。建议自己把上面的代码敲一遍跑起来,改动几处观察输出,比看十篇原理文章印象都深。

数据绑定数据劫持发布订阅模式修改时间:2026-09-05 02:38:35

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