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


Posted in Javascript onSeptember 02, 2019

平时开发中,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;
}

接下来我们测试下代码:

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

完全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函数,没起什么作用?这当然都是为后面做铺垫的!

测试一下代码:

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

观测所有属性

到目前为止我们都只在针对一个属性,而一个对象可能有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等也都是对象。

测试一下代码:

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

深度观测

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

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,就可以获取值了。

测试下代码:

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

这里有个细节,我们看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的响应式(对象观测)

避免重复收集依赖

观测表达式

在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方法来获取被修改后的值。

测试下代码:

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

结果好像有点不对?输出了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是否已经存在。

测试下代码:

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

只输出了一次,完全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次只是个例子,实际可能会更多!

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

Javascript 相关文章推荐
11款基于Javascript的文件管理器
Oct 25 Javascript
jQuery异步加载数据并添加事件示例
Aug 24 Javascript
javascript trim函数在IE下不能用的解决方法
Sep 12 Javascript
JavaScript通过function定义对象并给对象添加toString()方法实例分析
Mar 23 Javascript
angularjs学习笔记之简单介绍
Sep 26 Javascript
浅析C/C++,Java,PHP,JavaScript,Json数组、对象赋值时最后一个元素后面是否可以带逗号
Mar 22 Javascript
在Node.js中使用Javascript Generators详解
May 05 Javascript
利用JavaScript实现栈的数据结构示例代码
Aug 02 Javascript
JS解决IOS中拍照图片预览旋转90度BUG的问题
Sep 13 Javascript
小程序封装wx.request请求并创建接口管理文件的实现
Apr 29 Javascript
使用vue构建多页面应用的示例
Oct 22 Javascript
vue3.0中友好使用antdv示例详解
Jan 05 Vue.js
Layui多选只有最后一个值的解决方法
Sep 02 #Javascript
解决layui checkbox 提交多个值的问题
Sep 02 #Javascript
LayUI动态设置checkbox不显示的解决方法
Sep 02 #Javascript
layui checkbox默认选中,获取选中值,清空所有选中项的例子
Sep 02 #Javascript
layui 选择列表,打勾,点击确定返回数据的例子
Sep 02 #Javascript
利用JS响应式修改vue实现页面的input值
Sep 02 #Javascript
layui 弹出层回调获取弹出层数据的例子
Sep 02 #Javascript
You might like
mysql5写入和读出乱码解决
2006/11/25 PHP
如何使用PHP获取指定日期所在月的开始日期与结束日期
2013/08/01 PHP
PHP中IP地址与整型数字互相转换详解
2014/08/20 PHP
关于PHP开发的9条建议
2015/07/27 PHP
PHP的Yii框架中Model模型的学习教程
2016/03/29 PHP
PHP集成环境XAMPP的安装与配置
2018/11/13 PHP
JavaScript中访问节点对象的方法有哪些如何使用
2013/09/24 Javascript
JavaScript动态修改背景颜色的方法
2015/04/16 Javascript
javascript如何操作HTML下拉列表标签
2015/08/20 Javascript
你所未知的3种Node.js代码优化方式
2016/02/25 Javascript
解析ajaxFileUpload 异步上传文件简单使用
2016/12/30 Javascript
jquery手机触屏滑动拼音字母城市选择器的实例代码
2017/12/11 jQuery
浅谈Vue 数据响应式原理
2018/05/07 Javascript
VUE-Table上绑定Input通过render实现双向绑定数据的示例
2018/08/27 Javascript
js实现input密码框显示/隐藏功能
2020/09/10 Javascript
详解vue项目中使用token的身份验证的简单实践
2019/03/08 Javascript
JS开发自己的类库实例分析
2019/08/28 Javascript
搭建Vue从Vue-cli到router路由护卫的实现
2019/11/14 Javascript
基于vue+echarts数据可视化大屏展示的实现
2020/12/25 Vue.js
python和C语言混合编程实例
2014/06/04 Python
在Python中使用base64模块处理字符编码的教程
2015/04/28 Python
Python socket实现简单聊天室
2018/04/01 Python
基于Python在MacOS上安装robotframework-ride
2018/12/28 Python
Django项目使用CircleCI的方法示例
2019/07/14 Python
Python BeautifulReport可视化报告代码实例
2020/04/13 Python
Space NK美国站:英国高端美妆护肤商城
2017/05/22 全球购物
如何将字串String转换成整数int
2015/02/21 面试题
应届优秀本科大学毕业生自我鉴定
2014/01/21 职场文书
《长征》教学反思
2014/04/27 职场文书
安全演讲稿大全
2014/05/09 职场文书
信息管理专业自荐书
2014/06/05 职场文书
国际会计专业求职信
2014/08/04 职场文书
2014派出所所长群众路线对照检查材料思想汇报
2014/09/18 职场文书
2014年药店店长工作总结
2014/11/17 职场文书
如何利用React实现图片识别App
2022/02/18 Javascript
Win10本地连接不见了怎么恢复? win10系统电脑本地连接不见了解决方法
2023/01/09 数码科技