通过源码分析Vue的双向数据绑定详解


Posted in Javascript onSeptember 24, 2017

前言

虽然工作中一直使用Vue作为基础库,但是对于其实现机理仅限于道听途说,这样对长期的技术发展很不利。所以最近攻读了其源码的一部分,先把双向数据绑定这一块的内容给整理一下,也算是一种学习的反刍。

本篇文章的Vue源码版本为v2.2.0开发版。

Vue源码的整体架构无非是初始化Vue对象,挂载数据data/props等,在不同的时期触发不同的事件钩子,如created() / mounted() / update()等,后面专门整理各个模块的文章。这里先讲双向数据绑定的部分,也是最主要的部分。

设计思想:观察者模式

Vue的双向数据绑定的设计思想为观察者模式,为了方便,下文中将被观察的对象称为观察者,将观察者对象触发更新的称为订阅者。主要涉及到的概念有:

1、Dep对象:Dependency依赖的简写,包含有三个主要属性id, subs, target和四个主要函数addSub, removeSub, depend, notify,是观察者的依赖集合,负责在数据发生改变时,使用notify()触发保存在subs下的订阅列表,依次更新数据和DOM。

  • id: 每个观察者(依赖对象)的唯一标识。
  • subs: 观察者对象的订阅者列表。
  • target: 全局唯一的订阅者对象,因为只能同时计算和更新一个订阅者的值。
  • addSub(): 使用`push()`方法添加一个订阅者。
  • removeSub(): 使用`splice()`方法移除一个订阅者。
  • depend(): 将自己添加到当前订阅者对象的依赖列表。
  • notify(): 在数据被更新时,会遍历subs对象,触发每一个订阅者的更新。

2、Observer对象:即观察者,包含两个主要属性value, dep。做法是使用getter/setter方法覆盖默认的取值和赋值操作,将对象封装为响应式对象,每一次调用时更新依赖列表,更新值时触发订阅者。绑定在对象的__ob__原型链属性上。

  • value: 原始值。
  • dep: 依赖列表。

源码实战解析

有过Vue开发基础的应该都了解其怎么初始化一个Vue对象:

new Vue({
 el: '#container',
 data: {
  count: 100
 },
 ...
});

那么我们就从这个count说起,看它是怎么完成双向数据绑定的。

下面的代码片段中英文注释为尤雨溪所写,中文注释为我所写,英文注释更能代表开发者的清晰思路。

首先从全局的初始化函数调用:initMixin(Vue$3); ,这里的Vue$3对象就是全局的Vue对象,在此之前已经挂载了Vue的各种基本数据和函数。这个函数体就是初始化我们上面声明Vue语句的过程化逻辑,取主体代码来看:

// 这里的options就是上面声明Vue对象的json对象
Vue.prototype._init = function (options) {
 ...
 var vm = this;
 ...
 initLifecycle(vm);
 initEvents(vm);
 initRender(vm);
 callHook(vm, 'beforeCreate');
 // 这里就是我们接下来要跟进的初始化Vue参数
 initState(vm);
 initInjections(vm);
 callHook(vm, 'created');
 ...
 };

这里主要完成了初始化事件、渲染、参数、注入等过程,并不断调用事件钩子的回调函数。下面来到如何初始化参数:

function initState (vm) {
 vm._watchers = [];
 var opts = vm.$options;
 if (opts.props) { initProps(vm, opts.props); }
 if (opts.methods) { initMethods(vm, opts.methods); }
 // 我们的count在这里初始化
 if (opts.data) {
 initData(vm);
 } else {
 observe(vm._data = {}, true /* asRootData */);
 }
 if (opts.computed) { initComputed(vm, opts.computed); }
 if (opts.watch) { initWatch(vm, opts.watch); }
}

这里依次检测参数中包含的props/methods/data/computed/watch并进入不同的函数进行初始化,这里我们只关心initData:

function initData (vm) {
 var data = vm.$options.data;
 data = vm._data = typeof data === 'function'
 ? data.call(vm)
 : data || {};
 if (!isPlainObject(data)) {
 data = {};
 }
 ...
 // observe data
 observe(data, true /* asRootData */);

可以看到Vue的data参数支持对象和回调函数,但最终返回的一定是对象,否则使用空对象。接下来就是重头戏了,我们如何将data参数设置为响应式的:

/**
 * Attempt to create an observer instance for a value,
 * returns the new observer if successfully observed,
 * or the existing observer if the value already has one.
 */
function observe (value, asRootData) {
 if (!isObject(value)) {
 return
 }
 var ob;
 if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
 ob = value.__ob__;
 } else if (
 /* 为了防止value不是单纯的对象而是Regexp或者函数之类的,或者是vm实例再或者是不可扩展的 */
 observerState.shouldConvert &&
 !isServerRendering() &&
 (Array.isArray(value) || isPlainObject(value)) &&
 Object.isExtensible(value) &&
 !value._isVue
 ) {
 ob = new Observer(value);
 }
 if (asRootData && ob) {
 ob.vmCount++;
 }
 return ob
}

