【2019 前端进阶之路】深入 Vue 响应式原理,活捉一个 MVVM

作者:江三疯,专注前端开发。欢迎关注公众号前端发动机,第一时间获得作者文章推送,还有各类前端优质文章,致力于成为推动前端成长的引擎。

前言

作为 Vue 面试中的必考题之一,Vue 的响应式原理,想必用过 Vue 的同学都不会陌生,Vue 官方文档 对响应式要注意的问题也都做了详细的说明。

但是对于刚接触或者了解不多的同学来说,可能还会感到困惑:为什么不能检测到对象属性的添加或删除?为什么不支持通过索引设置数组成员?相信看完本期文章,你一定会豁然开朗。

本文会结合 Vue 源码分析,针对整个响应式原理一步步深入。当然,如果你已经对响应式原理有一些认识和了解,大可以 直接前往实现部分 MVVM

文章仓库和源码都在 ?? fe-code,欢迎 star

Vue 官方的响应式原理图镇楼。

640?wx_fmt=png

思考

进入主题之前,我们先思考如下代码。

<template>	
    <div>	
        <ul>	
            <li v-for="(v, i) in list" :key="i">{{v.text}}</li>	
        </ul>	
    </div>	
</template>	
<script>	
    export default{	
        name: 'responsive',	
        data() {	
            return {	
                list: []	
            }	
        },	
        mounted() {	
            setTimeout(_ => {	
                this.list = [{text: 666}, {text: 666}, {text: 666}];	
            },1000);	
            setTimeout(_ => {	
                this.list.forEach((v, i) => { v.text = i; });	
            },2000)	
        }	
    }	
</script>

我们知道在 Vue 中,会通过 Object.defineProperty 将 data 中定义的属性做数据劫持,用来支持相关操作的发布订阅。而在我们的例子里,data 中只定义了 list 为一个空数组,所以 Vue 会对它进行劫持,并添加对应的 getter/setter。

所以在 1 s 的时候,通过 this.list=[{text:666},{text:666},{text:666}] 给 list 重新赋值,便会触发 setter,进而通知对应的观察者(这里的观察者是模板编译)做更新。

在 2 s 的时候,我们又通过数组遍历,改变了每一个 list 成员的 text 属性,视图再次更新。这个地方需要引起我们的注意,如果在循环体内直接用 this.list[i]={text:i} 来做数据更新操作,数据可以正常更新,但是视图不会。这也是前面提到的,不支持通过索引设置数组成员。

但是我们用 v.text=i 这样的方式,视图却能正常更新,这是为什么?按照之前说的,Vue 会劫持 data 里的属性,可是 list 内部成员的属性,明明没有进行数据劫持啊,为什么也能更新视图呢?

这是因为在给 list 做 setter 操作时,会先判断赋的新值是否是一个对象,如果是对象的话会再次进行劫持,并添加和 list 一样的观察者。

我们把代码再稍微修改一下:

// 视图增加了 v-if 的条件判断	
<ul>	
    <li v-for="(v, i) in list" :key="i" v-if="v.status === '1'">{{v.text}}</li>	
</ul>	
// 2 s 时,新增状态属性。	
mounted() {	
    setTimeout(_ => {	
        this.list = [{text: 666}, {text: 666}, {text: 666}];	
    },1000);	
    setTimeout(_ => {	
        this.list.forEach((v, i) => {	
            v.text = i;	
            v.status = '1'; // 新增状态	
        });	
    },2000)	
}

如上,我们在视图增加了 v-if 的状态判断,在 2 s 的时候,设置了状态。但是事与愿违,视图并不会像我们期待的那样在 2 s 的时候直接显示 0、1、2,而是一直是空白的。

这是很多新手易犯的错误,因为经常会有类似的需求。这也是我们前面提到的 Vue 不能检测到对象属性的添加或删除。如果我们想达到预期的效果该怎么做呢?很简单:

// 在 1 s 进行赋值操作时,预置 status 属性。	
setTimeout(_ => {	
    this.list = [{text: 666, status: '0'}, {text: 666, status: '0'}, {text: 666, status: '0'}];	
},1000);

当然 Vue 也 提供了 vm.$set(target,key,value) 方法来解决特定情况下添加属性的操作,但是我们这里不太适用。

Vue 响应式原理

前面我们讲了两个具体例子,举了易犯的错误以及解决办法,但是我们依然只知道应该这么去做,而不知道为什么要这么去做。

Vue 的数据劫持依赖于 Object.defineProperty,所以也正是因为它的某些特性,才引起这个问题。不了解这个属性的同学看这里 MDN。

Object.defineProperty 基础实现

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。— MDN

看一个基础的数据劫持的栗子,这也是响应式最根本的依赖。

function defineReactive(obj, key, val) {	
    Object.defineProperty(obj, key, {	
        enumerable: true, // 可枚举	
        configurable: true, // 可写	
        get: function() {	
            console.log('get');	
            return val;	
        },	
        set: function(newVal) {	
            // 设置时,可以添加相应的操作	
            console.log('set');	
            val += newVal;	
        }	
    });	
}	
let obj = {name: '成龙大哥', say: ':其实我之前是拒绝拍这个游戏广告的,'};	
Object.keys(obj).forEach(k => {	
    defineReactive(obj, k, obj[k]);	
});	
obj.say = '后来我试玩了一下,哇,好热血,蛮好玩的';	
console.log(obj.name + obj.say);	
// 成龙大哥:其实我之前是拒绝拍这个游戏广告的,后来我试玩了一下,哇,好热血,蛮好玩的	
obj.eat = '香蕉'; // ** 没有响应

