一步一步实现Vue的响应式(对象观测)

平时开发中,Vue的响应式系统让我们不再去操作DOM,只需关心数据逻辑的处理,极大地降低了代码的复杂度。而响应式系统也是Vue的核心,作为开发者有必要了解其实现原理!

简易版

以watch为切入点

watch是平时开发中使用率非常高的功能,其目的是观测一个数据,当数据变化时执行我们预先定义的回调。使用方式如下:

{
  watch: {
    obj(val, oldVal) {
      console.log(val, oldVal);
    }
  }
}

上面观测了Vue实例的obj属性,当其值发生变化时,打印出新值与旧值。

因此,我们定义一个watch函数:

function watch (data, key, cb) {
  // do something
}
  1. watch函数接收3个属性,分别是
  2. data: 被观测对象 key: 被观测的属性
  3. cb: 数据变化后要执行的回调

Object.defineProperty

既然要在数据变化后再执行回调,所以需要知道数据是什么时候被修改的,这就是Object.defineProperty的作用,其为数据定义了访问器属性。在数据被读取时会触发get,在数据被修改时会触发set。

我们定义一个defineReactive函数,其用来将一个数据变成响应式的:

function defineReactive(data, key) {
  let val = data[key];

  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get: function() {
      return val;
    },
    set: function(newVal) {
      if (newVal === val) {
        return;
      }

      val = newVal;
    }
  });
}

defineReactive函数为data对象的key属性定义了get、set,get返回属性key的值val,set中修改key的值为新值newVal。到目前为止,key属性还是没有什么特殊之处。

数据被修改会触发set,那cb一定是在set中被执行。但set与cb之间好像并没有什么联系,所以我们来搭建一座桥梁,来构建两者的联系:

let target = null;

我们在全局定义了一个target变量,它用来保存cb的值,然后在set中调用。所以,cb什么时候被保存在target中?回到出发点,我们要调用watch函数来观测data的key属性,当值被修改时执行我们定义的回调cb,这就是cb被保存在target中的时机了:

function watch(data, key, cb) {
  target = cb;
}

watch函数中target被修改了,但我要是再想调用watch函数一次,也就是说我想在data[key]被修改时,执行两个不同的回调,又或者说,我想再观测data的其它属性,那该怎么办?必须得在target被再次修改前,将其值保存到别处。因为,target是同个属性的不同回调或不同属性的回调所共有的。

我们有必要为key属性建立一个私有的仓库,来保存回调。其实defineReactive函数有一点特殊地方:函数内部定义了一个val变量,然后在get和set函数都使用了val变量,这形成一个闭包,defineReactive函数的作用域是key属性私有的,这就是天然的私有仓库了:

function defineReactive(data, key) {
  let val = data[key];
  const dep = [];

  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get: function() {
      target && dep.push(target);

      return val;
    },
    set: function(newVal) {
      if (newVal === val) {
        return;
      }

      dep.forEach(fn => fn(newVal, val));

      val = newVal;
    }
  });
}

我们在defineReactive函数内定义了一个数组dep,其保存着每个属性key的回调集合,也称为依赖集合。在get函数中将依赖收集到dep中,在set函数中循环dep执行每一个依赖。总结起来就是:在get中收集依赖,set中触发依赖。

既然是在get中收集依赖,那就要想办法在tatget被修改时候触发get,所以我们在watch函数中读取一下属性key的值:

function watch(data, key, cb) {
  target = cb;
  data[key];
  target = null;
}

接下来我们测试下代码:

完全ok!

依赖

回想简易版中,我们一共提到3个角色:defineReactive、dep、watch,三者其实各司其职,但我们把三者代码耦合在了一起,不方便接下来扩展与理解,所以我们来做一下归类。

Watcher

观察者,也称为依赖,它的职责就是订阅一个数据,当数据发生变化时,做些什么:

class Watcher {
  constructor(data, key, cb) {
    this.vm = data;
    this.key = key;
    this.cb = cb;
    this.value = this.get();
  }

  get() {
    Dep.target = this;
    const value = this.vm[this.key];
    Dep.target = null;

    return value;
  }

  update() {
    const oldValue = this.value;
    this.value = this.vm[this.key];

    this.cb.call(this.vm, this.value, oldVal);
  }
}

首先在构造函数中读取了属性key的值,这会触发属性key的set,然后将自己作为依赖存入其dep数组中。当然,在读取属性值之前,需要将自己赋值给桥梁Dep.target,这是get方法所做的事。最后是update方法,这是当订阅的数据发生变化后,需要被执行的,其主要目的就是要执行cb,因为cd需要变化后的新值作为参数,所以要再一次读取属性值。

Dep

