vue 响应式原理的学习总结

102 阅读7分钟

参考

基础准备

  • 当一个对象的属性被访问和修改的时候想要知道,那么该怎么办呢?作为一个后端程序猿立马想到的就是重写他的get/set方法,而前端也提供了类似的方法,也就是 Object.defineProperty()
  • 例如一下代码,就是给o添加了'b'属性,同时声明了get/set 方法
let o = {}
let bValue = 38;
Object.defineProperty(o, "b", {
  get() { return bValue; },
  set(newValue) { bValue = newValue; },
  enumerable : true,
  configurable : true
});

监听一个对象

let obj = {
    a: {
        m: 1
    },
    b: 2,
}
let key = 'b'
let value = obj.b
Object.defineProperty(obj, key, {
  get() { 
      console.log(`属性${key}被访问了`)
      return value; 
  },
  set(newValue) {
      if(value === newValue) return;
      console.log(`属性${key}被修改了`)
      value = newValue; 
  },
});
obj.b // 属性b被访问了
obj.b = 20 // 属性b被修改了
  • 可以看到当访问和修改时候都会有相应的输出。
  • 仔细看以上代码,依赖一个全局变量value。因此我们优化下代码
function defineReactive(obj, key, value = obj[key]) {
   Object.defineProperty(obj, key, {
      get() { 
          console.log(`属性${key}被访问了`)
          return value; 
      },
      set(newValue) {
          if(value === newValue) return;
          console.log(`属性${key}被修改了`)
          value = newValue; 
      },
    });
}
defineReactive(obj, 'b')
obj.b // 属性b被访问了
obj.b = 20 // 属性b被修改了
  • 现在我们可以直接调用方法就可以知道对象的属性被访问和修改。
  • 如果一个对象有多个属性呢?那么该如何做?
function walk(obj) {
    Object.keys(obj).forEach((key) => defineReactive(obj, key))
}
// 因此只需要调用walk方法即可
walk(obj)
obj.a
obj.b
obj.b = 20
obj.a.m
  • 此时已经可以很方便的监听属性了。但是当调用obj.a.m此时输出的依然是属性a被访问了,因此我们可以知道目前只监听了第一层,想要监听每一层就需要递归下。
  • 思考下我们需要在哪里开启递归?我们来看 defineReactive方法,这个方法的value 如果是一个对象,而不是一个值那么就会出现上述的问题。因此在这个方法里开启递归。
function observe(obj) {
    if(typeof obj !== 'object') return
    walk(obj)
}

function defineReactive(obj, key, value = obj[key]) {
   observe(value) // 新增的,如果value不是对象直接返回,继续后面代码,如果是对象,那么递归。
   Object.defineProperty(obj, key, {
      get() { 
          console.log(`属性${key}被访问了`)
          return value; 
      },
      set(newValue) {
          if(value === newValue) return;
          console.log(`属性${key}被修改了`)
          value = newValue; 
          observe(value) // 新增的。newValue也可能是一个对象,因此这里要记得。
      },
    });
}
// 此时我们监听对象就可以直接调用observe
observe(obj)
obj.a.m
obj.b = {d: 1}
obj.b.d
  • 此时调用a.m.n就可以看到输出了。属性a被访问了 属性m被访问了

优化下监听对象的代码。

  • 建立文件目录,先忽略其他的文件名称,后续会一一讲解. 截屏2022-02-15 下午4.53.16.png

// index.js 文件
import observe from './observe.js'
let obj = {
    a: {
        m: 1
    },
    b: 2,
}
observe(obj)


// observe.js
import Observer from './Observer.js'

function observe(obj) {
    if(typeof obj !== 'object') return
    new Observer(obj)
}
export default observe


// Observer.js
import defineReactive from './defineReactive.js'
class Observer {
    constructor(obj){
        this.value = obj
        this.walk()
      }
      walk() {
        // 遍历该对象,并进行数据劫持
        Object.keys(this.obj).forEach((key) => defineReactive(this.obj, key))
      }
}
export default Observer


