【前端发动机】深入 Vue 响应式原理,从源码分析

9,934 阅读11分钟

前言

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

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

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

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

经大佬提醒,Vue 并不完全是 MVVM 模型,大家审慎阅读。

虽然没有完全遵循 MVVM 模型,但是 Vue 的设计也受到了它的启发。因此在文档中经常会使用 vm (ViewModel 的缩写) 这个变量名表示 Vue 实例。 — Vue 官网

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

思考

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

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

另外,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 大大提高了前端er 的生产力,我们这次就参考 Vue 自己实现一个简易的 Vue 框架。

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

什么是 MVVM ?

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

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

图片来自 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>

运行效果:

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

从图中看,我们现在需要做哪些事情呢?数据劫持、数据代理、模板编译、发布订阅,咦,等一下,这些名词是不是看起来很熟悉?这不就是之前分析 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,取值的时候便直接运行 计算方法
        // 注意 computed 需要代理,不需要Observer
            for(let key in computed){
                Object.defineProperty(this.$data, key, {
                    enumerable: true,
                    configurable: true,
                    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, {
                enumerable: true,
                configurable: true,
                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, {
            enumerable: true,
            configurable: true,
            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,方便理解。

模板编译

其实前面已经提到了一些模板编译相关的东西,这一部分主要做的事就是将 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 等前端知识点的文章,感兴趣的同学可以关注下。

参考文章

交流群

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

后记

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

更多文章:

前端进阶之路系列

从头到脚实战系列

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