可以看见, Object.defineProperty 是对已有属性进行的劫持操作,所以 Vue 才要求事先将需要用到的数据定义在 data 中,同时也无法响应对象属性的添加和删除。被劫持的属性会有相应的 get、set 方法。

640?wx_fmt=png

另外,Vue 官方文档 上说:由于 JavaScript 的限制,Vue 不支持通过索引设置数组成员。对于这一点,其实直接通过下标来对数组进行劫持,是可以做到的。

let arr = [1,2,3,4,5];	
arr.forEach((v, i) => { // 通过下标进行劫持	
    defineReactive(arr, i, v);	
});	
arr[0] = 'oh nanana'; // set

那么 Vue 为什么不这么处理呢?尤大官方回答是性能问题。关于这个点更详细的分析,各位可以移步 Vue为什么不能检测数组变动?

Vue 源码实现

以下代码 Vue 版本为:2.6.10。

Observer

我们知道了数据劫持的基础实现,顺便再看看 Vue 源码是如何做的。

// observer/index.js	
// Observer 前的预处理方法	
export function observe (value: any, asRootData: ?boolean): Observer | void {	
  if (!isObject(value) || value instanceof VNode) { // 是否是对象或者虚拟dom	
    return	
  }	
  let ob: Observer | void	
  // 判断是否有 __ob__ 属性,有的话代表有 Observer 实例,直接返回,没有就创建 Observer	
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {	
    ob = value.__ob__	
  } else if ( // 判断是否是单纯的对象	
    shouldObserve &&	
    !isServerRendering() &&	
    (Array.isArray(value) || isPlainObject(value)) &&	
    Object.isExtensible(value) &&	
    !value._isVue	
  ) {	
    ob = new Observer(value) // 创建Observer	
  }	
  if (asRootData && ob) {	
    ob.vmCount++	
  }	
  return ob	
}	
// Observer 实例	
export class Observer { 	
  value: any;	
  dep: Dep;	
  vmCount: number; // number of vms that have this object as root $data	
  constructor (value: any) {	
    this.value = value	
    this.dep = new Dep() // 给 Observer 添加 Dep 实例,用于收集依赖,辅助 vm.$set/数组方法等	
    this.vmCount = 0	
    // 为被劫持的对象添加__ob__属性,指向自身 Observer 实例。作为是否 Observer 的唯一标识。	
    def(value, '__ob__', this)	
    if (Array.isArray(value)) { // 判断是否是数组	
      if (hasProto) { // 判断是否支持__proto__属性,用来处理数组方法	
        protoAugment(value, arrayMethods) // 继承	
      } else {	
        copyAugment(value, arrayMethods, arrayKeys) // 拷贝	
      }	
      this.observeArray(value) // 劫持数组成员	
    } else {	
      this.walk(value) // 劫持对象	
    }	
  }	
  walk (obj: Object) { // 只有在值是 Object 的时候,才用此方法	
    const keys = Object.keys(obj)	
    for (let i = 0; i < keys.length; i++) {	
      defineReactive(obj, keys[i]) // 数据劫持方法	
    }	
  }	
  observeArray (items: Array<any>) { // 如果是数组,则调用 observe 处理数组成员	
    for (let i = 0, l = items.length; i < l; i++) {	
      observe(items[i]) // 依次处理数组成员	
    }	
  }	
}

上面需要注意的是 __ob__ 属性,避免重复创建, __ob__上有一个 dep 属性,作为依赖收集的储存器,在 vm.$set、数组的 push 等多种方法上需要用到。然后 Vue 将对象和数组分开处理,数组只深度监听了对象成员,这也是之前说的导致不能直接操作索引的原因。但是数组的一些方法是可以正常响应的,比如 push、pop 等,这便是因为上述判断响应对象是否是数组时,做的处理,我们来看看具体代码。

// observer/index.js	
import { arrayMethods } from './array'	
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)	
// export function observe 省略部分代码	
if (Array.isArray(value)) { // 判断是否是数组	
  if (hasProto) { // 判断是否支持__proto__属性,用来处理数组方法	
    protoAugment(value, arrayMethods) // 继承	
  } else {	
    copyAugment(value, arrayMethods, arrayKeys) // 拷贝	
  }	
  this.observeArray(value) // 劫持数组成员	
}	
// ···	
// 直接继承 arrayMethods	
function protoAugment (target, src: Object) { 	
  target.__proto__ = src	
}	
// 依次拷贝数组方法	
function copyAugment (target: Object, src: Object, keys: Array<string>) {	
  for (let i = 0, l = keys.length; i < l; i++) {	
    const key = keys[i]	
    def(target, key, src[key])	
  }	
}	
// util/lang.js  def 方法长这样,用来给对象添加属性	
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {	
  Object.defineProperty(obj, key, {	
    value: val,	
    enumerable: !!enumerable,	
    writable: true,	
    configurable: true	
  })	
}

可以看到关键点在 arrayMethods上,我们再继续看:

// observer/array.js	
import { def } from '../util/index'	
const arrayProto = Array.prototype // 存储数组原型上的方法	
export const arrayMethods = Object.create(arrayProto) // 创建一个新的对象,避免直接改变数组原型方法	
const methodsToPatch = [	
  'push',	
  'pop',	
  'shift',	
  'unshift',	
  'splice',	
  'sort',	
  'reverse'	
]	
// 重写上述数组方法	
methodsToPatch.forEach(function (method) {	
  const original = arrayProto[method]	
  def(arrayMethods, method, function mutator (...args) { // 	
    const result = original.apply(this, args) // 执行指定方法	
    const ob = this.__ob__ // 拿到该数组的 ob 实例	
    let inserted	
    switch (method) {	
      case 'push':	
      case 'unshift':	
        inserted = args	
        break	
      case 'splice':	
        inserted = args.slice(2) // splice 接收的前两个参数是下标	
        break	
    }	
    if (inserted) ob.observeArray(inserted) // 原数组的新增部分需要重新 observe	
    // notify change	
    ob.dep.notify() // 手动发布,利用__ob__ 的 dep 实例	
    return result	
  })	
})

由此可见,Vue 重写了部分数组方法,并且在调用这些方法时,做了手动发布。但是 Vue 的数据劫持部分我们还没有看到,在第一部分的 observer 函数的代码中,有一个 defineReactive 方法,我们来看看:

export function defineReactive (	
  obj: Object,	
  key: string,	
  val: any,	
  customSetter?: ?Function,	
  shallow?: boolean	
) {	
  const dep = new Dep() // 实例一个 Dep 实例	
  const property = Object.getOwnPropertyDescriptor(obj, key) // 获取对象自身属性	
  if (property && property.configurable === false) { // 没有属性或者属性不可写就没必要劫持了	
    return	
  }	
  // 兼容预定义的 getter/setter	
  const getter = property && property.get	
  const setter = property && property.set	
  if ((!getter || setter) && arguments.length === 2) { // 初始化 val	
    val = obj[key]	
  }	
  // 默认监听子对象,从 observe 开始,返回 __ob__ 属性 即 Observer 实例	
  let childOb = !shallow && observe(val)	
  Object.defineProperty(obj, key, {	
    enumerable: true,	
    configurable: true,	
    get: function reactiveGetter () {	
      const value = getter ? getter.call(obj) : val // 执行预设的getter获取值	
      if (Dep.target) { // 依赖收集的关键	
        dep.depend() // 依赖收集,利用了函数闭包的特性	
        if (childOb) { // 如果有子对象,则添加同样的依赖	
          childOb.dep.depend() // 即 Observer时的 this.dep = new Dep();	
          if (Array.isArray(value)) { // value 是数组的话调用数组的方法	
            dependArray(value)	
          }	
        }	
      }	
      return value	
    },	
    set: function reactiveSetter (newVal) {	
      const value = getter ? getter.call(obj) : val	
      // 原有值和新值比较,值一样则不做处理	
      // newVal !== newVal && value !== value 这个比较有意思,但其实是为了处理 NaN	
      if (newVal === value || (newVal !== newVal && value !== value)) {	
        return	
      }	
      if (process.env.NODE_ENV !== 'production' && customSetter) {	
        customSetter()	
      }	
      if (getter && !setter) return	
      if (setter) { // 执行预设setter	
        setter.call(obj, newVal)	
      } else { // 没有预设直接赋值	
        val = newVal	
      }	
      childOb = !shallow && observe(newVal) // 是否要观察新设置的值	
      dep.notify() // 发布,利用了函数闭包的特性	
    }	
  })	
}	
// 处理数组	
function dependArray (value: Array<any>) {	
  for (let e, i = 0, l = value.length; i < l; i++) {	
    e = value[i]	
    e && e.__ob__ && e.__ob__.dep.depend() // 如果数组成员有 __ob__,则添加依赖	
    if (Array.isArray(e)) { // 数组成员还是数组,递归调用	
      dependArray(e)	
    }	
  }	
}
Dep

在上面的分析中,我们弄懂了 Vue 的数据劫持以及数组方法重写,但是又有了新的疑惑,Dep 是做什么的?Dep 是一个发布者,可以被多个观察者订阅。

// observer/dep.js	
let uid = 0	
export default class Dep {	
  static target: ?Watcher;	
  id: number;	
  subs: Array<Watcher>;	
  constructor () {	
    this.id = uid++ // 唯一id	
    this.subs = [] // 观察者集合	
  }	
 // 添加观察者	
  addSub (sub: Watcher) {	
    this.subs.push(sub)	
  }	
 // 移除观察者	
  removeSub (sub: Watcher) {	
    remove(this.subs, sub)	
  }	
  depend () { // 核心,如果存在 Dep.target,则进行依赖收集操作	
    if (Dep.target) {	
      Dep.target.addDep(this)	
    }	
  }	
  notify () {	
    const subs = this.subs.slice() // 避免污染原来的集合	
    // 如果不是异步执行,先进行排序,保证观察者执行顺序	
    if (process.env.NODE_ENV !== 'production' && !config.async) {	
      subs.sort((a, b) => a.id - b.id)	
    }	
    for (let i = 0, l = subs.length; i < l; i++) {	
      subs[i].update() // 发布执行	
    }	
  }	
}	
Dep.target = null // 核心,用于闭包时,保存特定的值	
const targetStack = []	
// 给 Dep.target 赋值当前Watcher,并添加进target栈	
export function pushTarget (target: ?Watcher) {	
  targetStack.push(target)	
  Dep.target = target	
}	
// 移除最后一个Watcher,并将剩余target栈的最后一个赋值给 Dep.target	
export function popTarget () {	
  targetStack.pop()	
  Dep.target = targetStack[targetStack.length - 1]	
}
Watcher

