坚持周总结系列第九周Vue源码学习(二)

本文深入解析Vue框架中响应式对象的实现机制,包括Object.defineProperty的应用、依赖收集与派发更新的过程,以及计算属性与侦听属性的区别。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Vue源码学习(二)

深入响应式原理

前端开发最重要的2个工作,一个是把数据渲染到页面,另一个是处理用户交互。

响应式对象

Object.defineProperty

Object.defineProperty方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象。

Object.definProperty(obj,prop,descriptor)
// obj是要在其上定义属性的对象
// prop是要定义或者修改的属性的名称
// descriptor是将被定义或者修改的属性描述符

initState

Vue初始化阶段,_init方法执行的时候,会执行initState(vm)方法,它的定义在src/instance/state.js中。

initState方法主要是对props、methods、data、computer和watcher等属性做了初始化。

  • initProps

props的初始化主要过程,就是遍历定义的props配置。遍历的过程主要做两件事情:一个是调用defineReactive方法把每个prop对应的值变成响应式,可以通过vm._props.xxx访问到定义prop中对应的属性。另一个是通过proxy把vm._props.xxx代理到vm.xxx上。

  • initData

data初始化的主要过程也是做两件事,一个是对定义data函数返回对象的遍历,通过proxy把每一个值vm._data.xxx都代理带vm.xxx上;另一个是调用observe方法观测整个data的变化,把data变成响应式,可以通过vm._data.xxx访问到定义data返回函数中对应的属性。

  • proxy

代理的作用是把props和data上的属性代理到vm实例上。

proxy方法的实现很简单,通过Object.definePropertytarget[sourceKey][key]的读写变成了对target[key]的读写。所以对于props而言,对vm._props.xxx的读写就变成了vm.xxx的读写。同理vm._data.xxx的读写变成了vm.xxx的读写。

observe

observe的功能就是用来监测数据的变化,它的定义在src/core/observe/index.js中。

observe方法的作用就是给非VNode的对象类型数据添加一个Observer,如果已经添加过则直接返回,否则再满足一定条件下去实例化一个Observer对象实例。

Observer

Observer是一个类,它的作用是给对象的属性添加getter和setter,用于依赖收集和派发更新。

Observer的构造函数很假单,首先实例化Dep对象,接着通过执行def函数把自身实例添加到数据对象value的__ob__属性上,def的定义在src/core/util/lang.js中。

def函数是一个非常简单的Object.definProperty的封装,这就是为什么我们在开发中输出data上对象类型数据,会发现该对象多了一个__ob__属性。

回到Observer的构造函数,接下来会对value做判断,对于数组会调用observeArray方法,否则对于纯对象,会调用walk方法。可以看到observeArray是遍历数组再次调用observe方法,而walk方法是遍历对象的key调用defineReactive方法。

defineReactive

defineReactive的功能就是定义一个响应式对象,给对象动态添加getter和setter,它的定义在src/core/observer/index.js中。

defineReactive函数最开始初始化Dep对象的实例,接着拿到obj的属性描述符,然后对子对象递归调用observe方法,这样就保证了无论obj的结构多复杂,它的所有子属性也能变成响应式对象,这样我们访问或者修改obj中一个嵌套比较深的属性,也能触发gettersetter。最后利用Object.defineProperty去给obj的属性key添加gettersetter

总结

响应式对象,核心就是利用Object.defineProperty给数据添加了gettersetter,目的就是为了在我们访问数据以及写入数据的时候自动执行一些逻辑:getter做的事情是收集依赖,setter做的事情是派发更新。

依赖收集

Vue会把普通对象变成响应式对象,响应式对象getter的相关逻辑就是做依赖收集。

definReactive中的getter中,我们需要关注2个地方,一个是const dep=new Dep()实例化一个Dep的实例,另一个是在get函数中通过dep.depend做依赖收集,还有个对childOb的判断逻辑。

Dep

Dep是整个getter函数依赖收集的核心,它的定义在src/core/observer/dep.js中。

Dep是一个class,它定义了一些属性和方法,需要特别注意的是它有一个静态属性target,这是一个全局唯一的Watcher,在同一时间里只能由一个全局的Watcher,另外它的属性subswatcher的数组。

Dep实际上就是对watcher的一种管理,Dep脱离Watcher单独存在是没有意义的,watcher的定义在src/core/observer/watcher.js

Watcher也是class,在它的构造函数中,定义了一些和Dep相关的属性。

