一、前言
Vue 最独特的特性之一,是其非侵入性的响应式系统。数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接,不过理解其工作原理同样重要,这样你可以避开一些常见的问题。
响应式原理的三大步骤:数据劫持(Proxy)、依赖收集(Dep)、触发更新(发布订阅模式)
在 new Vue() 后, Vue 会调用 _init 函数进行初始化,也就是init 过程,在这个过程Data通过Observer(封装了Proxy的方法)转换成了getter/setter的形式(进行数据劫持),来对数据追踪变化,当被设置的对象被读取的时候会执行getter 函数(getter里面有track方法会对依赖进行收集),而在当被赋值的时候会执行 setter函数(setter里面有trigger方法会触发更新)。
二、什么是响应式
我们先来看个例子:
1 | <div id="app"> |
1 | var app = new Vue({ |
上例中当price 发生变化的时候,Vue就知道自己需要做三件事情:
- 更新页面上price的值
- 计算表达式 price*quantity 的值,更新页面
- 调用totalPriceWithTax 函数,更新页面
数据发生变化后,会重新对页面渲染,这就是Vue响应式,那么这一切是怎么做到的呢?
想完成这个过程,我们需要:
- 侦测数据的变化
- 收集视图依赖了哪些数据
- 数据变化时,自动“通知”需要更新的视图部分,并进行更新
对应专业俗语分别是:(这也就是响应式原理的三大步骤)
- 数据劫持 / 数据代理(Proxy)
- 依赖收集(Dep)
- 发布订阅模式(Watcher)
三、如何侦测数据的变化(如何实现数据劫持?)
首先有个问题,在Javascript中,如何侦测一个对象的变化? 其实有两种办法可以侦测到变化:使用Object.defineProperty和ES6的Proxy,这就是进行数据劫持或数据代理。这部分代码主要参考珠峰架构课。
1、方法1.Object.defineProperty实现
Vue通过设定对象属性的 setter/getter 方法来监听数据的变化,通过getter进行依赖收集,而每个setter方法就是一个观察者,在数据变更的时候通知订阅者更新视图。
1 | function render () { |
上面这段代码的主要作用在于:observe这个函数传入一个 obj(需要被追踪变化的对象),通过遍历所有属性的方式对该对象的每一个属性都通过 defineReactive 处理,以此来达到实现侦测对象变化。值得注意的是,observe 会进行递归调用。 那我们如何侦测Vue中data 中的数据,其实也很简单:
1 | class Vue { |
这样我们只要 new 一个 Vue 对象,就会将 data 中的数据进行追踪变化。 不过这种方式有几个注意点需补充说明:
- 无法检测到对象属性的添加或删除(如data.location.a=1)。
这是因为 Vue 通过Object.defineProperty来将对象的key转换成getter/setter的形式来追踪变化,但getter/setter只能追踪一个数据是否被修改,无法追踪新增属性和删除属性。如果是删除属性,我们可以用vm.$delete实现,那如果是新增属性,该怎么办呢?
1)可以使用 Vue.$set(location, a, 1) 方法向嵌套对象添加响应式属性;
2)也可以给这个对象重新赋值,比如data.location = {…data.location,a:1}
- Object.defineProperty 不能监听数组的变化,需要进行数组方法的重写,具体代码如下:
1 | function render() { |
这种方法将数组的常用方法进行重写,进而覆盖掉原生的数组方法,重写之后的数组方法需要能够被拦截。但有些数组操作Vue时拦截不到的,当然也就没办法响应,比如:
1 | obj.length-- // 不支持数组的长度变化 |
ES6提供了元编程的能力,所以有能力拦截,Vue3.0可能会用ES6中Proxy 作为实现数据代理的主要方式。
2、方法2.Proxy实现
Proxy 是 JavaScript 2015 的一个新特性。Proxy 的代理是针对整个对象的,而不是对象的某个属性,因此不同于 Object.defineProperty 的必须遍历对象每个属性,Proxy 只需要做一层代理就可以监听同级结构下的所有属性变化,当然对于深层结构,递归还是需要进行的。此外Proxy支持代理数组的变化。
1 | function render() { |
以上代码不仅精简,而且还是实现一套代码对对象和数组的侦测都适用。不过Proxy兼容性不太好!
四、为什么要收集依赖(视图都依赖了哪些数据?)
我们之所以要观察数据,其目的在于当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。比如第一例子中,模板中使用了price 数据,当它发生变化时,要向使用了它的地方发送通知。那如果多个Vue实例中共用一个变量,如下面这个例子:
1 | let globalData = { |
如果我们执行下面这条语句:
1 | globalData.text = '前端工匠'; |
此时我们需要通知 test1 以及 test2 这两个Vue实例进行视图的更新,我们只有通过收集依赖才能知道哪些地方依赖我的数据,以及数据更新时派发更新。那依赖收集是如何实现的?其中的核心思想就是“事件发布订阅模式”。接下来我们先介绍两个重要角色 – 订阅者 Dep和观察者 Watcher ,然后阐述收集依赖的如何实现的。
1、订阅者 Dep
(1)为什么引入 Dep
收集依赖需要为依赖找一个存储依赖的地方,为此我们创建了Dep、它用来收集依赖、删除依赖和向依赖发送消息等。
于是我们先来实现一个订阅者 Dep 类,用于解耦属性的依赖收集和派发更新操作,说得具体点,它的主要作用是用来存放 Watcher 观察者对象。我们可以把Watcher理解成一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。
(2)Dep的简单实现
1 | class Dep { |
以上代码主要做两件事情:
- 用 addSub 方法可以在目前的 Dep 对象中增加一个 Watcher 的订阅操作;
- 用 notify 方法通知目前 Dep 对象的 subs 中的所有 Watcher 对象触发更新操作。
所以当需要依赖收集的时候调用 addSub,当需要派发更新的时候调用 notify。调用也很简单:
1 | let dp = new Dep() |
收集依赖收集的是使用到该数据的地方。当该数据更新时,通过Watcher通知所有收集到的依赖。
2、观察者 Watcher
(1)为什么引入Watcher
Vue 中定义一个 Watcher 类来表示观察订阅依赖。至于为啥引入Watcher,《深入浅出vue.js》给出了很好的解释:
当属性发生变化后,我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch,这时需要抽象出一个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个,再由它负责通知其他地方。
依赖收集的目的是将观察者 Watcher 对象存放到当前闭包中的订阅者 Dep 的 subs 中。形成如下所示的这样一个关系(图参考《剖析 Vue.js 内部运行机制》)。
(2)Watcher的简单实现
1 | class Watcher { |
以上就是 Watcher 的简单实现,在执行构造函数的时候将 Dep.target 指向自身,从而使得收集到了对应的 Watcher,在派发更新的时候取出对应的 Watcher ,然后执行 update 函数。
3、收集依赖
所谓的依赖,其实就是Watcher。至于如何收集依赖,总结起来就一句话,在getter中收集依赖,在setter中触发依赖。先收集依赖,即把用到该数据的地方收集起来,然后等属性发生变化时,把之前收集好的依赖循环触发一遍就行了。
具体来说,当外界通过Watcher读取数据时,便会触发getter从而将Watcher添加到依赖中,哪个Watcher触发了getter,就把哪个Watcher收集到Dep中。当数据发生变化时,会循环依赖列表,把所有的Watcher都通知一遍。
最后我们对 defineReactive 函数进行改造,在自定义函数中添加依赖收集和派发更新相关的代码,实现了一个简易的数据响应式。
1 | function observe (obj) { |
当 render function 被渲染的时候,读取所需对象的值,会触发 reactiveGetter 函数把当前的 Watcher 对象(存放在 Dep.target 中)收集到 Dep 类中去。之后如果修改对象的值,则会触发 reactiveSetter 方法,通知 Dep 类调用 notify 来触发所有 Watcher 对象的 update 方法更新对应视图。
五、总结
1、最后我们依照下图(参考《深入浅出vue.js》),再来回顾下整个过程:
- 在 new Vue() 后, Vue 会调用 _init 函数进行初始化,也就是init 过程,在这个过程Data通过Observer(封装了Proxy的方法)转换成了getter/setter的形式,来对数据追踪变化,当被设置的对象被读取的时候会执行getter 函数,而在当被赋值的时候会执行 setter函数。(所以修改某一个变量的时候,使用到该变量的地方都会自动更新视图。)
- 当render function 执行的时候,因为会读取所需对象的值,所以会触发getter函数从而将Watcher添加到依赖中进行依赖收集。
- 在修改对象的值的时候,会触发对应的setter,setter通知之前依赖收集得到的 Dep 中的每一个 Watcher,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher就会开始调用 update 来更新视图。
2、Vue 的响应式系统依赖于三个重要的类:
(1)Observer 类:数据观察器(封装了Proxy),负责观察数据的更新。如果观察到数据更新,就把数据更新消息发送给Dep类。
(2)Dep类:响应式系统的调度器,负责接收来自Observer的数据更新消息,并把这个消息发送给相应的Watcher对象。
(3)Watcher 类:数据监听器。负责接收来自Dep的数据更新消息,执行相应的响应操作。
如图所示,当用户更新了一个变量后,Observer观察到这种更新,就会把更新消息发送给Dep,Dep再把更新消息发送给所有依赖这个变量的Watcher,Watcher会执行相应的操作,来对变量更新做出响应。
数据监听器Watcher分为三种:
(1)常规Watcher(normal-watcher):对于在组件的watch选项中监听的变量,就使用这种normal-watcher。当被监听的变量发生变化,normal-watcher会立即执行watch选项中的相应函数。
(2)计算属性Watcher(computed-watcher):对于在computed选项中定义的计算属性,就使用computed-watcher。对于每个计算属性,都对应一个computed-watcher对象。computed-watcher具有lazy(懒计算)特性:假定计算属性b依赖变量a,当变量a更新时,computed-watcher并不会立即重新计算b,而是只有当需要读取b时,才会重新计算b。本章3.2.6节的末尾也通过实验演示了这种lazy特性。
(3)渲染Watcher(render-watcher):每一个Vue组件都会有相应的render-watcher, 当Vue组件的data选项中的变量或者computed选项中的计算属性发生变化时,render-watcher就会重新渲染组件的DOM。
以上三种 Watcher 有固定的执行顺序,按照先后顺序分别是:normal-watcher、computed-watcher和render-watcher。
三种Watcher的执行顺序可以保证数据在业务逻辑上的一致性。
参考: