【2019 前端进阶之路】深入 Vue 响应式原理,活捉一个 MVVM(超详细!)

栏目: 编程工具 · 发布时间: 6年前

内容简介:作为 Vue 面试中的必考题之一,Vue 的响应式原理,想必用过 Vue 的同学都不会陌生,Vue 官方文档 对响应式要注意的问题也都做了详细的说明。但是对于刚接触或者了解不多的同学来说,可能还会感到困惑:为什么不能检测到对象属性的添加或删除?为什么不支持通过索引设置数组成员?相信看完本期文章,你一定会豁然开朗。本文会结合

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

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

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

文章仓库和源码都在 :tropical_drink::cake: fe-code ,欢迎 star 。

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

【2019 前端进阶之路】深入 Vue 响应式原理,活捉一个 MVVM(超详细!)

思考

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

<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 方法。

【2019 前端进阶之路】深入 Vue 响应式原理,活捉一个 MVVM(超详细!)

另外,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 界面展现给用户。

【2019 前端进阶之路】深入 Vue 响应式原理,活捉一个 MVVM(超详细!)

图片来自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>
复制代码

运行效果:

【2019 前端进阶之路】深入 Vue 响应式原理,活捉一个 MVVM(超详细!)

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

【2019 前端进阶之路】深入 Vue 响应式原理,活捉一个 MVVM(超详细!)

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

new MVVM()

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

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}) // 我们紧跟着实例化一个观察者
复制代码

这个时候会触发什么操作?我们知道 new Watcher() 的时候,会触发一次取值。根据刚才的取值函数,这时候会去取 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,方便理解。

【2019 前端进阶之路】深入 Vue 响应式原理,活捉一个 MVVM(超详细!)

模板编译

其实前面已经提到了一些模板编译相关的东西,这一部分主要做的事就是将 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 这类相对复杂些的指令,感兴趣的同学可以自己再探究。

总结

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

参考文章

交流群

qq前端交流群:960807765,欢迎各种技术交流,期待你的加入

后记

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

更多文章:

前端进阶之路系列

从头到脚实战系列

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

【2019 前端进阶之路】深入 Vue 响应式原理,活捉一个 MVVM(超详细!)

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

ANSI Common Lisp

ANSI Common Lisp

Paul Graham / Prentice Hall / 1995-11-12 / USD 116.40

For use as a core text supplement in any course covering common LISP such as Artificial Intelligence or Concepts of Programming Languages. Teaching students new and more powerful ways of thinking abo......一起来看看 《ANSI Common Lisp》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

图片转BASE64编码
图片转BASE64编码

在线图片转Base64编码工具

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换