图解Vue 响应式流程及原理

作者:井柏然 时间:2024-05-09 15:26:23 

图解Vue 响应式流程及原理

阅读本文能够帮助你什么?

  • 在学习vue源码的时候发现组件化过程很绕?

  • 在响应式过程中ObserverDepWatcher三大对象傻傻分不清?

  • 搞不清楚对象、数组依赖收集、派发更新的流程?depwatcher互调造成混乱?

  • 学了一遍好像懂了又好像不全懂的感觉?而且缺乏大体流程概念?

  • 或者像我一样,有段时间没看vue源码好像有点遗忘?但是想快速回顾却无从下手?

本文主要分为1. 组件化;2. 响应式原理;3. 彩蛋(computed和watch)进行讲解。本文调试源码的vue版本是v2.6.14。整篇将采用源码讲解 + 流程图的方式详细还原整个Vue响应式原理的全过程。你可以了解到Dep.targetpushTargetpopTarget;响应式中的三大Watcher;DepWathcer多对多的,互相收集的关系。

这篇是进阶的 Vue 响应式源码解析,文章比较长,内容比较深,大家可以先mark后看。看不懂的不要强行看,可以先看看其他作者的偏简单一点的源码解析文章,然后好好消化。等过段时间再回来看这篇,相信你由浅入深后再看本文,一定会有意想不到的收获~

一、组件化流程

在讲解整个响应式原理之前,先介绍一下Vue中另一个比较核心的概念——组件化,个人认为这也是学习响应式的前置核心。搞懂组件化,响应式学习如虎添翼!

1. 整个new Vue阶段做了什么?

  • 执行init操作。包括且不限制initLifecycleinitState

  • 执行mount。进行元素挂载

  • compiler步骤在runtime-only版本中没有。

    • compiler步骤对template属性进行编译,生成render函数。

    • 一般在项目中是在.vue文件开发,通过vue-loader处理生成render函数。

执行render。生成vnode

<div id="app">{{ message }}</div>
render (h) {
 return h('div', {
    attrs: {
       id: 'app'
     },
 }, this.message)
}
  • render例子,如下

  • 对应手写的render函数

  • patch。新旧vnode经过diff后,渲染到真实dom上

图解Vue 响应式流程及原理

2. 普通dom元素如何渲染到页面?

  • 执行$mount

    • 实际执行mountComponent

    • 这里会实例化一个Watcher

    • Watcher中会执行get方法,触发updateComponent

  • 执行updateComponent。执行vm._update(vm._render(), hydrating)

  • 执行vm.render()

    • render其实调用createElment(h函数)

    • 根据tag的不同,生成组件、原生VNode并返回

  • 执行vm.update()createElm() 到 createChildren() 递归调用

  • 将VNode转化为真实的dom,并且最终渲染到页面

图解Vue 响应式流程及原理

3. 组件如何渲染到页面?

这里以如下代码案例讲解更加清晰~没错,就是这么熟悉!就是一个初始化的Vue项目

// mian.js
import Vue from 'vue'
import App from './App.vue'
new Vue({
 render: h => h(App),
}).$mount('#app')
// App.vue
<template>
   <div id="app">
     <p>{{ msg }}</p>
   </div>
</template>
<script>
   export default {
       name: 'App',
       data () {
           return {
               msg: 'hello world'
           }
       }
   }
</script>

主要讲解组件跟普通元素的不同之处,主要有2点:

如何生成VNode&mdash;&mdash;创建组件VNodecreateComponent

图解Vue 响应式流程及原理

如何patch&mdash;&mdash;组件new Vue到patch流程createComponent

$vnode:占位符vnode。最终渲染vnode挂载的地方。所有的组件通过递归调用createComponent直至不再存在组件VNode,最终都会转化成普通的dom。

{
   tag: 'vue-component-1-App',
   componentInstance: {组件实例},
   componentOptions: {Ctor, ..., }
}

_vnode:渲染vnode。

{
   tag: 'div',
   {
       "attrs": {
           "id": "app"
       }
   },
   // 对应占位符vnode: $vnode
   parent: {
       tag: 'vue-component-1-App',
       componentInstance: {组件实例},
       componentOptions: {Ctor, ..., }
   },
   children: [
       // 对应p标签
       {
           tag: 'p',
           // 对应p标签内的文本节点{{ msg }}
           children: [{ text: 'hello world' }]
       }, {
         // 如果还有组件VNode其实也是一样的
         tag: 'vue-component-2-xxx'
       }              
   ]
}

(注意:这一步对应上图render流程的紫色块的展开!!!)

区分普通元素VNode

  • 普通VNode:tag是html的保留标签,如tag: 'div'

  • 组件VNode:tag是以vue-component开头,如tag: 'vue-component-1-App'

(注意:这一步对应上图patch流程的紫色块的展开!!!)

图解Vue 响应式流程及原理

4. Vue组件化简化流程

相信你看完细粒度的Vue组件化过程可能已经晕头转向了,这里会用一个简化版的流程图进行回顾,加深理解

图解Vue 响应式流程及原理

二、响应式流程

案例代码

// 案例
export default {
   name: 'App',
   data () {
       return {
           msg: 'hello world',
           arr = [1, 2, 3]
       }
   }
}

1. 依赖收集

这里会从Observer、Dep、Watcher三个对象进行讲解,分 objectarray 两种依赖收集方式。

  • 一定要注意!数组 的依赖收集 跟 对象的属性 是不一样的。对象属性经过深度遍历后,最终就是以一个基本类型的数据为单位收集依赖,但是数组仍然是一个引用类型。

  • 如果这里不懂,先想一个问题: 我们用 this.msg = 'xxx' 能触发 setter 派发更新,但是我们修改数组并不是用 this.arr = xxx ,而是用 this.arr.push(xxx) 等修改数组的方法。很显然,这时候并不是通过触发 arr 的 setter 去派发更新的。那是怎么做的呢?先带着这个问题继续往下看吧!

三个核心对象:Observer(蓝)、Dep(绿)、Watcher(紫)

图解Vue 响应式流程及原理

依赖收集准备阶段&mdash;&mdash;Observer、Dep的实例化

// 以下是initData调用的方法讲解,排列遵循调用顺序
function observe (value, asRootData) {
 if (!isObject(value)) return // 非对象则不处理
 // 实例化Observer对象
 var ob;
 ob = new Observer(value);
 return ob
}
function Observer (value) {
 this.value = value; // 保存当前的data
 this.dep = new Dep(); // 实例化dep,数组进行依赖收集的dep(对应案例中的arr)
 def(value, '__ob__', this);    
 if (Array.isArray(value)) {
   if (hasProto) {
     // 这里会改写数组原型。__proto__指向重写数组方法的对象
     protoAugment(value, arrayMethods);
   } else {
     copyAugment(value, arrayMethods, arrayKeys);
   }
   this.observeArray(value);
 } else {
   this.walk(value);
 }
}
// 遍历数组元素,执行对每一项调用observe,也就是说数组中有对象会转成响应式对象
Observer.prototype.observeArray = function observeArray (items) {
 for (var i = 0, l = items.length; i < l; i++) {
   observe(items[i]);
 }
}
// 遍历对象的全部属性,调用defineReactive
Observer.prototype.walk = function walk (obj) {
 var keys = Object.keys(obj);
 // 如案例代码,这里的 keys = ['msg', 'arr']
 for (var i = 0; i < keys.length; i++) {        
   defineReactive(obj, keys[i]);
 }
}
function defineReactive (obj, key, val) {
 // 产生一个闭包dep
 var dep = new Dep();
 // 如果val是object类型,递归调用observe,案例代码中的arr会走这个逻辑
 var childOb = !shallow && observe(val);
 Object.defineProperty(obj, key, {    
   get: function reactiveGetter () {
     // 求value的值
     var value = getter ? getter.call(obj) : val;
     if (Dep.target) { // Dep.target就是当前的Watcher
       // 这里是闭包dep
       dep.depend();
       if (childOb) {
         // 案例代码中arr会走到这个逻辑
         childOb.dep.depend(); // 这里是Observer里的dep,数组arr在此依赖收集
         if (Array.isArray(value)) {
           dependArray(value);
         }
       }
     }
     return value
   },
   set: function reactiveSetter (newVal) {
     // 下文派发更新里进行讲解
   }
 });
}