其中,this.depsthis.newDeps表示Watcher实例持有的Dep实例的数组;而this.depIdsthis.newDepIds分别代表this.depsthis.newDeps的id。

Watcher还定义了一些原型的方法,和收集依赖相关的有get、addDepcleanupDeps方法。

过程分析

当对数据对象访问的时候会触发它们的getter方法,这些对象的访问时机就是在执行mountComponent函数的过程中。

当我们去实例化一个渲染Watcher的时候,首先进入Watcher的构造函数逻辑,然后执行他的this.get()方法,进入get函数,首先会执行pushTarget(this)

pushTarget的定义在src/core/observer/dep.js中。

实际上就是把Dep.target赋值为当前Watcher并压栈,接着执行 value = this.getter.call(vm,vm)

this.getter对应的就是updateComponent函数,所以实际上就是执行:

vm._update(vm._render(),hydrating)

它会先执行vm._render()方法,因为之前分析过这个方法会生成渲染VNode,并且在这个过程中会对vm上的数据进行访问,这时候就触发了数据对象的getter

那么每个对象值得getter都持有一个dep,再触发getter的时候会调用dep.depend()方法,也就会执行Dep.target.addDep(this)

这个时候Dep.target已经被赋值为渲染Watcher,那么就执行到了Watcher的addDep方法。

addDep方法中会做一些逻辑判断(保证统一数据不会被添加多次)后执行dep.addSub(this),那么就会执行Dep类中的this.subs.push(sub),它的目的是为了后续数据变化时通知哪些subs做准备。

所以在vm._render()过程中,会触发所有数据的getter,这样实际上已经完成了一个依赖收集过程。在依赖收集完成之后还有几个逻辑需要执行:

首先是:if(this.deep){ traverse(value) },这个是要递归去访问value,触发它的所有子项的getter。

接下来执行popTarget(),实际上就是把Dep.target恢复成上一个状态,因为当前vm的数据依赖收集已经完成,那么对应的渲染WatcherDep.target也需要改变。

最后执行this.cleanupDeps(),考虑到Vue是数据驱动的,所以每次数据变化都会重新执行render,那么vm._render()方法会再次执行,并在此触发数据的getters,所以在Watcher构造函数中会初始化2个Dep实例数组,newDeps表示新添加的Dep实例数组,而deps表示上一次添加的Dep实例数组。

在执行cleanupDeps函数的时候,首先会遍历deps,移除对dep.subs数组中Watcher的订阅,然后把newDepIdsdepIds交换,newDepsdeps交换,并把newDepIdsnewDeps清空。

这样做的目的是考虑到一种场景。我们的模板会根据v-if去渲染不同的字模板a和b,当我们满足a模板渲染条件的时候,会访问a中的数据,这时候会对a中的数据做依赖收集。如果我们条件改变了,满足b模板,这时候就又会对b中的数据做依赖收集。如果没有做依赖清除的工作,当模板数据变化的时候,依旧会去通知a模板数据的订阅回调,造成性能浪费。

总结

依赖收集的目的是为了当这些响应式数据发生变化,触发它们的setter的时候,能够知道应该通知哪些订阅者去做响应的逻辑处理,这个过程就做派发更新。

派发更新

响应式数据依赖收集的目的就是为了当我们修改数据的时候,可以对相关的依赖派发更新。

defineReactive中setter的逻辑有2个关键点,一个是childOb=!shallow && observe(newVal),如果shallow为false的情况,会对新设置的值编程一个响应式对象,另一个是dep.notify(),通知所有的订阅者,派发更新。

过程分析

当我们组件中对响应的数据做了修改,就会触发setter的逻辑,最后调用dep.notify()方法,它是Dep的一个实例方法,定义在src/core/observer/dep.js中。

这里的逻辑非常简单,遍历所有的subs,也就是Watcher实例的数组,然后调用每一个watcher的update方法,它定义在src/core/observer/watcher.js中。

这里对于Watcher的不同状态,会执行不同的逻辑,computed和sync等状态的分析后面会介绍,在一般组件数据更新的场景,会走到最后一个queueWatcher(this)的逻辑,它定义在src/core/observer/scheduler.js中。

这里引入了一个队列的概念,这也是Vue在做派发更新的时候的一个优化的点,他并不是每次数据改变都会触发Watcher的回调,而是把这些Watcher先添加到一个队列中,然后再nextTick后执行flushSchedulerQueue

这里有几个细节需要注意一下,首先用has对象保证同一个Watcher只添加一次,接着对flushing做判断;最后通过waiting保证对nextTick(flushSchedulerQueue)的调用逻辑只有一次,nextTick就是异步的执行flushSchedulerQueue

flushSchedulerQueue的实现,定义在src/core/observer/scheduler.js中。

  • 队列排序

queue.sort((a,b)=>a.id-b.id)对队列做了从小到大的排序,这么做主要为了确保以下几点:

1.组件的更新由父到子;因为父组件的创建过程是先于子组件的,所以watcher的创建也是先父后子,执行顺序也应该保持先父后子。

2.用户的自定义watcher要优先于渲染watcher执行;因为用户自定义watcher是在渲染watcher之前创建的。

3.如果一个组件在父组件的watcher执行期间被销毁,那么它对应的watcher执行都可以被跳过,所以父组件的watcher应该先执行。

  • 队列遍历

在对queue排序后,接着就要对它做遍历,拿到对应的watcher,执行watcher.run()。这里需要注意一个细节,在遍历的时候每次都会对queue.length求值,因为在执行watcher.run()的时候,很可能用户会再次添加新的watcher,这样就会再次执行queueWatcher

这种情况下,flushing为true,就会执行else逻辑,然后就会从后往前找,找到第一个待插入watcher的id比当前队列中watcher的id大的位置。把watcher按照id插入到队列中,因为queue的长度就发生了变化。

  • 回复状态

这个过程就是执行resetScheduleeState函数,它的定义是在src/core/observer/scheduler.js中。

该函数中,就是把这些控制流程状态的变量恢复到初始值,把Watcher队列清空。

Watcher类的run函数实际上就是执行this.getAndInvoke方法,并传入watcher的回调函数。getAndInvoke函数逻辑也很简单,先通过this.get()得到当前值,然后做判断,如果满足新旧值不等、新值是对象类型、deep模式任何一个条件,则执行watcher的回调,注意回调函数执行的时候会把第一个和第二个参数传入新值value的旧值oldValue,这就是当我们添加自定义watcher的时候能够在回调函数的参数中拿到新旧值得原因。

对于渲染watcher而言,它在执行this.get()的方法求值的时候,就会执行getter方法。所以当我们去修改组件相关响应式数据的时候,会触发组件重新渲染的原因,接着就会重新执行patch的过程。

总结

当数据发生变化的时候,会触发setter逻辑,把在依赖过程中订阅的所有观察者,也就是watcher遍历,触发它们的update过程,这个过程中利用队列做了进一步优化,在nextTick后执行Watcher的run方法,之后执行它们的回调函数,重新执行patch过程,实现页面更新。

监测变化的注意事项

对于使用Object.definPeoperty实现响应式的对象,当我们去给这个对象添加一个新属性的时候,是不能触发它的setter的。

对于给对象添加新属性,在Vue中定义了一个全局API Vue.set()方法,他在src/core/global-api/index.js中初始化。

这个set方法定义在src/core/observer/index.js中。

set方法接受3个参数,target可能是数组或者普通对象,key代表的是数组的下标或者对象的键值,val代表添加端的值。首先判断target是数组且key是一个合法的下标,则通过splice将val添加进数组然后返回。

接着判断key是否已经存在于target中,已存在则直接返回。

然后再获取到target.__ob__并赋值给ob,之前分析过他是在Observer的构造函数执行的时候初始化的,表示Observer的一个实例,如果它不存在,则说明target不是一个响应式对象,那么久直接赋值并返回。

最后通过defineReactive(ob.value,key,val)把新添加的属性变成响应式对象,然后再通过ob.dep.notify()手动的触发依赖通知。

前面看到,在getter过程中判断了childOb,并调用了childOb.dep.depend()收集了依赖,这就是为什么执行Vue.set的时候通过ob.dep.notify()能够通知到watcher,从而让添加新的属性到对象也可以检测到变化。这里如果value是一个数组,那么久通过dependArray把数组每个元素也去做依赖收集。

数组

Vue也是不能检测到数组的变动的。

在通过observe方法去观察对象的时候会实例化Observer,在它的构造函数中对数组做了专门的处理,它定义在src/core/observer/index.js中。

这里我们只需要关注value是Array的情况,首先获取argument,这里的hasProto实际上就是判断对象中是否存在__proto__,如果存在则argument指向protoAugment,否则指向copyAugment

