vue响应式详解(重学前端-vue篇1)

数据发生变化后,会重新对页面渲染,这就是Vue响应式

响应式图例

2 想完成这个过程,我们需要做些什么

侦测数据的变化

收集视图依赖了哪些数据

数据变化时,自动“通知”需要更新的视图部分,并进行更新

它们对应专业俗语分别是:

数据劫持 / 数据代理

依赖收集

发布订阅模式

3 如何侦测数据的变化

有两种办法可以侦测到变化:

使用Object.defineProperty和ES6的Proxy,这就是进行数据劫持或数据代理。

3.1 Object.defineProperty实现

Vue通过设定对象属性的 setter/getter 方法来监听数据的变化,通过getter进行依赖收集,而每个setter方法就是一个观察者,在数据变更的时候通知订阅者更新视图。

代码如下:

functionrender() {

//set的时候会走这里,重新渲染

console.log('模拟视图渲染')

}

letdata = {

name:'浪里行舟',

location: { x: 100, y: 100 }

}

observe(data)

定义核心函数

functionobserve (obj) { // 我们来用它使对象变成可观察的

// 判断类型

if(!obj || typeof obj !=='object') {

return

}

Object.keys(obj).forEach(key => {

defineReactive(obj, key, obj[key])

})

functiondefineReactive (obj, key, value) {

// 递归子属性

observe(value)

Object.defineProperty(obj, key, {

enumerable:true, //可枚举(可以遍历)

configurable:true, //可配置(比如可以删除)

get:functionreactiveGetter() {

console.log('get', value) // 监听

returnvalue

},

set:functionreactiveSetter (newVal) {

observe(newVal) //如果赋值是一个对象,也要递归子属性

if(newVal !== value) {

console.log('set', newVal) // 监听

render()

value = newVal

}

}

})

}

}

改变data的属性,会出发set;然后获取data的属性,会触发get

data.location = {

x: 1000,

y: 1000

} //打印set{x: 1000,y: 1000} 模拟视图渲染

data.name //打印   get 浪里行舟

上面这段代码的主要作用在于:

observe这个函数传入一个 obj(需要被追踪变化的对象),通过遍历所有属性的方式对该对象的每一个属性都通过 defineReactive 处理,给每个属性加上set和get方法,以此来达到实现侦测对象变化。值得注意的是,observe 会进行递归调用。

那我们如何侦测Vue中data 中的数据,其实也很简单:

class Vue {

/* Vue构造类 */

constructor(options) {

this._data = options.data;

observer(this._data);

}

}

这样我们只要 new 一个 Vue 对象,就会将 data 中的数据进行追踪变化。

但是我们发现一个问题,上面的代码无法检测到对象属性的添加或删除(如data.location.a=1,增加一个a属性)。

这是因为 Vue 通过Object.defineProperty来将对象的key转换成getter/setter的形式来追踪变化,但getter/setter只能追踪一个数据是否被修改,无法追踪新增属性和删除属性。如果是删除属性,我们可以用vm.$delete实现,那如果是新增属性,该怎么办呢?

可以使用 Vue.set(location, a, 1) 方法向嵌套对象添加响应式属性;

也可以给这个对象重新赋值,比如data.location = {...data.location,a:1}

Object.defineProperty 不能监听数组的变化,需要进行数组方法的重写

3.2 Proxy实现

Proxy 是 JavaScript 2015 的一个新特性。Proxy 的代理是针对整个对象的,而不是对象的某个属性,因此不同于 Object.defineProperty 的必须遍历对象每个属性,Proxy 只需要做一层代理就可以监听同级结构下的所有属性变化,当然对于深层结构,递归还是需要进行的。此外Proxy支持代理数组的变化。

functionrender() {

console.log('模拟视图的更新')

}

letobj = {

name:'前端工匠',

age: { age: 100 },

arr: [1, 2, 3]

}

lethandler = {

get(target, key) {

// 如果取的值是对象就再对这个对象进行数据劫持

if(typeof target[key] =='object'&& target[key] !== null) {

returnnew Proxy(target[key], handler)

}

returnReflect.get(target, key)

},

set(target, key, value) {

//key为length时,表示遍历完了最后一个属性

if(key ==='length')returntrue

render()

returnReflect.set(target, key, value)

}

}

letproxy = new Proxy(obj, handler)

proxy.age.name ='浪里行舟'// 支持新增属性

console.log(proxy.age.name) // 模拟视图的更新 浪里行舟

proxy.arr[0] ='浪里行舟'//支持数组的内容发生变化

console.log(proxy.arr) // 模拟视图的更新 ['浪里行舟', 2, 3 ]