Dep的职责就是构建属性key与依赖Watcher之间的联系,其实例一定有一个独一无二的属于属性key的依赖收集框:

class Dep {
  constructor() {
    this.subs = [];
  }

  addSub(sub) {
    this.subs.push(sub);
  }

  depend() {
    Dep.taget && this.addSub(Dep.target);
  }

  notify() {
    for (let sub of subs) {
      sub.update();
    }
  }
}

subs就是依赖收集框,当属性值被读取时,在depend方法中将依赖收入到框内;当属性值被修改时,在notify方法中将依赖收集框遍历,每一个依赖的update方法都将被执行。

Observer

defineReactive函数只做了一件事,将数据转换成响应式的,我们定义一个Observer类来聚合其功能:

class Observer {
  constructor(data, key) {
    this.value = data;

    defineReactive(data, key);
  }
}

function defineReactive(data, key) {
  let val = data[key];
  const dep = new Dep();

  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get: function() {
      dep.depend();

      return val;
    },
    set: function(newVal) {
      if (newVal === val) {
        return;
      }

      dep.notify();

      val = newVal;
    }
  });
}

dep不再是一个纯粹的数组,而是一个Dep类的实例。get函数中的依赖收集、set函数中的依赖触发的逻辑,分别用dep.depend、dep.update替代,这让defineReactive函数逻辑变得变得更加清晰。但是Observer类只是在构造函数中调用defineReactive函数,没起什么作用?这当然都是为后面做铺垫的!

测试一下代码:

观测所有属性

到目前为止我们都只在针对一个属性,而一个对象可能有n多个属性,因此我们要对做下调整。

观测一个对象的所有属性

观测一个属性主要是要定义其访问器属性,对于我们的代码来说,就是要执行defineReactive函数,所以对Observer类做下修改:

class Observer {
  constructor(data) {
    this.value = data;

    if (isPlainObject(data)) {
      this.walk(data);
    }
  }

  walk(value) {
    const keys = Object.keys(value);

    for (let key of keys) {
      defineReactive(value, key);
    }
  }
}

function isPlainObject(obj) {
  return ({}).toString.call(obj) === '[object Object]';
}

我们在Observer类中定义一个walk方法,其作用就是遍历对象的所有属性,然后在构造函数中调用。调用的前提是对象是一个纯对象,即对象是通过字面量或new Object()初始化的,因为像Array、Function等也都是对象。

测试一下代码:

深度观测

我们只要对象是可以嵌套的,即一个对象的某个属性值也可以是对象,我们的代码目前还做不到这一点。其实也很简单,做一下递归遍历的就好了:

class Observer {
  constructor(data) {
    this.value = data;

    if (isPlainObject(data)) {
      this.walk(data);
    }
  }

  walk(value) {
    const keys = Object.keys(value);

    for (let key of keys) {
      const val = value[key];

      if (isPlainObject(val)) {
        this.walk(val);
      }
      else {
        defineReactive(value, key);
      }
    }
  }
}

我们在walk方法中做了判断,如果key的属性值val是个纯对象,那就调用walk方法去遍历其属性值。既然是深度观测,那watcher类中的key的用法也发生了变化,比如说:'a.b.c',那我们就要兼容这种嵌套key的写法:

class Watcher {
  constructor(data, path, cb) {
    this.vm = data;
    this.cb = cb;
    this.getter = parsePath(path);
    this.value = this.get();
  }

  get() {
    Dep.target = this;
    const value = this.getter.call(this.vm);
    Dep.target = null;

    return value;
  }

  update() {
    const oldValue = this.value;
    this.value = this.getter.call(this.vm, this.vm);

    this.cb.call(this.vm, this.value, oldValue);
  }
}

function parsePath(path) {
  if (/.$_/.test(path)) {
    return;
  }

  const segments = path.split('.');

  return function(obj) {
    for (let segment of segments) {
      obj = obj[segment]
    }

    return obj;
  }
}

Watcher类实例新增了getter属性,其值为parsePath函数的返回值,在parsePath函数中,返回的是一个匿名函数,匿名函数接收一个参数obj,最后又将obj作为返回值返回,那么这里的重点是匿名函数对obj做了什么处理。

匿名函数内只有一个for...of迭代,迭代对象为segments,segments是通过path对'.'分割得到的一个数组,比如path为'a.b.c',那么segments就为['a', 'b', 'c']。迭代内只有一个语句,obj被赋值为obj的属性值,这相当于一层一层去读取,比如说,obj初始值为:

obj = {
  a: {
    b: {
      c: 1
    }
  }
}

那么最后的结果为:

obj = 1

读取属性值的目的就是为了收集依赖,比如我们要观测obj.a.b.c,那么目的就达到了。 既然知道了getter是一个函数,那么在get方法中执行getter,就可以获取值了。