这里的英文注释非常清晰,就是为了给该对象新建一个观察者类,如果存在则返回已存在的(比如互相引用或依赖重复),可以看到这个观察者列表放置在对象的__ob__属性下。下面我们看下这个Observer观察者类:

/**
 * Observer class that are attached to each observed
 * object. Once attached, the observer converts target
 * object's property keys into getter/setters that
 * collect dependencies and dispatches updates.
 */
var Observer = function Observer (value) {
 this.value = value;
 this.dep = new Dep();
 this.vmCount = 0;
 // def函数是defineProperty的简单封装
 def(value, '__ob__', this);
 if (Array.isArray(value)) {
 // 在es5及更低版本的js里,无法完美继承数组,这里检测并选取合适的函数
 // protoAugment函数使用原型链继承,copyAugment函数使用原型链定义(即对每个数组defineProperty)
 var augment = hasProto
  ? protoAugment
  : copyAugment;
 augment(value, arrayMethods, arrayKeys);
 this.observeArray(value);
 } else {
 this.walk(value);
 }
};

在Observer类的注释里也清楚的说明,它会被关联到每一个被检测的对象,使用getter/setter修改其默认读写,用于收集依赖和发布更新。其中出现了三个我们需要关心的东西Dep类/observeArray/walk,我们先看observeArray的源码:

/**
 * Observe a list of Array items.
 */
Observer.prototype.observeArray = function observeArray (items) {
 for (var i = 0, l = items.length; i < l; i++) {
 observe(items[i]);
 }
};

它不过是在Observer类和observe方法中间的一层递归,因为我们观察的只能是对象,而不能是数字、字符串或者数组(数组的观察比较特殊,事实上是重构了方法来触发更新,后面会讲到)。那我们接下来看下Dep类是做什么用的:

/**
 * A dep is an observable that can have multiple
 * directives subscribing to it.
 */
var Dep = function Dep () {
 this.id = uid$1++;
 this.subs = [];
};

注释里告诉我们Dep类是一个会被多个指令订阅的可被观察的对象,这里的指令就是我们在html代码里书写的东西,如:class={active: hasActive}{{ count }} {{ count * price }} ,而他们就会订阅hasActive/count/price这些对象,而这些订阅他们的对象就会被放置在Dep.subs列表中。每一次新建Dep对象,就会全局uid递增,然后传给该Dep对象,保证唯一性id。

我们接着看刚才的walk函数做了什么:

/**
 * Walk through each property and convert them into
 * getter/setters. This method should only be called when
 * value type is Object.
 */
Observer.prototype.walk = function walk (obj) {
 var keys = Object.keys(obj);
 for (var i = 0; i < keys.length; i++) {
 defineReactive$$1(obj, keys[i], obj[keys[i]]);
 }
};

看来和名字一样,它只是走了一遍,那我们来看下defineReactive$$1做了什么:

/**
 * Define a reactive property on an Object.
 */
function defineReactive$$1 (obj, key, val, customSetter) {
 var dep = new Dep();

 var property = Object.getOwnPropertyDescriptor(obj, key);
 if (property && property.configurable === false) {
 return
 }

 // cater for pre-defined getter/setters
 var getter = property && property.get;
 var setter = property && property.set;

 var childOb = observe(val);
 Object.defineProperty(obj, key, {
 enumerable: true,
 configurable: true,
 get: function reactiveGetter () {
  var value = getter ? getter.call(obj) : val;
  if (Dep.target) {
  dep.depend();
  if (childOb) {
   childOb.dep.depend();
  }
  if (Array.isArray(value)) {
   dependArray(value);
  }
  }
  return value
 },
 set: function reactiveSetter (newVal) {
  var value = getter ? getter.call(obj) : val;
  // 脏检查,排除了NaN !== NaN的影响
  if (newVal === value || (newVal !== newVal && value !== value)) {
  return
  }
  if (setter) {
  setter.call(obj, newVal);
  } else {
  val = newVal;
  }
  childOb = observe(newVal);
  dep.notify();
 }
 });
}

终于找到重头戏了,这里真正使用了getter/setter代理了对象的默认读写。我们首先新建一个Dep对象,利用闭包准备收集依赖,然后我们使用observe观察该对象,注意此时与上面相比少了一个asRootData = true的参数。