protoAugment方法是直接把target.__proto__原型直接修改为src,而copyAgument方法是遍历keys,通过def,也就是Object.defineProperty去定义它自身的属性值。对于大部分现代浏览器会走到protoAugment,那么它实际上就把value的原型指向了arrayMethodsarrayMethods的定义在src/core/observer/array.js

arrayMethods中,首先继承了Array,然后对数组中所有能够改变自身的方法,如push、pop等这些方法进行重写。重写之后的方法会先执行它们本身原有的逻辑,并对能增加数组长度的3个方法push、unshift、splice方法做了判断,获取到插入的值,然后把新添加的值变成一个响应式对象,并且再调用ob.dep.notify()手动触发依赖通知。

总结

通过这一节的分析,我们对响应式对象又有了更全面的认识,如果在实际工作中遇到了这些特殊情况,我们就可以知道如何把它们也变成响应式的对象。其实对于对象属性的删除也会用同样的问题,Vue同样提供了 Vue.del 的全局 API

计算属性 VS 侦听属性

Vue的组件对象支持了计算属性computed和侦听属性watch 2个选项,接下来分析一下它们的区别。

computed

计算属性的初始化是发生在Vue的实例初始化阶段的initState函数中,执行了if(pots.computed) initComputed(vm,opts.computed)initComputed的定义在src/core/instance/state.js中。

函数首先会创建vm._computedWatchers为一个空对象,接着对computed对象做遍历,拿到计算属性的每一个userDef,然后尝试获取这个userDef对应的getter函数,拿不到则在开发环境下报警告。

接下来为每一个getter创建一个Watcher,这个watcher和渲染Watcher有一点很大的不同,它是一个computed watcher,因为const computedWatcherOptions={computed:true}computed watcher和普通watcher的差别后面会分析。

最后判断如果key不是vm的属性,则调用defineComputed(vm,key,userDef),否则判断计算属性对应的key是否已经被data或者props占用,如果占用在开发环境报相应的警告。

接下来分析下defineComputed函数的实现:

其实就是根据Object.defineProperty给计算属性对应的key值添加getter和setter,setter通常是计算的一个对象,并且拥有set方法的时候才有,否则是一个空函数。在平时的开发场景中,计算属性有setter的情况比较少,我们重点关注下getter部分,缓存配置也先忽略,最终getter对应的是createComputedGetter(key)的 返回值。

createComputedGetter(key)返回一个函数computedGetter,他就是计算属性对应的getter。

整个计算属性的初始化过程到此结束,我们知道计算属性是一个computed watcher,它和普通的watcher的区别我们通过一个例子来分析:

var vm=new Vue({
    data:{
        firstName:'Foo',
        lastname:'Bar'
    },
    computed:{
        fullName:function(){
            return this.firstName+' '+this.lastName
        }
    }
})

可以发现computed watcher并不会立刻求值,同时持有一个dep实例。

然后当我们的render函数执行访问到this.fullName的时候,就触发了计算属性的getter,它会拿到计算属性对应的watcher,然后执行watcher.depend()

注意,这时候的Dep.target是渲染watcher,所以this.dep.depend()相当于渲染watcher订阅了这个computed watcher的变化。

然后再执行watcher.evaluate()求值。

evaluate的逻辑非常简单,判断this.dirty,如果为true则通过this.get()求值,然后把this.dirty设置为false。在求值过程中,会执行value=this.getter.call(vm,vm),这实际上就是执行了计算属性定义的getter函数,在我们的例子中就是执行了return this.firstName+' '+this.lastName

这里需要特别注意的是,由于this.firstNamethis.lastName都是响应式对象,这里会触发它们的getter,根据我们之前的分析,它们会把自身持有的dep添加到当前正在计算的watcher中,这时候Dep.terget就是这个computed watcher。

最后通过return this.value拿到计算属性对应的值。

一旦我们对计算属性依赖的数据做修改,则会触发setter过程,通知所有订阅它变化的watcher更新,执行watcher.update()方法。

那么对于计算属性这样的computed watcher,它实际上有2中模式,lazy和active。如果this.dep.subs.length===0成立,说明没有人订阅这个computed watcher的变化,仅仅把this.dirty=true,只有当下一次再访问这个计算属性的时候才会重新求值。在我们的例子中,渲染watcher订阅了computed watcher的变化,getAndInvoke函数会重新计算,然后对比新旧值,如果变化了则执行回调函数,那么这里这个回调函数是this.dep.notify(),在我们的例子中就是触发了渲染watcher的重新渲染。

