响应式原理解析
发布于 4 年前 作者 xiuying19 2777 次浏览 来自 分享

Vue响应式

前言

数据响应式系统是Vue最显著的一个特性,我们通过Vue官方文档回顾一下。

数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接,不过理解其工作原理同样重要,这样你可以避开一些常见的问题。

现在是时候深入一下了!
本文针对响应式系统的原理进行一个详细介绍。

响应式是什么

我们先来看一个例子

<div id="app">
  <p>{{color}}</p>
  <button @click="changeColor">change color!</button>
</div>

new Vue({
  el: '#app',
  data() {
    color: 'blue'
  },
  methods: {
    changeColor() {
      this.color = 'yellow';
    }
  }
})

当我们点击按钮的时候,视图的p标签文本就会从 blue改变成yellow

Vue要完成这次更新,其实需要做两件事情:

  1. __监听__数据color的变化。
  2. 当数据color更新变化时,自动__通知__依赖该数据的视图。

换成专业那么一点点点的名词就是利用数据劫持/数据代理去进行依赖收集发布订阅模式

我们只需要记住一句话:在getter中收集依赖,在setter中触发依赖

如何追踪侦测数据的变化

首先有个问题,如何侦测一个对象的变化?

目前来说,侦测对象变化有两种方法。大家都知道的!

Object.defineProperty

vue 2.x就是使用Object.defineProperty来数据响应式系统的。但是用此方法来来侦测变化会有很多缺陷。例如:

  1. Object.defineProperty无法监控到数组下标的变化,导致通过数组下标添加元素,不能实时响应;
  2. Object.defineProperty只能劫持对象的属性,从而需要对每个对象,每个属性进行遍历,如果,属性值是对象,还需要深度遍历。

本文也是利用Object.defineProperty来介绍响应式系统。

Proxy

vue3就是通过proxy实现响应式系统的。而且在国庆期间已经发布pre-alpha版本

相比旧的Object.defineProperty, proxy可以代理整个对象,并且提供了多个traps,可以实现诸多功能。此外,Proxy支持代理数组的变化等等

当然proxy也有一个致命的缺点,就是无法通过polyfill模拟,兼容性较差。

依赖收集的重要角色 Dep Watcher

DepWatcher是数据响应式中两个比较重要的角色。

收集依赖的地方 Dep

因为在视图模板上可能有多处地方都引用同一个数据,所以要有一个地方去存放数据的依赖,这个地方就是Dep。

Dep主要维护一个__依赖的数组__,当我们利用render函数生成VNode的时候,会触发数据的getter,然后则会把依赖push到Dep的依赖数组中。

依赖是Watcher!

我们可以把Watcher理解成一个__中介的角色__,数据发生变化时,会触发数据的setter,然后通过遍历Dep中的依赖Watcher,然后Watcher通知额外的操作,额外的操作可能是更新视图、更新computed、更新watch等等…

原理实现

还是那句话:在getter中收集依赖,在setter中触发依赖

下面我们看看代码:

代码有点长,下面会有步骤来讲解一次。

/*
 * 劫持数据的getter、setter
 */
function defineReactive(data, key, val) {
  // 1-1:把color数据变成响应式
  const dep = new Dep();
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get() {
      // 3. 因为模板编译watcher访问到了color,从而触发get方法,触发了收集依赖的方法
      dep.depend();
      return val;
    },
    set(newVal) {
      if (val === newVal) {
        return;
      }
      val = newVal;
      // 4-1. 假设我们通过 `this.color = 'yellow';`去更改`color`的值,就会触发set方法。
      dep.notify();
    }
  });
}
/*
 * dep类,收集依赖,和触发依赖
 */
class Dep {
  constructor() {
    this.subs = []; // 收集依赖的数组
  }

  // 收集依赖
  depend() {
    // 3-1. 通过外部的变量来添加到color的依赖中.
    if (window.target && !this.subs.includes(window.target)) {
      this.subs.push(window.target);
    }
  }

  // 通知依赖更新
  notify() {
    // 4-2. 遍历
    this.subs.forEach(watcher => {
      watcher.update();
    });
  }
}

/**
 * 数据与外部的中介
 */
class Watcher {
  constructor(expr, cb) {
    this.cb = cb;
    this.expr = expr;
    // 2-1. 这里触发了get方法
    this.value = this.get();
  }