单个看 Dep 可能不太好理解,我们结合 Watcher 一起来看。

// observer/watcher.js	
let uid = 0	
export default class Watcher {	
  // ...	
  constructor (	
    vm: Component, // 组件实例对象	
    expOrFn: string | Function, // 要观察的表达式,函数,或者字符串,只要能触发取值操作	
    cb: Function, // 被观察者发生变化后的回调	
    options?: ?Object, // 参数	
    isRenderWatcher?: boolean // 是否是渲染函数的观察者	
  ) {	
    this.vm = vm // Watcher有一个 vm 属性,表明它是属于哪个组件的	
    if (isRenderWatcher) {	
      vm._watcher = this	
    }	
    vm._watchers.push(this) // 给组件实例的_watchers属性添加观察者实例	
    // options	
    if (options) {	
      this.deep = !!options.deep // 深度	
      this.user = !!options.user	
      this.lazy = !!options.lazy	
      this.sync = !!options.sync // 同步执行	
      this.before = options.before	
    } else {	
      this.deep = this.user = this.lazy = this.sync = false	
    }	
    this.cb = cb // 回调	
    this.id = ++uid // uid for batching // 唯一标识	
    this.active = true // 观察者实例是否激活	
    this.dirty = this.lazy // for lazy watchers	
    // 避免依赖重复收集的处理	
    this.deps = []	
    this.newDeps = []	
    this.depIds = new Set()	
    this.newDepIds = new Set()	
    this.expression = process.env.NODE_ENV !== 'production'	
      ? expOrFn.toString()	
      : ''	
    // parse expression for getter	
    if (typeof expOrFn === 'function') {	
      this.getter = expOrFn	
    } else { // 类似于 Obj.a 的字符串	
      this.getter = parsePath(expOrFn)	
      if (!this.getter) {	
        this.getter = noop // 空函数	
        process.env.NODE_ENV !== 'production' && warn(	
          `Failed watching path: "${expOrFn}" ` +	
          'Watcher only accepts simple dot-delimited paths. ' +	
          'For full control, use a function instead.',	
          vm	
        )	
      }	
    }	
    this.value = this.lazy	
      ? undefined	
      : this.get()	
  }	
  get () { // 触发取值操作,进而触发属性的getter	
    pushTarget(this) // Dep 中提到的:给 Dep.target 赋值	
    let value	
    const vm = this.vm	
    try {	
      // 核心,运行观察者表达式,进行取值,触发getter,从而在闭包中添加watcher	
      value = this.getter.call(vm, vm)	
    } catch (e) {	
      if (this.user) {	
        handleError(e, vm, `getter for watcher "${this.expression}"`)	
      } else {	
        throw e	
      }	
    } finally {	
      if (this.deep) { // 如果要深度监测,再对 value 执行操作	
        traverse(value)	
      }	
      // 清理依赖收集	
      popTarget()	
      this.cleanupDeps()	
    }	
    return value	
  }	
  addDep (dep: Dep) {	
    const id = dep.id	
    if (!this.newDepIds.has(id)) { // 避免依赖重复收集	
      this.newDepIds.add(id)	
      this.newDeps.push(dep)	
      if (!this.depIds.has(id)) {	
        dep.addSub(this) // dep 添加订阅者	
      }	
    }	
  }	
  update () { // 更新	
    /* istanbul ignore else */	
    if (this.lazy) {	
      this.dirty = true	
    } else if (this.sync) {	
      this.run() // 同步直接运行	
    } else { // 否则加入异步队列等待执行	
      queueWatcher(this)	
    }	
  }	
}

到这里,我们可以大概总结一些整个响应式系统的流程,也是我们常说的 观察者模式:第一步当然是通过 observer 进行数据劫持,然后在需要订阅的地方(如:模版编译),添加观察者(watcher),并立刻通过取值操作触发指定属性的 getter 方法,从而将观察者添加进 Dep (利用了闭包的特性,进行依赖收集),然后在 Setter 触发的时候,进行 notify,通知给所有观察者并进行相应的 update。

我们可以这么理解 观察者模式:Dep 就好比是掘金,掘金有很多作者(相当于 data 的很多属性)。我们自然都是充当订阅者(watcher)角色,在掘金(Dep)这里关注了我们感兴趣的作者,比如:江三疯,告诉它江三疯更新了就提醒我去看。那么每当江三疯有新内容时,我们都会收到类似这样的提醒: 江三疯发布了【2019前端进阶之路***】,然后我们就可以去看了。

但是,每个 watcher 可以订阅很多作者,每个作者也都会更新文章。那么没有关注江三疯的用户会收到提醒吗 ?不会,只给已经订阅了的用户发送提醒,而且只有江三疯更新了才提醒,你订阅的是江三疯,可是站长更新了需要提醒你吗?当然不需要。这,也就是闭包需要做的事情。