测试下代码:

这里有个细节,我们看Watcher类的get方法:

get() {
  Dep.target = this;
  const value = this.getter.call(this.vm);
  Dep.target = null;

  return value;
}

在执行this.getter函数的时候,Dep.target的值一直都是当前依赖,而this.getter函数中一层一层读取属性值,在这路径之中的所有属性其实都收集了当前依赖。比如上面的例子来说,属性'a.b.c'的依赖,被收集到obj.a、obj.a.b、obj.a.b.c的dep中,那么修改obj.a或obj.b都是会触发当前依赖的:

避免重复收集依赖

观测表达式

在Vue中,$watch方法的第一个参数是可以传函数的:

this.$watch(() => {
  return this.a + this.b;
}, (val, oldVal) => {
  console.log(val, oldVal);
});

这种写法相当于观测一个表达式,类似与Vue中computed,依赖会被收集到属性a与属性b的dep中,无论修改其中任一,只要表达式的值发生变化,依赖都将会触发。

为了兼容函数的传入,我们稍微修改下Watcher类:

class Watcher {
  constructor(data, pathOrFn, cb) {
    this.vm = data;
    this.cb = cb;
    this.getter = typeof pathOrFn === 'function' ? pathOrFn : parsePath(pathOrFn);
    this.value = this.get();
  }

  ...

  update() {
    const oldValue = this.value;
    this.value = this.get();

    this.cb.call(this.vm, this.value, oldValue);
  }
}

对于第二个参数pathOrFn,我们优先判断其本身是否已经是函数,是则直接赋值给this.getter,否则调用parsePath函数解析。在update方法中,再次调用了get方法来获取被修改后的值。

测试下代码:

结果好像有点不对?输出了1949次!而且还在增加之中,一定是某个陷入无限循环了。仔细回看我们修改的点,在update方法中,我们再次调用了get方法,这又会触发一次依赖的收集。然后我们在Dep类的notify方法中遍历依赖集合,每次触发依赖都会导致依赖的再次收集,这就是个无限循环了!

发现了问题,就来解决问题。我们要对依赖做唯一性校验:

let uid = 1;

class Watcher {
  constructor(data, pathOrFn) {
    this.id = uid++;
    ...
  }
}

class Dep() {
  construct() {
    this.subs = [];
    this.subIds = new Set();
  }
  ...
  addSub(sub) {
    const id = sub.id;

    if (!this.subIds.has(id)) {
      this.subs.push(sub);
      this.subIds.add(id);
    }
  }
  ...
}

既然要做唯一性校验,我们给Watcher类实例增加了独一无二的id。在Dep类中,我们给构造函数里增加了属性subIds,其初始值为空Set,作用是存储依赖的id。然后在addSub方法中,在将依赖添加到subs之前,先判断这个依赖的id是否已经存在。

测试下代码:

只输出了一次,完全ok。

在Vue中的意义

防止依赖的重复收集,除了防止上面提到的陷入无限循环,在Vue中还有更重要的意义,比如一下模板:

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