注意 对象 、 数组 的不同处理方式。这里以 核心代码 + 图 进行讲解

接下来核心分析 defineReactive 做了什么。注意 childOb ,这是数组进行依赖收集的地方(也就是为什么我们 this.arr.push(4) 能找到 Watcher 进行派发更新)

图解Vue 响应式流程及原理

依赖收集触发阶段&mdash;&mdash;Wather实例化、访问数据、触发依赖收集

// new Wathcer核心
function Watcher (vm, expOrFn, cb, options, isRenderWatcher) {
 if (typeof expOrFn === 'function') {
 // 渲染watcher中,这里传入的expOrFn是updateComponent = vm.update(vm.render())
 // this.getter等价于vm.update(vm.render())
   this.getter = expOrFn;
 } else {
   ...
 }
 // 这里进行判断,lazy为true时(计算属性)则什么都不执行,否则执行get
 this.value = this.lazy
   ? undefined
   : this.get(); // 本次为渲染Watcher,执行get,继续往下看~
}
// Watcher的get方法
Watcher.prototype.get = function get () {
 // 这里很关键,pushTarget就是把当前的Wather赋值给“Dep.target”
 pushTarget(this);
 var value;
 var vm = this.vm;
 try {
   // 1. 这里调用getter,也就是执行vm.update(vm.render())
   // 2. 执行vm.render函数就会访问到响应式数据,触发get进行依赖收集
   // 3. 此时的Dep.target为当前的渲染Watcher,数据就可以理所应当的把Watcher加入自己的subs中
   // 4. 所以此时,Watcher就能监测到数据变化,实现响应式
   value = this.getter.call(vm, vm);
 } catch (e) {
   ...
 } finally {
   popTarget();
   /*
   * cleanupDeps是个优化操作,会移除Watcher对本次render没被使用的数据的观测
   * 效果:处于v-if为false中的响应式数据改变不会触发Watcher的update
   * 感兴趣的可以自己去debugger调试,这里就不展开了
   */
   this.cleanupDeps();
 }
 return value
}

Dep.target相关讲解

  • targetStack:栈结构,用来保存Watcher

  • pushTarget:往targetStackpush当前的Watcher(排在前一个Watcher的后面),并把Dep.target赋值给当前Watcher

  • popTarget:先把targetStack最后一个元素弹出(.pop),再把Dep.target赋值给最后一个Watcher(也就是还原了前一个Watcher)

  • 通过上述实现,vue保证了全局唯一的Watcher,准确赋值在Dep.target

图解Vue 响应式流程及原理

细节太多绕晕了?来个整体流程,从宏观角度再过一遍(computed部分可看完彩蛋后再回来重温一下)

图解Vue 响应式流程及原理

2. 派发更新

派发更新区分对象属性、数组方法进行讲解

如果想要深入了解组件的异步更新,戳这里,了解Vue组件异步更新之nextTick。本文只针对派发更新流程,不会对异步更新DOM进行展开讲解~

这里可以先想一下,以下操作会发生什么?

this.msg = 'new val'

this.arr.push(4)

是的,毫无疑问都会先触发他们之中的get,那再触发什么呢?我们接下来看

对象属性修改触发set,派发更新。this.msg = 'new val'

