资讯专栏INFORMATION COLUMN

Vue: Binding与Watcher

tyheist / 3456人阅读

摘要:当数据改变时,我们不需要直接触发所有的回调函数,而是去通知对应的数据的,然后由去执行相应的逻辑。对于其逻辑可能是一个指令用于连接与响应式数据或者是一个侦听器的回调函数,这样就能符合单一职责原则,解除模块之间的耦合度,让程序更易维护。

前言

  首先欢迎大家关注我的Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。接下来的日子我应该会着力写一系列关于Vue与React内部原理的文章,感兴趣的同学点个关注或者Star。

回顾

  上一篇文章Vue响应式数据: Observer模块实现我们介绍Vue早期代码中的Observer模块,Observer模块实现了数据的响应化并且用监听者模式对外提供功能。任何模块想要感知到数据变化,只要监听Observer模块对应的事件,从而将整个数据响应化的过程与相应的处理逻辑相独立。

  其实我们可以思考一下,在Vue中一个响应式数据发生改变可能会触发那些逻辑呢?可能是一个对应的DOM改变,也可能是一个watch侦听器的回调函数的调用,或者是导致一个computed计算属性函数的调用。其实在之前的文章响应式数据与数据依赖基本原理我们就引入了一个DepWatcher的概念。同时还附上了一个概念图:

  我们当时为了解耦响应式数据和对应的数据改变后处理逻辑,我们增加了DepWatcher的概念,每一个响应式数据都有一个Dep用于集中管理和维护该数据改变时对应执行回调函数。当数据改变时,我们不需要直接触发所有的回调函数,而是去通知对应的数据的Dep,然后由Dep去执行相应的逻辑。
  
  

  将这个概念抽象出现出来,其基本逻辑就是上图所示。了解设计模式的同学,应该很快就能意识到这是一个代理模式。引入Dep的目的其实也就是代理模式的优点,分离调用者和被调用者的逻辑,降低耦合性。可见设计模式在软件开发中作用是非常广泛的,甚至有时候你都没有意识到它的存在。

  我们前面说过,响应式数据改变后可能对应的是DOM修改的处理逻辑或者是watch函数对应的处理逻辑。为了弱化不同类型的处理逻辑,我们引入了Watcher类。Dep并不会关心每一个不同的注册者的逻辑,Dep只知道每一个注册者都是一个Watcher的实例,每次发生改变时只需要调用对应的update方法,具体的逻辑被隐藏在update函数之后。
  

Vue的早期实现思路

  Vue的内部实现逻辑基本上和我们的逻辑是一样的。由bindings模块负责上面所讲的Dep的功能。
  

bindings模块

  在Vue组件的初始化函数_init中调用了:
  

this._initBindings()

  目的就是创建组件对应的binding Tree,在研究_initBindings函数之前,我们先看看Binding:

function Binding () {
  this._subs = []
}

var p = Binding.prototype

p._addChild = function (key, child) {
  child = child || new Binding()
  this[key] = child
  return child
}

p._addSub = function (sub) {
  this._subs.push(sub)
}

p._removeSub = function (sub) {
  this._subs.splice(this._subs.indexOf(sub), 1)
}

p._notify = function () {
  for (var i = 0, l = this._subs.length; i < l; i++) {
    this._subs[i].update()
  }
}

  Binding类的定义非常简单,内部属性_subs数组用来存储对应的订阅者(subscription),也就是我们后面将要说的Watcher,原型方法分别是:
  

_addSub: 用来增加对应的订阅者

_removeSub: 用来删除对应的订阅者

_notify: 通知所有的订阅者,其实就是遍历整个订阅者数据,并调用对应的update方法。

_addChild: 用来增加一个属性名为key值的子Binding,其实也就用来构建Binding Tree。

  看完Binding类我们接着看_initBindings函数的定义:
  

var Binding = require("../binding")
var Path = require("../parse/path")
var Observer = require("../observe/observer")