// defineReactive.js
import observe from './observe.js'
function defineReactive(obj, key, value = obj[key]) {
   observe(value) // 新增的,如果value不是对象直接返回,继续后面代码,如果是对象,那么递归。
   Object.defineProperty(obj, key, {
      get() { 
          console.log(`属性${key}被访问了`)
          return value; 
      },
      set(newValue) {
          if(value === newValue) return;
          console.log(`属性${key}被修改了`)
          value = newValue; 
          observe(value) // 新增的。newValue也可能是一个对象,因此这里要记得。
      },
    });
}
export default defineReactive
  • 此时我们来梳理下这里的递归逻辑。 1.jpg

响应式的原理

  • 深入响应式原理 2.jpg
  • 我们可以知道大概分3部分:数据变成响应式、依赖收集、派发更新。
  • 黄色部分:Vue的渲染方法,初始化视图和视图更新的时候都会调用vm._render方法进行重新渲染。
  • 渲染时候不可避免的会touch到每个需要展示到视图上的数据(紫色部分),触发这些数据的get方法从而收集到本次渲染所需要的依赖。收集依赖和更新派发都是基于蓝色部分的 Watcher 观察者。
  • 当我们在修改这些收集到依赖的数据时,会触发数据中的 set 属性方法,该方法会修改数据的值并 notify 到依赖到它的观察者,从而触发视图的重新渲染。绿色部分是渲染过程中生成的 Virtual DOM Tree,这棵树不仅关系到视图渲染,更是 Vue 优化视图更新过程的基础。
  • 简言之,数据响应式的中心思想,是通过重写数据的 get 和 set 属性方法,让数据在被渲染时把所有用到自己的订阅者存放在自己的订阅者列表中,当数据发生变化时将该变化通知到所有订阅了自己的订阅者,达到重新渲染的目的。

收集依赖、派发更新

  • 什么是依赖?
    • 依赖就是Watcher的实例。 3.jpg
  • 思考下Watcher都要做些啥?
    • 订阅数据,要知道是那个对象,对象的那个key。
    • 当数据发生变化后,要做一些事情(比如通知视图更新),也就要执行一些回调。
  • 代码如下:
// Watcher.js
export default class Watcher {
    // obj 订阅的数据对象, expression 属性的表达式比如'a.m.n', callback 回调。
    constructor(obj, expression, callback) {
        this.obj = obj
        this.expression = expression
        this.callback = callback
        this.value = this.get() // this.value 是代表订阅的数据的当前值。通过obj和expression 计算出来。因此我们要实现get()函数。
    }
    // 获取当前值
    get () {
        let value = parsePath(this.obj, this.expression)
        return value
    }
    // 派发更新的函数
    update () {
        let oldValue = this.value
        this.value = parsePath(this.obj, this.expression)
        this.callback.call(this.obj, this.value, oldValue)
    }
    
}
// 从obj对象中取出expression代表的值。
function parsePath(obj, expression) {
  const segments = expression.split('.')
  for (let key of segments) {
    if (!obj) return
    obj = obj[key]
  }
  return obj
}
  • 现在我们把Watcher写好了,那什么时候收集依赖?
    • 在getter中收集依赖。在setter中派发更新。那我们来更改下defineReactive方法
    • 一个数据可以在多个地方使用,那么就会有多个依赖,也就是多个watcher实例。因此需要一个数组来保存。
// defineReactive.js
import observe from './observe.js'
function defineReactive(obj, key, value = obj[key]) {
   observe(value)
   let deps = [] // 新增的,用于存储watcher
   Object.defineProperty(obj, key, {
      get() { 
          console.log(`属性${key}被访问了`)
          dep.push(watcher) // 新增的,但是这个地方我们是取不到watcher的,那么怎么办呢?把watcher变成全局的呀。因此代码需要进一步修改。比如我们可以放在window.target 属性上。
          return value; 
      },
      set(newValue) {
          if(value === newValue) return;
          console.log(`属性${key}被修改了`)
          value = newValue; 
          observe(value) 
          deps.forEach((watcher) => watcher.update() ) // 新增的,永来派发更新
      },
    });
}
export default defineReactive
  • 为了降低代码的耦合程度,我们把deps也封装一下。思考下dep都要完成哪些功能。
    • 要有一个数组,来保存依赖。
    • 要有收集依赖的方法,也就是把watcher实例放到数组中去。
    • 当数据发生变化的时候要派发数据。