Proxy

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。— 阮一峰老师的 ECMAScript 6 入门

我们都知道,Vue 3.0 要用 Proxy 替换 Object.defineProperty,那么这么做的好处是什么呢?

好处是显而易见的,比如上述 Vue 现存的两个问题,不能响应对象属性的添加和删除以及不能直接操作数组下标的问题,都可以解决。当然也有不好的,那就是兼容性问题,而且这个兼容性问题 babel 还无法解决。

基础用法

我们用 Proxy 来简单实现一个数据劫持。

let obj = {};	
// 代理 obj	
let handler = {	
    get: function(target, key, receiver) {	
        console.log('get', key);	
        return Reflect.get(target, key, receiver);	
    },	
    set: function(target, key, value, receiver) {	
        console.log('set', key, value);	
        return Reflect.set(target, key, value, receiver);	
    },	
    deleteProperty(target, key) {	
        console.log('delete', key);	
        delete target[key];	
        return true;	
    }	
};	
let data = new Proxy(obj, handler);	
// 代理后只能使用代理对象 data,否则还用 obj 肯定没作用	
console.log(data.name); // get name 、undefined	
data.name = '尹天仇'; // set name 尹天仇	
delete data.name; // delete name

在这个栗子中,obj 是一个空对象,通过 Proxy 代理后,添加和删除属性也能够得到反馈。再来看一下数组的代理:

let arr = ['尹天仇', '我是一个演员', '柳飘飘', '死跑龙套的'];	
let array = new Proxy(arr, handler);	
array[1] = '我养你啊'; // set 1 我养你啊	
array[3] = '先管好你自己吧,傻瓜。'; // set 3 先管好你自己吧,傻瓜。

数组索引的设置也是完全 hold 得住啊,当然 Proxy 的用处也不仅仅是这些,支持拦截的操作就有 13 种。有兴趣的同学可以去看 阮一峰老师的书,这里就不再啰嗦。

Proxy 实现观察者模式

我们前面分析了 Vue 的源码,也了解了观察者模式的基本原理。那用 Proxy 如何实现观察者呢?我们可以简单写一下:

class Dep {	
    constructor() {	
        this.subs = new Set(); 	
        // Set 类型,保证不会重复	
    }	
    addSub(sub) { // 添加订阅者	
        this.subs.add(sub);	
    }	
    notify(key) { // 通知订阅者更新	
        this.subs.forEach(sub => {	
            sub.update();	
        });	
    }	
}	
class Watcher { // 观察者	
    constructor(obj, key, cb) {	
        this.obj = obj;	
        this.key = key;	
        this.cb = cb; // 回调	
        this.value = this.get(); // 获取老数据	
    }	
    get() { // 取值触发闭包,将自身添加到dep中	
        Dep.target = this; // 设置 Dep.target 为自身	
        let value = this.obj[this.key];	
        Dep.target = null; // 取值完后 设置为nul	
        return value;	
    }	
    // 更新	
    update() {	
        let newVal = this.obj[this.key];	
        if (this.value !== newVal) {	
            this.cb(newVal);	
            this.value = newVal;	
        }	
    }	
}	
function Observer(obj) {	
    Object.keys(obj).forEach(key => { // 做深度监听	
        if (typeof obj[key] === 'object') {	
            obj[key] = Observer(obj[key]);	
        }	
    });	
    let dep = new Dep();	
    let handler = {	
        get: function (target, key, receiver) {	
            Dep.target && dep.addSub(Dep.target);	
            // 存在 Dep.target,则将其添加到dep实例中	
            return Reflect.get(target, key, receiver);	
        },	
        set: function (target, key, value, receiver) {	
            let result = Reflect.set(target, key, value, receiver);	
            dep.notify(); // 进行发布	
            return result;	
        }	
    };	
    return new Proxy(obj, handler)	
}

代码比较简短,就放在一块了。整体思路和 Vue 的差不多,需要注意的点仍旧是 get 操作时的闭包环境,使得 Dep.target&&dep.addSub(Dep.target) 可以保证再每个属性的 getter 触发时,是当前 Watcher 实例。闭包不好理解的话,可以类比一下 for 循环 输出 1、2、3、4、5 的例子。

再看一下运行结果:

let data = {	
    name: '渣渣辉'	
};	
function print1(data) {	
    console.log('我系', data);	
}	
function print2(data) {	
    console.log('我今年', data);	
}	
data = Observer(data);	
new Watcher(data, 'name', print1);	
data.name = '杨过'; // 我系 杨过	
new Watcher(data, 'age', print2);	
data.age = '24'; // 我今年 24

MVVM

说了那么多,该练练手了。Vue 作为典型的 MVVM 框架,大大提高了前端er 的生产力,我们这次就参考 Vue 自己实现一个简易的 MVVM。

实现部分参考自 剖析Vue实现原理 - 如何实现双向绑定mvvm

什么是 MVVM ?

简单介绍一下 MVVM,更全面的讲解,大家可以看这里 MVVM 模式。MVVM 的全称是 Model-View-ViewModel,它是一种架构模式,最早由微软提出,借鉴了 MVC 等模式的思想。

ViewModel 负责把 Model 的数据同步到 View 显示出来,还负责把 View 对数据的修改同步回 Model。而 Model 层作为数据层,它只关心数据本身,不关心数据如何操作和展示;View 是视图层,负责将数据模型转化为 UI 界面展现给用户。