proxy.arr.length-- // 无效

以上代码不仅精简,而且还是实现一套代码对对象和数组的侦测都适用。不过Proxy兼容性不太好!

4. 收集依赖

4.1 为什么要收集依赖

我们之所以要观察数据,其目的在于当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。比如例子中,模板中使用了location 数据,当它发生变化时,要向使用了它的地方发送通知。

letglobalData = {

text:'浪里行舟'

};

lettest1 = new Vue({

template:

`

{{text}}

`,

data: globalData

});

lettest2 = new Vue({

template:

`

{{text}}

`,

data: globalData

});

如果我们执行下面这条语句:

globalData.text ='前端工匠';

此时我们需要通知 test1 以及 test2 这两个Vue实例进行视图的更新,我们只有通过收集依赖才能知道哪些地方依赖我的数据,以及数据更新时派发更新。那依赖收集是如何实现的?其中的核心思想就是“事件发布订阅模式”。接下来我们先介绍两个重要角色-- 订阅者 Dep和观察者 Watcher ,然后阐述收集依赖的如何实现的。

4.2 订阅者 Dep

为什么引入 Dep:

收集依赖需要为依赖找一个存储依赖的地方,为此我们创建了Dep,它用来收集依赖、删除依赖和向依赖发送消息等。

于是我们先来实现一个订阅者 Dep 类,用于解耦属性的依赖收集和派发更新操作,说得具体点:它的主要作用是用来存放 Watcher 观察者对象。我们可以把Watcher理解成一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。

Dep的简单实现:

class Dep {

constructor() {

/* 用来存放Watcher对象的数组 */

this.subs = [];

}

/* 在subs中添加一个Watcher对象 */

addSub (sub) {

this.subs.push(sub);

}

/* 通知所有Watcher对象更新视图 */

notify() {

this.subs.forEach((sub) => {

sub.update();

})

}

}

以上代码主要做两件事情:

用 addSub 方法可以在目前的 Dep 对象中增加一个 Watcher 的订阅操作;

用 notify 方法通知目前 Dep 对象的 subs 中的所有 Watcher 对象触发更新操作。 所以当需要依赖收集的时候调用 addSub,当需要派发更新的时候调用 notify。

调用也很简单:

letdp = new Dep()

dp.addSub(() => {//依赖收集的时候

console.log('emit here')

})

dp.notify()//派发更新的时候

5 观察者 Watcher

5.1 为什么引入Watcher

Vue 中定义一个 Watcher 类来表示观察订阅依赖。至于为啥引入Watcher,《深入浅出vue.js》给出了很好的解释:

当属性发生变化后,我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch,这时需要抽象出一个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个,再由它负责通知其他地方。

依赖收集的目的是: 将观察者 Watcher 对象存放到当前闭包中的订阅者 Dep 的 subs 中。形成如下所示的这样一个关系(图参考《剖析 Vue.js 内部运行机制》)。

5.2 Watcher的简单实现

class Watcher {

constructor(obj, key, cb) {

// 将 Dep.target 指向自己

// 然后触发属性的 getter 添加监听

// 最后将 Dep.target 置空

Dep.target = this

this.cb = cb

this.obj = obj

this.key = key

this.value = obj[key]

Dep.target = null

}

update() {

// 获得新值

this.value = this.obj[this.key]

// 我们定义一个 cb 函数,这个函数用来模拟视图更新,调用它即代表更新视图

this.cb(this.value)

}

}

以上就是 Watcher 的简单实现,在执行构造函数的时候将 Dep.target 指向自身,从而使得收集到了对应的 Watcher,在派发更新的时候取出对应的 Watcher ,然后执行 update 函数。

依赖的本质:

所谓的依赖,其实就是Watcher。

至于如何收集依赖,总结起来就一句话:

在getter中收集依赖,在setter中触发依赖。先收集依赖,即把用到该数据的地方收集起来,然后等属性发生变化时,把之前收集好的依赖循环触发一遍就行了。

具体来说,当外界通过Watcher读取数据时,便会触发getter从而将Watcher添加到依赖中,哪个Watcher触发了getter,就把哪个Watcher收集到Dep中。当数据发生变化时,会循环依赖列表,把所有的Watcher都通知一遍。

最后我们对 defineReactive 函数进行改造,在自定义函数中添加依赖收集和派发更新相关的代码,实现了一个简易的数据响应式:

functionobserve (obj) {

// 判断类型

if(!obj || typeof obj !=='object') {

return

}

Object.keys(obj).forEach(key => {

defineReactive(obj, key, obj[key])

})

functiondefineReactive (obj, key, value) {

observe(value)  // 递归子属性

letdp = new Dep() //新增

Object.defineProperty(obj, key, {

enumerable:true, //可枚举(可以遍历)

configurable:true, //可配置(比如可以删除)

get:functionreactiveGetter() {

console.log('get', value) // 监听

// 将 Watcher 添加到订阅

if(Dep.target) {

dp.addSub(Dep.target) // 新增

}

returnvalue

},

set:functionreactiveSetter (newVal) {

observe(newVal) //如果赋值是一个对象,也要递归子属性

if(newVal !== value) {

console.log('set', newVal) // 监听

render()

value = newVal

// 执行 watcher 的 update 方法

dp.notify() //新增

}

}

})

}

}

class Vue {

constructor(options) {

this._data = options.data;

observer(this._data);

/* 新建一个Watcher观察者对象,这时候Dep.target会指向这个Watcher对象 */

new Watcher();

console.log('模拟视图渲染');

}

}

当 render function 被渲染的时候,读取所需对象的值,会触发 reactiveGetter 函数把当前的 Watcher 对象(存放在 Dep.target 中)收集到 Dep 类中去。之后如果修改对象的值,则会触发 reactiveSetter 方法,通知 Dep 类调用 notify 来触发所有 Watcher 对象的 update 方法更新对应视图。

完整流程图:

在 new Vue() 后, Vue 会调用_init 函数进行初始化,也就是init 过程,在 这个过程Data通过Observer转换成了getter/setter的形式,来对数据追踪变化,当被设置的对象被读取的时候会执行getter 函数,而在当被赋值的时候会执行 setter函数。

当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖中。

在修改对象的值的时候,会触发对应的setter, setter通知之前依赖收集得到的 Dep 中的每一个 Watcher,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher就会开始调用 update 来更新视图。

最后完整的响应式代码:

大概结构

//defineReactive是对Observer的抽离

const defineReactive =function(obj, key) {

// 以下代码省略

}

const Vue =function(options) {

console.log("Vue",this)

//打印1  Vue {

_data:{

text:"123"

get text: ƒ get()

settext: ƒset(newVal)

},

mount: ƒ (),

render: ƒ ()

}

// 以下代码省略

}

const Watcher =function(vm, fn) {

console.log("Watcher",this)

//打印3 Watcher  this是下面的Dep中subs的对象

// 以下代码省略

}

const Dep =function() {

console.log("Dep",this)

//打印2  Dep   {

target: null,

subs: [

{        //是一个Watcher实例

subs: Array(1)

0: Watcher

vm: {    //是一个Vue实例

_data:{

text:"123",//该属性有了get和set方法

get text: ƒ get(),

settext: ƒset(newVal)

},

mount: ƒ (),

render: ƒ ()

},

addDep: ƒ (dep),

update: ƒ (),

value: undefined

}

],

depend: ƒ (),

addSub: ƒ (watcher),

notify: ƒ ()

}

// 以下代码省略

}

const vue = new Vue({

data() {

return{

text:'hello world'

};

}

})

vue.mount();

vue._data.text ='123';

详细代码

const Observer =function(data) {

console.log(1)   //开始4 new Vue的时候就会执行

// 循环修改为每个属性添加getset

for(letkeyindata) {

defineReactive(data, key);

}

}

const defineReactive =function(obj, key) {

console.log(2)    //开始5 new Vue的时候就会执行

// 局部变量dep,用于getset内部调用

const dep = new Dep();

// 获取当前值

letval = obj[key];

Object.defineProperty(obj, key, {

// 设置当前描述属性为可被循环

enumerable:true,

// 设置当前描述属性可被修改

configurable:true,

get() {

console.log(3)//开始10  开始19

console.log('in get');

// 调用依赖收集器中的addSub,用于收集当前属性与Watcher中的依赖关系

dep.depend();

returnval;

},

set(newVal) {

console.log(4)//开始15

if(newVal === val) {

return;

}

val = newVal;

// 当值发生变更时,通知依赖收集器,更新每个需要更新的Watcher,

// 这里每个需要更新通过什么断定?dep.subs

dep.notify();

}

});

}

const observe =function(data) {

console.log(5)  //开始3 new Vue的时候就会执行

returnnew Observer(data);

}

