利用 Object.defineProperty 简单实现 MVVM 的订阅与数据劫持

456 阅读3分钟

这是我参与8月更文挑战的第22天,活动详情查看:8月更文挑战

MVVM

MVVM 是一种设计模式,或者也可以称为一种架构模式。它的全拼是 Model-View-ViewModel。

  • Model 代表模型
  • View 代表视图
  • ViewModel 代表了一个控制器

这其中,我们可以将 model 看作是后端接口返回的数据

{
    name: '晴天',
    age: '18',
    mark: 'always 18'
}

而 view 就是前端显示在浏览器中让用户看到的页面

image.png

那一段 json 数据是如何显示到页面里的呢?这就是 ViewModel 负责的事情。

所以,它们之间的关系可以用一个图来表示

image.png

前端最常见的 MVVM 的框架,应该就是

  • AngularJs ,不过这个框架,国内用的貌似比较少
  • 尤大大的 vue,我们经常在 vue 的 demo 中看到var vm = new Vue(...),这个 vm 就是 viewmodel 的简写。

image.png

响应式原理

MVVM 的初衷是想利用数据绑定函数,从视图层面删除所有和界面数据渲染逻辑相关的代码。

那么,应该如何使用或者如何编写数据绑定函数,才能达到这样的效果呢?

我们看 vue 的表现:

<div id="counter">
  Counter: {{ counter }}
</div>

const Counter = {
  data() {
    return {
      counter: 0
    }
  }
}

Vue.createApp(Counter).mount('#counter')

当然这个例子和 Object.defineProperty 没有关系,我只是在这里体现 “数据绑定后从视图层面删除了和界面数据渲染逻辑相关的代码” 这句话

很明显,我们没有自己编写

document.getElementById(counter).innerText = counter

或者

$('#counter).text(counter)

类似这种的渲染代码。

你可能觉得这只是一行代码啊,这么简单。

其实不然,假如我们需要counter做递增

mounted() {
    setInterval(() => {
      this.counter++
    }, 1000)
  }

你就知道 MVVM 到底帮我们省了多大的力气。

好了,到这里已经展示了 MVVM 的表现,那么回到正题,如何利用 Object.defineProperty 简单实现 MVVM 呢?

其实 vue2 的响应式原理,是数据劫持,即数据变化的时候,自动重新渲染相关页面。

其实这个需求是非常容易的,但是首先要理解一个方法,叫做 Object.defineProperty,理解之后大部分人都可以模拟一个简单的实现。

虽然跟 vue 差很多,但是面试考察的也不是让你去写个 vue 。

defineProperty 数据劫持

Object.defineProperty 方法会在一个对象上添加一个新的属性,或者修改一个对象的已有属性,最后返回此对象.

Object.defineProperty 方法可以接收三个参数

  • object (required, 要定义属性的对象)
  • propertyname (required, 要定义或修改的属性的名称)
  • descriptor (required, 要定义或修改的属性描述符)
Object.defineProperty(object, propertyname, descriptor)

针对 descriptor,它是一个对象类型,用于配置 propertyname 的属性描述符,因此 descriptor 的属性可以选择如下两种中的一种:

  1. 数据描述符
key值类型描述默认值
valueanyobject.propertyname 的值undefined
writablebooleanobject.propertyname 是否可以被赋值运算符修改false
configurablebooleanobject.propertyname 是否可以被修改和删除false
enumerablebooleanobject.propertyname 是否可以被枚举false
  1. 存取描述符
key值类型描述默认值
getfunction读取 object.propertyname 时调用的函数undefined
setfunction设置 object.propertyname 时调用的函数undefined
configurablebooleanobject.propertyname 是否可以被修改和删除false
enumerablebooleanobject.propertyname 是否可以被枚举false

小伙伴可能看出来了,数据描述符和存取描述符具有共同的 key,也有不同的 key,那么当一个描述符内部没有valuewritablegetset时,它默认是一个数据描述符。

下面的例子展示了存取描述符的作用:


let data = {}, temp = 'aa'

Object.defineProperty(data, 'key1', {
    set(value){
        console.log('this is a new value: ' + value)
        temp = value
        //some code like $('div').html(value) will automatic execute when key1 changed
    },
    get(){
        return temp
    }
})

data.key1 = 'Jack'

当我们在浏览器运行上面这段代码,控制台会输出:

this is a new value: Jack

这就是存取描述符的作用,他可以用来做数据劫持。

订阅模式

我们题目要实现的订阅与数据劫持,就是要通过Object.defineProperty的存取描述符来实现。

订阅器,我们可以简单的将其理解为一个队列,队列内都是即将在某个时刻执行的函数。

当然,为了方便查找,我们可以将其定义为一个对象类型,其中的每个属性,都是数组类型。

var a = {a:[], b:[], c:[]}

下面我们来实现一个订阅器:

let Deep = {
  deepList: {},

  listen(key, fn){
    if(!this.deepList[key])
      this.deepList[key] = []
    
    this.deepList[key].push(fn)
  },

  trigger(){
    let key = Array.prototype.shift.call(arguments)

    let fnList = this.deepList[key]

    if(!key || !fnList || !fnList.length)
      return false
    
    for(let i=0, fn; fn = fnList[i++];) {
      fn.apply(this.arguments)
    }
  }
}

将订阅器与数据劫持绑定到一起

这里就是要实现,数据劫持发生后,去执行订阅器内相应的代码。

这样,就可以实现类似 vue 的,改变了某个 message,页面能同步渲染最新的结果。

这部分代码的逻辑十分简单:

  • 首先,我们通过 Deep.listen 将页面标签与内容绑定到一起并放入到订阅器中
  • 然后,我们在数据劫持中调用订阅器的 trigger 方法,更新数据的同时同步更新 html
let dataHijack = ({data, tag, datakey, selector}) => {
  let value = '', el = document.querySelector(selector);

  Object.defineProperty(data, datakey, {
    get(){
      return value
    },
    set(newVlaue){
      value = newVlaue
      Deep.trigger(tag, newVlaue)
    }
  })

  Deep.listen(tag, content=>{
    el.innerHTML = content
  })
}