640?wx_fmt=png

图片来自 MVVM 模式

如何实现一个 MVVM?

想知道如何实现一个 MVVM,至少我们得先知道 MVVM 有什么。我们先看看大体要做成个什么模样。

<body>	
<div id="app">	
    姓名:<input type="text" v-model="name"> <br>	
    年龄:<input type="text" v-model="age"> <br>	
    职业:<input type="text" v-model="profession"> <br>	
    <p> 输出:{{info}} </p>	
    <button v-on:click="clear">清空</button>	
</div>	
</body>	
<script src="mvvm.js"></script>	
<script>	
    const app = new MVVM({	
        el: '#app',	
        data: {	
            name: '',	
            age: '',	
            profession: ''	
        },	
        methods: {	
            clear() {	
                this.name = '';	
                this.age =  '';	
                this.profession = '';	
            }	
        },	
        computed: {	
            info() {	
                return `我叫${this.name},今年${this.age},是一名${this.profession}`;	
            }	
        }	
    })	
</script>

运行效果:

640?wx_fmt=png

好,看起来是模仿(抄袭)了 Vue 的一些基本功能,比如双向绑定、computed、v-on等等。为了方便理解,我们还是大致画一下原理图。

640?wx_fmt=jpeg

从图中看,我们现在需要做哪些事情呢?数据劫持、数据代理、模板编译、发布订阅,咦,等一下,这些名词是不是看起来很熟悉?这不就是之前分析 Vue 源码时候做的事吗?(是啊,是啊,可不就是抄的 Vue 嘛)。OK,数据劫持、发布订阅我们都比较熟悉了,可是模板编译还没有头绪。不急,这就开始。

new MVVM()

我们按照原理图的思路,第一步是 newMVVM(),也就是初始化。初始化的时候要做些什么呢?可以想到的是,数据的劫持以及模板(视图)的初始化。

class MVVM {	
    constructor(options) { // 初始化	
        this.$el = options.el;	
        this.$data = options.data;	
        if(this.$el){ // 如果有 el,才进行下一步	
            new Observer(this.$data);	
            new Compiler(this.$el, this);	
        }	
    }	
}

好像少了点什么,computed、methods 也需要处理,补上。

class MVVM {	
    constructor(options) { // 初始化	
        // ··· 接收参数	
        let computed = options.computed;	
        let methods = options.methods;	
        let that = this;	
        if(this.$el){ // 如果有 el,才进行下一步	
        // 把 computed 的key值代理到 this 上,这样就可以直接访问 this.$data.info,取值的时候便直接运行 计算方法	
            for(let key in computed){	
                Object.defineProperty(this.$data, key, {	
                    get() {	
                        return computed[key].call(that);	
                    }	
                })	
            }	
        // 把 methods 的方法直接代理到 this 上,这样可以访问 this.clear	
            for(let key in methods){	
                Object.defineProperty(this, key, {	
                    get(){	
                        return methods[key];	
                    }	
                })	
            }	
        }	
    }	
}

上面代码中,我们把 data 放到了 this.$data 上,但是想想我们平时,都是用 this.xxx 来访问的。所以,data 也和计算属性它们一样,需要加一层代理,方便访问。对于计算属性的详细流程,我们在数据劫持的时候再讲。

class MVVM {	
    constructor(options) { // 初始化	
        if(this.$el){	
            this.proxyData(this.$data);	
            // ··· 省略	
        }	
    }	
    proxyData(data) { // 数据代理	
        for(let key in data){	
           // 访问 this.name 实际是访问的 this.$data.name	
            Object.defineProperty(this, key, {	
                get(){	
                    return data[key];	
                },	
                set(newVal){	
                    data[key] = newVal;	
                }	
            })	
        }	
    }	
}

数据劫持、发布订阅

初始化后我们还剩两步操作等待处理。

new Observer(this.$data); // 数据劫持 + 发布订阅	
new Compiler(this.$el, this); // 模板编译

数据劫持和发布订阅,我们文章前面花了很长的篇幅一直在讲这个,大家应该都很熟悉了,所以先把它干掉。

class Dep { // 发布订阅	
    constructor(){	
        this.subs = []; // watcher 观察者集合	
    }	
    addSub(watcher){ // 添加 watcher	
        this.subs.push(watcher);	
    }	
    notify(){ // 发布	
        this.subs.forEach(w => w.update());	
    }	
}	
class Watcher{ // 观察者	
    constructor(vm, expr, cb){	
        this.vm = vm; // 实例	
        this.expr = expr; // 观察数据的表达式	
        this.cb = cb; // 更新触发的回调	
        this.value = this.get(); // 保存旧值	
    }	
    get(){ // 取值操作,触发数据 getter,添加订阅	
        Dep.target = this; // 设置为自身	
        let value = resolveFn.getValue(this.vm, this.expr); // 取值	
        Dep.target = null; // 重置为 null	
        return value;	
    }	
    update(){ // 更新	
        let newValue = resolveFn.getValue(this.vm, this.expr);	
        if(newValue !== this.value){	
            this.cb(newValue);	
            this.value = newValue;	
        }	
    }	
}	
class Observer{ // 数据劫持	
    constructor(data){	
        this.observe(data);	
    }	
    observe(data){	
        if(data && typeof data === 'object') {	
            if (Array.isArray(data)) { // 如果是数组,遍历观察数组的每个成员	
                data.forEach(v => {	
                    this.observe(v);	
                });	
                // Vue 在这里还进行了数组方法的重写等一些特殊处理	
                return;	
            }	
            Object.keys(data).forEach(k => { // 观察对象的每个属性	
                this.defineReactive(data, k, data[k]);	
            });	
        }	
    }	
    defineReactive(obj, key, value) {	
        let that = this;	
        this.observe(value); //对象属性的值,如果是对象或者数组,再次观察	
        let dep = new Dep();	
        Object.defineProperty(obj, key, {	
            get(){ // 取值时,判断是否要添加 Watcher,收集依赖	
                Dep.target && dep.addSub(Dep.target);	
                return value;	
            },	
            set(newVal){	
                if(newVal !== value) {	
                    that.observe(newVal); // 观察新设置的值	
                    value = newVal;	
                    dep.notify(); // 发布	
                }	
            }	
        })	
    }	
}

