深入源码学习vue响应式原理

最近一段时间在阅读Vue源码,从它的核心原理入手,开始了源码的学习,而其核心原理就是其数据的响应式。并且结合设计模式进行学习

观察者模式&&发布订阅者模式

这里简短的介绍这两种模式的联系和差异,

观察者模式

观察者模式定义了对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知,并自动更新。观察者模式属于行为型模式,行为型模式关注的是对象之间的通讯,观察者模式就是观察者和被观察者之间的通讯。

观察者模式有一个别名叫“发布-订阅模式”,或者说是“订阅-发布模式”,订阅者和订阅目标是联系在一起的,当订阅目标发生改变时,逐个通知订阅者。我们可以用报纸期刊的订阅来形象的说明,当你订阅了一份报纸,每天都会有一份最新的报纸送到你手上,有多少人订阅报纸,报社就会发多少份报纸,报社和订报纸的客户就是上面文章开头所说的“一对多”的依赖关系。

发布订阅者模式

其实24种基本的设计模式中并没有发布订阅模式,上面也说了,他只是观察者模式的一个别称。

但是经过时间的沉淀,似乎他已经强大了起来,已经独立于观察者模式,成为另外一种不同的设计模式。

在现在的发布订阅模式中,称为发布者的消息发送者不会将消息直接发送给订阅者,这意味着发布者和订阅者不知道彼此的存在。在发布者和订阅者之间存在第三个组件,称为调度中心或事件通道,它维持着发布者和订阅者之间的联系,过滤所有发布者传入的消息并相应地分发它们给订阅者。

举一个例子,你在微博上关注了A,同时其他很多人也关注了A,那么当A发布动态的时候,微博就会为你们推送这条动态。A就是发布者,你是订阅者,微博就是调度中心,你和A是没有直接的消息往来的,全是通过微博来协调的(你的关注,A的发布动态)。

差异

可以看出,发布订阅模式相比观察者模式多了个事件通道,事件通道作为调度中心,管理事件的订阅和发布工作,彻底隔绝了订阅者和发布者的依赖关系。即订阅者在订阅事件的时候,只关注事件本身,而不关心谁会发布这个事件;发布者在发布事件的时候,只关注事件本身,而不关心谁订阅了这个事件。

观察者模式有两个重要的角色,即目标和观察者。在目标和观察者之间是没有事件通道的。一方面,观察者要想订阅目标事件,由于没有事件通道,因此必须将自己添加到目标(Subject) 中进行管理;另一方面,目标在触发事件的时候,也无法将通知操作(notify) 委托给事件通道,因此只能亲自去通知所有的观察者。

响应式原理

当我们在data中定义一个值的时候,如下:

const vm = new Vue({
    data() {
        return {
            message: ''
        }
    },
    template: '<div>{{message}}</div>'
})
vm.message = 'hello';

此时Vue内部发生了什么,下面列出需要解决的问题如下:

  1. 如何进行依赖收集的
  2. data中的值发生改变时,是如何更新视图的

上面是表示定义一个data值的时候,内部这个流程是如何的,结合讲解相信你对响应式原理有更深入的理解。为了让结构更加清晰,这里只考虑一个视图,并且不会有computed的情况。 在讲解原理之前,首先对几个单词进行定义:

  • Watcher: 订阅者
  • Observer: 观察者
  • Dep: 发布者
  • Data: 实例中的数据项

Observer

首先看看当实例化Vue的时候,对data是如何进行处理的

_init
    => mount
    => this._watcher = new Watcher(vm, updateComponent, noop)
    => Dep.target = this._watcher
    => observe(data, true)
    => new Observer(data)
  1. 首先new Vue会调用_init函数
  2. mount把需要渲染的模板挂载到元素上
  3. 创建一个Watcher实例
  4. 将上面创建的Watcher实例赋值给Dep.target
  5. data返回的数据进行observe
  6. 调用new Observer遍历data进行settergetter绑定

下面来看看observe函数的实现:

function observe(value, asRootData) {
    let ob;
    // 检测当前数据是否被observe过,如果是则不必重复绑定
    if(hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
        ob = value.__ob__
    } else {
        ob = new Observer(value);
    }
    if (asRootData && ob) {
      ob.vmCount++;
    }
    return ob;
}