  get() {
    // 2-2. 这里把自己(watcher)赋值给了外部其中的一个变量
    window.target = this;
    // 2-3. data[this.expr]触发了color的get
    const value = data[this.expr];
    window.target = undefined;
    return value;
  }

  update() {
    this.value = this.get();
    this.cb(this.value);
  }
}

下面我们来走一次流程。
括号里面的1-1,2-2是对应代码的执行点。

1. 把数据变成响应式

利用defineReactive把color数据变成响应式(1-1),执行过这个方法后,我们调用console.log(this.color)的时候可以触发get方法。同理当我们this.color = 'yellow'

注意:在Object.defineProperty上面初始化一个存放依赖的__dep__,这里其实是把__dep__作为数据color的一个__私有变量__,让get和set的方法可以访问到,也是我们经常说的闭包。

2. 编译模板创建watcher

假设我们现在编译模板遇到{{color}}
Vue就会创建一个Watchter,伪代码如下:

new Watcher('color', () => {
  // 当color发生变化的时候,会触发这里的方法。
});

这里高能!!

Watcher的构造函数里面调用了get()方法(2-2),把自己(watcher)赋值给了一个__外部变量__。

然后再触发get方法(2-3)。

3. get中收集依赖

因为模板编译watcher访问到了color,从而触发get方法,触发了收集依赖的方法。

进入到dep.depend方法中(3-1),这里因为在Watcher中把自己存到了外部变量中,所以在dep.depend方法中可以收集到依赖

现在,依赖就被收集了。

4. 通过setter触发依赖

假设我们通过 this.color = 'yellow';去更改color的值,就会触发set方法,执行dep.notify(4-1)。

会遍历依赖数组,从而去触发Watcher的cb方法。 cb就是上面伪代码new Watcher的那个回调函数。

只要回调函数里面运行了操作dom方法,或者触发了diff算法更新dom,都可以把视图进行更新。

响应式简易流程大概就是这样了…

侦测数据变化的类型

其实数据监听变化有两种类型,一种是“”(push),另一种是“”(pull)。

React和Angular中的变化侦测都属于“拉”,就是说在数据发生变化的时候,它不知道哪个数据变了,然后会发送一个信号给框架,框架收到信号后,会进行一个暴力比对来找出那些dom节点需要重新渲染。Angular中使用的是脏检查,在React使用虚拟dom的diff。

vue的数据监听属于“推”。当数据发生变化时,就知道哪个数据发生变化了,从而去更新有此数据依赖的视图。

因此,框架知道的数据更新信息越多,也就可以进行更细粒度的更新。比如,直接通过dom api操作dom。

相对“拉”的力度是最粗的。看到这里,是不是觉得vue的更新效率最快。

我们看看下面的例子

<template>
  <div>{{a}}</div>
  <div>{{b}}</div>
</template>

这里我们看出模板只有a、b两个数据依赖,也就是说我们要创建两个闭包dep去存放两个watcher依赖,我们知道闭包的缺点就是内存泄露。如果有1000个数据依赖在模板上,每个数据所绑定的依赖就越多,依赖追踪在内存上的开销就会越大。

所以,从Vue.js2.0开始,它引入了虚拟dom,一个状态所绑定的依赖不再是具体的dom节点,而是一个__组件__,即一个组件一个Watcher。 这样状态变化后,会通知到组件,组件内部再使用虚拟 dom进行比对。这可以大大降低依赖数量,从而降低依赖追踪所消耗的内存。但并不是引入虚拟dom后,渲染速度变快了。准确的来说,应该是__80%的场景下变得更快了,而剩下的20%反而变慢了__。

个人觉得,鱼和熊掌不可兼得。
“推”是牺牲内存来换更新速度。
“拉”则是牺牲更新速度来获取内存。

总结

Vue响应式的灵魂:在getter中收集依赖,在setter中触发依赖

我们再看看图,回顾一下整个流程。

  • 通过defineReactive,遍历data里面的属性,把数据的getter/setter劫持,用来收集和触发依赖。
  • 当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到Dep依赖中。
  • 当数据发生变化时,会触发setter,从而Dep会向依赖(Wachter)发送通知。
  • Watcher收到通知后,会向外界发送通知,变化通知到外界后可能接触视图更新,也有可能触发用户的某个回调函数等等。

参考文献:《深入浅出Vue.js》

回到顶部