详细分析vue响应式原理
前言
响应式原理作为Vue的核心,使用数据劫持实现数据驱动视图。在面试中是经常考查的知识点,也是面试加分项。
本文将会循序渐进的解析响应式原理的工作流程,主要以下面结构进行:
- 分析主要成员,了解它们有助于理解流程
- 将流程拆分,理解其中的作用
- 结合以上的点,理解整体流程
文章稍长,但大部分是代码实现,还请耐心观看。为了方便理解原理,文中的代码会进行简化,如果可以请对照源码学习。
主要成员
响应式原理中,Observe、Watcher、Dep这三个类是构成完整原理的主要成员。
- Observe,响应式原理的入口,根据数据类型处理观测逻辑
- Watcher,用于执行更新渲染,组件会拥有一个渲染Watcher,我们常说的收集依赖,就是收集Watcher
- Dep,依赖收集器,属性都会有一个Dep,方便发生变化时能够找到对应的依赖触发更新
下面来看看这些类的实现,包含哪些主要属性和方法。
Observe:我会对数据进行观测
温馨提示:代码里的序号对应代码块下面序号的讲解
//源码位置:/src/core/observer/index.js
classObserve{
constructor(data){
this.dep=newDep()
//1
def(data,'__ob__',this)
if(Array.isArray(data)){
//2
protoAugment(data,arrayMethods)
//3
this.observeArray(data)
}else{
//4
this.walk(data)
}
}
walk(data){
Object.keys(data).forEach(key=>{
defineReactive(data,key,data[key])
})
}
observeArray(data){
data.forEach(item=>{
observe(item)
})
}
}
- 为观测的属性添加__ob__属性,它的值等于this,即当前Observe的实例
- 为数组添加重写的数组方法,比如:push、unshift、splice等方法,重写目的是在调用这些方法时,进行更新渲染
- 观测数组内的数据,observe内部会调用newObserve,形成递归观测
- 观测对象数据,defineReactive为数据定义get和set,即数据劫持
Dep:我会为数据收集依赖
//源码位置:/src/core/observer/dep.js
letid=0
classDep{
constructor(){
this.id=++id//dep唯一标识
this.subs=[]//存储Watcher
}
//1
depend(){
Dep.target.addDep(this)
}
//2
addSub(watcher){
this.subs.push(watcher)
}
//3
notify(){
this.subs.forEach(watcher=>watcher.update())
}
}
//4
Dep.target=null
exportfunctionpushTarget(watcher){
Dep.target=watcher
}
exportfunctionpopTarget(){
Dep.target=null
}
exportdefaultDep
- 据收集依赖的主要方法,Dep.target是一个watcher实例
- 添加watcher到数组中,也就是添加依赖
- 属性在变化时会调用notify方法,通知每一个依赖进行更新
- Dep.target用来记录watcher实例,是全局唯一的,主要作用是为了在收集依赖的过程中找到相应的watcher
pushTarget和popTarget这两个方法显而易见是用来设置Dep.target的。Dep.target也是一个关键点,这个概念可能初次查看源码会有些难以理解,在后面的流程中,会详细讲解它的作用,需要注意这部分的内容。
Watcher:我会触发视图更新
//源码位置:/src/core/observer/watcher.js
letid=0
exportclassWatcher{
constructor(vm,exprOrFn,cb,options){
this.id=++id//watcher唯一标识
this.vm=vm
this.cb=cb
this.options=options
//1
this.getter=exprOrFn
this.deps=[]
this.depIds=newSet()
this.get()
}
run(){
this.get()
}
get(){
pushTarget(this)
this.getter()
popTarget(this)
}
//2
addDep(dep){
//防止重复添加dep
if(!this.depIds.has(dep.id)){
this.depIds.add(dep.id)
this.deps.push(dep)
dep.addSub(this)
}
}
//3
update(){
queueWatcher(this)
}
}
- this.getter存储的是更新视图的函数
- watcher存储dep,同时dep也存储watcher,进行双向记录
- 触发更新,queueWatcher是为了进行异步更新,异步更新会调用run方法进行更新页面
响应式原理流程
对于以上这些成员具有的功能,我们都有大概的了解。下面结合它们,来看看这些功能是如何在响应式原理流程中工作的。
数据观测
数据在初始化时会通过observe方法来创建Observe类
//源码位置:/src/core/observer/index.js
exportfunctionobserve(data){
//1
if(!isObject(data)){
return
}
letob;
//2
if(data.hasOwnProperty('__ob__')&&data.__ob__instanceofObserve){
ob=data.__ob__
}else{
//3
ob=newObserve(data)
}
returnob
}
在初始化时,observe拿到的data就是我们在data函数内返回的对象。
- observe函数只对object类型数据进行观测
- 观测过的数据都会被添加上__ob__属性,通过判断该属性是否存在,防止重复观测
- 创建Observe类,开始处理观测逻辑
对象观测
进入Observe内部,由于初始化的数据是一个对象,所以会调用walk方法:
walk(data){
Object.keys(data).forEach(key=>{
defineReactive(data,key,data[key])
})
}
defineReactive方法内部使用Object.defineProperty对数据进行劫持,是实现响应式原理最核心的地方。
functiondefineReactive(obj,key,value){
//1
letchildOb=observe(value)
//2
constdep=newDep()
Object.defineProperty(obj,key,{
get(){
if(Dep.target){
//3
dep.depend()
if(childOb){
childOb.dep.depend()
}
}
returnvalue
},
set(newVal){
if(newVal===value){
return
}
value=newVal
//4
childOb=observe(newVal)
//5
dep.notify()
returnvalue
}
})
}
- 由于值可能是对象类型,这里需要调用observe进行递归观测
- 这里的dep就是上面讲到的每一个属性都会有一个dep,它是作为一个闭包的存在,负责收集依赖和通知更新
- 在初始化时,Dep.target是组件的渲染watcher,这里dep.depend收集的依赖就是这个watcher,childOb.dep.depend主要是为数组收集依赖
- 设置的新值可能是对象类型,需要对新值进行观测
- 值发生改变,dep.notify通知watcher更新,这是我们改变数据后能够实时更新页面的触发点
通过Object.defineProperty对属性定义后,属性的获取触发get回调,属性的设置触发set回调,实现响应式更新。
通过上面的逻辑,也能得出为什么Vue3.0要使用Proxy代替Object.defineProperty了。Object.defineProperty只能对单个属性进行定义,如果属性是对象类型,还需要递归去观测,会很消耗性能。而Proxy是代理整个对象,只要属性发生变化就会触发回调。
数组观测
对于数组类型观测,会调用observeArray方法:
observeArray(data){
data.forEach(item=>{
observe(item)
})
}
与对象不同,它执行observe对数组内的对象类型进行观测,并没有对数组的每一项进行Object.defineProperty的定义,也就是说数组内的项是没有dep的。
所以,我们通过数组索引对项进行修改时,是不会触发更新的。但可以通过this.$set来修改触发更新。那么问题来了,为什么Vue要这样设计?
结合实际场景,数组中通常会存放多项数据,比如列表数据。这样观测起来会消耗性能。还有一点原因,一般修改数组元素很少会直接通过索引将整个元素替换掉。例如:
exportdefault{
data(){
return{
list:[
{id:1,name:'Jack'},
{id:2,name:'Mike'}
]
}
},
cretaed(){
//如果想要修改name的值,一般是这样使用
this.list[0].name='JOJO'
//而不是以下这样
//this.list[0]={id:1,name:'JOJO'}
//当然你可以这样更新
//this.$set(this.list,'0',{id:1,name:'JOJO'})
}
}
数组方法重写
当数组元素新增或删除,视图会随之更新。这并不是理所当然的,而是Vue内部重写了数组的方法,调用这些方法时,数组会更新检测,触发视图更新。这些方法包括:
- push()
- pop()
- shift()
- unshift()
- splice()
- sort()
- reverse()
回到Observe的类中,当观测的数据类型为数组时,会调用protoAugment方法。
if(Array.isArray(data)){
protoAugment(data,arrayMethods)
//观察数组
this.observeArray(data)
}else{
//观察对象
this.walk(data)
}
这个方法里把数组原型替换为arrayMethods,当调用改变数组的方法时,优先使用重写后的方法。
functionprotoAugment(data,arrayMethods){
data.__proto__=arrayMethods
}
接下来看看arrayMethods是如何实现的:
//源码位置:/src/core/observer/array.js
//1
letarrayProto=Array.prototype
//2
exportletarrayMethods=Object.create(arrayProto)
letmethods=[
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice'
]
methods.forEach(method=>{
arrayMethods[method]=function(...args){
//3
letres=arrayProto[method].apply(this,args)
letob=this.__ob__
letinserted=''
switch(method){
case'push':
case'unshift':
inserted=args
break;
case'splice':
inserted=args.slice(2)
break;
}
//4
inserted&&ob.observeArray(inserted)
//5
ob.dep.notify()
returnres
}
})
- 将数组的原型保存起来,因为重写的数组方法里,还是需要调用原生数组方法的
- arrayMethods是一个对象,用于保存重写的方法,这里使用Object.create(arrayProto)创建对象是为了使用者在调用非重写方法时,能够继承使用原生的方法
- 调用原生方法,存储返回值,用于设置重写函数的返回值
- inserted存储新增的值,若inserted存在,对新值进行观测
- ob.dep.notify触发视图更新
依赖收集
依赖收集是视图更新的前提,也是响应式原理中至关重要的环节。
伪代码流程
为了方便理解,这里写一段伪代码,大概了解依赖收集的流程:
//data数据
letdata={
name:'joe'
}
//渲染watcher
letwatcher={
run(){
dep.tagret=watcher
document.write(data.name)
}
}
//dep
letdep=[]//存储依赖
dep.tagret=null//记录watcher
//数据劫持
Object.defineProperty(data,'name',{
get(){
//收集依赖
dep.push(dep.tagret)
},
set(newVal){
data.name=newVal
dep.forEach(watcher=>{
watcher.run()
})
}
})
初始化:
- 首先会对name属性定义get和set
- 然后初始化会执行一次watcher.run渲染页面
- 这时候获取data.name,触发get函数收集依赖。
更新:
修改data.name,触发set函数,调用run更新视图。
真正流程
下面来看看真正的依赖收集流程是如何进行的。
functiondefineReactive(obj,key,value){
letchildOb=observe(value)
constdep=newDep()
Object.defineProperty(obj,key,{
get(){
if(Dep.target){
dep.depend()//收集依赖
if(childOb){
childOb.dep.depend()
}
}
returnvalue
},
set(newVal){
if(newVal===value){
return
}
value=newVal
childOb=observe(newVal)
dep.notify()
returnvalue
}
})
}
首先初始化数据,调用defineReactive函数对数据进行劫持。
exportclassWatcher{
constructor(vm,exprOrFn,cb,options){
this.getter=exprOrFn
this.get()
}
get(){
pushTarget(this)
this.getter()
popTarget(this)
}
}
初始化将watcher挂载到Dep.target,this.getter开始渲染页面。渲染页面需要对数据取值,触发get回调,dep.depend收集依赖。
classDep{
constructor(){
this.id=id++
this.subs=[]
}
depend(){
Dep.target.addDep(this)
}
}
Dep.target为watcher,调用addDep方法,并传入dep实例。
exportclassWatcher{
constructor(vm,exprOrFn,cb,options){
this.deps=[]
this.depIds=newSet()
}
addDep(dep){
if(!this.depIds.has(dep.id)){
this.depIds.add(dep.id)
this.deps.push(dep)
dep.addSub(this)
}
}
}
addDep中添加完dep后,调用dep.addSub并传入当前watcher实例。
classDep{
constructor(){
this.id=id++
this.subs=[]
}
addSub(watcher){
this.subs.push(watcher)
}
}
将传入的watcher收集起来,至此依赖收集流程完毕。
补充一点,通常页面上会绑定很多属性变量,渲染会对属性取值,此时每个属性收集的依赖都是同一个watcher,即组件的渲染watcher。
数组的依赖收集
methods.forEach(method=>{
arrayMethods[method]=function(...args){
letres=arrayProto[method].apply(this,args)
letob=this.__ob__
letinserted=''
switch(method){
case'push':
case'unshift':
inserted=args
break;
case'splice':
inserted=args.slice(2)
break;
}
//对新增的值观测
inserted&&ob.observeArray(inserted)
//更新视图
ob.dep.notify()
returnres
}
})
还记得重写的方法里,会调用ob.dep.notify更新视图,__ob__是我们在Observe为观测数据定义的标识,值为Observe实例。那么ob.dep的依赖是在哪里收集的?
functiondefineReactive(obj,key,value){
//1
letchildOb=observe(value)
constdep=newDep()
Object.defineProperty(obj,key,{
get(){
if(Dep.target){
dep.depend()
//2
if(childOb){
childOb.dep.depend()
}
}
returnvalue
},
set(newVal){
if(newVal===value){
return
}
value=newVal
childOb=observe(newVal)
dep.notify()
returnvalue
}
})
}
- observe函数返回值为Observe实例
- childOb.dep.depend执行,为Observe实例的dep添加依赖
所以在数组更新时,ob.dep内已经收集到依赖了。
整体流程
下面捋一遍初始化流程和更新流程,如果你是初次看源码,不知道从哪里看起,也可以参照以下的顺序。由于源码实现比较多,下面展示的源码会稍微删减一些代码
初始化流程
入口文件:
//源码位置:/src/core/instance/index.js
import{initMixin}from'./init'
import{stateMixin}from'./state'
import{renderMixin}from'./render'
import{eventsMixin}from'./events'
import{lifecycleMixin}from'./lifecycle'
import{warn}from'../util/index'
functionVue(options){
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
exportdefaultVue
_init:
//源码位置:/src/core/instance/init.js exportfunctioninitMixin(Vue:Class){ Vue.prototype._init=function(options?:Object){ constvm:Component=this //auid vm._uid=uid++ //mergeoptions if(options&&options._isComponent){ //optimizeinternalcomponentinstantiation //sincedynamicoptionsmergingisprettyslow,andnoneofthe //internalcomponentoptionsneedsspecialtreatment. initInternalComponent(vm,options) }else{ //mergeOptions对mixin选项和传入的options选项进行合并 //这里的$options可以理解为newVue时传入的对象 vm.$options=mergeOptions( resolveConstructorOptions(vm.constructor), options||{}, vm ) } //exposerealself vm._self=vm initLifecycle(vm) initEvents(vm) initRender(vm) callHook(vm,'beforeCreate') initInjections(vm)//resolveinjectionsbeforedata/props //初始化数据 initState(vm) initProvide(vm)//resolveprovideafterdata/props callHook(vm,'created') if(vm.$options.el){ //初始化渲染页面挂载组件 vm.$mount(vm.$options.el) } } }
上面主要关注两个函数,initState初始化数据,vm.$mount(vm.$options.el)初始化渲染页面。
先进入initState:
//源码位置:/src/core/instance/state.js
exportfunctioninitState(vm:Component){
vm._watchers=[]
constopts=vm.$options
if(opts.props)initProps(vm,opts.props)
if(opts.methods)initMethods(vm,opts.methods)
if(opts.data){
//data初始化
initData(vm)
}else{
observe(vm._data={},true/*asRootData*/)
}
if(opts.computed)initComputed(vm,opts.computed)
if(opts.watch&&opts.watch!==nativeWatch){
initWatch(vm,opts.watch)
}
}
functioninitData(vm:Component){
letdata=vm.$options.data
//data为函数时,执行data函数,取出返回值
data=vm._data=typeofdata==='function'
?getData(data,vm)
:data||{}
//proxydataoninstance
constkeys=Object.keys(data)
constprops=vm.$options.props
constmethods=vm.$options.methods
leti=keys.length
while(i--){
constkey=keys[i]
if(props&&hasOwn(props,key)){
process.env.NODE_ENV!=='production'&&warn(
`Thedataproperty"${key}"isalreadydeclaredasaprop.`+
`Usepropdefaultvalueinstead.`,
vm
)
}elseif(!isReserved(key)){
proxy(vm,`_data`,key)
}
}
//observedata
//这里就开始走观测数据的逻辑了
observe(data,true/*asRootData*/)
}
observe内部流程在上面已经讲过,这里再简单过一遍:
- newObserve观测数据
- defineReactive对数据进行劫持
initState逻辑执行完毕,回到开头,接下来执行vm.$mount(vm.$options.el)渲染页面:
$mount:
//源码位置:/src/platforms/web/runtime/index.js
Vue.prototype.$mount=function(
el?:string|Element,
hydrating?:boolean
):Component{
el=el&&inBrowser?query(el):undefined
returnmountComponent(this,el,hydrating)
}
mountComponent:
//源码位置:/src/core/instance/lifecycle.js
exportfunctionmountComponent(
vm:Component,
el:?Element,
hydrating?:boolean
):Component{
vm.$el=el
callHook(vm,'beforeMount')
letupdateComponent
/*istanbulignoreif*/
if(process.env.NODE_ENV!=='production'&&config.performance&&mark){
updateComponent=()=>{
constname=vm._name
constid=vm._uid
conststartTag=`vue-perf-start:${id}`
constendTag=`vue-perf-end:${id}`
mark(startTag)
constvnode=vm._render()
mark(endTag)
measure(`vue${name}render`,startTag,endTag)
mark(startTag)
vm._update(vnode,hydrating)
mark(endTag)
measure(`vue${name}patch`,startTag,endTag)
}
}else{
//数据改变时会调用此方法
updateComponent=()=>{
//vm._render()返回vnode,这里面会就对data数据进行取值
//vm._update将vnode转为真实dom,渲染到页面上
vm._update(vm._render(),hydrating)
}
}
//执行Watcher,这个就是上面所说的渲染wacther
newWatcher(vm,updateComponent,noop,{
before(){
if(vm._isMounted&&!vm._isDestroyed){
callHook(vm,'beforeUpdate')
}
}
},true/*isRenderWatcher*/)
hydrating=false
//manuallymountedinstance,callmountedonself
//mountediscalledforrender-createdchildcomponentsinitsinsertedhook
if(vm.$vnode==null){
vm._isMounted=true
callHook(vm,'mounted')
}
returnvm
}
Watcher:
//源码位置:/src/core/observer/watcher.js
letuid=0
exportdefaultclassWatcher{
constructor(vm,exprOrFn,cb,options){
this.id=++id
this.vm=vm
this.cb=cb
this.options=options
//exprOrFn就是上面传入的updateComponent
this.getter=exprOrFn
this.deps=[]
this.depIds=newSet()
this.get()
}
get(){
//1.pushTarget将当前watcher记录到Dep.target,Dep.target是全局唯一的
pushTarget(this)
letvalue
constvm=this.vm
try{
//2.调用this.getter相当于会执行vm._render函数,对实例上的属性取值,
//由此触发Object.defineProperty的get方法,在get方法内进行依赖收集(dep.depend),这里依赖收集就需要用到Dep.target
value=this.getter.call(vm,vm)
}catch(e){
if(this.user){
handleError(e,vm,`getterforwatcher"${this.expression}"`)
}else{
throwe
}
}finally{
//"touch"everypropertysotheyarealltrackedas
//dependenciesfordeepwatching
if(this.deep){
traverse(value)
}
//3.popTarget将Dep.target置空
popTarget()
this.cleanupDeps()
}
returnvalue
}
}
至此初始化流程完毕,初始化流程的主要工作是数据劫持、渲染页面和收集依赖。
更新流程
数据发生变化,触发set,执行dep.notify
//源码位置:/src/core/observer/dep.js
letuid=0
/**
*Adepisanobservablethatcanhavemultiple
*directivessubscribingtoit.
*/
exportdefaultclassDep{
statictarget:?Watcher;
id:number;
subs:Array;
constructor(){
this.id=uid++
this.subs=[]
}
addSub(sub:Watcher){
this.subs.push(sub)
}
removeSub(sub:Watcher){
remove(this.subs,sub)
}
depend(){
if(Dep.target){
Dep.target.addDep(this)
}
}
notify(){
//stabilizethesubscriberlistfirst
constsubs=this.subs.slice()
if(process.env.NODE_ENV!=='production'&&!config.async){
//subsaren'tsortedinschedulerifnotrunningasync
//weneedtosortthemnowtomakesuretheyfireincorrect
//order
subs.sort((a,b)=>a.id-b.id)
}
for(leti=0,l=subs.length;i
wathcer.update:
//源码位置:/src/core/observer/watcher.js
/**
*Subscriberinterface.
*Willbecalledwhenadependencychanges.
*/
update(){
/*istanbulignoreelse*/
if(this.lazy){//计算属性更新
this.dirty=true
}elseif(this.sync){//同步更新
this.run()
}else{
//一般的数据都会进行异步更新
queueWatcher(this)
}
}
queueWatcher:
//源码位置:/src/core/observer/scheduler.js
//用于存储watcher
constqueue:Array=[]
//用于watcher去重
lethas:{[key:number]:?true}={}
/**
*Flushbothqueuesandrunthewatchers.
*/
functionflushSchedulerQueue(){
letwatcher,id
//对watcher排序
queue.sort((a,b)=>a.id-b.id)
//donotcachelengthbecausemorewatchersmightbepushed
//aswerunexistingwatchers
for(index=0;index
nextTick:
//源码位置:/src/core/util/next-tick.js
constcallbacks=[]
letpending=false
functionflushCallbacks(){
pending=false
constcopies=callbacks.slice(0)
callbacks.length=0
//遍历回调函数执行
for(leti=0;i{
p.then(flushCallbacks)
}
}
exportfunctionnextTick(cb?:Function,ctx?:Object){
let_resolve
//将回调函数加入数组
callbacks.push(()=>{
if(cb){
cb.call(ctx)
}
})
if(!pending){
pending=true
//遍历回调函数执行
timerFunc()
}
//$flow-disable-line
if(!cb&&typeofPromise!=='undefined'){
returnnewPromise(resolve=>{
_resolve=resolve
})
}
}
这一步是为了使用微任务将回调函数异步执行,也就是上面的p.then。最终,会调用watcher.run更新页面。
至此更新流程完毕。
写在最后
如果没有接触过源码的同学,我相信看完可能还是会有点懵的,这很正常。建议对照源码再自己多看几遍就能知道流程了。对于有基础的同学就当做是复习了。
想要变强,学会看源码是必经之路。在这过程中,不仅能学习框架的设计思想,还能培养自己的逻辑思维。万事开头难,迟早都要迈出这一步,不如就从今天开始。
简化后的代码我已放在github,有需要的可以看看。
以上就是详细分析vue响应式原理的详细内容,更多关于Vue响应式原理的资料请关注毛票票其它相关文章!