首先调用的就是上面这个函数,__ob__用户判断是否有Observer实例,如果有就使用原来的,如果没有就创建一个新的Observer实例。vmCount表示该Vue实例使用的次数,asRootData表示是否是data的跟,例如在一个template中一个相同的组件使用了两次:

<div>
  <my-component />
  <my-component />
</div>

这个时候vmCount就为2。接下来看Observer的实现:

class Observer {
    constructor(value) {
        this.value = value;
        this.dep = new Dep();
        this.vmCount = 0;
        def(value, '__ob__', this)
        if (Array.isArray(value)) {
            // 如果是数组则需要遍历数组的每个成员进行observe
            // 这里会对数组原有的方法进行重新定义
            this.observeArray(value)
        } else {
            // 如果对象则调用下面的程序
            this.walk(value)
        }
    }
    walk(obj) {
        const keys = Object.keys(obj);
        for (let i = 0; i < keys.length; i++) {
            defineReactive(obj, keys[i], obj[keys[i]])
        }
    }
}

下图是Observer类的结构 这里主要就是遍历data中定义的值,然后在每个遍历的属性下面添加__ob__,然后在__ob__定义Dep,根据数据类型的不同调用不同的方法,如果是数组则使用observeArray,该方法会重写数数组的7种方法,对数组的每个成员调用observe函数,如果是普通对象,则遍历他的属性调用defineReactive,进行getter/setter绑定。 defineReactiveVue最核心的内容,使用方法如: defineReactive(obj, keys[i], obj[keys[i]])。当在data中定义一个属性的时候,当我们更改该值的时候,视图是如何知道,这个值发生了改变来更新视图的。

function defineReactive(obj, key, val) {
  // 在闭包中定义一个dep对象
  const dep = new Dep();
  // 对象的子对象递归进行observe并返回子节点的Observer对象
  let childOb = observe(val);
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      const value = getter ? getter.call(obj) : val;
      if (Dep.target) {
        // 进行依赖收集,dep.depend就是将dep和watcher进行互相绑定
        // Dep.target表示需要绑定的watcher
        dep.depend();
        if (childOb) {
          // 子对象进行依赖收集,其实就是将同一个watcher观察者实例放进两个depend中
          // 一个是正在本身闭包中的depend,另一个是子元素的depend
          childOb.dep.depend();
        }
        if (Array.isArray(value)) {
          // 如果是数组,需要对数组的每个成员都进行依赖收集
          dependArray(value)
        }
      }
      return value;
    },
    set: function reactiveSetter(newVal) {
      // 通过getter方法获取当前值,与新值发生比较,一致则不需要执行下面的操作
      const value = getter ? getter.call(obj) : val;
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return false;
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 新的值需要重新observe,保证数据响应式
      childOb = observe(newVal)
      // 通知所有观察者
      dep.notify()
    }
  })
}

通过Object.defineProperty把数据进行了gettersetter绑定。getter用于依赖收集,setter用于通过dep去通知watcher, watcher进行执行变化。 如何进行依赖收集的,可以通过一个例子进行解释:

data() {
  return {
    message: [1, 2]
  }
}

结合一个流程图进行分析上面例子:

observe(data)
=> data.__ob__ = new Observer(data)
=> walk(data)
=> childOb = observe(message)
  => message.__ob__ = new Observer(data)
  => message.__ob__.dep = new Dep;
=> childOb ? childOb.dep.depend();

分析其过程就是:

  1. 先对data函数返回的对象添加__ob__,返回具体的内容如下:
const res = {
  message: [1, 2]
  __ob___: new Observer(data)
}
  1. 遍历res, 因为res为对象,所以执行walk
  2. 执行到observe(message)
  3. message添加__ob____ob__上存在一个dep用于依赖收集
  4. childOb = message.__ob__,此时同一个watcher放入子对象中,也就是message.__ob__.dep

回顾上面的分析,就能够区分出ObserverdefineReactive中两个dep的区别了,这两个地方都声明了new DepObserverdep用于收集对象和数组的订阅者,挂载在对象的属性上。当对象或者数组增删元素时调用$set,获取到__ob__进行依赖收集,然后调用ob.dep.notifyj进行更新。在defineReactive中,这个dep是存在一个闭包中,这是对对象属性服务的,在获取属性值的时候进行依赖收集,设置属性值的时候发布更新。

Dep

下面来介绍一下dep,源码如下:

let uid = 0;
class Dep {
  constructor() {
    this.id = uid++;
    this.subs = []
  }
  // 添加一个订阅者
  addSub(sub) {
    this.subs.push(sub)
  }
  // 移除一个观察者对象
  removeSub(sub) {
    remove(this.subs, sub)
  }
  // 依赖收集,当存在Dep.target的时候添加观察者对象
  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
  // 通知所有订阅者
  notify() {
    const subs = this.subs.slice();
    for(let i = 0, l = subs.length; i < l; i++) {
      subs[i].update();
    }
  }
}

结构如下: 当对象中的属性触发get的时候,先前defineReactiveconst dep = new Dep()闭包中,就会把当前的Watcher订阅者加入到subs中。 Dep是发布订阅者模型中的发布者,Watcher是订阅者,一个Dep实例对应一个对象属性或一个被观察的对象,用于收集和数据改变时,发布更新。比如说有这个一个data

data() {
  return {
    message: 'a'
  }
}

触发视图有两种方法:

  1. 利用getter/setter,重新设置message的值,设置的过程中会触发dep.notify进行发布更新, 比如this.message = 'b'
  2. 使用$set函数: this.$set(this.message, 'fpx', 'number-one'),这会获取到message__ob__上的dep进行发布更新

Watcher

Watcher是一个订阅者。依赖收集后watcher会被存放在Depsubs中,数据变动的时候通过dep发布者发布信息,相关的订阅者watcher收到信息后通过cb进行视图更新。 Watcher内容很多,我们只关注最重要的一些部分:

class Watcher {
  constructor(vm, expOrFn, cb, options) {
    this.vm = vm;
    // 存放订阅者实例
    vm._watchers.push(this)
    this.deps = [];
    this.newDeps = []
    this.depsIds= new Set();
    this.newDepIds new Set();
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
    this.value = this.get();
  }
  get() {
    pushTarget(this)
    const vm = this.vm;
    value = this.getter.call(vm, vm);
    popTarget();
    this.cleanupDeps();
    return value
  }
  // 添加一个依赖关系到Deps集合中
  addDep(dep) {
    const id = dep.id;
    if (!this.newDepsIds.has(id)) {
      this.newDepsIds.add(id)
      this.newDeps.push(dep);
      // 这里做一个去重,如果depIds里包含这个id,那么之前给depId添加这个id的时候
      // 已经调用过dep.addSub(this),避免了重复添加
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }
  // 用于更新模板
  update() {
    if (this.sync) {
      // 同步则执行run直接渲染视图
      this.run();
    } else {
      // 异步推送到观察者队列中,下一个tick时调用,最后会调用run方法
      queueWatcher(this)
    }
  }
  // 收集该watcher的所有deps原理
  depend() {
    let i = this.deps.length;
    while(i--) {
      this.deps[i].depend();
    }
  }
}

Watcher结构如下: 首先还是理清Watcher构造函数做的事情:

Dep.target = new Watcher(vm, updateComponent, noop = {})
  => 初始化变量
  => 获取getter函数
  => 调用get函数,get函数会调用getter函数,从而收集依赖

在创建Vue实例的时候,触发getter就会进行依赖收集,下面是这几种情况: Watcher有四个使用的场景,只有在这四种场景中,Watcher才会收集依赖,更新模板或表达式

  1. 观察模板中的数据
  2. 创建Vue实例时watch选项里的数据
  3. computed选型里的数据所依赖的数据
  4. 使用$watch观察的数据或者表达式

在前面代码中声明了Dep.target,这个是干嘛用的呢。在前面提到依赖收集的时机,是当我们获取元素属性值的时候,但是此时不知道哪个是正确的watcher,所以定义一个全局变量记录当前的Watcher,方便添加当前正在执行的WatcherWatcher对象中有两个属性: depsnewDeps。他们用来记录上一次Watcher收集的依赖和新一轮Watcher收集的依赖,每一次数据的更新都需要重新收集依赖, 流程如下:

setter
  => notify
  => run
  => get

当数据发布更新后,会调用notify方法,notify会调用run方法,run方法会调用get方法,重新获取值,重新进行依赖收集。举一个上面的例子,如果我们更改了message的值,并且模板依赖了新更改的值,this.message = {key: 'val'},因为上一轮没有对新值进行依赖,所以这一轮需要重新收集依赖。

总结

Vue初始化的时候,会生成一个watcher,依赖收集就是通过属性的getter完成的。结合文章开头给出的图片,ObserverDep是一对一的关系,DepWatcher是多对多的关系,Dep则是ObserverWatcher之间的纽带。依赖收集完成偶,当属性变化会执行被Observer对象的dep.notify()方法,这个方法会遍历订阅者Watcher列表向其发送消息,Watcher会执行run方法去更新视图。

本来还想讲点computed的,但是估计您看着也累,我写着也累,computed将由另外一篇文章进行讲解。 一篇文章写下来,颇有些难度。下面有三点:

  1. 代码太多: 因为源码考虑的情况很多,当我们对单个点进行分析的时候,我们需要摒弃其他没有必要的代码
  2. 流水账:记录每行代码的作用,没有对更深层次的进行探索
  3. 有机结合:分析了以后,不能和以前学习的知识进行结合

所以给出一些措施来弥补这些问题:

  1. 尽量少些代码,把整个流程图画出来,图比代码更加直观
  2. 从点上,扩展到线,在扩展到面进行思考
  3. 结合以前学过的知识,比如说这里的设计模式,结合起来学习

第一次写这种源码分析文章,诸多不足,欢迎大家提出宝贵的建议,也请多多关注我的GitHub~~

原文链接:segmentfault.com

上一篇:这些前端资源,你值得拥有
下一篇:深入源码学习Vue响应式原理

相关推荐

  • 🔥手写大厂前端知识点源码系列(上)

    如今前端攻城狮的要求越来越高,会使用常见的API已经不能满足现如今前端日益快速发展的脚步。现在大厂基本都会要求面试者手写前端常见API的原理,以此来证明你对该知识点的理解程度。

    2 个月前
  • 🔥基于vue3.0.1 beta搭建仿京东淘宝的电商商城项目!

    前言 就在前段时间,vue官方发布了3.0.0beta.1 版本,趁着五一假期有时间,就把之前的一个电商商城的项目,用最新的Composition API拿来改造一下! 👉GitHub地址请访问🔗...

    1 个月前
  • 🔥前端面试大厂手写源码系列(上)

    如今前端攻城狮的要求越来越高,会使用常见的API已经不能满足现如今前端日益快速发展的脚步。现在大厂基本都会要求面试者手写前端常见API的原理,以此来证明你对该知识点的理解程度。

    2 个月前
  • (源码分析)为什么 Vue 中 template 有且只能一个 root ?

    引言 今年,疫情并没有影响到各种面经的正常出现,可谓是络绎不绝(学不动...)。然后,在前段时间也看到一个这样的关于 Vue 的问题,为什么每个组件 template 中有且只能一个 root? 可能...

    2 个月前
  • (小白篇)vue-cli3.0创建项目+引入element-ui

    vuecli在2018年8月份发布了3.0版本,经过重构之后,可以说是一个船心版本! 在项目都落地之后,就想升级一下cli版本,尝一尝3.0带来的舒适,也是为后面项目的开展做一个准备。

    1 年前
  • (小小黑科技)vue+echarts实现半圆图表

    如何用echarts实现半圆图表?在echarts官方实例倒腾一波,发现官方并没有提供半圆图表的写法,那怎么办呢?官方没提供,但需求还是要实现的。 半圆图表其实就是饼图的一半,那么简单的思路如下:设...

    1 年前
  • (原创)vue-router的Import() 异步加载模块问题的解决方案

    关注不迷路,如果解决了问题,留下个赞。 1、问题现象 (/public/upload/e221e3db24c3f24a41062b6e4e389df8) 2、出现问题的代码点 (/publ...

    17 天前
  • (vue框架)为element组件赋初始值以后无法更改值得问题

    情况描述:组件未加载时已有初始值,mounted里面加载数据,赋值,渲染以后,组件无法更改内容 data里面已经有这个表单对象的初始值但还是无法修改,之前有过一次,没有给表单绑定对象,所以赋值以后无法...

    1 年前
  • (vuejs学习)2、使用ElementUI(*)

    1.element安装 开发环境是win10,一到node官网下载node的.msi包(https://npm.taobao.org/mirrors/node/v10.16.0/nodev10.16....

    10 个月前
  • (vuejs学习)1、Vue初上手(*)

    参考《官方(https://cli.vuejs.org/zh/guide/installation.html)》官方: Node 版本要求: Vue CLI 需要 Node.js 8.9 或更高...

    10 个月前

官方社区

扫码加入 JavaScript 社区