vue源码解读--Object数据响应式(变化侦测)

89 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第17天,点击查看活动详情

参考自Vue源码系列-Vue中文社区

前言

Vue最大的特点之一就是数据驱动视图,数据一旦发生改变,页面也随之发生变化。根据这个概念得出一个公式 UI = render(state)

状态 state 是输入,页面 UI 输出,这两个都是用户定的,而不变就是 render(),所以Vue就扮演了 render()这个角色,那么Vue是怎么知道 state变化了呢?

Vue是通过变化侦测知道 state 发生了变化。变化侦测就是监听数据的变化,一旦发生了变化,就要去更新视图。

Object的变化侦测

数据驱动视图的关键点在于如何知道数据发生了变化,数据发生变化,通知视图更新即可。JS 提供了 Object.defineProperty 方法。

使Object数据变得“可观测”

知道数据什么时候被读取了或数据被修改了,称为数据变得“可观测”。

看一个示例:

let person = {
    name: "jiaji",
    age: 20
}
person.name = "jack";
console.log(person.age);

定义了一个 person 对象,当这个对象的属性被读取或者修改时,我们并不知青,使用 Object.defineProperty()让这个对象主动告诉我们它的属性被读取或者修改了。

使用 Object.defineProprety()改写上面的额例子:

let person = {name:"jiaji"};
let age = 20;
Object.defineProperty(person, "age", {
    enumerable: true,
    configurable: true,
    get(){
        console.log("age属性被读取了");
        return age;
    },
    set(val){
        console.log("age属性被修改了");
        age = val;
    }
})
console.log(person.age);
person.age = 30;
console.log(person.age);

image-20220812220041706.png

使用 Object.defineProperty() 方法给 person 对象定义了一个 age 属性,当 person 对象进行读或写的操作时,就会触发 get()set() 方法,现在这个对象就变成“可观测”啦

把 person 对象的所有属性都变成“可观测”:

// 源码位置:src/core/observer/index.js
/**
* Observer类会通过递归的方式把一个对象的所有属性都转化成可观测对象
*/
export class Observer {
    constructor(value) {
        this.value = value;
        if (Array.isArray(value)) {
            // 当value为数组时的逻辑
            // ...
        } else {
            this.walk(value);
        }
    }
​
    walk(obj) {
        const keys = Object.keys(obj);
        for (let i = 0; i < keys.length; i++) {
            defineReactive(obj, keys[i]);
        }
    }
}
/**
 * 使一个对象转化成可观测对象
 * @param { Object } obj 对象
 * @param { String } key 对象的key
 * @param { Any } val 对象的某个key的值
 */
function defineReactive(obj, key, val) {
    // 如果只传了obj和key,那么val = obj[key]
    if (arguments.length === 2) {
        val = obj[key];
    }
    if (typeof val === 'object') {
        new Observer(val);
    }
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get() {
            console.log(`${key}属性被读取了`);
            return val;
        },
        set(newVal) {
            if (val === newVal) {
                return;
            }
            console.log(`${key}属性被修改了`);
            val = newVal;
        }
    })
}

判断数据类型,当类型为 Object 时调用 walk 将每一个属性都调用 defineReactive 方法,如果 value 的数据类型还是 Object,就递归子属性,把所有的属性都转换成 get()/set() 的形式来侦测变化。

现在,可以直接这样定义 person:

let person = new Observer({
    name:"jiaji",
    age:20
})
console.log(person)

依赖收集

现在数据已经变得可侦测了,只要数据发生变化,去更新视图就好了,但是一个视图这么多节点,应该更新哪些呢?

数据发生变化,使用了这个数据的节点就都要更新,也就是说:谁(节点)依赖了这个数据,谁就要更新,一个数据可能被多个节点同时使用,所以给每个数据都创建一个依赖数组,总的来说就是:谁(节点)依赖就这个数据,就把谁放进这个数据的依赖数组中,当这个数据放生变化时,就去这个数组的依赖数组中,告诉这些依赖,该更新视图了。

当可观测的数据被获取时会触发 get() 方法,数据发生变化时会触发 set() 方法,总结起来就是:get() 中收集依赖,在 set() 中通知依赖更新

依赖的保存是通过数组的形式,但是只用一个数组来保存依赖显然是不合理的,更好的做法是为每一个数据都创建一个依赖数组,各自管理自己的依赖,所以依赖管理器 Dep 类应运而生:

// 源码位置:src/core/observer/dep.js
export default class Dep {
    constructor () {
        this.subs = []
    }
​
    addSub (sub) {
        this.subs.push(sub)
    }
    // 删除一个依赖
    removeSub (sub) {
        remove(this.subs, sub)
    }
    // 添加一个依赖
    depend () {
        if (window.target) {
            this.addSub(window.target)
        }
    }
    // 通知所有依赖更新
    notify () {
        const subs = this.subs.slice()
        for (let i = 0, l = subs.length; i < l; i++) {
            subs[i].update()
        }
    }
}
​
export function remove (arr, item) {
    if (arr.length) {
        const index = arr.indexOf(item)
        if (index > -1) {
            return arr.splice(index, 1)
        }
    }
}

使用 subs 数组来存放依赖,定义了一些实例方法对依赖进行添加、删除、通知更新等操作。接下来在 get() 中收集依赖,在set() 中通知依赖更新。

function defineReactive(obj, key, val) {
  if (arguments.length === 2) {
    val = obj[key];
  }
  if (typeof val === "object") {
    new Observer(val);
  }
  const dep = new Dep(); //实例化一个依赖管理器,生成一个依赖管理数组dep
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      dep.depend(); // 在getter中收集依赖
      return val;
    },
    set(newVal) {
      if (val === newVal) {
        return;
      }
      val = newVal;
      dep.notify(); // 在setter中通知依赖更新
    },
  });
}

一直在说依赖,也知道依赖就是使用了这个数据的节点,但是在收集、更新的时候怎么把这个依赖描述出来?

Vue中还有一个 Watcher 类,前面说到谁用了数据,谁就是依赖,Vue就会给这个依赖创建一个 Watcher实例,在数据发生变化后,去通知依赖对应的 Watcher 实例,由这个实例去通知真正的视图。

export default class Watcher {
  constructor (vm,expOrFn,cb) {
    this.vm = vm;
    this.cb = cb;
    this.getter = parsePath(expOrFn)
    this.value = this.get()
  }
  get () {
    window.target = this;
    const vm = this.vm
    let value = this.getter.call(vm, vm)
    window.target = undefined;
    return value
  }
  update () {
    const oldValue = this.value
    this.value = this.get()
    this.cb.call(this.vm, this.value, oldValue)
  }
}
​
/**
 * Parse simple path.
 * 把一个形如'data.a.b.c'的字符串路径所表示的值,从真实的data对象中取出来
 * 例如:
 * data = {a:{b:{c:2}}}
 * parsePath('a.b.c')(data)  // 2
 */
const bailRE = /[^\w.$]/
export function parsePath (path) {
  if (bailRE.test(path)) {
    return
  }
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

逻辑分析:

  1. 实例化 watcher类时,会执行构造函数
  2. 在构造函数中调用 this.get() 实例方法,通过 window。target = this 把自身实例赋给了全局的唯一对象 window.target 上,然后通过 let value = this.getter.call(vm,vm) 获取被依赖额数据,这时会触发这个数据的 get() 方法,通过调用 dep.depend() 收集依赖,将挂载到 window。target 上的值存入依赖数组中。
  3. 数据发生变化时,会触发数据的 set() 方法,通过调用 dep.notify(),遍历所有依赖( watcher 实例 ),执行依赖的 update() 方法,在 update() 方法中调用数据变化的更新回调函数,从而更新视图。

简单总结:依赖( watcher 实例 )创建后挂载到 window.target 属性上,然后读取数据,触发 get() 将这个依赖收集到依赖数组中,数据发生变化后,触发 set(),遍历所有依赖,执行 update()实例方法,更新视图。

不足之处

通过 Object.defineproperty方法实现了对象数据的”可观测“,但是只能观测到属性的读取和修改,如果直接给对象新增一个属性或者删除一个属性,它是无法观测到的,导致无法通知依赖更新视图。

Vue增加了两个全局 API 解决了这个问题

  • vue.set
  • vue.delete

小结

  1. Observer 类,实现数据的”可观测“
  2. Dep 类,将所有依赖保存到依赖数组中
  3. watcher 类,视图通过 watcher 读取数据时,会将 watcher 实例添加到依赖中,发生变化时,会触发 set(),遍历依赖,告诉每个依赖更新视图。