取值的时候,我们用到了 resolveFn.getValue 这么一个方法,这是一个工具方法的集合,后续编译的时候还有很多。我们先仔细看看这个方法。

resolveFn = { // 工具函数集	
    getValue(vm, expr) { // 返回指定表达式的数据	
        return expr.split('.').reduce((data, current)=>{	
            return data[current]; // this[info]、this[obj][a]	
        }, vm);	
    }	
}

我们在之前的分析中提到过,表达式可以是一个字符串,也可以是一个函数(如渲染函数),只要能触发取值操作即可。我们这里只考虑了字符串的形式,哪些地方会有这种表达式呢?比如 {{info}}、比如 v-model="name"中 = 后面的就是表达式。它也有可能是 obj.a 的形式。所以这里利用 reduce 达到一个连续取值的效果。

计算属性 computed

初始化时候遗留了一个问题,因为涉及到发布订阅,所以我们在这里详细分析一下计算属性的触发流程,初始化的时候,模板中用到了 {{info}},那么在模板编译的时候,就需要触发一次 this.info 的取值操作获取真实的值用来替换 {{info}} 这个字符串。我们就同样在这个地方添加一个观察者。

    compileText(node, '{{info}}', '') // 假设编译方法长这样,初始值为空	
    new Watcher(this, 'info', () => {do something}) // 我们紧跟着实例化一个观察者

这个时候会触发什么操作?我们知道 newWatcher() 的时候,会触发一次取值。根据刚才的取值函数,这时候会去取 this.info,而我们在初始化的时候又做了代理。

for(let key in computed){	
    Object.defineProperty(this.$data, key, {	
        get() {	
            return computed[key].call(that);	
        }	
    })	
}

所以这时候,会直接运行 computed 定义的方法,还记得方法长什么样吗?

computed: {	
    info() {	
        return `我叫${this.name},今年${this.、age},是一名${this.profession}`;	
    }	
}

于是又会接连触发 name、age 以及 profession 的取值操作。

defineReactive(obj, key, value) {	
    // ···	
    let dep = new Dep();	
    Object.defineProperty(obj, key, {	
        get(){ // 取值时,判断是否要添加 Watcher,收集依赖	
            Dep.target && dep.addSub(Dep.target);	
            return value;	
        }	
        // ···	
    })	
}

这时候就充分利用了 闭包 的特性,要注意的是现在仍然还在 info 的取值操作过程中,因为是 同步 方法,这也就意味着,现在的 Dep.target 是存在的,并且是观察 info 属性的 Watcher。所以程序会在 name、age 和 profession 的 dep 上,分别添加上 info 的 Watcher,这样,在这三个属性后面任意一个值发生变化,都会通知给 info 的 Watcher 重新取值并更新视图。

打印一下此时的 dep,方便理解。 640?wx_fmt=png

模板编译

其实前面已经提到了一些模板编译相关的东西,这一部分主要做的事就是将 html 上的模板语法编译成真实数据,将指令也转换为相对应的函数。

在编译过程中,避免不了要操作 Dom 元素,所以这里用了一个 createDocumentFragment 方法来创建文档碎片。这在 Vue 中实际使用的是虚拟 dom,而且在更新的时候用 diff 算法来做 最小代价渲染

文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(对元素位置和几何上的计算)。因此,使用文档片段通常会带来更好的性能。— MDN