exports._initBindings = function () {
  var root = this._rootBinding = new Binding()
  root._addChild("$data", root)
  
  if (this.$parent) {
    root._addChild("$parent", this.$parent._rootBinding)
    root._addChild("$root", this.$root._rootBinding)
  }

  this._observer
    // simple updates
    .on("set", this._updateBindingAt)
    .on("mutate", this._updateBindingAt)
    .on("delete", this._updateBindingAt)
    .on("add", this._updateAdd)
    .on("get", this._collectDep)
}

  _initBindings是在初始化Vue组件实例中调用的,因此this也就是指向的是当前的Vue实例对象。
首先我们看到给Vue的实例对象中创建了私有属性_rootBinding,作为Bindings Tree的根节点,并且_rootBinding$data属性指向就是根节点本身。如果当前的Vue实例中存在父节点($parent),则通过给给_rootBinding添加$parent属性来构建起与父级Bindings Tree的关联。我们知道Bindings的主要作用就是在响应式数据改变时触发对应的逻辑,因此_initBindings函数监听了实例属性_observer的各个事件。

set: 监听响应式数据对象属性值修改

mutate: 监听响应式数据数组修改

delete: 监听响应式数据对象属性删除

add: 监听响应式数据对象属性增加

get: 监听响应式数据某个属性被调用

  我们看到对于setmutatedelete事件我们都调用了内部的_updateBindingAt函数,接着看
_updateBindingAt函数定义:

exports._updateBindingAt = function (path) {
  // root binding updates on any change
  this._rootBinding._notify()
  var binding = this._getBindingAt(path, true)
  if (binding) {
    binding._notify()
  }
}

exports._getBindingAt = function (path) {
  return Path.getFromObserver(this._rootBinding, path)
}

  假如说数据是下面格式:

var vm = new Vue({
    data: {
        a: {
            b: 1
        }   
    }
})

  当设置vm.a.b = 2时,我们调用_updateBindingAtpathab_updateBindingAt函数首先会任何数据变化的时候都通知调用根级_rootBinding中的所有订阅者,然后调用_getBindingAt函数去获得当前路径abbinding,如果存在,则通知调用所有的订阅者(下面箭头指向的就是对应路径abBinding)。关于Path模块我们这里不做过多的介绍,我们只要知道Path.getFromObserver函数能遍历Binding Tree找到对应路径的Binding

  接下来我们当响应式数据触发add事件时就会触发_updateAdd函数:
  

exports._updateAdd = function (path) {
  var index = path.lastIndexOf(Observer.pathDelimiter)
  if (index > -1) path = path.slice(0, index)
  this._updateBindingAt(path)
}

 
  假设是下列的数据格式:

var vm = new Vue({
    data: {
        a: {}   
    }
})

  当我们执行vm.a.$add("b", 1)时,此时函数_updateAdd的参数pathab,但是对应的binding还未创建,因此对应的Watcher还没有依赖到该Binding。对于这种不存在BindingWatcher,会暂时依赖于父级的Binding,因此函数_updateAdd也就是找到了对应父级的Binding,然后通知调用所有的订阅者。
  
  
  接下来触发响应式数据的get事件时,对应调用函数:
  

exports._collectDep = function (path) {
  var watcher = this._activeWatcher
  if (watcher) {
    watcher.addDep(path)
  }
}

  函数_collectDep的主要目的就是收集依赖,当get事件触发的时候,会将_activeWatcher添加到路径pathBinding中。关于_activeWatcheraddDep函数,马上我们会在Watcher模块中介绍到。
  

Watcher模块

  我们前面已经讲过,Binding中的订阅者都是Watcher实例,Binding并不关心数据更改后的操作,对于Binding而言只需要调用订阅者的update方法,具体的处理逻辑都隐藏在Watcher的背后。对于Watcher,其逻辑可能是一个指令directive(用于连接DOM与响应式数据)或者是一个watch侦听器的回调函数,这样就能符合单一职责原则,解除模块之间的耦合度,让程序更易维护。

  在介绍Watcher之前,我们先介绍一下Batcher模块,顾名思义,主要就是批处理任务,看过React源码的同学应该也在其中看到过相似的概念。在这些框架中,有可能是因为某个操作过于昂贵(比如DOM操作),我们如果数据一改变就触发相应的操作其实是不合适的,比如:

//修改前vm.a === 1
vm.a = 2; 
vm.a = 1;

  其实两次操作下来,我们的完全可以不需要进行操作,因为前后数据并没有发生改变,这时如果我们进行批量处理,将两次操作统一起来,就能在一定程度提升效率。
  

var _ = require("./util")

function Batcher () {
  this._preFlush = null
  this.reset()
}

var p = Batcher.prototype

p.push = function (job) {
  if (!job.id || !this.has[job.id]) {
    this.queue.push(job)
    this.has[job.id] = job
    if (!this.waiting) {
      this.waiting = true
      _.nextTick(this.flush, this)
    }
  } else if (job.override) {
    var oldJob = this.has[job.id]
    oldJob.cancelled = true
    this.queue.push(job)
    this.has[job.id] = job
  }
}

p.flush = function () {
  // before flush hook
  if (this._preFlush) {
    this._preFlush()
  }
  // do not cache length because more jobs might be pushed
  // as we run existing jobs
  for (var i = 0; i < this.queue.length; i++) {
    var job = this.queue[i]
    if (!job.cancelled) {
      job.run()
    }
  }
  this.reset()
}

p.reset = function () {
  this.has = {}
  this.queue = []
  this.waiting = false
}

module.exports = Batcher

  Batcher内部有四个属性并对外提供三个方法:

属性:

has: 用来记录某个任务(job)是否已经在队列中

queue: 用来存储当前的任务队列

waiting: 用来表示当前的任务队列处于等待执行状态

_preFlush: 用来在执行任务队列前调用的钩子函数

方法:

reset:重置参数属性

push: 将任务放入批处理队列

flush: 执行批处理队列中的所有任务

  上面的代码逻辑非常简单,不用逐一介绍,值得注意的是,每一个任务job都含有id属性,用来唯一标识任务,如果当前任务队列中已经存在并且任务的override属性为false就不会重复放入。override属性就是用来表示是否需要覆盖已经存在的任务。任务的cancelled属性用来表示该任务是否需要被取消执行。所有的任务job中的run方法就是任务所需要执行的内容。关于Vue.nextTick之后的文章我们会介绍,现在你可以就可以简单理解成setTimeOut

  接下来我们来看一下Watcher模块的实现:
  

var _ = require("./util")
var Observer = require("./observe/observer")
var expParser = require("./parse/expression")
var Batcher = require("./batcher")

var batcher = new Batcher()
var uid = 0

function Watcher (vm, expression, cb, ctx, filters, needSet) {
  this.vm = vm
  this.expression = expression
  this.cb = cb // change callback
  this.ctx = ctx || vm // change callback context
  this.id = ++uid // uid for batching
  this.value = undefined
  this.active = true
  this.deps = Object.create(null)
  this.newDeps = Object.create(null)
  var res = _.resolveFilters(vm, filters, this)
  this.readFilters = res && res.read
  this.writeFilters = res && res.write
  // parse expression for getter/setter
  res = expParser.parse(expression, needSet)
  this.getter = res.get
  this.setter = res.set
  this.initDeps(res.paths)
}

  Watcher模块主要做的就是解析表达式,从中收集依赖并且在数据改变的时候调用注册的回调函数。

vm: 就是对应的响应式数据所在的Vue实例

expression: 待解析的表达式

cb: 注册的回调函数,在数据改变时会调用

ctx: 回调函数执行的上下文

id: Watcher的标识,用在Batcher对应的job.id,每一个Watcher其实就是一个job

value: 表达式expression对应的计算值

active: 该watcher是否是激活的

deps: 用来存储当前Watcher依赖的数据路径

  在整个Watcher构造函数中我们需要注意的是两个部分:
  

  var res = _.resolveFilters(vm, filters, this)
  this.readFilters = res && res.read
  this.writeFilters = res && res.write

  res = expParser.parse(expression, needSet)
  this.getter = res.get
  this.setter = res.set
  this.initDeps(res.paths)

  第一部分对应的就是过滤器的处理,比如存在:

