一个基于ES6 的Mvvm Demo

很多次面试都被问到双向绑定的原理,从一开始的啥都不知道到后来知道使用Object.defineProperty 劫持属性,使用发布订阅进行消息传递,再后来看了很多篇相关的文章和代码,依然应付不了面试官的追问。还是对其中的原理和实现了解的不透彻,所以最终决定自己亲手写一个。网上写mvvm 的博客有很多,都挺详细的也都贴了大段的代码,想了解的可以直接走下面的传送门,这篇文章的代码实现很大一部分都是参考了以下两个链接:

剖析Vue原理&实现双向绑定MVVM

https://github.com/DMQ/mvvm

为了防止内容同质化,这篇文章就讲讲跟这两篇文章中不一样的地方(具体表现就是没有大量代码)。一方面是将ES5 实现转换成了ES6;另一方面,更多的还是讲我在实现这个mvvm demo 中遇到的一些问题和困惑,以及在动手开始写代码之前应该如何从整体上来看待mvvm,就算是以上两篇文章的补充吧。

1. 观察者模式与发布订阅

(1)先看概念

观察者模式与发布订阅是两个很相似的概念,所以经常也会被一起提及,这两个概念确实差别很小,主要差别就在于调度中心。发布订阅是有调度中心(松耦合)的,而观察者模式是没有调度中心的,详细讲解可以参考这篇文章:

https://juejin.im/post/5a14e9edf265da4312808d86

(2)举个例子

Mvvm 中使用的是发布订阅,所以我们就来详细看一下这个模式是如何工作的。为了通俗易懂,先来讲个例子吧。

小明放暑假了,天天在家里做作业。有一天,家里要来客人,客人说是上午过来,但不一定几点来。吃过早饭,小明爸爸说:“我去上班了,如果客人来了,你给我打电话,我回来陪客人喝茶”。小明妈妈说:“我去打麻将了,如果客人来,你给我发微信,我去菜市场买菜回来做饭”。小明默默地把这一切记在了心上,等爸妈走后就在家里看电视直到客人来了然后通知他们。

在这个例子中的几个角色分别对应了发布订阅模式中的几个概念。首先,对于小明父母来说,他们是两个订阅者,他们需要在发生某件事情的时候有人通知自己并执行相应的操作(喝茶和买菜)。小明是一个订阅管理器,也就是我们上面说到的调度中心,他可以添加或移除订阅者,并可以执行通知操作(当客人来了,他需要把这个消息通知给他的父母,至于通知的手段,不管是打电话还是发微信都无所谓了)。客人则是发布者,因为他的到来会促使小明通知父母,所以他是消息的源头。

(3)小明一家之于mvvm

从上面小明的故事里可以看到,在订阅发布模式中需要有三种角色的参与:订阅者、订阅管理器、发布者。三者分别对应了mvvm ES6 实现中的三个类:Watcher、Dep、Observer。

Watcher 类中必须具备的方法包含三个(不算构造函数):

① 更新操作(update),更新操作中执行需要执行回调函数,对应以上故事里的泡茶和买菜做饭。

② 将自己添加到订阅管理器中(addDep),从上面的故事中我们可以看到,是小明的父母告诉小明当客人来的时候通知他们,相当于是父母将自己添加到了小明这个订阅管理器中,因为只有订阅者才知道自己想要订阅的消息以及回调操作的内容。

③ 获取当前的值(get),因为要订阅消息,所以订阅者首先要知道当前的值是什么,这样它下一次拿到值才知道是否发生了变化。

Dep 类中必须具备的方法包含以下几个:

① 添加订阅者(addSub),很好理解,把妈妈爸爸分别添加到自己的通知队列中去。

② 移除订阅者(removeSub),上午爸爸突然打来电话说今天单位里领导来视察工作,即使客人来了也不能回家陪客人喝茶,这个时候如果客人来了小明就不需要再通知爸爸了。

③ 通知订阅者有消息了(notify),客人来了,小明通知爸爸妈妈回家。

④ depend 方法,这个方法不知道该怎么描述,也是花了一点时间才弄清楚这个方法存在的意义。与这个方法配合使用的一个属性是Dep.target,代码如下:

depend() {
  // Dep.target 为Watcher 实例
  Dep.target.addDep(this);
}

那这个Dep.target 在这里是什么意思呢?爸爸和妈妈都告诉小明客人来了要通知他们,可是小明在记住这两个消息的时候怎么记住谁是爸爸谁是妈妈呢?这样说有点抽象,咱们稍微写实一下。上面的例子中提到一点是给爸爸打电话,给妈妈发微信,于是这里可以把Dep.target 用来作为区分打电话和发微信的标志。当爸爸交代小明的时候,此时的target 是爸爸,而妈妈交代小明的时候,此时的target 是妈妈。于是通过target 小明就可以知道打电话和发微信的目标是谁。(这个场景只是为了来强行理解target,与实际代码功能可能稍有出入,具体作用还是建议去看代码)。