// Dep.js
export default class Dep {
    constructor() {
        this.subs = []  // 用于存储依赖。
    }
    // 用于添加依赖(watcher)
    depend () {
        if(window.target) { // 这里是使用,但是要思考下在哪里设置哦。
            this.addSub(window.target)
        }
    }
    addSub(sub) {
        this.subs.push(sub)
    }
    // 当数据变化的时候派发数据。
    notify () {
        this.subs.forEach((watcher) => watcher.update())
    }
}
  • 写好了Dep类,我们来改写下 defineReactive函数。
// defineReactive.js
import observe from './observe.js'
import Dep from './Dep.js'
function defineReactive(obj, key, value = obj[key]) {
   observe(value)
   // let deps = []
   let dep = new Dep() // 新增的
   Object.defineProperty(obj, key, {
      get() { 
          console.log(`属性${key}被访问了`)
         // dep.push(watcher) // 但是这个地方我们是取不到watcher的,那么怎么办呢?把watcher变成全局的呀。因此代码需要进一步修改。比如我们可以放在window.target 属性上。
          dep.depend() // 新增的
          return value; 
      },
      set(newValue) {
          if(value === newValue) return;
          console.log(`属性${key}被修改了`)
          value = newValue; 
          observe(value)
          // deps.forEach((item) => item.update() )
          dep.notify() // 新增的。
      },
    });
}
export default defineReactive
  • 思考在哪里设置window.target
    • window.target的值是Watcher实例。那么我们是否可以在Watcher的构造函数里设置呢?当然可以。因此修改 Watcher
export default Watcher {
    // obj 订阅的数据对象, expression 属性的表达式比如'a.m.n', callback 回调。
    constructor(obj, expression, callback) {
        this.obj = obj
        this.expression = expression
        this.callback = callback
        this.value = this.get() // this.value 是代表订阅的数据的当前值。通过obj和expression 计算出来。因此我们要实现get()函数。
    }
    // 获取当前值
    get () {
        window.target = this // 新增的 把watcher设置到window.target上。
        let value = parsePath(this.obj, this.expression)
        window.target = null // 新增的 收集完以后设置为null,以方便后续使用。
        return value
    }
    // 派发更新的函数
    update () {
        let oldValue = this.value
        this.value = parsePath(this.obj, this.expression)
        this.callback.call(this.obj, this.value, oldValue)
    }
    
}
// 从obj对象中取出expression代表的值。
function parsePath(obj, expression) {
  const segments = expression.split('.')
  for (let key of segments) {
    if (!obj) return
    obj = obj[key]
  }
  return obj
}
  • 需要数据的地方,会实例化一个watcher,实例化watcher就会对依赖的数据求值,从而触发getter,数据的getter函数就会添加依赖自己的watcher,从而完成依赖收集。

  • 思考为啥要有这句 window.target = null?

  • 有一个对象obj: { a: 1, b: 2 }

    • 我们先实例化了一个watcher1watcher1依赖obj.a,那么window.target就是watcher1
    • 之后我们访问了obj.b,会发生什么呢?
    • 访问obj.b会触发obj.bgettergetter会调用dep.depend(),那么obj.bdep就会收集window.target, 也就是watcher1,这就导致watcher1依赖了obj.b,但事实并非如此。为解决这个问题因此设置window.target = null

Watcher的使用

// index.js
import observe from './observe.js' 
import Watcher from './Watcher.js'
let obj = { a: { m: 1 }, b: 2 } 
observe(obj)
new Watcher(ojb, 'a', (value, oldValue) => {
    console.log(`value is ${value}, oldValue is ${oldValue}`)
})
  • 运行代码发现依然会报错 ReferenceError: window is not defined
  • 那么我们可以把代码里所有用到的window改成Dep window.target ===> Dep.target 这样就OK了,可以重新运行。

总结

  • 数据变成响应式、依赖收集、派发更新。
  • 数据变成响应式 对应 observe
  • 依赖收集,派发更新 对应 Dep
  • 依赖本身 对应 Watcher