class Compiler{	
    constructor(el, vm) {	
        this.el = this.isElementNode(el) ? el : document.querySelector(el); // 获取app节点	
        this.vm = vm;	
        let fragment = this.createFragment(this.el); // 将 dom 转换为文档碎片	
        this.compile(fragment); // 编译	
        this.el.appendChild(fragment); // 变易完成后,重新放回 dom	
    }	
    createFragment(node) { // 将 dom 元素,转换成文档片段	
        let fragment = document.createDocumentFragment();	
        let firstChild;	
        // 一直去第一个子节点并将其放进文档碎片,直到没有,取不到则停止循环	
        while(firstChild = node.firstChild) {	
            fragment.appendChild(firstChild);	
        }	
        return fragment;	
    }	
    isDirective(attrName) { // 是否是指令	
        return attrName.startsWith('v-');	
    }	
    isElementNode(node) { // 是否是元素节点	
        return node.nodeType === 1;	
    }	
    compile(node) { // 编译节点	
        let childNodes = node.childNodes; // 获取所有子节点	
        [...childNodes].forEach(child => {	
            if(this.isElementNode(child)){ // 是否是元素节点	
                this.compile(child); // 递归遍历子节点	
                let attributes = child.attributes; 	
                // 获取元素节点的所有属性 v-model class 等	
                [...attributes].forEach(attr => { // 以  v-on:click="clear" 为例	
                    let {name, value: exp} = attr; // 结构获取 "clear"	
                    if(this.isDirective(name)) { // 判断是不是指令属性	
                        let [, directive] = name.split('-'); // 结构获取指令部分 v-on:click	
                        let [directiveName, eventName] = directive.split(':'); // on,click	
                        resolveFn[directiveName](child, exp, this.vm, eventName); 	
                        // 执行相应指令方法	
                    }	
                })	
            }else{ // 编译文本	
                let content = child.textContent; // 获取文本节点	
                if(/\{\{(.+?)\}\}/.test(content)) { // 判断是否有模板语法 {{}}	
                    resolveFn.text(child, content, this.vm); // 替换文本	
                }	
            }	
        });	
    }	
}	
// 替换文本的方法	
resolveFn = { // 工具函数集	
    text(node, exp, vm) {	
        // 惰性匹配,避免连续多个模板时,会直接取到最后一个花括号	
        // {{name}} {{age}} 不用惰性匹配 会一次取全 "{{name}} {{age}}"	
        // 我们期望的是 ["{{name}}", "{{age}}"]	
        let reg = /\{\{(.+?)\}\}/;	
        let expr = exp.match(reg);	
        node.textContent = this.getValue(vm, expr[1]); // 编译时触发更新视图	
        new Watcher(vm, expr[1], () => { // setter 触发发布	
            node.textContent = this.getValue(vm, expr[1]);	
        });	
    }	
}

在编译元素节点(this.compile(node))的时候,我们判断了元素属性是否是指令,并调用相对应的指令方法。所以最后,我们再来看看一些指令的简单实现。

  • 双向绑定 v-model

resolveFn = { // 工具函数集	
    setValue(vm, exp, value) {	
        exp.split('.').reduce((data, current, index, arr)=>{ // 	
            if(index === arr.length-1) { // 最后一个成员时,设置值	
                return data[current] = value;	
            }	
            return data[current];	
        }, vm.$data);	
    },	
    model(node, exp, vm) {	
        new Watcher(vm, exp, (newVal) => { // 添加观察者,数据变化,更新视图	
            node.value = newVal;	
        });	
        node.addEventListener('input', (e) => { //监听 input 事件(视图变化),事件触发,更新数据	
            let value = e.target.value;	
            this.setValue(vm, exp, value); // 设置新值	
        });	
        // 编译时触发	
        let value  = this.getValue(vm, exp);	
        node.value = value;	
    }	
}

双向绑定大家应该很容易理解,需要注意的是 setValue 的时候,不能直接用 reduce 的返回值去设置。因为这个时候返回值,只是一个值而已,达不到重新赋值的目的。

  • 事件绑定 v-on 还记得我们初始化的时候怎么处理的 methods 吗?

for(let key in methods){	
    Object.defineProperty(this, key, {	
        get(){	
            return methods[key];	
        }	
    })	
} 

我们将所有的 methods 都代理到了 this 上,而且我们在编译 v-on:click="clear" 的时候,将指令解构成了 'on'、'click'、'clear' ,那么 on 函数的实现是不是呼之欲出了呢?

on(node, exp, vm, eventName) { // 监听对应节点上的事件,触发时调用相对应的代理到 this 上的方法	
    node.addEventListener(eventName, e => {	
        vm[exp].call(vm, e);	
    })	
}

Vue 提供的指令还有很多,比如:v-if,实际是将 dom 元素添加或移除的操作;v-show,实际是操作元素的 display 属性为 block 或者 none;v-html,是将指令值直接添加给 dom 元素,可以用 innerHTML 实现,但是这种操作太不安全,有 xss 风险,所以 Vue 也是建议不要将接口暴露给用户。还有 v-for、v-slot 这类相对复杂些的指令,感兴趣的同学可以自己再探究。

总结

文章完整代码在 文章仓库 ??fe-code 。 本期主要讲了 Vue 的响应式原理,包括数据劫持、发布订阅、Proxy 和 Object.defineProperty 的不同点等等,还顺带简单写了个 MVVM。Vue 作为一款优秀的前端框架,可供我们学习的点太多,每一个细节都值得我们深究。后续还会带来系列的 Vue、javascript 等前端知识点的文章,感兴趣的同学可以关注下。

参考文章

  • 剖析Vue实现原理 - 如何实现双向绑定mvvm

  • Vue 源码分析

  • 关于正则,推荐老姚的《JavaScript正则迷你书》,讲得非常易读

后记

如果你看到了这里,且本文对你有一点帮助的话,希望你可以动动小手支持一下作者,感谢?。文中如有不对之处,也欢迎大家指出,共勉。

  • 文章仓库 ??fe-code

  • 社交聊天系统(vue + node + mongodb)- ???Vchat

欢迎关注公众号 前端发动机,第一时间获得作者文章推送,还有海量前端大佬优质文章,致力于成为推动前端成长的引擎。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值