vue2的响应式原理

116 阅读8分钟

Vue2-响应式原理

核心就是利用 Object.defineProperty 给数据添加了 getter 和 setter,目的就是为了在我们访问数据以及写数据的时候能自动执行一些逻辑:getter 做的事情是依赖收集,setter 做的事情是派发更新

在实际实现中,利用了一套典型的发布订阅模式来实现的。主要实现了有3个类 :

Observer

被观察数据类,所有定义在data的数据均会被递归变成 Observer的实例,即数据响应式化

class Observer {
    constructor(value) {
        this.value = value
        
        // 用于判断该数据是否被响应式过
        Object.defineProperty(value, '_ob_', {
          value: this,
          enumerable: false, // 不可枚举
          writable: true,
          configurable: true
        })
        
        this.walk(value)
    }
    
    // 为对象的每个属性进行响应式处理
    walk(obj) {
        const keys = Object.keys(obj)
        for (let i = 0; i < keys.length; i++) {
            const key = keys[i]
            const value = obj[key]
            defineReactive(obj, key, value)
        }
    }
}
​
// 为对象属性定义get,set
function defineReactive(obj, key, value) {
    const dep = new Dep() // 依赖管理类
    observe(value) // 递归响应式化子项
    Object.defineProperty(obj, key, {
        get() {
            if (Dep.target) { // 存在正常运行的依赖
                dep.depend() // 收集依赖
            }
            return value
        },
        set(newVal) {
            if (newVal === value) return
            value = newVal // 赋值
            observe(newVal) // 新值也要响应式化
            dep.notify() // 派发更新
        }
    })   
}
​
// 响应式化对象或数组
function observe(data) {
    if (
        Object.prototype.toString.call(data) === "[object Object]" ||
        Array.isArray(data)
    ) {
        if (data._ob_) return data._ob_
        return new Observer(data)
    }
}

Dep

依赖管理类 , 用来收集依赖,触发依赖更新的类

let uid = 0 
class Dep {
    constructor() {
        this.id = uid++ // 唯一标识,可用于依赖去重
        this.subs = [] // 收集依赖的容器
    }
    
      // 收集当前正在运行的依赖
      depend() {
        // 如果当前存在watcher
        if (Dep.target) {
          Dep.target.addDep(this); // 把自身-dep实例存放在watcher里面, 在watcher里会再调用dep的addSub收集watcher到dep的subs里。watcher和dep其实是双向的,互相知道谁收集了谁,以及谁被谁收集了,方法后面处理依赖的删除,去重之类的复杂操作
        }
      }
      notify() {
        // 执行subs里面的watcher更新方法
        this.subs.forEach((watcher) => watcher.update());
      }
      addSub(watcher) {
        // 把watcher加入到自身的subs容器
        this.subs.push(watcher);
      }
}
​
// 全局静态属性,用于标记当前正在执行的watcher
Dep.target = null

Watcher

观察者类,也即是依赖,vue里的观察者包含3种,用于渲染的渲染watcher、用于用户配置watch的用户watcher、用于computed的computed watcher。这里就实现最简单的渲染watcher

class Watcher {
    constructor(render) {
        
        this.deps = []
        this.depsId = new Set()
        
        this.getter = render // 由于是渲染watcher我们就简单的默认它就是render函数了,
        
        this.get()//立即执行一次
    }
    get() {
        // 执行watcher时,设置当前运行watcher到全局
        Dep.target = this
        this.getter() // 执行watcher,执行中用到了data中的数据就会,触发data的get,然后就会收集依赖收集Dep.target也就是现在的这个watcher
        Dep.target = null
    }
    
    addDep(dep) {
        let id = dep.id
        // 去重处理,保证同一个dep只会收集一次同一个watcher
        if (!this.depsId.has(id)) {
            this.depsId.add(id)
            this.deps.push(dep)
            // dep收集当前依赖
            dep.addSub(this)
        }
    }
    
    update() {
        // 更新即重新运行一次
        this.get()
    }
    
}

至此简易响应式完成,接下来就是联系起来运行

模拟运行