我们先来看取值的代理get,这里用到了Dep.target属性和depend()方法,我们来看看它是做什么的:

// the current target watcher being evaluated.
// this is globally unique because there could be only one
// watcher being evaluated at any time.
Dep.target = null;

Dep.prototype.depend = function depend () {
 if (Dep.target) {
 Dep.target.addDep(this);
 }
};

Dep.prototype.notify = function notify () {
 // stablize the subscriber list first
 var subs = this.subs.slice();
 for (var i = 0, l = subs.length; i < l; i++) {
 subs[i].update();
 }
};

注释看的出来Dep.target是全局唯一的watcher对象,也就是当前正在指令计算的订阅者,它会在计算时赋值成一个watcher对象,计算完成后赋值为null。而depend是用于对该订阅者添加依赖,告诉它你的值依赖于我,每次更新时应该来找我。另外还有notify()的函数,用于遍历所有的依赖,通知他们更新数据。

这里多看一下addDep()的源码:

/**
 * Add a dependency to this directive.
 */
Watcher.prototype.addDep = function addDep (dep) {
 var id = dep.id;
 if (!this.newDepIds.has(id)) {
 this.newDepIds.add(id);
 this.newDeps.push(dep);
 if (!this.depIds.has(id)) {
  // 使用push()方法添加一个订阅者
  dep.addSub(this);
 }
 }
};

可以看到它有去重的机制,当重复依赖时保证相同ID的依赖只有一个。订阅者包含3个属性newDepIds/newDeps/depIds分别存储依赖信息,如果之前就有了这个依赖,那么反过来将该订阅者加入到这个依赖关系中去。

接着看get方法中的dependArray()

/**
 * Collect dependencies on array elements when the array is touched, since
 * we cannot intercept array element access like property getters.
 */
function dependArray (value) {
 for (var e = (void 0), i = 0, l = value.length; i < l; i++) {
 e = value[i];
 e && e.__ob__ && e.__ob__.dep.depend();
 if (Array.isArray(e)) {
  dependArray(e);
 }
 }
}

可以看到我们不能像对象一样监听数组的变化,所以如果获取一个数组的值,那么就需要将数组中所有的对象的观察者列表都加入到依赖中去。

这样get方法读取值就代理完成了,接下来我们看set方法代理赋值的实现,我们先获取原始值,然后与新赋的值进行比较,也叫脏检查,如果数据发生了改变,则对该数据进行重新建立观察者,并通知所有的订阅者更新。

接下来我们看下数组的更新检测是如何实现的:

/*
 * not type checking this file because flow doesn't play well with
 * dynamically accessing methods on Array prototype
 */
var arrayProto = Array.prototype;
var arrayMethods = Object.create(arrayProto);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
 // cache original method
 var original = arrayProto[method];
 def(arrayMethods, method, function mutator () {
 var arguments$1 = arguments;
 // avoid leaking arguments:
 // http://jsperf.com/closure-with-arguments
 var i = arguments.length;
 var args = new Array(i);
 while (i--) {
  args[i] = arguments$1[i];
 }
 var result = original.apply(this, args);
 var ob = this.__ob__;
 var inserted;
 switch (method) {
  case 'push':
  inserted = args;
  break
  case 'unshift':
  inserted = args;
  break
  case 'splice':
  inserted = args.slice(2);
  break
 }
 if (inserted) { ob.observeArray(inserted); }
 // notify change
 ob.dep.notify();
 return result
 });
});

看的出来我们模拟了一个数组对象,代理了push/pop/shift/unshift/splice/sort/reverse方法,用于检测数组的变化,并通知所有订阅者更新。如果有新建元素,会补充监听新对象。

这就是从代码上解释为什么Vue不支持数组下标修改和长度修改的原因,至于为什么这么设计,我后面会再次更新或再开篇文章,讲一些通用的设计问题以及Js机制和缺陷。

总结

从上面的代码中我们可以一步步由深到浅的看到Vue是如何设计出双向数据绑定的,最主要的两点:

  • 使用getter/setter代理值的读取和赋值,使得我们可以控制数据的流向。
  • 使用观察者模式设计,实现了指令和数据的依赖关系以及触发更新。
  • 对于数组,代理会修改原数组对象的方法,并触发更新。

明白了这些原理,其实你也可以实现一个简单的数据绑定,造一个小轮子,当然,Vue的强大之处不止于此,我们后面再来聊一聊它的组件和渲染,看它是怎么一步一步将我们从DOM对象的魔爪里拯救出来的。

好了,以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作能带来一定的帮助,如果有疑问大家可以留言交流,谢谢大家对三水点靠木的支持。

参考资料