Observer 类是消息源,在小明的故事里代表着客人到来,在mvvm 中就是代表着数据发生了变化,怎么样才能知道数据发生了变化,此时就轮到Object.defineProperty 出场了。使用Object.defineProperty 去监听数据对象的属性,一旦数据发生了变化,就会通知订阅管理器,让订阅管理器通知所有订阅者执行他们的回调操作。

2. 什么时候订阅

通过小明一家的例子我们大概知道了mvvm 工作的过程以及都需要哪些角色和方法。但是在实际开发过程中面临的就是很具体的问题了,比如说什么时候进行订阅,订阅的代码要写在哪里,牵涉到哪几个类中的哪几个方法?这一节就以文本框输入内容绑定到js 变量上然后实时显示在界面上的p 标签中为例,来看一下两个方向的绑定是如何通过代码中的环环相扣实现的。

(1)文本框输入值到js 变量的映射(View –> Model)

上面提到的三个类是实现发布订阅的基础类。在实现mvvm 的过程中,我们首先必须要有一个入口类,Mvvm。另外,还要有一个Compiler 类,在页面初始化的瞬间遍历页面上的dom 元素,对使用不同标签定义的元素解析并执行相应的操作。概念说起来很干,来看场景。

如果我的html 代码如下:

<div id="mvvm">
  <input type="text" v-model="word">
  <p>{{word}}</p>
</div>

Js 代码如下:

const vm = new MVVM({
  el: '#mvvm',
  data: {
    word: 'hello world'
  }
})

那么我希望页面启动的时候可以将我的输入框的input 值单向绑定到data 中的word 属性上,就是说word 的值会随着我在输入框中输入的内容的变化而变化。怎么做呢?很简单,给input 添加事件监听,检测到值发生变化的时候就改变js 中data 对象的word 属性的值,主要代码如下:

node.addEventListener('input', (e) => {
  let newVal = e.target.value;
  if (val === newVal) {
    return;
  }
  // exp 为变量名,此处为word
  this._setVMVal(vm, exp, newVal);
})

(2)js 变量到p 标签内容的映射(Model -> View)

除此之外,我想让word 的值可以及时反映到页面的p 标签中,即实现变量值到页面展示内容的绑定。

这一点要讲的内容就体现了这一小节的title:什么时候订阅?Compiler 类中做的主要是dom 相关的操作,所以它需要知道什么时候数据发生了变化并修改dom 的值。上面讲了,页面初始化的时候Compiler 会去遍历所有的dom 元素,所以这一节的重点就在于当他遍历到以下内容的html 时做了什么操作。

<p>{{word}}</p>

当遇到上面的html 的时候,他会实例化一个Watcher 类,传递的参数包括当前的全局实例(vm)、变量名(word)和回调(操作dom 改变内容)。Watcher 在实例化的过程中会用我们上面提到过的get 方法来获取一下它订阅的变量当前的值,这样当下次收到消息的时候他才知道值是否发生了变化。Get 方法中的代码如下:

get() {
  // 设置当前实例为Dep.target
  Dep.target = this;
  // expOrFn 是指令关联的变量或者函数,这里就是word
  let value = this.vm[this.expOrFn];
  Dep.target = null;
  return value;
}

从代码中可以看到,get 方法只做了三件事:首先将Dep 类的target 指向当前Watcher 的实例,然后获取了一下相关变量的值,再将Dep.target 设为null。

做完这三件事就已经完成了订阅操作,为啥?是因为我们之前提到过的属性劫持啊!!来看看Observer 中的相关代码:

Object.defineProperty(data, key, {
  enumerable: true,
  configurable: false,
  get: () => {
    if (Dep.target) {
      // 这里订阅了啊
      dep.depend();
    }
    return val;
  },
  set: (newVal) => {
    if (val === newVal) {
      return;
    }
    val = newVal;
    // 变量的值发生了变化就发布消息
    dep.notify();
  }
});

3. 简单总结一下

Ok,说到这里了,需要来捋一捋在这个过程中发布订阅的流程。

(1)页面初始化的时候会新建一个Compiler 类来遍历页面中的dom 元素并根据不同元素的标签对它们进行操作。

(2)操作的内容可能包含实例化一个Watcher 类并订阅相关变量的信息。

(3)在Watcher 类实例化过程中我们使用get 方法获取变量的值,已经劫持了变量的get 属性的Observer 会通知Dep 的实例将当前的Watcher 实例添加到自己的列表中。

(4)当变量的值发生变化的时候,劫持了变量的set 属性的Observer 会让Dep 的实例通知它所有列表中的订阅者进行更新操作。

嗯……如此说来,是先进行属性劫持,然后实例化Compiler 的类的,那当然啦!

class MVVM {
  constructor(options) {
    this.$options = options;
    let data = this._data = this.$options.data;
    observe(data, this);
    this.$compile = new Compiler(options.el || document.body, this);
  }
}

4. github demo

https://github.com/TerminatorSd/mvvmDemo

Ref:已经写在上面了~

猜你喜欢

转载自blog.csdn.net/qq_33594380/article/details/81711353