// 模拟 data
const data = {
    name: '张三'
}
​
// 模拟渲染render
const render = () => {
    console.log('渲染数据-》' + data.name)
}
​
// 响应式化数据和渲染watcher创建
observe(data)
new Watcher(render)
​
// 模拟操作
​
data.name = '李四'
data.name = '王五'/** 打印------
* 渲染数据-》张三
* 渲染数据-》李四
* 渲染数据-》王五
/

思考这样的数据劫持方式对数组支持吗?有什么影响?

先验证是否支持数据下标的修改

// 模拟 data
const data = {
    list: ['张三']
}
​
// 模拟渲染render
const render = () => {
    console.log('渲染数据-》' + data.list[0])
}
​
// 响应式化数据和渲染watcher创建
observe(data)
new Watcher(render)
​
// 模拟操作
​
data.list[0] = '李四'
data.list[0] = '王五'/** 打印------
* 渲染数据-》张三
* 渲染数据-》李四
* 渲染数据-》王五
/

结果显示是支持的,那为什么vue2的响应式系统不支持数组的下标直接修改呢?

其实尤大有解释过,原因就是性能。相比对象,数组一般是用来存储数据的,所以数组可能非常大,想象一下一个数组要是有成千上万个元素,给每一个元素都设置get和set这对性能来讲是承担不起的,所以vue2中对数组做了特殊处理,此方法便只用了劫持对象。

数组的处理

改写Observer类,单独处理数组

import { arrayMethods } from "./array";
class Observer {
  constructor(value) {
    this.dep = new Dep() // 添加dep,用于收集数组的依赖
    if (Array.isArray(value)) {
      // 这里对数组做了额外判断
      // 通过重写数组原型方法来对数组的七种方法进行拦截
      value.__proto__ = arrayMethods;
      // 如果数组的子项是对象或者数组,则还要递归响应式化
      this.observeArray(value);
    } else {
      this.walk(value);
    }
  }
  observeArray(items) {
    for (let i = 0; i < items.length; i++) {
      observe(items[i]);
    }
  }
}
​
function defineReactive(obj, key, value) {
    const dep = new Dep() 
    const childOb = observe(value) // 递归响应式化子项
    Object.defineProperty(obj, key, {
        get() {
            if (Dep.target) { 
                dep.depend()
                if (childOb) { // 由于数组没有设置get, 数组push时只会触发数组Oberver类的dep,并不会触发父对象的get,所以这里要收集一下
                    chidObj.dep.depend()
                    if (Array.isArray(value)) {
                        // 同样数组的子项要还是数组的话也要收集,所以这里递归收集数组依赖
                        dependArray(value)
                    }
                }
            }
            return value
        }
    })   
}
// 递归收集数组依赖
function dependArray(value) {
  for (let e, i = 0, l = value.length; i < l; i++) {
    e = value[i];
    // e.__ob__代表e已经被响应式观测了 但是没有收集依赖 所以把他们收集到自己的Observer实例的dep里面
    e && e.__ob__ && e.__ob__.dep.depend();
    if (Array.isArray(e)) {
      // 如果数组里面还有数组  就递归去收集依赖
      dependArray(e);
    }
  }
}

重写数组原型方法

// 先保留数组原型
const arrayProto = Array.prototype;
// 然后将arrayMethods继承自数组原型
export const arrayMethods = Object.create(arrayProto);
const methodsToPatch = [
  "push",
  "pop",
  "shift",
  "unshift",
  "splice",
  "reverse",
  "sort",
];
methodsToPatch.forEach((method) => {
  arrayMethods[method] = function (...args) {
    //   这里保留原型方法的执行结果
    const result = arrayProto[method].apply(this, args);
    // 这句话是关键
    // this代表的就是数据本身 比如数据是{a:[1,2,3]} 那么我们使用a.push(4)  this就是a  ob就是a.__ob__ 这个属性就是上段代码增加的 代表的是该数据已经被响应式观察过了指向Observer实例
    const ob = this.__ob__;
​
    // 这里的标志就是代表数组有新增操作
    let inserted;
    switch (method) {
      case "push":
      case "unshift":
        inserted = args;
        break;
      case "splice":
        inserted = args.slice(2);
      default:
        break;
    }
    // 如果有新增的元素 inserted是一个数组 调用Observer实例的observeArray对数组每一项进行观测
    if (inserted) ob.observeArray(inserted);
    // 派发更新
    ob.dep.notify()
    return result;
  };
});

思考,既然vue2不支持数组下标修改,那么为啥我有时使用数组下标修改数据,能成功触发更新呢? wtf?

其实是我们没理清下面这两种情况

// 这样可以触发更新
new Vue({
    data() {
        return {
            peoples:[{
                name: '张三'
            }]
        }
    },
    created() {
        this.peoples[0].name = '李四'
    }
})
// 这样不会触发更新
new Vue({
    data() {
        return {
            peoples:['张三']
        }
    },
    created() {
        this.peoples[0] = '李四'
    }
})

原因就是上面写的,数组的子项如果还是对象,那么子项也会被响应式,所以第一种实际是触发了子项的get和set然后派发的更新

侦听属性原理

侦听属性即vue选项中watch或vue实例方法的$watch。vue源码中选项watch最终会被转换成调用实例的$watch,所以我们就只考虑$watch

先看官网用法:

// 键路径
vm.$watch('a.b.c', function (newVal, oldVal) {
  // 做点什么
})
// 函数
vm.$watch(
  function () {
    return this.a + this.b
  },
  function (newVal, oldVal) {
    // 做点什么
  }
)

再看源码:

// src/core/instance/state.js
/* @flow */
Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    const vm: Component = this
    // ...省略部分代码...
    options = options || {}
    options.user = true // 配置user为true即 用户watcher
    // 创建watcher
    const watcher = new Watcher(vm, expOrFn, cb, options)
    // 配置了立即执行
    if (options.immediate) {
      const info = `callback for immediate watcher "${watcher.expression}"`
      // 开启不作依赖收集
      pushTarget()
      // 就是调用cb,等价于`cd()`,由于是用户的代码所以做了兼容错误处理
      invokeWithErrorHandling(cb, vm, [watcher.value], vm, info)
      popTarget()
    }
    // ...省略部分代码...
  }