数据的响应化:https://github.com/Ma63d/vue-...
Vue v2.2.0 源代码文件
es6 Proxy: http://es6.ruanyifeng.com/#do...

Javascript 相关文章推荐
解决Extjs 4 Panel作为Window组件的子组件时出现双重边框问题
Jan 11 Javascript
获取数组中最大最小值方法js代码(自写)
Aug 12 Javascript
JS的encodeURI和java的URLDecoder.decode使用介绍
May 08 Javascript
Jquery api 速查表分享
Jan 12 Javascript
多个jQuery版本共存的处理方案
Mar 17 Javascript
关于js里的this关键字的理解
Aug 17 Javascript
JavaScript中ES6 Babel正确安装过程
Jul 18 Javascript
javascript中sort排序实例详解
Jul 24 Javascript
jQuery 全选 全部选 反选 实现代码
Aug 17 Javascript
vue webpack重写cookie路径的方法
Jul 10 Javascript
layui 解决form表单点击无反应的问题
Oct 25 Javascript
JS前端使用canvas实现扩展物体类和事件派发
Aug 05 Javascript
JS实现手写parseInt的方法示例
Sep 24 #Javascript
Js利用console计算代码运行时间的方法示例
Sep 24 #Javascript
详解Web使用webpack构建前端项目
Sep 23 #Javascript
vue webuploader 文件上传组件开发
Sep 23 #Javascript
jQuery使用zTree插件实现可拖拽的树示例
Sep 23 #jQuery
一个有意思的鼠标点击文字特效jquery代码
Sep 23 #jQuery
JQuery用$.ajax或$.getJSON跨域获取JSON数据的实现代码
Sep 23 #jQuery
You might like
163的邮件用phpmailer发送(实例详解)
2013/06/24 PHP
php将HTML表格每行每列转为数组实现采集表格数据的方法
2015/04/03 PHP
JS option location 页面跳转实现代码
2008/12/27 Javascript
Iframe自适应高度绝对好使的代码 兼容IE,遨游,火狐
2011/01/27 Javascript
JavaScript实现打字效果的方法
2015/07/10 Javascript
jQuery满屏焦点图左右滚动特效代码分享
2015/09/07 Javascript
javascript图片切换综合实例(循环切换、顺序切换)
2016/01/13 Javascript
ECMAscript 变量作用域总结概括
2017/08/18 Javascript
javascript实现Java中的Map对象功能的实例详解
2017/08/21 Javascript
js实现本地时间同步功能
2017/08/26 Javascript
详解React Native 采用Fetch方式发送跨域POST请求
2017/11/15 Javascript
小程序显示弹窗时禁止下层的内容滚动实现方法
2019/03/20 Javascript
Node.js实现一个HTTP服务器的方法示例
2019/05/13 Javascript
OpenLayers3实现地图鹰眼以及地图比例尺的添加
2020/09/25 Javascript
Openlayers学习之地图比例尺控件
2020/09/28 Javascript
[56:45]DOTA2上海特级锦标赛D组小组赛#1 EG VS COL第一局
2016/02/28 DOTA
[01:00:30]TFT vs VGJ.T Supermajor 败者组 BO3 第一场 6.5
2018/06/06 DOTA
[48:22]VGJ.S vs VG 2018国际邀请赛小组赛BO2 第一场 8.16
2018/08/17 DOTA
python设置检查点简单实现代码
2014/07/01 Python
Django中处理出错页面的方法
2015/07/15 Python
Python爬虫之正则表达式基本用法实例分析
2018/08/08 Python
Pandas 按索引合并数据集的方法
2018/11/15 Python
pandas按行按列遍历Dataframe的几种方式
2019/10/23 Python
分享PyCharm的几个使用技巧
2019/11/10 Python
TensorFlow自定义损失函数来预测商品销售量
2020/02/05 Python
tensorflow:指定gpu 限制使用量百分比,设置最小使用量的实现
2020/02/06 Python
Xadmin+rules实现多选行权限方式(级联效果)
2020/04/07 Python
HTML5 Convas APIs方法详解
2015/04/24 HTML / CSS
前端使用canvas生成盲水印的加密解密的实现
2020/12/16 HTML / CSS
Kneipp克奈圃美国官网:德国百年精油配方的传承
2018/02/07 全球购物
罗兰·穆雷官网:Roland Mouret
2018/09/28 全球购物
业务员岗位职责
2013/11/16 职场文书
知识竞赛活动方案
2014/02/18 职场文书
作文批改评语大全
2014/04/23 职场文书
2016党员学习心得体会范文
2016/01/23 职场文书
教你一步步实现一个简易promise
2021/11/02 Javascript