Vue响应式原理和实现方式,Observer、Dep、Watcher源码解析(上)浅入浅出

使用过vue的人都知道vue是的数据是双向绑定的,data里的属性赋值之后ui会自动更新。但是具体的代码实现,很多人并不完全明白。这篇文章主要对于响应式核心原理进行分解,由浅入深。

这是上篇,还有下篇

附示例源码

初学者要先掌握以下知识点:

Object.defineProperty的使用

Object.defineProperty的使用,可以参考MDN 的API说明文档,es2015之后提供了提供了更强大的新接口Proxy、可以实现代理gettersetter以及其他的属性和方法,vue3已升级为Proxy方式,更多信息可参考MDN Proxy使用说明文档

使用方法如下:

var obj = {};
Object.defineProperty(obj,"newKey",{
    get:function (){
        console.log('getValue')
        return 'getValue';    
    },
    set:function (value){
        console.log(value)
        return value
    }
})

let newValue = obj.newKey    // 输出 'getValue'
obj.newKey = 'myValue'       // 输出 'myValue'
newValue = obj.newKey        // 输出 'getValue'  

//结果:newValue = 'getValue'
复制代码

解释:obj通过Object.defineProperty设置newKey属性之后,属性的取值操作会调用get方法,赋值操作会调用set方法,由于get方法的返回值是'getValue',所以最终newValue的值是'getValue'

发布订阅模式

  • 事件发布订阅模型,这是程序设计中用得非常多的一种设计模式,简单实用。代码参考
class Event {
  constructor() {
    this.events = {};
  }
  on(event, callback) {
    this.events[event] = callback;
  }
  emit(event, arg) {
    this.events[event](arg);
  }
  off(event, arg) {
    delete this.events[event];
  }
}

let EventInstance = new Event();

// 订阅事件
EventInstance.on("eventName", (arg) => {
  console.log(arg);
});

// 发布事件
EventInstance.emit("eventName", "message"); //输出:message

// 取消订阅
EventInstance.off("eventName");


复制代码

以上就是事件订阅发布模式的基本代码框架

观察者模式

观察者模式,总体思路的也是订阅和发布,先看代码:

// 被观测者
class Observed {
  constructor() {
    this.listener = [];
  }
  subscribe(subject) {
    this.listener.push(subject);
  }
  unsubscribe(subject) {
    this.listener = this.listener.filter((item) => item !== subject);
  }
  notify(message) {
    this.listener.forEach((subject) => {
      subject(message);
    });
  }
}

// 被观测者,用于添加观测方法或对象
let observer = new Observed();
let subject = () => {
  console.log("subject is a function");
};

// 添加观察者subject
observer.subscribe(subject);

// 通知所有的观察者
observer.notify("message");

// 移除观察者subject
observer.unsubscribe(subject);

// 输出结果:
// subject is a function
复制代码

PS:上面说的观察者,如果不好理解的话,可以直接看作是回调函数,被观测者就是事件对象

从上面的代码可以看出这两种模式,都要先注册,再发布,总的思想是一样的,二者几乎没区别。硬要说具体区别的话就是事件发布之前需要知道具体的事件名,而订阅者模式不需要。

用大白话讲,比如我们要把学校所有年级的教室门打开。

事件模式 要这么喊 :“一年级芝麻开门”、“二年级芝麻开门”、“三年级芝麻开门” ……

订阅者模式 则直接大喊一声:“芝麻开门”, 完事!

分割线


Vue里的订阅和发布是用的那种模式呢,答案是:观察者模式

这里留一个小小的疑问,为什么要使用观察者模式呢?可以用事件模型来实现吗?

那基于上面的了解,我们可以预先设想一下Vue响应式的基本思路:

1、设计一个被观测者,用于添加观测者,观测者可以处理发布的消息

2、 被观测对象通过Object.defineProperty进行包装,劫持getset方法。

3、设计一个观测者,用于处理发布更新后的操作,即订阅后的回调

4、观测时机:取值操作时,触发get用于添加观测者。赋值操作时,触发set通知观测者进行更新

接下来,我们根据上面的思路一步一步来对响应式原理进行实现,为了便于理解Vue的源码,我们尽量和Vue中所使用的的属性和方法名保持一至,同时忽略大部分兼容和边界情况的处理细节

设计Dep类

第1步设计一个被观察者Vue里面叫Dep,这个和上面的示例Observed没有太大的区别,只是对应的方法名和属性有所不同。