...
Object.defineProperty (obj, key, {
   get () {...},
   set: function reactiveSetter (newVal) {
     var value = getter ? getter.call(obj) : val;
     // 判断新值相比旧值是否已经改变
     if (newVal === value || (newVal !== newVal && value !== value)) {
       return
     }
     // 如果新值是引用类型,则将其转化为响应式
     childOb = !shallow && observe(newVal);
     // 这里通知dep的所有watcher进行更新
     dep.notify();
   }
}        
...

图解Vue 响应式流程及原理

数组调用方法。this.arr.push(4)

// 数组方法改写是在 Observer 方法中
function Observer () {
   if (hasProto) {
       // 用案例讲解,也就是this.arr.__proto__ = arrayMethods
       protoAugment(value, arrayMethods);
   }
}  
// 以下是数组方法重写的实现
var arrayProto = Array.prototype; // 保存真实数组的原型
var arrayMethods = Object.create(arrayProto); // 以真数组为原型创建对象
// 可以看成:arrayMethods.__proto__ = Array.prototype
var methodsToPatch = [
 'push',
 'pop',
 'shift',
 'unshift',
 'splice',
 'sort',
 'reverse'
];
// 一个装饰器模型,重写7个数组方法
methodsToPatch.forEach(function (method) {
 // 保存原生的数组方法
 var original = arrayProto[method];
 // 劫持arrayMethods对象中的数组方法
 def(arrayMethods, method, function mutator () {
   var args = [], len = arguments.length;
   while ( len-- ) args[ len ] = arguments[ len ];
   var result = original.apply(this, args);
   var ob = this.__ob__; // 当我门调用this.arr.push(),这里就能到数组对象的ob实例
   var inserted;
   switch (method) {
     case 'push':
     case 'unshift':
       inserted = args;
       break
     case 'splice':
       inserted = args.slice(2);
       break
   }
   if (inserted) { ob.observeArray(inserted); }
   // 由于数组对象在new Observer中实例化了一个dep,并通过childOb逻辑收集了依赖,这里就能在ob实例中拿到dep属性
   ob.dep.notify();
   return result
 });
})
  • 这里可以联合数组的依赖收集再看一遍,你就恍然大悟了。为什么 对象的属性 、数组 的依赖收集方式不一样

图解Vue 响应式流程及原理

整个new Vue阶段、到依赖收集、派发更新的全部流程就到这里结束了。可以纵观流程图看出,Vue应用就是一个个Vue组件组成的,虽然整个组件化、响应式流程很多,但核心的路径一旦走通,你就会恍然大悟。

三、彩蛋篇

1. computed依赖收集

  • 案例代码

<template>
   <div id="app">
       {{ name }}
   </div>
</template>
<script>
export default {
   name: 'App',
   computed: {
     name () {
       return this.firstName + this.secondName
     }
   },
   data () {
       return {
           firstName: 'jing',
           secondName: 'boran'
       }
   }
}
</script>
  • 我们先看流程图。图有点大~大家可以放大看看,每个核心步骤都附有文字说明

图解Vue 响应式流程及原理

根据案例概括一下,加深理解

// 访问computed时触发get的核心代码
function createComputedGetter (key) {
 return function computedGetter () {
   var watcher = this._computedWatchers && this._computedWatchers[key];
   if (watcher) {
     if (watcher.dirty) { // dirty第一次为true
       watcher.evaluate(); // 这里是对computed进行求值,对computed watcher执行依赖收集
     }
     if (Dep.target) {
       watcher.depend(); // 这里是对渲染Watcher进行依赖收集
     }
     return watcher.value
   }
 }
}

computed中的name其实就是一个computed Watcher,这个Watcher在init阶段生成

当App组件render的阶段,render函数会访问到模版中的{{ name }},则会触发computed的求值,也就是执行上面代码computedGetter()。执行watcher.evaluate()。也就是执行wathcer.get。上文依赖收集的第3点:依赖收集触发阶段有对get方法进行讲解,忘了的可以上去回顾一下执行watcher.depend()

Watcher.prototype.depend = function depend () {
 var i = this.deps.length;
 while (i--) {
   // 也就是调用Dep.depend => Watcher.addDep => dep.addSub
   this.deps[i].depend();
 }
}
// this.firstName和this.secondName的dep.subs
dep.subs: [name的computed watcher, App组件的渲染Watcher]

代码中判断watcher.dirty标志是什么?有什么用?

只有computed的值发生改变(也就是其依赖的数据改变),watcher.dirty才会被设为true

只有watcher.dirtytrue才会对computed进行 求值 或 重新求值

总结:也就是组件每次render,如果computed的值没改变,直接返回value值(是不需要重新计算的),这也是computed的一个特点

  • 首先pushTargetDep.target从App组件的渲染Watcher改为name的computed Watcher

  • 其次执行cb:function() { return this.firstName + this.secondName }

  • 执行cb的过程中,必然会访问到firstNamesecondName,这时候就是我们熟悉的依赖收集阶段了。firstName、secondName都会把name这个computed watcher收集到自己的dep.subs[]

  • 最后popTarget把name的computed Watcher弹出栈,并恢复Dep.target为当前App组件的渲染Watcher

  • 遍历computed watcher的deps。其实就是firstName、secondName实例的Dep

  • dep.depend也就是调用watcher.addDep(把Dep收集进watcher.deps中),再由watcher.appDep调用dep.addSub(把Watcher收集进dep.subs中)

  • 这样一来,就完成了firstName、secondName对App组件的渲染watcher进行收集

  • 结果如下。响应式数据中会存在两个Watcher

  • 至于为什么响应式数据要收集2个watcher?下文computed派发更新会讲解

讲到这里,我以自己的理解讲解下文章开头引言的问题:为什么Watcher、Dep多对多且相互收集? 这可能也是大家阅读Vue源码中一直存在的一个疑惑(包括我自己刚开始读也是这样)

对的,当然是为了computed中的响应式数据收集渲染Watcher啦!!!

还有!!! 还记得前文中依赖收集的第3点&mdash;&mdash;依赖收集触发阶段的代码讲解中我写了很多注释的cleanupDeps吗?

// 此时flag为true,也就是说msg2没有渲染在页面中
<div v-if="flag">{{ msg1 }}</div>
<div v-else>{{ msg2 }}</div>
<button @click=() => { this.msg2 = 'change' }>changeMsg2</button>
function cleanupDeps () {
 var i = this.deps.length;
 while (i--) {
   // 这里对watcher所观测的响应式数据的dep进行遍历
   // 对的,这样一来,是不是watcher中的deps就发挥作用了呢?
   var dep = this.deps[i];
   if (!this.newDepIds.has(dep.id)) {
     // 这里对当前渲染中没有访问到的响应式数据进行依赖移除
     dep.removeSub(this);
   }
 }
 ...
}
  • cleanupDeps的作用就是清除掉当前没有使用到的响应式数据。怎么清除?我们往下看

  • 首先看个案例回答个问题,代码如下。当flag为true时,msg2并没有渲染在页面中,那么此时我们点击按钮修改msg2的值会不会、或者应不应该触发这个组件的重新渲染呢?

  • 答案肯定是不会、不应该。所以:cleanupDeps就是为此而存在的

  • cleanupDeps是怎么工作的呢?接着看下面代码

  • 到此,你是否已经懂得了watcher中为什么要收集自己观测的响应式数据对应的dep呢?

2. computed派发更新

派发相对来说比较简单了~跟响应式的派发更新基本一致,继续以案例来讲解吧!

当我们修改firstName会发生什么?this.firstName = 'change'

首先触发firstName的set,最终会调用dep.notify()。firstName的dep.subs中有2个watcher,分别执行对应watcher的notify

Watcher.prototype.update = function update () {      
 if (this.lazy) {
   this.dirty = true; // computed会走到这里,然后就结束了
 } else if (this.sync) {
   this.run();
 } else {
   queueWatcher(this); // 渲染watcher会走到这里
 }
}

computed watcher:将dirty属性置为true。

渲染watcher会执行派发更新流程(如本文响应式流程&mdash;&mdash;2.派发更新一致)

nextTick阶段执行flushSchedulerQueue,则会执行watcher.run()

watcher.run会执行watcher.get方法,也就是重新执行render、update的流程

执行render又会访问到name的computed,从而又会执行computedGetter

此时的watcher.dirty在本步骤3已经置为true,又会执行watcher.evaluate()进行computed的求值,执行watcher.depend()......后续的流程就是派发更新的流程了~

3. user Watcher依赖收集

user Watcher的依赖收集相比computed会简单一点,这里不会赘述太多,只说核心区别,还有watch的常用配置immediatedeepsync

user Watcher在init阶段会执行一次watcher.get(),在这里会访问我们watch的响应式数据,从而进行依赖收集。回顾下computed,computed在这个阶段什么也没做。

// 没错,又是这段熟悉的代码
this.value = this.lazy
 ? undefined
 : this.get(); // user Watcher和渲染 Watcher都在new Watcher阶段执行get()

如果userWatcher设置的immediate: true,则会在new Watcher后主动触发一次cb的执行

Vue.prototype.$watch = function (expOrFn, cb, options) {
 ...
 var watcher = new Watcher(vm, expOrFn, cb, options);
 if (options.immediate) {
   // immediate则会执行我们传入的callback
   try {
     cb.call(vm, watcher.value);
   } catch (error) {
   }
 }
 return function unwatchFn () {
   watcher.teardown();
 }
};

deep逻辑很简单,大概讲下:深度遍历这个对象,访问到该对象的所有属性,以此来触发所有属性的getter。这样,所有属性都会把当前的user Watcher收集到自己的dep中。因此,深层的属性值修改(触发set派发更新能通知到user Watcher),watch自然就能监测到数据改变~感兴趣的同学可以自己去看看源码中traverse的实现。

sync。当前tick执行,以此能先于渲染Wathcer执行。不设置同步的watcher都会放到nextTick中执行。

Watcher.prototype.update = function update () {
 if (this.lazy) {
   this.dirty = true; // 计算属性
 } else if (this.sync) {
   this.run(); // 同步的user Wathcer
 } else {
   queueWatcher(this); // 普通user Watcher和渲染Watcher
 }
}

图解Vue 响应式流程及原理

总体来说,Vue的源码其实是比较好上手的,整体代码流程非常的清晰。但是想要深入某一块逻辑,最好结合流程图加debugger方式亲自上手实践。毕竟真正搞懂一门框架的源码并非易事,我也是通过不断debugger调试,一遍遍走核心流程,才能较好的学习理解vue的实现原理~

写在最后,这篇文章也算是自己的一个知识沉淀吧,毕竟很早之前就学习过Vue的源码了,但是也一直没做笔记。现在回顾一下,发现很多都有点忘了,但是缺乏一个快速记忆、回顾的笔记。如果要直接硬磕源码重新记忆,还是比较费时费力的~作为知识分享,希望可以帮助到想学习源码,想要进阶的你,大家彼此共勉,一同进步!

来源:https://juejin.cn/post/7074422512318152718

标签:Vue,响应式,流程图解
0
投稿

猜你喜欢

  • MySQL数据库优化之分表分库操作实例详解

    2024-01-20 10:33:53
  • 基于Pyinstaller打包Python程序并压缩文件大小

    2023-11-10 06:41:05
  • Python+OpenCV 实现简单的高斯滤波(推荐)

    2021-07-18 20:31:26
  • vue中typescript装饰器的使用方法超实用教程

    2024-05-28 15:47:06
  • MySQL命令终端有beep声

    2009-02-26 15:27:00
  • vue+element实现图片上传及裁剪功能

    2024-05-29 22:22:12
  • vue-quill-editor实现图片上传功能

    2024-04-30 10:22:40
  • Python教程教你如何去除背景

    2023-01-08 17:19:40
  • MySQL取消了Query Cache的原因

    2024-01-20 19:57:30
  • mysql 5.5 开启慢日志slow log的方法(log_slow_queries)

    2024-01-15 15:05:36
  • python中偏函数partial用法实例分析

    2021-03-24 21:35:23
  • pymssql数据库操作MSSQL2005实例分析

    2024-01-15 02:23:42
  • python3实现无权最短路径的方法

    2023-07-11 23:26:40
  • 详解JavaScript作用域 闭包

    2024-04-19 10:07:20
  • Python处理XML格式数据的方法详解

    2021-04-10 22:25:07
  • Python实现将MySQL数据库表中的数据导出生成csv格式文件的方法

    2024-01-21 05:57:53
  • js弹出新窗口而不会被浏览器阻止的方法

    2010-04-06 12:38:00
  • Python中如何向函数传递列表

    2022-09-23 19:10:23
  • python list语法学习(带例子)

    2023-08-20 05:36:40
  • python基于turtle绘制几何图形

    2023-06-22 22:44:43
  • asp之家 网络编程 m.aspxhome.com