var vm = new Vue({
    data: {
        a: 1,
        b: -2
    },
    filters: {
        abs: function(v){
            return Math.abs(v);
        }
    }
})

  那么Watcher在解析表达式a+b|abs,得到对应的结果值就是1_.resolveFilters函数将filters解析成readFilterswriteFilters,其实本人也是从Vue2.0才开始入手的,之前并没有听过还存在什么writeFilter,于是翻看了Vue1.0的文档,找了已经被废弃的Vue1.0的双向过滤器。比如:

Vue.filter("currency", {
  read: function (value) {
    return "$" + value.toFixed(2)
  },
  write: function (value) {
    var number = +value.replace(/[^d.]/g, "")
    return isNaN(number) ? 0 : number
  }
})

var vm = new Vue({
  el: "#app",
  data: {
    price: 0
  }
})

  currency过滤器就是双边过滤器,当在输入框中输入例如: $12的时候,我们发现vm.price已经被赋值为12。这就是write过滤器生效的结果。

  我们来看一下工具类utilsfilter模块所提供的两个方法resolveFiltersapplyFilters:

exports.resolveFilters = function (vm, filters, target) {
  if (!filters) {
    return
  }
  var res = target || {}
  var registry = vm.$options.filters
  filters.forEach(function (f) {
    var def = registry[f.name]
    var args = f.args
    var reader, writer
    if (!def) {
      _.warn("Failed to resolve filter: " + f.name)
    } else if (typeof def === "function") {
      reader = def
    } else {
      reader = def.read
      writer = def.write
    }
    if (reader) {
      if (!res.read) {
        res.read = []
      }
      res.read.push(function (value) {
        return args
          ? reader.apply(vm, [value].concat(args))
          : reader.call(vm, value)
      })
    }
    // only watchers needs write filters
    if (target && writer) {
      if (!res.write) {
        res.write = []
      }
      res.write.push(function (value) {
        return args
          ? writer.apply(vm, [value, res.value].concat(args))
          : writer.call(vm, value, res.value)
      })
    }
  })
  return res
}

  resolveFilters在被Watcher调用的时候,vm参数对应的就是Vue的实例,而target则是Watcher实例本身,传入的filters就比较特殊了,比如我们上面的例子:a+b|abs,对应的filters就是

[{  
    name: "abs"
    args: null
}]

  我们看到filters是一个数组,其实每个元素的name对应的就是应用的过滤器函数名,而args则是传入的预定的其他参数。代码的逻辑非常的简单,遍历filters数组,将其中的每一个使用到的过滤器从vm.$options.filters取出,将对应的readwrite包装成新的函数,并分别放入res.readres.write,并将res返回。然后配合下面的模块提供的applyFilters函数,我们就可以一个值经过给定的一系列过滤器处理,得到最终的数值了。

exports.applyFilters = function (value, filters) {
  if (!filters) {
    return value
  }
  for (var i = 0, l = filters.length; i < l; i++) {
    value = filters[i](value)
  }
  return value
}

  第二部分代码:

  res = expParser.parse(expression, needSet)
  this.getter = res.get
  this.setter = res.set
  this.initDeps(res.paths)

  涉及到的就是表达式的处理,我们之前讲过,每个Watcher其实都是从一个表达式中收集依赖,并且在相应的数据发生改变的时候调用对应的回调函数,expParser模块不是我们本次文章的重点内容,我们不需要知道它是怎么实现的,我们只要只要它是做什么的,可以看下面的代码:
  