let uid = 0;
class Dep {
  constructor() {
    this.id = ++uid;
    this.subs = [];
  }
  // 添加被观测者
  addSub(sub) {
    this.subs.push(sub);
  }
  // 移除被观测者
  removeSub(sub) {
    const index = this.subs.indexOf(sub);
    if (index > -1) {
      return this.subs.splice(index, 1);
    }
  }
  // 发布更新
  notify() {
    const subs = this.subs.slice();
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update();
    }
  }
  depend() {}
}
复制代码

这样Dep就可以订阅和发布了,比如:

let dep = new Dep();
let watcher = { update: () => { console.log("update"); }};
dep.addSub(watcher);
dep.notify();
复制代码

比如我们要用Vue写一个名片Card组件,大概是这样,htmlcss已省略

class Card extends Vue {
  data = {
    name: "",
    phone: "",
  };
  mounted() {
    this.data.name = "xiaomin";
    this.data.phone = 1234557;
  }
}
复制代码

那么我们使用Vue的时候,要观测变化的对象是什么呢,是上面的Dep吗?显然不是,我们都知道是data。但是Dep既然被定义为被观察者,那说明它和data之间肯定是有一个绑定的关系。那具体是怎么关联起来的呢?

我们可以按照第2步的思路来看,既然被观测者是data,那么我们要劫持的对象也是data

我们定义一个方法叫defineProperty

function defineReactive(obj, key, val) {
  const property = Object.getOwnPropertyDescriptor(obj, key);
  const getter = property && property.get;
  const setter = property && property.set;
  val = val || obj[key];
  
  // 这里就是 data 和 Dep 结合起来的地方
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    get: function reactiveGetter() {
      const value = getter ? getter.call(obj) : val;
      dep.depend()
      return value;
    },
    set: function reactiveSetter(newVal) {
      const value = getter ? getter.call(obj) : val;
      if (value === newVal) {
        return;
      }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      dep.notify()
    },
  });
}
复制代码

上面dataDep结合起来的地方,乍一看,好像看不出个所以然。这么说你就明白了,其原理就是利用js闭包的机制。在每个obj[key]之下,都有一个dep变量,并且一直被getset引用。

这样我们在取值和赋值的时候就具有了初步的响应式效果,我们用下面的代码进行测试

let data = { name: "aaa", phone: 1212 };
defineReactive(data, "name");

let name = data.name;       // 取值调用 dep.addSub()
data.name = "bbb";          // 赋值调用 dep.notify()

// subs[i].update();  报错,因为此方法未实现,先将其注释

let phone = data.phone;      
data.phone = "bbb";         
复制代码

从上面的测试代码代码可以看出,data.name具有响应式的效果,而data.phone不具备,因为没有调用defineReactive(data, "phone")。接下来我们提供observe方法让data的所字段都具备响应式效果。

function observe(value) {
  for (var key in value) {
    defineReactive(value, key);
  }
}

observe(data);
observe(data);
复制代码

上面的方法可以让data所有的字段都具备响应式效果,但如果多次调用observe(data)会发现get set被多次拦截,造成同样的观察者被重复添加。所以我们要对已添加过get set的属性进行标识,避免重复添加。

设计Observer类

为了避免get set被重复拦截,Vue设计了Observer类,在每个value上都添加__ob__标识,下面是具体的实现代码

class Observer {
  constructor(value) {
  
    // 这里的dep主要是用于对数组的观测支持
    this.dep = new Dep();
    
    Object.defineProperty(value, "__ob__", { value: this });
    this.walk(value);
  }
  walk(obj) {
    const keys = Object.keys(obj);
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i]);
    }
  }
}

function observe(value) {
  let ob;
  if (value.__ob__ instanceof Observer) {
    ob = value.__ob__;
  } else if (Object.isExtensible(value)) {
    ob = new Observer(value);
  }
  return ob;
}
复制代码

这样就避免了重复拦截get set的问题,但现在只支持单层的data,比如:

let data = { person: { name: "aaa", phone: 1212 } };
observe(data);
let name = data.person.name;
data.person.name = "bbb";
复制代码

上面的赋值是不会被Dep监听到的,这里只指出问题,具体的解决不在这里展开。解决方法是在defineReactive里实现的,可以参考Vue的源码 或 Vue响应式原理和实现方式,Observer、Dep、Watch源码解析(下)深入浅出

设计Watcher类

现在前两步已经实现,接下看下一步,第3步我们要定义一个观察者,对发布的订阅进行处理