分析上面的源码就是 配置options.user = true 并创建用户watcher,如果配置了立即执行则立即执行,可以看到new Watcher时wacher的参数有4个,而我们之前写渲染watcher时只配置了一个参数,所以改造下之前的Watcher类

class Watcher {
    constructor(vm, exprOrFn, cb, options) {
        this.vm = vm
        this.cb = cb
        // ...省略之前的代码
        this.user = options.user
        
        if (typeof exprOrFn === 'function') {
            this.getter = exprOrFn; // exprOrFn就是上面配的render,这里是参照源码命名
        } else {
             // 【*****】watch监听的数据一般定义在data中,当创建user watcher时会执行watcher里的get(),读取监听的数据;这个过程中就触发了数据的getter,会进行依赖收集(当前的Dep.target为user watcher(在get()中设置的),所以收集的是user watcher)
            this.getter = function () {
            // watcher监听的数据可能是第一层 obj1,也可能是深层的某个属性 obj1.a.b,后者需要处理成 vm.obj1.a.b
            let path = exprOrFn.split('.')
            let obj = vm
            for (let i = 0; i < path.length; i++) {
              obj = obj[path[i]]
            }
            return obj
        }
            
        this.value = this.get()//立即执行一次   
    }
     get() {
     	Dep.target = this
        this.getter()
        Dep.target = null
    }
    
    // 源码里update还有与computed watcher和 调度 相关的操作,这里就保持和源码命名一致
    update() {
        this.run()
    }
        
    run() {
        const newVal = this.get(); //新值
        const oldVal = this.value; //老值
        this.value = newVal; //现在的新值将成为下一次变化的老值
        // 用户watcher
        if (this.user) {
          // 如果两次的值不相同  或者值是引用类型 因为引用类型新老值是相等的 他们是指向同一引用地址
          if (newVal !== oldVal || isObject(newVal)) {
            this.cb.call(this.vm, newVal, oldVal);
          }
        } else {
          this.cb.call(this.vm);
        }
    }
    
}

总结,用户watcher就是一种特殊的watcher,主要改造有两点

1.实例化的时候为了兼容用户 watch 的写法 会将传入的字符串写法转成 Vue 实例对应的值 并且调用 get 方法获取并保存一次旧值

2.run 方法判断如果是用户 watch 那么执行用户传入的回调函数 cb 并且把新值和旧值作为参数传入进去

计算属性原理

计算属性即vue选项参数中的computed配置。

计算属性初始化

查看源码 new Vue后会调用vue的初始化方法_init(),然后调用初始化状态方法initState(),里面会调用计算属性的初始化方法 initComputed

function initComputed (vm: Component, computed: Object) {
    
  const watchers = vm._computedWatchers = Object.create(null)

  // ...省略部分代码...
  // 遍历computed配置
  for (const key in computed) {
    const userDef = computed[key] // 用户配置的内容
 
    // 根据用户get配置,创建computed watcher,即把conputed的key对应值的的执行和重新计算交给watcher管理
 	const getter = typeof userDef === 'function' ? userDef : userDef.get   
    watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        { lazy: true } // lazy代表的就是 computed watcher
    )
    // 没有已经定义在实例上的key, 例如相同的key已经在data上定义了就会抛错
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    }
    // ...省略部分代码...
  }
}

计算属性可以写成一个函数也可以写成一个对象 对象的形式 get 属性就代表的是计算属性依赖的值 set 代表修改计算属性的依赖项的值 我们主要关心 get 属性 然后类似侦听属性 我们把 lazy:true 传给构造函数 Watcher 用来创建计算属性 Watcher 那么 defineComputed 是什么意思呢

const noop = () => {}
// 共享的对象定义内容
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}
// 定义computed
function defineComputed (
  target: any, // vue实例vm 
  key: string,
  userDef: Object | Function
) {
 	// ...省略部分代码,同时删除了部分ssr代码...
  // 根据配置不同设置get和set, function类型的就只有get没有set
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get = createComputedGetter(key)
    sharedPropertyDefinition.set = noop
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? createComputedGetter(key)
      : noop
    sharedPropertyDefinition.set = userDef.set || noop
  }
	// ...省略部分代码
  // 将定义好的computed key挂载到vue实例上,也就是方面我们通过this获取
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

// 重写计算属性的get方法 来判断是否需要进行重新计算
function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key] // 获取对应的computed watcher
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate() // /计算属性取值的时候 如果是脏的,就需要重新求值, 否则直接返回之前的值,computed watcher的值是否是脏的,取决于执行computed watcher时所依赖的响应式对象的值是否有变动,如果没有变动,那么在此获取computed watcher的值是就不会计算了,这就是computed watcher的缓存计算的原理
      }
   	  // 触发computed的get时还存在watcher,那就是使用了当前computed值的地方,一般是渲染watcher
      if (Dep.target) {
        watcher.depend() // 通知所有收集了这个computed watcher作为依赖的响应式对象,收集使用了computed值watcher,以便后面响应式对象发生变动时能直接通知引用了computed值的地方更新
      }
      return watcher.value
    }
  }
}

改造Watcher适配 computed watcher

class Watcher {
    constructor(vm, exprOrFn, cb, options) {
        // ...省略之前的代码
        this.lazy = options.lazy; // lazy为true即computed watcher
    	this.dirty = this.lazy; //dirty可变  表示计算watcher是否需要重新计算 默认值是true
        // computed watcher 首次不执行,仅将dirty直接置为true
    	this.value = this.lazy ? undefined : this.get();
    }
    
    update() {
        // 计算属性依赖的值发生变化 只需要把dirty置为true  下次访问到了重新计算
        if (this.lazy) {
          this.dirty = true;
        } else {
          this.run()
        }
    }
    // 计算属性重新进行计算 并且计算完成把dirty置为false
    evaluate() {
        this.value = this.get();
        this.dirty = false;
    }
    
    depend() {
        // 计算属性的watcher存储了依赖项的dep
        let i = this.deps.length;
        while (i--) {
            this.deps[i].depend(); //调用依赖项的dep去收集渲染watcher
        }
    }

}

通过上面我们其实可以把 computedWatcher理解为,用到了computed值的渲染watcher和computed中使用过的响应式对象间的一个桥梁watcher,即:

首次渲染watcher渲染时,用到了computed的值,就会触发computed的get,然后computed就会计算(首次渲染dirty为true),计算computed值又会触发到computed里引用的响应式对象的get,响应式对象就会先收集computed watcher为依赖,计算完computed值后,computed 会主动通知,所有刚刚收集了computed watcher为依赖的响应式对象,去收集此时使用了computed值的渲染watcher,最后computed watcher和使用了computed值的渲染watcher均被computed引用了的响应式对象收集。
当这些响应式对象发生修改时,就会依次先触发computed watcher的update,此时只是把 dirty 置为 true,然后触发对应的渲染watcher的update更新,更新时就会使用到computed的值,然后触发computed的get,此时由于dirty是true就会重新计算computed的值,计算完再将dirty置为false。如果在渲染watcher更新中即渲染时多次用到了computed的值,由于此时dirty的值已经是false了,所以就不会重新计算computed的值,直接返回先前的值。因为这两触发computed的get时,对应的响应式对象其实是没变化的,所以就节省了重新计算的性能,所以就会有vue的计算属性是可以缓存计算结果的说法