describe("Expression Parser", function () {
 it("parse getter", function () {
    var res = expParser.parse("a - b * 2 + 45");
    expect(res.get({
      a: 100,
      b: 23
    })).toEqual(100 - 23 * 2 + 45)
    expect(res.paths[0]).toEqual("a");
    expect(res.paths[b]).toEqual("b");
    expect(res.paths.length).toEqual(2);
  })
  
   it("parse setter", function () {
    var res = expParser.parse("a.b.d");
    var scope = {};
    scope.a = {b:{c:0}}
    res.set(scope, 123)
    expect(scope.a.b.c).toBe(123)
    expect(res.paths[0]).toEqual("a");
    expect(res.paths.length).toEqual(1);
  })
});

  其实从上面两个测试用例中我们已经能看出expParser.parse的功能了,expParser.parse能转化一个表达式,返回值res中的paths表示表达式依赖数据的根路径,get函数用于从一个值域scope中取得表达式对应的计算值。而set函数用于给值域scope中设置表达式的值。

  我们接着看this.initDeps(res.paths)
 

var p = Watcher.prototype

p.initDeps = function (paths) {
  var i = paths.length
  while (i--) {
    this.addDep(paths[i])
  }
  this.value = this.get()
}

p.addDep = function (path) {
  var vm = this.vm
  var newDeps = this.newDeps
  var oldDeps = this.deps
  if (!newDeps[path]) {
    newDeps[path] = true
    if (!oldDeps[path]) {
      var binding =
        vm._getBindingAt(path) ||
        vm._createBindingAt(path)
      binding._addSub(this)
    }
  }
}

  initDeps函数的首先就是将表达式依赖根路径中的每一个值调用函数addDep,将Watcher实例添加进入对应的Binding中,addDep内部实现也是非常的简洁,调用_getBindingAt函数(已经存在对应的Binding)或者_createBindingAt(创建新的Binding)获取到对应Binding并将自身添加进入。newDeps用来记录此次addDep过程中之前不存在的依赖项。之后initDeps函数调用了this.get()获取当前表达式对应的值。
  

p.get = function () {
  this.beforeGet()
  var value = this.getter.call(this.vm, this.vm.$scope)
  value = _.applyFilters(value, this.readFilters)
  this.afterGet()
  return value
}

p.beforeGet = function () {
  Observer.emitGet = true
  this.vm._activeWatcher = this
  this.newDeps = Object.create(null)
}

p.afterGet = function () {
  this.vm._activeWatcher = null
  Observer.emitGet = false
  _.extend(this.newDeps, this.deps)
  this.deps = this.newDeps
}

  get函数内部实质就是调用表达式对应的get函数获取表达式当前对应的结果,然后通过applyFilters得到当前表达式对应过滤器处理后的值。值得注意的是,在调用之前执行了钩子函数beforeGet,其目的就是开启ObserveremitGet使得我们可以接受响应式数据的get事件,然后将当前Vue实例的_activeWatcher赋值成当前的Watcher并置空newDeps准备存储本次新增的依赖数据项。我们在Binding提到过:
  

this._observer.on("get", this._collectDep)
    
exports._collectDep = function (path) {
  var watcher = this._activeWatcher
  if (watcher) {
    watcher.addDep(path)
  }
}

  beforeGet所作的就是为了收集依赖所做的准备。afterGet所做的就是清除为依赖收集所做准备,逻辑和beforeGet正好相反。

  我们知道Watcher会在相应的响应式数据改变的时候被对应Binding所调用,因此Watcher一定包含方法update:
  

p.update = function () {
  batcher.push(this)
}

p.run = function () {
  if (this.active) {
    var value = this.get()
    if (
      (typeof value === "object" && value !== null) ||
      value !== this.value
    ) {
      var oldValue = this.value
      this.value = value
      this.cb.call(this.ctx, value, oldValue)
    }
  }
}

  update并没有理解调用对应回调函数,而且将Watcher放入Batcher队列,Batcher会在恰当的时间调用Watcherrun函数。run内部会调用this.get()得到表达式当前的计算值,并且触发回调函数。

  Watcher还有一个函数用于从所有的依赖的Binding中移除自身:

p.teardown = function () {
  if (this.active) {
    this.active = false
    var vm = this.vm
    for (var path in this.deps) {
      vm._getBindingAt(path)._removeSub(this)
    }
  }
}

  teardown内部逻辑非常简单,不再赘述。
  