通过以上分析,我们知道计算属性本质上就是一个computed watcher,也了解了它的创建过程和访问触发getter以及依赖更新的过程,其实这是最新的计算属性的实现,只有当最终计算出来的值发生变化才会触发渲染watcher重新渲染,本质上是一种优化。

watch

侦听属性watch的初始化也发生在Vue的实例的初始化阶段initState函数中,在computed初始化之后执行initWatch

initWatch定义在src/core/instance/state.js中。

该函数中就是对watch对象做遍历,拿到每一个handler,因为Vue支持watch的同一个key对应多个handler,所以如果handler是一个数组,则遍历这个数组,调用createWatcher方法,否则直接调用createWatcher

这里逻辑也比较简单,首先对handler的类型做判断,拿到它最终的回调函数,最后调用vm.$watch(keyOrFn,handler,options)函数,$watchVue原型上的方法,它是在执行stateMixin的时候定义的。

也就是说,侦听属性watch最终会调用$watch方法,该方法首先判断cb如果是一个对象,则调用createWatcher方法,这是因为$watch方法是用户可以直接调用的,它可以传递一个对象,也可以传递函数。

接着执行const watcher=new Watcher(vm,expOrFn,cb,options)实例化了一个watcher,这里需要注意的是,这里的watcher是一个user watcher,因为options.user=true

通过实例化watcher的方式,一旦我们watch的数据发生变化,它就会执行watcher的run方法,执行回调函数cb,并且如果我们设置了immediate为true,则会直接执行回调函数cb。最后返回了一个unwatchFn方法,它会调用teardown方法移除当前watcher。

所以本质上侦听属性也是基于Watcher实现的,他是一个user watcher。

Watcher options

Watcher总共有4中类型,我们来一一分析。

  • deep watcher

通常,如果我们相对一个对象做深度观测的时候,需要设置deep为true,考虑到下面这种情况:

var vm=new Vue({
    data(){
        a:{
            b:1
        }
    },
    watch:{
        a:{
            handler(newVal){
                console.log(newVal)
            }
        }
    }
})

vm.a.b=2

这个时候是不会log任何数据的,因为我们只是watch了a对象,值触发了a的getter,并没有触发a.b的getter,所以并没有订阅它的变化,导致我们对vm.a.b=2赋值的时候,虽然触发了setter,但是没有可通知的对象,所以并不会触发watch的回调函数。

想要触发回调,需要设置deep属性为true,这时候就会创建一个deep watcher,在watcher执行get求值的时候会调用traverse函数,它的定义在src/coer/observer/traverse.js中。

traverse的逻辑比较简单,实际上就是对一个对象做深层递归遍历,因为遍历过程中就是对一个子对象的访问,会触发它们的getter过程,这样就可以做到依赖收集,也就是订阅它们变化的watcher,这个函数的实现还有一个小小的优化,遍历过程中会把子响应式对象通过它们的dep id记录到seenObjects中,避免重复访问。

那么在执行了traverse之后,我们再对watch的对象内部的任何一个值做修改,就会调用watcher的回调函数。

  • user watcher

前面我们分析过vm.$watch创建的watcher是一个user watcher,其实它的功能很简单,在对watcher求值以及在执行回调函数的时候,会处理一下错误handleError(e, this.vm, callback for watcher "${this.expression}")handlerErrorVue中是一个错误捕获并且暴露给用户的一个利器。

  • computed watcher

computed watcher几乎就是为了计算属性量身定制的,前面已经分析过了。

  • sync watcher

在前面我们的分析中知道,当响应式数据发生变化后,触发了watcher.update(),它只是把这个watcher推送到一个队列中,在nextTick后才会真正执行watcher的回调函数。而一旦我们设置了sync属性,就可以在当前Tick中同步执行watcher的回调函数。

只有当我们需要watch的值变化到执行watcher的回调函数是一个同步过程的时候才会去设置sync属性为true。

总结

计算属性的本质是computed watcher,而侦听属性的本质是user watcher。就应用场景而言,计算属性适合用在模板渲染中,某个值是依赖了其它响应式对象甚至是计算属性计算而来;而侦听属性适用于观测某个值得变化去完成一段负责的业务逻辑。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值