VUE2.x数据绑定原理

254 阅读4分钟

开始使用vue不久后其实就知道了vue的数据绑定其实就是用Object.defineProperty()来实现的了,但是其实一直都没有完整的通过这个方法自己来实现一次数据绑定的功能。这两天抽时间就自己来实现了一下,感叹尤大神咋这么聪明。

知识点准备

在完成自己的数据绑定功能之前,需要知道两个比较关键的知识点,一个是Object.defineProperty()方法,另一个是观察者模式。这里就简单的说一下我们要用的信息,关于该方法和设计模式更细节的用法自行百度文档即可。

Object.defineProperty()

文档

参数:target,key,options

其中target表示操作的对象,key表示具体操作的属性名,options是一个对该对象属性的一些定义,这里我们需要使用到的主要是options参数中的getset两个方法。get方法在获取属性值(target.key)的时候调用,set方法是在设置属性值(target.key = xx)的时候调用。其他的相关属性请参考文档。

观察者模式

有的地方认为观察者模式和发布订阅模式是一样的,有的认为两者有区别,这里不做争论,叫啥都行。主要简单了解观察者模式要解决的问题,以及如何解决就可以了,更多的拓展方法不讨论了。

假设你是卖猪肉的(现在猪肉真的贵),现在店里没有猪肉了,有很多顾客为了能在你有猪肉的时候抢到猪肉于是给你交了定金留了电话,让你在猪肉到货的时候告诉他们一声。

好了,现在需求出来了,核心问题就是猪肉到了需要分别通知订货的客户。你不会真的挨个打电话通知吧?这要是有1000个人订购了不得累死?最好的方式是不是可以存他们电话的时候分个组,然后给整个组的人群发短信,一秒搞定。

本来应该由你一个一个发短信的操作变成了手机替你操作,你是不是就解脱了。这个就是观察者模式很普通的应用,客户就是订阅者,你就是发布者。

需求分析

完整的实现vue的功能肯定不行的,这里面还涉及到虚拟dom等其他模块,我们通过模拟的方式来分析。

// index.js

const data = {
	text: '初始化数据'
}

// 模拟vue中data被绑定到dom上的过程( <div>{{data.text}}</div> )
function Vdom() {
	console.log(`${data.text}---被我渲染到dom上了`)
}
Vdom()

vue在初始化的时候会调用Vdom方法来将data.text的数据渲染到相应的dom节点上,我们需要实现的就是当data.text变化的时候触发Vdom()方法,使相应的dom节点完成更新。

看到这里是不是有那味儿了,要是没有的话,我们再完善一下:

// index.js

const data = {
	text: '初始化数据'
}

function Vdom1(){
	console.log(`${data.text}--被渲染到div标签了`)
}
Vdom1()

function Vdom2(){
	console.log(`${data.text}--被渲染到span标签了`)
}
Vdom2()

我们再梳理一下需求,有多个地方使用到了data.text属性,当这个属性变化的时候我们需要通知每一个使用过的地方,让他们做相应的更新。到这里是不是有那味儿了,有多个人要猪肉,当猪肉到货以后通知订货的人,让他们来拿猪肉。bingo!!所以,这里我们知道要使用观察者模式了。

实现观察者模式

实现之前,要先分析观察者模式需要的几个要素:

  1. 需要保存好订阅者(就是要记录好哪些方法调用了data.text或者哪些人订购了猪肉)
  2. 需要一个方法把订阅者存到相应的空间中(同时要看看存重复没有,重复了就不要存了)
  3. 需要一个方法在data.text被改变或猪肉到货后自动的通知每一个订阅者

我使用的node环境进行测试的,所以使用的module.exports的方式导出,浏览器环境请使用export关键字导出。

因为考虑到有很多的数据都需要绑定,所以使用了类的方式进行了封装,为了保证订阅者的唯一性则使用了set数据类型。

// observer.js

class Observer {

  constructor() {
    // 订阅者缓存
    this.subList = new Set()
  }

  // 设置订阅者
  setSub(sub) {
    if (this.subList.has(sub)) return //如果该订阅者已经存在就不保存
    this.subList.add(sub)
  }

  // 发布信息
  pub(data) {
    this.subList.forEach(sub => {
      sub(data)
    })
  }
}
module.exports = Observer

依赖注入和发布

到目前为止,我们已经走了一半了,现在还有两个问题:1、在什么时候,在哪里将订阅者(调用data.text的方法或预定猪肉的客户)存起来;2、在什么时候,在哪里发布信息通知客户来那猪肉了。

这时候就是Object.defineProperty()上场的时候了,我们知道在获取data.text的时候会触发get方法,在设置data.text值的时候会触发set方法。那么我们就可以:1、在调用data.text的时候即在get方法中记录调用者(订阅者);2、在设置data.text的时候即在set方法中发布更新消息。

封装一个方法,给每个属性都设置好相应的observer:

const Observer = require('./observer')

const setObserver = function (target, key, value) {
  const observer = new Observer()
  Object.defineProperty(target, key, {
    get() {
      observer.setSub(global.sub)  // 保存订阅者,global最后解释
      return value
    },
    set(newValue) {
      value = newValue
      observer.pub(newValue)
    }
  })
}

module.exports = { setObserver }

最后,我们回到最初的index.js将observer方法挂载上去:

const { setObserver } = require('./setObserver')

const data = {
  text: '初始化数据'
}

// 通过全局对象缓存当前订阅者
global.sub = new Function()  // 这里很关键哦
setObserver(data, 'text', '初始化数据')

function Vdom1(value) {
  console.log(`${value}--被渲染到div标签了`)
}
Vdom1(data.text)

// 这两步非常非常关键哦
sub = Vdom1 // 将订阅者放入缓存
data.text  // 通过读取data.text的方式间接调用get方法,将订阅者注入到observer中

function Vdom2(value) {
  console.log(`${value}--被渲染到span标签了`)
}
Vdom2(data.text)
sub = Vdom2 // 将订阅者放入缓存
data.text // 通过读取data.text的方式间接调用get方法,将订阅者注入到observer中

先解决第一个问题,global.sub是什么意思?这里其实为了在全局缓存刚调用了data.text的订阅者,这样我们在触发get方法的时候,就可以直接将全局缓存的订阅者保存起来,只要每次注入订阅者之前将其保存到这个全局对象中就可以保证在任何地方都可以获取到当前需要保存的订阅者,从而实现在get方法中获取到这个订阅则会。(在浏览器环境下请将global替换为window

第二个问题,为什么要单独执行一次data.text?通过读取data.text间接触发get方法而将global.sub中的订阅者自动注入到subList中。

测试

//  index.js

... 

data.text = '我被修改了' 
/*
我被修改了--被渲染到div标签了
我被修改了--被渲染到span标签了
*/

通过结果我们可以看到,一旦修改data.text就会触发更新dom的方法,从而实现了简易版的数据绑定。

写在最后

VUE中的数据绑定远不止这些,还包括数组绑定,数组中数据的绑定等,还有关于订阅者,发布者的相关封装。相关的细节就需要更加仔细的去阅读分析源码了。

本文参考了《深入浅出Vue.js》,作者将vue各个功能模块剖析得非常深入,推荐大家阅读。