资讯专栏INFORMATION COLUMN

Vue源码解析:双向绑定原理

Kross / 569人阅读

摘要:无论是还是都提倡单向数据流管理状态,那我们今天要谈的双向绑定是否和单向数据流理念有所违背我觉得不是,从上篇文章语法树转函数了解到,双向绑定,实质是的单向绑定和事件侦听的语法糖。源码解析今天涉及到的代码全在文件夹下。

通过对 Vue2.0 源码阅读,想写一写自己的理解,能力有限故从尤大佬2016.4.11第一次提交开始读,准备陆续写:

模版字符串转AST语法树

AST语法树转render函数

Vue双向绑定原理

Vue虚拟dom比较原理

其中包含自己的理解和源码的分析,尽量通俗易懂!由于是2.0的最早提交,所以和最新版本有很多差异、bug,后续将陆续补充,敬请谅解!包含中文注释的Vue源码已上传...

开始

在说双向绑定之前,我们先聊聊单向数据流的概念,引用一下Vuex官网的一张图:

这是单向数据流的极简示意,即状态(数据源)映射到视图,视图的变化(用户输入)触发行为,行为改变状态。但在实际的开发中,大部分的情况是多个视图依赖同一状态,多个行为影响同一状态,Vuex的处理是将共同状态提取出来,转化成单向数据流实现。另外,在Vue的父子组件中prop传值中,也有用到单向数据流的概念,即父级 prop 的更新会向下流动到子组件中,但是反过来则不行。

无论是react还是vue都提倡单向数据流管理状态,那我们今天要谈的双向绑定是否和单向数据流理念有所违背?我觉得不是,从上篇文章AST语法树转render函数了解到,Vue双向绑定,实质是 value 的单向绑定和 oninput/onchange 事件侦听的语法糖。这种机制在某些需要实时反馈用户输入的场合十分方便,这只是Vue内部对 action 进行了封装而形成的。

所以我们今天要说是,状态的变化怎么引起视图的变化?

第一个难点是如何监听状态的变化。Vue2.0主要是采用defineProperty,但它有个缺点是不能检测到对象和数组的变化。尤大佬说3.0将采用proxy,不过兼容仍是问题,有兴趣的同学可以去了解下;

另外一个难点就是状态变化后如何触发视图的变化。Vue2.0采用的发布/订阅模式,即每个状态都会有自己的一个订阅中心,订阅中心放着一个个订阅者,订阅者身上有关于dom的更新函数。当状态改变时会发布消息:我变了!订阅中心会挨个告诉订阅者,订阅者知道了就去执行自己的更新函数。

源码解析

今天涉及到的代码全在observer文件夹下。流程大致如下:

function Vue (options) {
    // ...
    var data = options.data;
    data = typeof data === "function" ? data() : data || {};
    observe(data, this);
    Watcher(this, this.render, this._update);
    // ...
}

先对 data 进行数据劫持(observe),然后为当前实例创建一个订阅者(Watcher)。具体如何实现,下面将逐一阐述。

数据劫持

数据劫持的实质就是使用 defineProperty 重写对象属性的 getter/setter 方法。但由于defineProperty 无法监测到对象和数组内部的变化,所以遇到子属性为对象时,会递归观察该属性直至简单数据类型;为数组时的处理是重写pushpopshift等方法,方法内部通知订阅中心:状态变化了!这样就能对所有类型数据进行监听了。

我们先看看入口函数observe()

function observe (value, vm) {
  // 若检测数据不是对象,则退出
  if (typeof value !== "object") return;
  var ob;
  if (value.__ob__ && value.__ob__ instanceof Observer) {
    ob = value.__ob__;
  } else {
    ob = new Observer(value);
  }
  return ob;
}

observe()方法尝试为 value 创建观察者实例,观察成功则返回新的观察者或已有的观察者。__ob__属性下面将提到,即对象被观察过后会有__ob__属性,用于存储观察者实例。再来看看Observer类:

function Observer (value) {
  this.value = value;
  // 给value对象通过defineProperty追加__ob__属性
  def(value, "__ob__", this); 
  // 特殊处理数组
  if (Array.isArray(value)) {
    value.__proto__ = arrayMethods;
    value.forEach(item => {
      observe(item);
    })
  } else {
    this.walk(value);
  }
}

很明显看到,Observer类除开属性的定义,就是对数组的特殊处理了。处理的方法是通过原型链去修改数组的pushpopshift...等等方法,当然,还需要对数组的每个元素进行observe(),因为数组元素也可能是对象,我们要继续劫持,直到基本类型!我们先来看下arrayMethods具体是怎么修改的这些方法:

const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);

