平时开发中,Vue的响应式系统让我们不再去操作DOM,只需关心数据逻辑的处理,极大地降低了代码的复杂度。而响应式系统也是Vue的核心,作为开发者有必要了解其实现原理!
简易版
以watch为切入点
watch是平时开发中使用率非常高的功能,其目的是观测一个数据,当数据变化时执行我们预先定义的回调。使用方式如下:
{
 watch: {
  obj(val, oldVal) {
   console.log(val, oldVal);
  }
 }
}
上面观测了Vue实例的obj属性,当其值发生变化时,打印出新值与旧值。
因此,我们定义一个watch函数:
function watch (data, key, cb) {
 // do something
}
- watch函数接收3个属性,分别是
 - data: 被观测对象 key: 被观测的属性
 - 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' "text-align: center">
结果好像有点不对?输出了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次只是个例子,实际可能会更多!
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。
Vue,响应式,对象观测
稳了!魔兽国服回归的3条重磅消息!官宣时间再确认!
昨天有一位朋友在大神群里分享,自己亚服账号被封号之后居然弹出了国服的封号信息对话框。
这里面让他访问的是一个国服的战网网址,com.cn和后面的zh都非常明白地表明这就是国服战网。
而他在复制这个网址并且进行登录之后,确实是网易的网址,也就是我们熟悉的停服之后国服发布的暴雪游戏产品运营到期开放退款的说明。这是一件比较奇怪的事情,因为以前都没有出现这样的情况,现在突然提示跳转到国服战网的网址,是不是说明了简体中文客户端已经开始进行更新了呢?
更新动态
- 小骆驼-《草原狼2(蓝光CD)》[原抓WAV+CUE]
 - 群星《欢迎来到我身边 电影原声专辑》[320K/MP3][105.02MB]
 - 群星《欢迎来到我身边 电影原声专辑》[FLAC/分轨][480.9MB]
 - 雷婷《梦里蓝天HQⅡ》 2023头版限量编号低速原抓[WAV+CUE][463M]
 - 群星《2024好听新歌42》AI调整音效【WAV分轨】
 - 王思雨-《思念陪着鸿雁飞》WAV
 - 王思雨《喜马拉雅HQ》头版限量编号[WAV+CUE]
 - 李健《无时无刻》[WAV+CUE][590M]
 - 陈奕迅《酝酿》[WAV分轨][502M]
 - 卓依婷《化蝶》2CD[WAV+CUE][1.1G]
 - 群星《吉他王(黑胶CD)》[WAV+CUE]
 - 齐秦《穿乐(穿越)》[WAV+CUE]
 - 发烧珍品《数位CD音响测试-动向效果(九)》【WAV+CUE】
 - 邝美云《邝美云精装歌集》[DSF][1.6G]
 - 吕方《爱一回伤一回》[WAV+CUE][454M]
 
                        