总结

  讲到这里大家可能被我粗糙的文笔搞的混乱了,我们举个例子来看看,理顺一下思路,假设存在下面的例子:

    new Vue({
        el: "#app",
        data: {
            a: { b: { c: 100 } },
            d: { e: { f: 200 } }
        }
    })
{{a.b.c + d.e.f}}

  对应于上面的数据,相应的构造好的Binding Tree如下的:
  
  

  我们在调用this.get去收集表达式a.b.c+d.c.e的对应值时,我们会被Observer模块的get事件触发六次,分别对应的值为:

a

a.b

a.b.c

d

d.e

d.e.f

  因此此时的Watcher中的dep存储的就是对应的依赖路径:

  而此时的Watcher实例在Binding Tree的注册情况如下:
  
 

  到此为止,我们已经了解响应式数据是如何与Watcher对应的和响应式数据改变时触发相应的操作。逻辑虽说不算特别难,但是还是有一定的复杂度的,建议可以对应看看源码,调试一下疑惑的地方,相信会有更多的收获。

  如果文章有不正确的地方欢迎指正。最后还是希望大家能给我的Github博客点个Star。愿共同学习,一同进步。
  
  

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

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

相关文章

  • 用proxy实现一个更优雅的vue

    摘要:以上引用内容来自阮一峰的教程的章节原文地址请戳这里。最后本文最终实现代码已经放在上,想要直接看效果的同学,可以上去直接,运行。 前言 如果你有读过Vue的源码,或者有了解过Vue的响应原理,那么你一定知道Object.defineProperty(),那么你也应该知道,Vue 2.x里,是通过 递归 + 遍历 data对象来实现对数据的监控的,你可能还会知道,我们使用的时候,直接通过数...

    objc94 评论0 收藏0
  • 少侠请留步,来一起实现个MVVM!

    摘要:一起来实现一个框架最近手痒,当然也是为了近阶段的跳槽做准备,利用周五时光,仿照用法,实现一下的双向绑定数据代理大胡子模板指令,等。 一起来实现一个mvvm框架 最近手痒,当然也是为了近阶段的跳槽做准备,利用周五时光,仿照vue用法,实现一下mvvm的双向绑定、数据代理、大胡子{{}}模板、指令v-on,v-bind等。当然由于时间紧迫,里面的编码细节没有做优化,还请各位看官多多包涵!看...

    lily_wang 评论0 收藏0
  • MVVM 中的动态数据绑定

    摘要:要实现最小化刷新,我们要将模板中的每个绑定都收集起来。思考题在最后的实现下,我们把模板改为下面这样虽然很少会有人这样写,就会出现重复的实例,该如何解决这个问题,参考早期源码学习系列之四如何实现动态数据绑定 上一篇文章我们了解了怎样实现一个简单模板引擎。但这个模板引擎只适合静态模板,因为它是将模板整体编译成字符串进行全量替换。如果每次数据改变都进行一次替换,会有两个最主要的问题: 性能...

    Meils 评论0 收藏0
  • 前端项目发布后的问题总结

    摘要:引言最近做的项目已经接近尾声刚刚发到线上回顾和总结一下这段时间遇到的问题和个人的一些想法。通过在指令中比较前后值以及设置避免不必要更新导致的弹窗渲染。 引言 最近做的项目已经接近尾声,刚刚发到线上,回顾和总结一下这段时间遇到的问题和个人的一些想法。 select下拉修改和复原 //部分下拉选项 {{o...

    endless_road 评论0 收藏0
  • Vue 双向数据绑定原理分析

    摘要:关于双向数据绑定当我们在前端开发中采用的模式时,,指的是模型,也就是数据,,指的是视图,也就是页面展现的部分。参考沉思录一数据绑定双向数据绑定实现数据与视图的绑定与同步,最终体现在对数据的读写处理过程中,也就是定义的数据函数中。 关于双向数据绑定 当我们在前端开发中采用MV*的模式时,M - model,指的是模型,也就是数据,V - view,指的是视图,也就是页面展现的部分。通常,...

    nanfeiyan 评论0 收藏0

发表评论

0条评论

tyheist

|高级讲师

TA的文章

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