["push","pop","shift","unshift","splice","sort","reverse"]
.forEach(method => {
  // 拿到对应的原生方法
  var original = arrayProto[method];
  def(arrayMethods, method, () => {
    // 参数处理
    var i = arguments.length;
    var args = new Array(i);
    while (i--) {
      args[i] = arguments[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);
    // 发布改变通知
    ob.dep.notify();
    return result;
  })
})

能看出arrayMethods的构造其实也很简单,首先是根据数组的prototype创建一个新对象,然后对数组方法进行逐个重写。方法重写的重点在于:

继续监听插入类方法(push、unshift、splice)带入的新数据

数组方法在调用时强行触发通知:dep.notify()

到这,defineProperty无法监听数组内部变化的问题解决了,当然,你通过数组下标修改内部数据还是察觉不到的!

我们继续来看,walk()函数:

Observer.prototype.walk = function (obj) {
  var keys = Object.keys(obj);
  for (var i = 0, l = keys.length; i < l; i++) {
    this.convert(keys[i], obj[keys[i]]);
  }
}
Observer.prototype.convert = function (key, val) {
  defineReactive(this.value, key, val);
}

walk()意思就是遍历对象的每个属性,并侵占(convert)它们的getter/setter,接下来就是整个数据劫持的重点函数defineReactive():

function defineReactive (obj, key, val) {
  var dep = new Dep();

  // 获取对象的对象描述
  var property = Object.getOwnPropertyDescriptor(obj, key);
  // 是否可配置
  if (property && property.configurable === false) return;
  // 获取原来的get、set
  var getter = property && property.get;
  var setter = property && property.set;

  // 递归:继续监听该属性值(只有val为对象时才有childOb)
  var childOb = observe(val);

  Object.defineProperty(obj, key, {
    enumerable: true,    // 可枚举
    configurable: true,    // 可配置
    get: ...,
    set: ...
  })
}

以上为defineReactive()函数的内部结构,先定义了依赖中心Dep,再获取对象的原生get/set方法,然后递归监听该属性,因为当前属性可能也是对象,最后通过defineProperty劫持getter/setter函数,依次看一下get/set:

get: function reactiveGetter () {
  // 计算value
  var value = getter ? getter.call(obj) : val
  if (Dep.target) {
    // 添加依赖
    dep.depend();
    // 如果有子观察者,也给它添加依赖
    if (childOb) {
      childOb.dep.depend();
    }
    // 如果该属性是数组,查看每项是否含观察者对象,有则添加依赖
    if (isArray(value)) {
      for (var e, i = 0, l = value.length; i < l; i++) {
        e = value[i];
        e && e.__ob__ && e.__ob__.dep.depend();
      }
    }
  }
  return value;
}

大家看完这个函数,除开if语句,其他的都是get的基本逻辑。至于Dep.target的含义,我的理解是它就像一个开关,当开关在打开的状态下访问该属性,则会被添加到订阅中心。至于什么时候开关打开、关闭,以及把谁添加到订阅中心,先留下疑问。继续看下set

set: function reactiveSetter (newVal) {
  // 计算value
  var value = getter ? getter.call(obj) : val;
  // 新旧值是否相等
  if (newVal === value) return;
  // 不相等,设置新值
  if (setter) {
    setter.call(obj, newVal);
  } else {
    val = newVal;
  }
  // 劫持新值
  childOb = observe(newVal);
  // 发送变更通知
  dep.notify();
}

set也比较好理解,先是新旧值的比较,若不相等,则需要:设置新值,劫持新值,发布通知

到这,数据劫持就完成了。总之,observe对数据对象进行了递归遍历,递归包括数组和子对象,将每个属性的getter/setter进行了改造,使得在特殊情况下获取值(xxx.name)会添加到订阅中心,在设置值(xxx.name = "Tom")会触发订阅中心的通知事件

订阅中心

订阅中心也就是前面提到的Dep,它要做的事情很简单,维护一个容器(数组)存储订阅者,也就是说它有添加订阅者功能和发布通知功能。简单看一下:

let uid = 0;
function Dep () {
  this.id = uid++;
  this.subs = [];
}
// 添加订阅者
Dep.prototype.addSub = function (sub) {
  this.subs.push(sub);
}
// 将自己作为依赖传给目标订阅者
Dep.prototype.depend = function () {
  Dep.target.addDep(this);
}
// 通知所有订阅者
Dep.prototype.notify = function () {
  var subs = this.subs.slice();
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }
}
Dep.target = null;

数据劫持中提到,当Dep.target存在时调用get,会触发dep.depend()添加订阅者,那么这个Dep.target.addDep()方法里肯定含添加订阅者addSub()方法。

注意Dep.target的默认值为null

订阅者

订阅者也就是前面提到的Watcher,因为它也用于$watch()接口,所以这边对其简化分析。

Watcher接收3个参数,vm:Vue实例对象,fn:渲染函数,cb:更新函数。先看看Watcher对象:

function Watcher (vm, fn, cb) {
  this.vm = vm;
  this.fn = fn;
  this.cb = cb;
  this.depIds = new Set();

  this.value = this.get();
}

// 向当前watcher添加依赖项
Watcher.prototype.addDep = function (dep) {
  var id = dep.id;
  // 防止重复向订阅中心添加订阅者
  if (!this.depIds.has(id)) {
    this.depIds.add(id);
    dep.addSub(this);
  }
}

WatcheraddDep()方法内为了防止重复添加订阅者到订阅中心,故维护了一个Set用于存储订阅中心(Dep)的id,每次添加前看是否已存在。
Watcher在初始化时,执行了get()函数,看看方法内部:

Watcher.prototype.get = function () {
  // 打开开关,指向自身(Watcher)
  Dep.target = this;
  // 指向渲染函数,会触发getter
  var value = this.fn.call(this.vm);
  // 关闭开关
  Dep.target = null;
  return value;
}

之前一直不理解这边为什么会将订阅者推入各个订阅中心,后来才发现巧妙的地方:Dep.target指向当前Watcher(打开开关),然后执行渲染函数,渲染函数用到的数据都会触发其get,这样就把当前Watcher加入到这些数据的订阅中心了!然后Dep.target = null(开关关闭)。

另外还有一个就是update函数,也就是数据的set被触发是,其订阅中心会发布通知(notify()),而notify()方法的本质就是依次执行订阅者的update()方法。让我们看一下:

Watcher.prototype.update = function () {
  var value = this.get();
  if (value !== this.value) {
    var oldValue = this.value;
    this.value = value;
    this.cb.call(this.vm, value, oldValue);
  }
}

update()方法其实就是拿新值和旧值比较,如果不一样就把它们作为参数,执行更新回调函数。

到这,关于订阅者部分的已经说完了。再回看到前面的调用Watcher(this, this.render, this._update);,这边的渲染函数也就是前篇文章讲的render函数,而_update函数是用于比较vdom并更新的函数,这是下一篇文章要说的内容。

总结

最后再来理一遍,observe递归遍历整个data,给每个属性创建一个订阅中心,而且重写他们的getter/setter方法:在特殊情况(Dep.target存在)下get会添加订阅者到订阅中心,在set时会通知订阅中心,继而通知每位订阅者;订阅者会特殊情况(Dep.target存在)下,执行render函数,get每一个涉及到的数据。这样,以后只要有数据发生变动,就会触发该订阅者的更新函数,就会引起dom的变化!

最近工作比较忙,博客写的比较慢,可能也会有各种问题(┬_┬)...

溜了溜了

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/53601.html

相关文章

  • Vue原理】VModel - 白话版

    摘要:执行的时候,会绑定上下文对象为组件实例于是中的就能取到组件实例本身,的代码块顶层作用域就绑定为了组件实例于是内部变量的访问,就会首先访问到组件实例上。其中的获取,就会先从组件实例上获取,相当于。 写文章不容易,点个赞呗兄弟专注 Vue 源码分享,文章分为白话版和 源码版,白话版助于理解工作原理,源码版助于了解内部详情,让我们一起学习吧研究基于 Vue版本 【2.5.17】 如果你觉得...

    keke 评论0 收藏0
  • Vue源码解析(2)-vue双向数据绑定原理

    摘要:与状态同步非常困难通过添加观察者监测变化,如和。应用中状态的属性会被监测,当它们发生变化时,只有依赖了发生变化属性的元素会被重新渲染。 现代 js 框架存在的根本原因 然而通常人们(自以为)使用框架是因为:它们支持组件化;它们有强大的社区支持;它们有很多(基于框架的)第三方库来解决问题;它们有很多(很好的)第三方组件;它们有浏览器扩展工具来帮助调试;它们适合做单页应用。 Keeping...

    Neilyo 评论0 收藏0
  • Vue双向绑定原理,教你一步一步实现双向绑定

    摘要:储存订阅器因为属性被监听,这一步会执行监听器里的方法这一步我们把也给弄了出来,到这一步我们已经实现了一个简单的双向绑定了,我们可以尝试把两者结合起来看下效果。总结本文主要是对双向绑定原理的学习与实现。 当今前端天下以 Angular、React、vue 三足鼎立的局面,你不选择一个阵营基本上无法立足于前端,甚至是两个或者三个阵营都要选择,大势所趋。 所以我们要时刻保持好奇心,拥抱变化,...

    Labradors 评论0 收藏0
  • 剖析Vue实现原理 - 如何实现双向绑定mvvm(转载)

    摘要:接下来要看看这个订阅者的具体实现了实现订阅者作为和之间通信的桥梁,主要做的事情是在自身实例化时往属性订阅器里面添加自己自身必须有一个方法待属性变动通知时,能调用自身的方法,并触发中绑定的回调,则功成身退。 本文能帮你做什么?1、了解vue的双向数据绑定原理以及核心代码模块2、缓解好奇心的同时了解如何实现双向绑定为了便于说明原理与实现,本文相关代码主要摘自vue源码, 并进行了简化改造,...

    nemo 评论0 收藏0
  • 剖析Vue原理&实现双向绑定MVVM

    摘要:所以无需太过介怀是实现的单向或双向绑定。监听数据绑定更新函数的处理是在这个方法中,通过添加回调来接收数据变化的通知至此,一个简单的就完成了,完整代码。 本文能帮你做什么?1、了解vue的双向数据绑定原理以及核心代码模块2、缓解好奇心的同时了解如何实现双向绑定为了便于说明原理与实现,本文相关代码主要摘自vue源码, 并进行了简化改造,相对较简陋,并未考虑到数组的处理、数据的循环依赖等,也...

    melody_lql 评论0 收藏0

发表评论

0条评论

Kross

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<