在Vue中,除了watch选项的依赖,还有一个特殊依赖叫渲染函数的依赖,其作用就是当模板中的变量发生变化时,更新VNode,重新生成DOM。在我们上面定义的模板中,一共使用a变量3次,当a变量被修改,如果没有防止重复依赖的收集,渲染函数就会被执行3次!这是完全必要的!并且3次只是个例子,实际可能会更多!

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • Vue的事件响应式进度条组件实例详解

    写在前面 找了很多vue进度条组件,都不包含拖拽和点击事件,input range倒是原生包含input和change事件,但是直接基于input range做进度条的话,样式部分需要做大量调整和兼容性处理.即使做好了,将来需要修改外观,又是一番折腾. 基于以上两个原因,做了一个可以响应input和change事件(即一个是拖动进度条到某处,一个是在进度条某位置点击使其值变为该位置)的div实现的Vue组件,这样既满足了对进度条事件的需求,也带来了如有需求变动,样式修改很方便的好处. 效果图 以

  • Vue响应式原理深入解析及注意事项

    前言 Vue最明显的特性之一便是它的响应式系统,其数据模型即是普通的 JavaScript 对象.而当你读取或写入它们时,视图便会进行响应操作.文章简要阐述下其实现原理,如有错误,还请不吝指正.下面话不多说了,来随着小编来一起学习学习吧. 响应式data <div id = "exp">{{ message }}</div> const vm = new Vue({ el: '#exp', data: { message: 'This is A' } }) vm

  • Vue如何实现响应式系统

    前言 最近深入学习了Vue实现响应式的部分源码,将我的些许收获和思考记录下来,希望能对看到这篇文章的人有所帮助.有什么问题欢迎指出,大家共同进步. 什么是响应式系统 一句话概括:数据变更驱动视图更新.这样我们就可以以"数据驱动"的思维来编写我们的代码,更多的关注业务,而不是dom操作.其实Vue响应式的实现是一个变化追踪和变化应用的过程. vue响应式原理 以数据劫持方式,拦截数据变化:以依赖收集方式,触发视图更新.利用es5 Object.defineProperty拦截数据的set

  • 谈谈对vue响应式数据更新的误解

    对于刚接触vue的同学会经常遇到数据更新了但是模板没有更新的问题,下面将结合vue的响应式特性以及异步更新机制分析常见的错误: 异步更新带来的数据响应式误解 异步数据的处理基本是一定会遇到的,处理不好就会遇到数据不更新的问题,但有一种情况是在未正确处理的情况下也能正常更新,这就会造成一种误解,详情如下所示: 模板 <div id="app"> <h2>{{dataObj.text}}</h2> </div> js new Vue({ el

  • Vue响应式原理详解

    Vue 嘴显著的特性之一便是响应式系统(reactivity system),模型层(model)只是普通JavaScript对象,修改它则更新视图(view). Vue 响应式系统的底层细节 如何追踪变化 把一个普通的JavaScript对象传给Vue实例的data选项,Vue将遍历此对象的所有属性,并使用Object.defineProperty 把这些属性全部转为 getter/setter.Object.defineProperty是仅ES5支持,并无法shim的特性,这也就是为什么Vu

  • 代码详解Vuejs响应式原理

    响应式原理 > vuejs中的模型(model)和视图(view)是保持同步的,在修改数据的时候会自动更新视图,这其实依赖于Object.defineProperty方法,所以vuejs不支持IE8及以下版本,vuejs通过劫持getter/setter方法来监听数据的变化,通过getter进行依赖收集,在数据变更执行setter的时候通知视图更新. Object.defineProperty > Object.defineProperty可以定义对象的属性或修改对象的属性 > 目前可以

  • 浅谈Vue 数据响应式原理

    前言 Vue的数据响应主要是依赖了Object.defineProperty(),那么整个过程是怎么样的呢?以我们自己的想法来走Vue的道路,其实也就是以Vue的原理为终点,我们来逆推一下实现过程. 本文代码皆为低配版本,很多地方都不严谨,比如 if(typeof obj === 'object')这是在判断obj是否为为一个对象,虽然obj也有可能是数组等其他类型的数据,但是本文为了简便,就直接这样写来表示判断对象,对于数组使用Array.isArray(). 改造数据 我们先来尝试写一个函数

  • 浅谈实现vue2.0响应式的基本思路

    最近看了vue2.0源码关于响应式的实现,以下博文将通过简单的代码还原vue2.0关于响应式的实现思路. 注意,这里只是实现思路的还原,对于里面各种细节的实现,比如说数组里面数据的操作的监听,以及对象嵌套这些细节本实例都不会涉及到,如果想了解更加细节的实现,可以通过阅读源码 observer文件夹以及instance文件夹里面的state文件具体了解. 首先,我们先定义好实现vue对象的结构 class Vue { constructor(options) { this.$options = o

  • Vue.js每天必学之内部响应式原理探究

    深入响应式原理 大部分的基础内容我们已经讲到了,现在讲点底层内容.Vue.js 最显著的一个功能是响应系统 -- 模型只是普通对象,修改它则更新视图.这让状态管理非常简单且直观,不过理解它的原理也很重要,可以避免一些常见问题.下面我们开始深挖 Vue.js 响应系统的底层细节. 如何追踪变化 把一个普通对象传给 Vue 实例作为它的 data 选项,Vue.js 将遍历它的属性,用 Object.defineProperty 将它们转为 getter/setter.这是 ES5 特性,不能打补丁

  • 通过图带你深入了解vue的响应式原理

    前言 如果自己去实现数据驱动的模式,如何解决一下几个问题: 通过什么手段去知道我的数据变了? 通过什么东西去同步更新视图? 数据劫持--obvserver 我们需要知道数据的获取和改变,数据劫持是最基础的手段.在Obeserver中,我们可以看到代码如下: Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { // ... }, set:

  • vue.js响应式原理解析与实现

    从很久之前就已经接触过了angularjs了,当时就已经了解到,angularjs是通过脏检查来实现数据监测以及页面更新渲染.之后,再接触了vue.js,当时也一度很好奇vue.js是如何监测数据更新并且重新渲染页面.今天,就我们就来一步步解析vue.js响应式的原理,并且来实现一个简单的demo. 首先,先让我们来了解一些基础知识. 基础知识 Object.defineProperty es5新增了Object.defineProperty这个api,它可以允许我们为对象的属性来设定gette

随机推荐