const Vue =function(options) {

console.log(6)//开始1 new Vue的时候就会执行

const self = this;

// 将data赋值给this._data,源码这部分用的Proxy所以我们用最简单的方式临时实现

if(options && typeof options.data ==='function') {

console.log(7)//开始2   new Vue的时候就会执行

this._data = options.data.apply(this);

}

// 挂载函数

this.mount =function() {

console.log(8)  //开始7  new Vue以后,执行vue.mount()

new Watcher(self, self.render);

}

// 渲染函数

this.render =function() {

console.log(9) //开始9 开始18  render函数执行后走到这里

with(self) {

_data.text;  //这里取data值的时候,就会走get方法

}

}

// 监听this._data

observe(this._data);  //new Vue的时候就会执行,这里执行完,就表示new Vue的过程执行完了

}

const Watcher =function(vm, fn) {

console.log(10)  //开始8  执行vue.mount()以后会走到这里

const self = this;

this.vm = vm;

// 将当前Dep.target指向自己

Dep.target = this;

// 向Dep方法添加当前Wathcer

this.addDep =function(dep) {

console.log(11) //开始13

dep.addSub(self);

}

// 更新方法,用于触发vm._render

this.update =function() {

console.log(12)//开始17

console.log('in watcher update');

fn();

}

// 这里会首次调用vm._render,从而触发text的get

// 从而将当前的Wathcer与Dep关联起来

this.value = fn();   //开始9  fn是render函数,这里fn()就会赋值的时候执行

// 这里清空了Dep.target,为了防止notify触发时,不停的绑定Watcher与Dep,

// 造成代码死循环

Dep.target = null;

}

const Dep =function() {

console.log(13)  //开始6  new Vue的时候就会执行到new Dep,然后执行到这里

const self = this;

// 收集目标

this.target = null;

// 存储收集器中需要通知的Watcher

this.subs = [];

// 当有目标时,绑定Dep与Wathcer的关系

this.depend =function() {

console.log(14)  //开始11   开始20 走了get获取属性后,就要进行依赖收集

if(Dep.target) {

console.log(15)//开始12

// 这里其实可以直接写self.addSub(Dep.target),

// 没有这么写因为想还原源码的过程。

Dep.target.addDep(self);

}

}

// 为当前收集器添加Watcher

this.addSub =function(watcher) {

console.log(16)//开始14

self.subs.push(watcher);

}

// 通知收集器中所的所有Wathcer,调用其update方法

this.notify =function() {

console.log(17) //开始16

for(leti = 0; i < self.subs.length; i += 1) {

self.subs[i].update();

}

}

}

const vue = new Vue({

data() {

return{

text:'hello world'

};

}

})

vue.mount(); //inget

vue._data.text ='123'; //inwatcher update /ninget

解析:

一开始new Vue ,会走到46行执行Vue构造函数,打印6

然后46行Vue的入参options实际上是127行的入参{data(){}},是一个包含了data函数的对象,所以options.data是一个data函数,打印7。将vue中的data函数返回的数据赋值给_data。

然后走到67行的observe,会继续往上走到41行定义它的地方。

然后43行 new Observer 的时候会走到第一行Observer(关键函数),打印1。我们发现Observer实际就是给data数据都添加上get和set方法,只不过不添加的方法defineReactive给抽离出去了。

然后走到第9行,执行defineReactive,打印2,然后15行给每个属性加上get和set方法。

然后走到12行,new Dep的时候,会走到95行执行Dep,打印13。Dep函数剩下的代码都只是定义函数,都不会执行,会跳出Dep函数。然后会到defineReactive函数第13行,defineReactive剩下的代码中的函数也不会执行,所以会回到Observer,再回到67行,即new Vue的过程走完了。

然后走到135行的vue.mount(),走到56行,打印8。

然后执行new Watcher走到70行,打印10。

72行到88行只是定义,没有执行。89行this.value = fn()中:fn实际是传进来的render函数(看57行),然后后面又加了()就会立即执行。然后走到60行的render函数,打印9。Watcher就执行完了,然后vue.mount()也执行完了。

接着会走到136行,vue._data.text = '123',注意:这里的vue._data是获取,后面的=才是改变值,所以会先走get,然后再走set。所以会走到21行,打印10。

然后走到25行,执行dep.depend(),再走到104行,打印14。

这时候判断Dep.target,这时候是存在的,所以打印15。

然后走到110行,再跳到77行,打印11。

79行执行后会跳到114行,打印16。

然后就是赋值操作了,这时候会走到28行的set,打印4。

继续向下走,到36行,dep.notify(),然后走到119行,打印17。

然后会走到122行,触发update,走到82行,打印12。

然后执行fn(),即render函数,走到60行,打印9。

然后走到63行,取data值,会走get,走到21,打印3。

然后25行,会跳到104行,打印14。Dep.target为null,15不会打印

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容