什么是响应式系统?
核心知识铺垫
get/set:javascript中的对象属性分为两类,分别是数据属性和访问器属性。数据属性可以真实地用来存取数据,而访问器属性则只对外提供一组get/set操作api。当我们读取访问器属性时,实际上就是在调用对应的get方法,而在设置访问器属性的值时,则是在调用对应的set方法。数据属性直接定义在对象中即可,而访问器属性一般使用Object.defineProperty(它也可以用来定义数据属性,但一般没有必要这样做)来定义,该api的前两个参数分别是目标对象和要定义的属性名,第三个参数则是属性描述符,对于访问器属性一般需要提供get和set两个方法,用于在读写该属性时调用,如:
let message = {
title: "old" // 数据属性
};
let value = "old";
Object.defineProperty(message, "title", {
get: function(){
console.log("你现在调用了message.title的get方法!");
return value; // 该访问器属性返回value的值
},
set: function( newVal ){
console.log("你现在正在修改message.title的值");
value = newVal; // 设置该属性的值时,实际上是写入到了value中
}
})
现在我们为message对象设置了一个名为title的访问器属性,当我们执行let temp = message.title时,控制台就会输出get方法中的提示消息,并把value的值赋值给temp变量;而执行message.title = "abc"时,就会执行set方法中的提示消息,并把"abc"写入到value变量中。通过定义这两个方法,我们就可以对数据的变化进行监听,Vue的响应式系统正是基于该接口实现的。
注意:这是目前2.x版本Vue的实现原理,Vue的作者表示3.0版本将使用ES6提供的proxy(代理)来实现。
响应式系统概述
Vue的响应式系统是一个精心搭建的数据监控系统,它负责监测项目中的数据变化,然后通知对该数据“感兴趣”的订阅者进行相关操作。
数据的监测分为两类,一类是对对象的监测,另一类是对数组的监测。对对象监测的基本原理是设置对象每个属性的get/set方法,使其变为响应式属性;对数组监测的基本原理是拦截数组的七个原型方法(如push、pop等),并将数组的对象或数组成员继续进行响应式转化。
我们先来理解“数据”、“感兴趣”以及“订阅者”这三个关键词。
这里指的数据,就是options中的data配置项,它通过以下两种方式定义:
//单文件中使用Vue
var app = new Vue({
el: "#app",
data: { ... },
template: "",
...
})
//单文件组件
<template>
...
</template>
<script>
name: "",
data(){
return {
...
}
}
</script>
通常我们把与数据操作相关的逻辑称为业务逻辑,而这些数据如何展现在页面上则是由视图层负责。MVVM的核心思想正是让开发者把目光聚焦于业务逻辑,关注如何进行数据操作,而不是考虑视图如何更新(虽然Vue不是严格的MVVM框架,但这里的思想是一致的)。
那么什么叫“感兴趣”呢?我们举个例子来说明,假设有下面一个组件:
//CustormForm.vue
<template>
<h1>{{ title }}</h1>
</template>
<script>
name: "custom-form",
data() {
title: "表单",
},
watch:{
"form.title": function(newVal, oldVal){
console.log("标题发生了变化:" + newVal);
}
}
</script>
现在组件中有一个被监听的数据title。我们在模板中以{{ title }}的形式将它的值绑定在模板里,这样在渲染该组件时,Vue就会把title的值替换进去,模板会被渲染为下面的样子:
<h2>表单</h2>
如果之后title的值发生了变化,Vue的响应式系统就会监听到这个变化,然后更新上面的视图。因为这里视图的渲染和更新需要依赖title的值,而在Vue中是借助一个watcher(订阅者)来根据数据变化进行视图的渲染和更新的,因此我们说这个watcher对title“感兴趣”。类似的,上面我们在watch中对title注册了一个回调函数,它会在title的值变化后,向控制台输出新的title值。而Vue也是通过生成一个watcher来负责根据数据变化执行上述回调函数的,因此这个watcher也对title的变化“感兴趣”。
简单来说,只要是关注某个数据的变化,就是对该数据“感兴趣”。
那什么是订阅者呢?
一个订阅者就是一个在数据变化后可以执行一定操作的javascript对象(也就是上面提到的watcher)。具体来说,在上面的例子中,当title发生变化时,会有一个对象负责重新渲染视图,同时会有另一个对象负责向控制台输出新的值。这两个对象都是订阅者。它们会被注册到title的依赖者列表中,当title变化时,就会调用它们的update方法进行对应的操作。
简单来说,订阅者就是数据变化时执行操作的对象。
响应式系统的简介就到这里,下面我们来看响应式系统的实现。现在假如我们有如下的Vue实例:
let app = new Vue({
el: "#app",
template: "<div>{{message.title}}</div>",
data: {
message: {
title: ""
},
author: []
}
})
我们以上面的例子为基础,分别介绍Vue对对象和数组的监听原理。
1. 对象监测篇
对象监测系统的结构设计
对象的监测系统基于三个核心类:Observer(观察者)、Dep(依赖)和Watcher(订阅者)。
从作用上来看:
- Observer负责监听数据的变化,并在数据变化时通知Dep
- Dep负责收集对该数据“感兴趣”的订阅者,并在收到Observer的数据变化通知时调用Watcher提供的update方法
- Watcher就是订阅者,它向Dep提供update接口,内部封装了在数据变化后需要执行的操作,可能是更新视图或执行回调
从结构上来看:
- Observer以对象的__ob__属性存在。比如一个对象:
data(){
return {
message: {title: ""}
}
}
执行new Observer(message)就可以把message变成响应式的对象,执行之后的message对象将变成(这里省略了__proto__属性,它与响应式系统无关):
message: {
title: "",
__ob__: Observer {value: {…}, dep: Dep, vmCount: 0},
get str: ƒ reactiveGetter(),
set str: ƒ reactiveSetter(newVal)
}
message新增了一个属性__ob__,它是一个Observer实例。具有该属性也表明message已经被封装为了一个响应式的对象(即它的属性具备在值发生变化时自动触发某些事件的能力)。get和set实际上是由它的父对象(在这里就是data)执行Object.defineProperty时为它定义的,get用于收集对message的变化“感兴趣”的订阅者,set用于触发响应。
- Dep作为__ob__的属性dep存在,或者以闭包的形式存在。继续展开上面的message对象,它的结构是这样的:
message: {
title: "",
__ob__: {
dep: Dep {id: 1, subs: Array(0)},
value: {__ob__: Observer},
vmCount: 0
},
get str: ƒ reactiveGetter()
set str: ƒ reactiveSetter(newVal)
}
上面是作为属性存在的Dep,用于收集对当前数据“感兴趣”的watcher,另外还有一种作为闭包存在的Dep,后面会讲到。
- Watcher作为Dep的subs数组元素存在。继续展开上面的对象:
message: {
title: "",
__ob__: {
dep: {
id: 1,
subs: [watcher1, watcher2, ...]
},
value: {__ob__: Observer},
vmCount: 0
},
get str: ƒ reactiveGetter()
set str: ƒ reactiveSetter(newVal)
}
每个watcher定义了在数据发生变化时该进行什么样的操作,它向外提供了一个update方法来执行该操作。
监测对象变化的大体思路是:在new Observer(data)时修改data每个属性的get/set(如果该属性是对象,就递归下去),并生成一个与该属性对应的Dep实例。一旦某个watcher通过get获取该属性的值,它就会被收集到对应的Dep实例的subs数组中。当属性值变化时,就会触发属性的set,Dep实例会依次调用subs中收集的watcher提供的update方法执行操作。
注意:响应式系统在进行响应式转化的时候是递归的,因此如果对象的某个属性是对象,那么它的每个属性也都会被递归地转化为响应式属性。有人可能疑惑,基本数据类型的属性在Vue中不是响应式的吗?当然不是。因为Vue构造响应式系统时最基本的操作是将一个对象的属性(而不是对象本身)转化为响应式的。也就是说,只要是以某个对象的属性存在的,它就是响应式的(无论它是什么类型的属性,data参数里声明的变量,都是以data的属性存在的)。同样这也意味着,作为响应式系统入口的根对象data本身不是响应式的(实际上Vue不允许直接修改该对象)。
对象监测系统的实现
上面说到,Vue的整个响应式系统是以data对象为入口的。我们回顾一下Vue实例的初始化过程:
//摘自src/core/instance/index.js
function Vue(options){
...
this._init(options);
}
initMixin(Vue);
...
//摘自src/core/instance/init.js
function initMixin(){
Vue.prototype._init = function(options){
const vm = this;
...
initState(vm);
...
}
}
//摘自src/core/instance/state.js
function initState(vm){
vm._watchers = []
const opts = vm.$options
...
if(opts.data){
initData(vm);
}
}
function initData(vm){
let data = vm.$options.data;
...
observe(data, true/* as root data */);
}
从observe函数开始,我们就正式进入到响应式系统的模块了。这个observe函数引自src/core/observer/index.js,负责将一个普通对象的属性转化为响应式的,这里我们要进行转化的就是根数据对象data。
首先我们来看observe函数所在文件的大致结构,它是响应式系统的入口文件。
//摘自src/core/observer/index.js
import Dep from './dep'
import VNode from '../vdom/vnode'
import { arrayMethods } from './array'
...
//定义Observer类
export class Observer {
value: any;
dep: Dep;
vmCount: number;
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
//如果是数组,需要使用observeArray进行监测
if (Array.isArray(value)) {
//向数组原型添加拦截器,如果浏览器不支持__proto__,则直接添加到数组上
//这里会在数组篇讨论
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
} else {
//如果是对象,则使用walk遍历所有属性
this.walk(value)
}
}
//用for循环遍历所有属性,使用defineReactive将其转化为响应式属性,
//这个defineReactive是对象观测的核心方法
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
//观测数组,第二篇讨论
observeArray(){
...
}
}
...
//观测对象的方法,负责将value转化为响应式对象
function observe (value, asRootData){
//为了方便理解,这里对源码进行了提取
let ob;
//当前对象有__ob__属性,说明已经是响应式对象了,直接返回__ob__属性即可
if(hasOwn(value, __ob__)){
ob = value.__ob__;
} else if(/*value具备转化为响应式对象的条件*/){
ob = new Observer(value);
}
...
return ob;
}
//负责将对象属性转化为响应式属性,这个函数接下来将详细分析
function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
){
...
}
我们知道,响应式系统的监测对象就是当前Vue实例的data对象(这个监测是递归的,也就是说data内的任何后代属性都会被监测),而对data的观测是以observe(data)为入口的。代码中value(此时,局部变量value就指代data)转化为响应式的流程如下:
- 执行observe(value)。首先检查当前对象是否已经是响应式对象(防止对一个对象重复监测),如果不是,就执行new Observer(value)。
- 调用Observer的构造函数。在构造函数内,根据value是普通对象还是数组执行不同的操作。这里的data是一个对象,因此将执行this.walk(value),遍历它的属性。
- 调用walk(value),将value的属性枚举出来,分别调用defineReactive(obj, key),将它的每个属性转化为响应式属性。
- 调用defineReactive(obj, key),进行get/set转化,通过修改该属性取值和设值的行为,将其转化为一个可观测的属性。
下面我们详细来看defineReactive函数的实现:
function defineReactive (
obj: Object, //当前属性所属的对象
key: string, //需要转化为响应式的属性
val: any, //当前的属性值
customSetter?: ?Function, //自定义的setter
shallow?: boolean //是否为浅观测,即如果当前属性是对象,是否要递归监测子属性
) {
//闭包形式存在的Dep实例,用于收集对当前属性“感兴趣”的watcher
const dep = new Dep()
...
//如果是深度监测,就递归调用observe函数监测该属性的子属性
let childOb = !shallow && observe(val)
//修改该属性的get/set,响应式系统的核心
Object.defineProperty(obj, key, {
enumerable: true, //规定该属性可遍历
configurable: true, //规定该属性可配置(可修改)
get: function reactiveGetter () {
//先取出该属性的值
const value = getter ? getter.call(obj) : val
//当某个watcher取当前属性的值时,就会将自身赋值给Dep类的
//静态属性target,这样在get中就可以把该watcher添加到dep中
if (Dep.target) {
dep.depend()
//如果当前属性还有子属性,那么子属性的dep中也要添加当前watcher,
//因为当前属性变化意味着其内存地址发生了变化,显然它的子属性也会变化
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
set: function reactiveSetter (newVal) {
//这里的getter是修改前的get方法,先获取该属性的原始值
const value = getter ? getter.call(obj) : val
//只在属性值变化时才发出通知,当值不发生变化时(如a.b = a.b)是不需要触发响应式操作的
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
//允许开发者提供自定义的setter
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
//如果setter不存在,说明当前属性不允许修改,直接return
if (getter && !setter) return
//调用setter修改属性,或直接通过赋值修改属性
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
//将该属性新的值转化为响应式的
childOb = !shallow && observe(newVal)
//数据发生了变化,通知dep去触发watcher
dep.notify()
}
})
}
让我们来仔细分析一下上面的代码。
- 在函数内部定义一个dep对象,它是一个Dep实例,用于收集对当前属性感兴趣的watcher。这个dep并不属于任何对象,它是以闭包的形式存在的。当前函数执行完毕后,在该函数外无法访问到这个dep,但是我们在函数内部为当前属性定义的get/set却可以访问该dep。因为我们定义的get/set是一直有效的,因此这个dep也不会被释放(闭包的基本原理),于是该dep现在就和当前属性一一对应了。
- 递归观测当前属性。比如data.message是一个对象,那么我们不仅要将message转化为响应式的,message的每一个属性也都要转化为响应式的,以此类推,这样才能实现对data的完全观测。这个递归过程直到需要转化的属性是非对象或数组时才会结束。
- 使用Object.defineProperty修改当前属性的get/set。get方法用于向dep中添加对当前属性感兴趣的watcher;set方法用于在属性值变化时通知dep,由dep去触发这些watcher的update方法。
这里的第三步我们还需要详细展开。首先我们需要先了解一下Dep和Watcher的结构。
Dep:
class Dep {
//静态属性,watcher在访问某个属性时,会临时把自身保存在该静态属性中,
//然后在该属性的get方法中,通过dep.depend()将这个watcher添加到依赖列表中
static target: ?Watcher;
id: number; //dep实例的唯一id
subs: Array<Watcher>; //依赖者列表
//构造函数,只是简单的初始化
constructor () {
this.id = uid++
this.subs = []
}
//向依赖数组subs中添加watcher的方法
addSub (sub: Watcher) {
this.subs.push(sub)
}
//从依赖数组中移除watcher,取消对某个属性的监听时会用到
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
//将当前dep注册到目标watcher的depIds中,防止重复注册,同时,
//watcher的addDep方法会将自身添加到dep的subs中,这里的Dep.target
//是一个watcher实例,调用的addDep是一个watcher的方法
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
//属性值变化时通知dep的接口
notify () {
...
//依次调用依赖者列表中每个watcher的update方法,
//这里就是对数据变化进行响应的分发接口
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
我们看到,dep的作用就是收集watcher,并在数据变化时依次触发这些watcher的update。
然后我们看一下watcher的结构:
class Watcher {
vm: Component;
expression: string;
cb: Function;
id: number;
deep: boolean;
user: boolean;
lazy: boolean;
sync: boolean;
dirty: boolean;
active: boolean;
deps: Array<Dep>;
newDeps: Array<Dep>;
depIds: SimpleSet;
newDepIds: SimpleSet;
before: ?Function;
getter: Function;
value: any;
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
this.vm = vm
if (isRenderWatcher) { //渲染类watcher,在模板中绑定数据时就是这类watcher
vm._watcher = this
}
vm._watchers.push(this) //将watcher注册到当前Vue实例的列表中
...
//调用watcher的get方法为value赋值
this.value = this.lazy
? undefined
: this.get()
}
get () {
//将当前的watcher添加为Dep.target,这样在取目标属性值时就会触发
//依赖收集,dep就会把Dep.target(即当前watcher)添加到依赖者列表
pushTarget(this)
let value
const vm = this.vm
//这里是简写,源码中还包括取值失败时的操作,以及对属性的深度观测
//这里会触发目标属性的get方法,由于已经在上面将当前watcher赋值
//给Dep.target,这样dep就可以把当前watcher收集进依赖列表
value = this.getter.call(vm, vm)
...
popTarget() //释放对Dep.target的占用
this.cleanupDeps() //释放失效的依赖
return value
}
//将当前watcher添加到传过来的dep的subs数组中,完整依赖收集,同时将
//该dep的id添加到自身相关的dep列表中,防止重复触发依赖收集
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
//清除已经失效的dep
cleanupDeps () {
...
}
//watcher提供的更新方法,它负责定义数据变化时的操作
update () {
if (this.lazy) { //懒更新,给当前watcher打上标志
this.dirty = true
} else if (this.sync) { //同步更新,直接调用run重新渲染视图或执行回调
this.run()
} else { //异步更新,将当前watcher推入循环队列,等主线程操作完成再触发回调
queueWatcher(this)
}
}
//数据变化时需要执行的实际操作
run () {
...
//run方法最重要的就是执行传入的回调函数,可能是虚拟DOM的patch方法,
//也可能是通过$watch定义的回调函数。当是前者时,当前的watcher就是
//一个renderWatcher(渲染类watcher),通过虚拟DOM提供的patch
//方法进行视图更新
this.cb.call(this.vm, value, oldValue);
...
}
//取目标属性值
evaluate () {
this.value = this.get()
this.dirty = false
}
...
//注销当前watcher
teardown () {
if (this.active) {
if (!this.vm._isBeingDestroyed) {
remove(this.vm._watchers, this) //从实例的_watchers中移除当前watcher
}
let i = this.deps.length
while (i--) {
this.deps[i].removeSub(this)//从与它相关的dep中移除当前watcher
}
this.active = false
}
}
}
现在我们可以重新来理解defineReactive的第三步所做的事了。我们把defineReactive的核心部分再精简一下:
function defineReactive (obj, key, val, customSetter, shallow) {
const dep = new Dep()
...
Object.defineProperty(obj, key, {
...
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
...
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
...
val = newVal
dep.notify()
}
})
}
我们来看上面的结构,首先通过闭包为当前属性生成一个dep,然后修改该属性的get/set。在之后的某个时间(如开发者通过$watch方法对该属性进行了监听),那么Vue就会执行new Watcher(vm)来生成一个watcher负责执行开发者传入的回调。该watcher在初始化时会取当前属性的值,这会触发该属性的get,由于watcher在取值之前已经把自己添加到Dep.target中,因此该属性对应的dep就会通过depend方法,将该watcher保存到自己的依赖者列表(subs)中(之所以选择Dep的静态属性来临时保存watcher,是因为dep实例和watcher实例都可以访问到它)。这样就完成了依赖收集。
随后,如果该属性的值发生了变化,就会触发它的set方法。而在set方法中,Vue通过dep.notify()将这个变化通知给dep。这个方法的行为就是遍历当前dep的依赖者列表subs,依次调用subs中每个watcher的update方法。最终watcher的update方法就会执行开发者传入的回调函数。就这样,属性值的变化最终导致回调函数的自动调用。如果这个回调函数是更新视图用的,那么数据的变化将引起视图的自动更新。
如果当前的watcher是渲染类watcher(也就是更新视图用的watcher),那么它的回调将是虚拟DOM提供的patch方法,只要调用这个patch方法,就可以对虚拟DOM进行修补,根据修补结果进行视图更新。这样数据的变化就导致了视图的自动更新,这是响应式系统在Vue中最大的价值所在。patch的实现会在虚拟DOM部分进行探讨。
提示:了解过Vue的同学应该知道,Vue无法监听到对象属性的添加和删除。如我们在代码中手动为message对象添加了一个属性message.date = “”,或者手动删除了一个属性delete message.title,Vue无法知道我们进行了这样的操作。原因是为对象添加和删除属性既不会触发对象的get/set,也不会触发该属性的get/set(因为实际上我们没有操作该属性的“值”,只有对属性值的操作才会触发get/set),因此这些操作导致的数据变化无法被Vue监测到。官网建议对于需要用到的属性,即使没有初始值,也需要提前传入data并赋予一个空值,以进行响应式转化。而要删除属性时,可以使用Vue提供的响应式方法$delete,如this.$delete(message, “title”)。
2. 数组监测篇
数组监测系统的原理
在Vue的响应式里,普通对象和数组(实际上也是对象)是区分对待的。对于普通对象,上一篇已经详细介绍,这里不再赘述;对于数组来说,Vue会通过设置拦截器来拦截数组原型对象上的七个方法push、pop、shift、unshift、splice、sort、reverse。如果开发者用到了这七个方法,实际上调用的是Vue的同名方法,而不是真正的数组原型方法。不过,Vue不会改变这些原型方法的默认行为,它只是向方法中添加了触发响应的逻辑。同时Vue会调用observeArray方法,来遍历数组成员,将其中对象成员的属性转化为响应式的。注意,这里只是将对象成员的属性转化为响应式,不包括该对象成员自身,举个例子:
<template>
<div>
<div>{{ arr[0] }}</div>
<div>{{ arr[0].name }</div>
<div>
</template>
<script>
data(){
return {
arr: [{name: "夕山雨"}],
}
},
mounted(){
this.arr[0].name = "Carter";
this.arr[0] = {name: "MrGoblet"};
}
</script>
上面的例子中,我们在该实例的mounted生命周期钩子函数中分别修改了arr[0].name和arr[0]的值。然后我们会看到,前一个语句会触发视图的更新,但是后一个语句并不会导致视图更新。同样的,如果数组成员是普通成员,通过index修改它的值,也不会触发视图更新。这就是说,只要是通过index来修改数组元素的值,如arr[0] = xx,都无法触发视图更新。出现这种情况的原因,是因为Vue区分对待数组和普通对象,并且在对待数组时,没有通过Object.defineProperty来转化数组元素(实际上经过测试,这种转化是可行的,个人补充中会谈到)。下面我们就分两个阶段来看数组的监控过程,分别是:原型方法拦截和遍历数组元素进行响应式转化。
一、原型方法遍历
我们知道,数组原型上的push、pop、shift、unshift、splice、sort、reverse这七个方法都会导致数组的元素变化,如果我们可以在开发者调用这些方法时拦截它,并在里面添加自己的逻辑(这里指的就是触发响应式系统),那么不就可以实现对数组元素的监听了吗?这就是Vue监测数组的基本原理了。
举个例子:
let originalPush = Array.prototype.push; //先保存默认的push方法
Array.prototype.push = function(...args){
console.log("你现在调用的是被我拦截过的push方法");
//执行Array原型上原本的push方法,这样就不会改变push本身的行为
originalPush.apply(this, args);
}
let arr = [];
arr.push("name");
现在Array.prototype上的push方法已经被替换为我们自己定义的push方法了,之后调用arr.push时,实际上调用的都是我们自己的push方法,因此语句arr.push(“name”)就会在控制台输出提示信息。我们自己定义的这个push方法就被称为原始push方法的拦截器。Vue就是在这里注入了响应式系统的逻辑,实现对数组的监听。
现在我们就来看一下源码是如何对这七个方法进行拦截的。
const arrayProto = Array.prototype //将Array的原型保存在局部变量中
//原型式继承,得到一个以Array.prototype为原型的空对象
export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [ //需要拦截的方法,原型方法中只有这七个会修改数组
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
//遍历上述数组,分别为每个方法设置拦截器
methodsToPatch.forEach(function (method) {
//保存原方法。拦截器不能改变原型方法的基本功能,因此后面要调用这个原始方法
const original = arrayProto[method]
//为Array.prototype添加自定义拦截器,mutator就是用于拦截原始方法的拦截器
def(arrayMethods, method, function mutator (...args) {
//调用原始方法,保证该方法的基本功能不变
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
//push、unshift和splice可能向数组添加新元素,
//需要将这些新添加的元素转化为响应式
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
//将新添加的元素纳入响应式系统
if (inserted) ob.observeArray(inserted)
//通知dep数据发生了改变
ob.dep.notify()
return result
})
})
经过上述步骤,我们得到了如下一个对象arrayMethods:
arrayMethods有7个实例方法,也就是我们定义的拦截器,它的原型对象正是数组的原型对象。假如我们把arrayMethods替换为一个数组的原型对象,那么根据原型链的查找规则,arrayMethods中的七个实例方法将覆盖它的原型(也就是数组真正的原型)上的七个同名方法。而除了这七个方法以外的其他方法都可以借助原型链正常访问,也就是说我们成功的拦截了这七个方法,并保证了其他方法不受影响。
Vue中并没有直接把上述arrayMethods替换到Array.prototype(即Array.prototype = arrayMethods,可能是为了避免对原生类型Array进行操作,因为这样会影响到所有的数组实例),而是直接替换当前数组实例的__proto__(它默认指向构造函数Array的原型对象,但是修改__proto__的指向只会影响到当前的数组实例),如果当前浏览器不支持__proto__,Vue会将这七个拦截器直接添加到数组实例上,作为实例方法存在。代码如下:
if (Array.isArray(value)) {
if (hasProto) { //浏览器支持__proto__
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value) //观测数组
}
//数组实例的__proto__存在时,将它指向我们上面的对象
function protoAugment (target, src: Object) {
/* eslint-disable no-proto */
target.__proto__ = src
/* eslint-enable no-proto */
}
//浏览器不支持__proto__时,直接把拦截器复制到当前数组中,
//同样可以覆盖原型上的同名方法
function copyAugment (target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
现在每当我们调用数组的这七个原型方法时,调用的实际上就是Vue的拦截器。那么Vue是如何在调用这七个方法时触发响应式的呢?非常简单,就是拦截器中的一行代码:
ob.dep.notify()
因为对当前数组元素的依赖都已经被收集到了该数组对应的dep中,那么我们只需要触发dep的notify方法,就可以通知订阅者执行回调或者更新视图。无论我们通过这七个方法的哪一个修改了数组,Vue都知道数组已经被更新,需要触发响应式系统。
现在当使用这七个原型方法修改数组时已经能触发响应式了,但是数组的某个元素如果是个对象,而视图中绑定的是这个对象的属性呢?比如:
//template
<div>{{ arr[0].name }}</div>
//mounted
this.arr[0].name = "123";
显然当我们修改这些属性时,不会触发数组的原型方法(因为我们不是在修改数组元素本身,而是它的某个属性)。同时,因为在对data进行递归转化时,一旦遇到数组就会执行专门针对数组的处理方法,所以数组的对象成员此时并不是响应式的。但是我们当然希望上述语句能够触发视图更新,所以Vue又专门写了observeArray这个函数,遍历数组元素,将它的对象成员的属性也转化为响应式的。observeArray的实现如下:
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
非常简单,遍历数组成员, 使用observe函数(对象篇提到过)将每个成员转化为响应式(注意,这里转化为响应式指的是成员的属性,而不是成员自身,如同我们将data对象转化为响应式时,并没有把data自身纳入响应式系统,而是它的所有属性)。这样,this.arr[0].name = xx这样的语句就会触发视图更新了。
现在我们来看看源码中是如何分别对待对象和数组的:
export class Observer {
value: any;
dep: Dep;
vmCount: number;
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) { //判断当前value是不是数组
if (hasProto) { //对数组来说,先拦截原型上的七个方法,
//如果支持__proto__,就替换原型对象,
//否则直接覆盖到数组上,作为实例方法
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
//遍历并观测数组的对象成员,将对象成员的属性转化为响应式
this.observeArray(value)
} else {
//这里是对象的处理逻辑
this.walk(value)
}
}
...
}
现在只要我们是通过调用数组的那七个原型方法修改了数组,视图就会自动更新,同时数组的对象成员的属性也都是响应式的,可以触发视图更新。但是arr[0] = xx这样的操作却不是响应式的(因为observeArray只将这些对象成员的属性转化为响应式,但不包括该成员本身,这里指的数组成员,是声明在data里的,而不是后来通过arr[1000]添加进去的)。
总结
阅读Vue的响应式系统相关的代码,最大的收获就是明白了它的工作原理,以及它为什么不能响应某些数据变化。这样,以后使用Vue时,就可以不光知其然,还可以知其所以然。
下一篇文章将讨论Vue的编译器。它的用途就是将template模板编译为渲染函数,而得到渲染函数的目的,就是通过嵌套调用生成虚拟DOM树。由于编译器的实现涉及的细节特别多,下文可能只会介绍它的实现原理,而不会完全展开分析(否则可能比本文还要长…)。所以敬请期待!
文章链接
Vue源码笔记之项目架构
Vue源码笔记之初始化
Vue源码笔记之响应式系统
Vue源码笔记之编译器
Vue源码笔记之虚拟DOM