上面我们看到subs[i].update();这一步会报错,说明观察者有一个update方法,除此之外,在设计Watcher之前,首先要清楚回调要处理什么问题。

根据上面的源码我们知道Dep.subs存放的是观察者,也就是要设计的Watcher类,那观察者来源有哪些呢?

我们来举个例子:

<template>
  <Component1>{{ name }}</Component1>
</template>;

// 一段时间后页面展示如下

<template>
  <Component1>{{ name }} - {{ age }}</Component1>
</template>;

// 最终展示如下

<template>
  <Component1>{{ name }}</Component1>
  <Component2>{{ phone }}</Component2>
</template>;


复制代码

说明:上面是页面显示的变化过程,我们要根据显示数据的变化,来修改对应的观察者,新增数据引用时添加观察者,数据引用消失时,则将原有观察者删除。

我们暂且先简化理解为观察者来源就是页面上显示的数据,当页面数据变化时,那么Dep.subs也应该相应的发生变化。

所以Watcher要处理的主要问题就可以简化为:根据数据的引用关系动态修改Dep.subs里的Watcher

到了这里,还剩最后一个问题,既然Dep.subs里存放的是Watcher实例,那它是 什么时候创建呢? 我们提供两种方案:

  1. 每个引用的数据都创建一个Watcher实例
  2. 每个组件创建一个Watcher实例

显然第2种更具优势,如果是第1种方案,每个Watcher只能处理自身的响应,局限性太强,其次会面临性能问题,因为会创建很多的Watcher。第2种的优势在于:

  1. 一个Watcher可以观测多个变量的变化,性能优于第1种。
  2. Watcher是根据组件生成的,所以可以和dom树一样,形成树形结构,便于管理

弄清楚了这些问题,下面我们可以开始代码的实现,上面defineProperty中的get是添加观察者的入口,其调用方法是dep.depend(),首先来实现depend这个方法。

由于每个组件下只有一个Watcher,先简化为watcher实例是个全局变量,通过全局变量的方法管理Dep.subs来简化其逻辑。

// Dep.target来作为全局变量,用于存放watcher,通过 addDep 方法来管理 Dep.subs
function depend() {
  if (Dep.target) {  
    Dep.target.addDep(this); 
  }
}
复制代码

接下来实现Watcher类的,他的主要功能如上所述

class Watcher {
  constructor(vm, expOrFn, cb) {
    this.id = ++uid;
    this.cb = this.cb;
    this.vm = vm; // 执行上下文
    this.getter = expOrFn;
    this.deps = [];
    this.newDeps = [];
    this.value = this.get();
  }
  get() {
    Dep.target = this;
    let value = this.getter.call(this.vm);
    Dep.target = null;
    // this.cleanupDeps();
    return value;
  }
  cleanupDeps() {
    let i = this.deps.length;
    while (i--) {
      const dep = this.deps[i];
      if (!this.newDeps.includes(dep)) {
        dep.removeSub(this);
      }
    }
    this.deps = this.newDeps;
    this.newDeps = [];
  }
  addDep(dep) {
    // 避免重复添加
    if (!this.deps.includes(dep)) {
      this.newDeps.push(dep);
      dep.addSub(this);
    }
  }
  update() {
    // 触发所有的defineProperty下get方法,重新收集依赖
    let oldValue  = this.value
    this.value = this.get();
    this.cb && this.cb.call(this.vm,this.value,oldValue);
  }
}
复制代码

以上就是Watcher类的基本代码,下面我们通过代码来测试,看一下效果

let data = { name: "aaa", phone: 1212, age: 18 };
observe(data);

// 初始化时触发this.get(),更新 Dep.subs
new Watcher(data, () => {
  let name = data.name;
  console.log('name');
});
new Watcher(data, () => {
  let phone = data.phone;
  console.log('phone');
});
复制代码
data.name = 'bbb'
// 输出:name
复制代码
data.phone = 1123
// 输出:phone
复制代码

可以看到,对使用过的值进行赋值,会触发的对应的Watcher处理函数expOrFn

Vue中对应的处理函数expOrFn则是updateComponent可以对组件进行重新取值渲染。完成页面更新

结尾

如有兴趣了解更详细的源码解读请点击下方链接

Vue响应式原理和实现方式,Observer、Dep、Watch源码解析(下)深入浅出

参考链接

猜你喜欢

转载自juejin.im/post/7105337345649901605