Vue源码解析之数据响应系统的使用

接下来重点来看Vue的数据响应系统。我看很多文章在讲数据响应的时候先用一个简单的例子介绍了数据双向绑定的思路,然后再看源码。这里也借鉴了这种方式,感觉这样的确更有利于理解。

数据双向绑定的思路

1. 对象

先来看元素是对象的情况。假设我们有一个对象和一个监测方法:

const data = {
 a: 1
};
/**
* exp[String, Function]: 被观测的字段
* fn[Function]: 被观测对象改变后执行的方法
*/
function watch (exp, fn) {

}

我们可以调用watch方法,当a的值改变后打印一句话:

watch('a', () => {
 console.log('a 改变了')
})

要实现这个功能,我们首先要能知道属性a被修改了。这时候就需要使用Object.defineProperty函数把属性a变成访问器属性:

Object.defineProperty(data, 'a', {
 set () {
  console.log('设置了 a')
 },
 get () {
  console.log('读取了 a')
 }
})

这样当我们修改a的值:data.a = 2时,就会打印出设置了 a, 当我们获取a的值时:data.a, 就会打印出读取了 a.

在属性的读取和设置中我们已经能够进行拦截并做一些操作了。可是在属性修改时我们并不想总打印设置了 a这句话,而是有一个监听方法watch,不同的属性有不同的操作,对同一个属性也可能监听多次。

这就需要一个容器,把对同一个属性的监听依赖收集起来,在属性改变时再取出依次触发。既然是在属性改变时触发依赖,我们就可以放在setter里面,在getter中收集依赖。这里我们先不考虑依赖被重复收集等一些情况

const dep = [];
Object.defineProperty(data, 'a', {
 set () {
  dep.forEach(fn => fn());
 },
 get () {
  dep.push(fn);
 }
})

我们定义了容器dep, 在读取a属性时触发get函数把依赖存入dep中;在设置a属性时触发set函数把容器内的依赖挨个执行。

那fn从何而来呢?再看一些我们的监测函数watch

watch('a', () => {
 console.log('a 改变了')
})

该函数有两个参数,第一个是被观测的字段,第二个是被观测字段的值改变后需要触发的操作。其实第二个参数就是我们要收集的依赖fn。

const data = {
 a: 1
};

const dep = [];
Object.defineProperty(data, 'a', {
 set () {
  dep.forEach(fn => fn());
 },
 get () {
  // Target就是该变量的依赖函数
  dep.push(Target);
 }
})

let Target = null;
function watch (exp, fn) {
 // 将fn赋值给Target
 Target = fn;
 // 读取属性,触发get函数,收集依赖
 data[exp];
}

现在仅能够观测a一个属性,为了能够观测对象data上面的所有属性,我们将定义访问器属性的那段代码封装一下:

function walk () {
 for (let key in data) {
  const dep = [];
  const val = data[key];
  Object.defineProperty(data, key, {
   set (newVal) {
    if (newVal === val) return;
    val = newVal;
    dep.forEach(fn => fn());
   },
   get () {
    // Target就是该变量的依赖函数
    dep.push(Target);
    return val;
   }
  })
 }
}

用for循环遍历data上的所有属性,对每一个属性都用Object.defineProperty改为访问器属性。

现在监测data里面基本类型值的属性没问题了,如果data的属性值又是一个对象呢:

data: {
 a: {
  aa: 1
 }
}

我们再来改一下我们的walk函数,当val还是一个对象时,递归调用walk:

function walk (data) {
 for (let key in data) {
  const dep = [];
  const val = data[key];
  // 如果val是对象,递归调用walk,将其属性转为访问器属性
  if (Object.prototype.toString.call(val) === '[object Object]') {
   walk(val);
  }

  Object.defineProperty(data, key, {
   set (newVal) {
    if (newVal === val) return;
    val = newVal;
    dep.forEach(fn => fn());
   },
   get () {
    // Target就是该变量的依赖函数
    dep.push(Target);
    return val;
   }
  })
 }
}

添加了一段判断逻辑,如果某个属性的属性值仍然是对象,就递归调用walk函数。

虽然经过上面的改造,data.a.aa是访问器属性了,但下面但代码仍然不能运行:

watch('a.aa', () => {
 console.log('修改了 a.b')
})

这是为什么呢?再看我们的watch函数:

function watch (exp, fn) {
 // 将fn赋值给Target
 Target = fn;
 // 读取属性,触发get函数,收集依赖
 data[exp];
}

在读取属性的时候是data[exp],放到这里就是data[a.aa],这自然是不对的。正确的读取方式应该是data[a][aa]. 我们需要对watch函数做改造:

function watch (exp, fn) {
 // 将fn赋值给Target
 Target = fn;

 let obj = data;
 if (/\./.test(exp)) {
  const path = exp.split('.');
  path.forEach(p => obj = obj[p])

  return;
 }

 data[exp];
}

这里增加了一个判断逻辑,当监测的字段中包含.时,就执行if语句块的内容。首先使用split函数将字符串转换为数组:a.aa => [a, aa]. 然后使用循环读取到嵌套的属性值,并且return结束。

Vue中提供了$watch实例方法来观测表达式,对复杂的表达式用函数取代:

// 函数
vm.$watch(
 function () {
 // 表达式 `this.a + this.b` 每次得出一个不同的结果时
 // 处理函数都会被调用。
 // 这就像监听一个未被定义的计算属性
 return this.a + this.b
 },
 function (newVal, oldVal) {
 // 做点什么
 }
)

当第一个函数执行时,就会触发this.a、this.b的get拦截器,从而收集依赖。

我们的watch函数第一个参数是函数时watch函数要做些什么改变呢?要想能够收集依赖,就得读取属性触发get函数。当第一个参数是函数时怎么读取属性呢?函数内是有读取属性的,所以只要执行一下函数就行了。

function watch (exp, fn) {
 // 将fn赋值给Target
 Target = fn;

 // 如果 exp 是函数,直接执行该函数
 if (typeof exp === 'function') {
  exp()
  return
 }

 let obj = data;
 if (/\./.test(exp)) {
  const path = exp.split('.');
  path.forEach(p => obj = obj[p])

  return;
 }

 data[exp];
}

对象的处理暂且就到这里,具体的我们在源码中去看。

2. 数组

数组有几个变异方法会改变数组本身:push pop shift unshift splice sort reverse, 那怎么才能知道何时调用了这些变异方法呢?我们可以在保证原来方法功能不变的前提下对方法进行扩展。可是如何扩展呢?

数组实例的方法都来自于数组构造函数的原型, 数组实例的__proto__属性指向数组构造函数的原型,即:arr.__proto__ === Array.prototype, 我们可以定义一个对象,它的原型指向Array.prototype,然后在这个对象中重新定义与变异方法重名的函数,然后让实例的__proto__指向该对象,这样调用变异方法的时候,就会先调用重定义的方法。
const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];

// 创建以Array.prototype为原型的对象
const arrayMethods = Object.create(Array.prototype);
// 缓存Array.prototype
const originMethods = Array.prototype;

mutationMethods.forEach(method => {
 arrayMethods[method] = function (...args) {
  // 调用原来的方法获取结果
  const result = originMethods[method].apply(this, args);
  console.log(`重定义了${method}方法`)
  return result;
 }
})

我们来测试一下:

const arr = [];
arr.__proto__ = arrayMethods;
arr.push(1);

可以看到在控制台打印出了重定义了push方法这句话。

先大概有个印象,接下来我们来看源码吧。

实例对象代理访问data

在initState方法中,有这样一段代码:

const opts = vm.$options
...
if (opts.data) {
 initData(vm)
} else {
 observe(vm._data = {}, true /* asRootData */)
}

opts就是vm.$options,如果opts.data存在,就执行initData方法,否则执行observe方法,并给vm._data赋值空对象。我们就从initData方法开始,开启探索数据响应系统之路。

initData方法定义在core/instance/state.js文件中:

function initData (vm: Component) {
 let data = vm.$options.data
 data = vm._data = typeof data === 'function'
 ? getData(data, vm)
 : data || {}
 if (!isPlainObject(data)) {
 data = {}
 process.env.NODE_ENV !== 'production' && warn(
  'data functions should return an object:\n' +
  'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
  vm
 )
 }
 // proxy data on instance
 const keys = Object.keys(data)
 const props = vm.$options.props
 const methods = vm.$options.methods
 let i = keys.length
 while (i--) {
 const key = keys[i]
 if (process.env.NODE_ENV !== 'production') {
  if (methods && hasOwn(methods, key)) {
  warn(
   `Method "${key}" has already been defined as a data property.`,
   vm
  )
  }
 }
 if (props && hasOwn(props, key)) {
  process.env.NODE_ENV !== 'production' && warn(
  `The data property "${key}" is already declared as a prop. ` +
  `Use prop default value instead.`,
  vm
  )
 } else if (!isReserved(key)) {
  proxy(vm, `_data`, key)
 }
 }
 // observe data
 observe(data, true /* asRootData */)
}

内容有点多我们从上往下依次来看,首先是这样一段代码:

let data = vm.$options.data
 data = vm._data = typeof data === 'function'
 ? getData(data, vm)
 : data || {}

我们知道在经过选项合并后,data已经变成一个函数了。那为何这里还有data是否是一个函数的判断呢?这是因为beforeCreate生命周期是在mergeOptions函数之后initState函数之前调用的,mergeOptions函数就是处理选项合并的。如果用户在beforeCreate中修改了vm.$options.data的值呢?那它就可能不是一个函数了,毕竟用户的操作是不可控的,所以这里还是有必要判断一下的。

正常情况下也就是data是一个函数,就会调用getData函数,并将data和Vue实例vm作为参数传过去。该函数也定义在当前页面中:

export function getData (data: Function, vm: Component): any {
 // #7573 disable dep collection when invoking data getters
 pushTarget()
 try {
 return data.call(vm, vm)
 } catch (e) {
 handleError(e, vm, `data()`)
 return {}
 } finally {
 popTarget()
 }
}

其实该函数就是通过调用data获取到数据对象并返回:data.call(vm, vm). 用try...catch包裹是为了捕获可能出现的错误,如果出错的话调用handleError函数并返回一个空对象。

函数的开头和结尾分别调用了pushTarget和popTarget, 这是为了防止使用 props 数据初始化 data 数据时收集冗余的依赖。

再回到initData函数中,所以现在data和vm._data就是最终的数据对象了。

接下来是一个if判断:

if (!isPlainObject(data)) {
 data = {}
 process.env.NODE_ENV !== 'production' && warn(
  'data functions should return an object:\n' +
  'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
  vm
 )
 }

isPlainObject是判断是否是一个纯对象的,如果data不是一个对象,在非生产环境下给出警告信息。

继续往下看:

// proxy data on instance
// 获取data对象的键
const keys = Object.keys(data)
// 获取props,是个对象
const props = vm.$options.props
// 获取methods,是个对象
const methods = vm.$options.methods
let i = keys.length
// 循环遍历data的键
while (i--) {
 const key = keys[i]
 // 如果methods存在,并且methods中存在与data对象相同的键,发出警告。data优先
 if (process.env.NODE_ENV !== 'production') {
  if (methods && hasOwn(methods, key)) {
  warn(
   `Method "${key}" has already been defined as a data property.`,
   vm
  )
  }
 }
 // 如果props存在,并且props中存在与data对象相同的键,发出警告。 props优先
 if (props && hasOwn(props, key)) {
  process.env.NODE_ENV !== 'production' && warn(
  `The data property "${key}" is already declared as a prop. ` +
  `Use prop default value instead.`,
  vm
  )
 } else if (!isReserved(key)) { // isReserved 函数用来检测一个字符串是否以 $ 或者 _ 开头,主要用来判断一个字段的键名是否是保留的
  proxy(vm, `_data`, key)
 }
}
// observe data
observe(data, true /* asRootData */)

while中的两个if条件判断了props和methods中是否有和data对象相同的键,因为这三者中的属性都可以通过实例对象代理访问,如果相同就会出现冲突了。

const vm = new Vue({
 props: { a: { default: 2 } }
 data: { a: 1 },
 methods: {
  a () {
   console.log(3)
  }
 }
})

当调用vm.a的时候,就会产生覆盖现象。为了防止这种情况出现,就在这里做了判断。

再看else if中的内容,当!isReserved(key)成立时,执行proxy(vm,_data, key)。 isReserved函数的作用是判断一个字符串是否以 $ 或者 _ 开头, 因为Vue内部的变量是以$或_开头,防止冲突。如果 key 不是以 $或 _ 开头,那么将执行 proxy 函数

const sharedPropertyDefinition = {
 enumerable: true,
 configurable: true,
 get: noop,
 set: noop
}

export function proxy (target: Object, sourceKey: string, key: string) {
 sharedPropertyDefinition.get = function proxyGetter () {
 return this[sourceKey][key]
 }
 sharedPropertyDefinition.set = function proxySetter (val) {
 this[sourceKey][key] = val
 }
 Object.defineProperty(target, key, sharedPropertyDefinition)
}

proxy函数通过Object.defineProperty在实例对象vm上定义了与data数据字段相同的访问器属性,代理的值是vm._data上对应的属性值。当访问this.a时,实际访问的是this._data.a的值。

最后一句代码是

// observe data
observe(data, true /* asRootData */)

调用observe将data数据对象转换成响应式的。

observe工厂函数

observe函数定义在core/observer/index.js文件中, 我们找到该函数的定义,一点点来看

if (!isObject(value) || value instanceof VNode) {
 return
}

首先判断如果数据不是一个对象或者是一个VNode实例,直接返回。

let ob: Observer | void

接着定义了ob变量,它是一个Observer实例,可以看到observe函数的最后返回了ob.

下面是一个if...else分支:

if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
 ob = value.__ob__
 } else if (
 shouldObserve &&
 !isServerRendering() &&
 (Array.isArray(value) || isPlainObject(value)) &&
 Object.isExtensible(value) &&
 !value._isVue
 ) {
 ob = new Observer(value)
 }

首先是if分支,用hasOwn判断了数据对象是否包含__ob__属性,并且判断属性值是否是Observer的实例。如果条件为真的话,就把value.__ob__的值赋给ob。

为什么会有这个判断呢?每个数据对象被观测后都会在该对象上定义一个__ob__属性, 所以这个判断是为了防止重复观测一个对象。

接着是else if分支,这个条件判断有点多,我们一个个来看。

shouldObserve必须为true

该变量也定义在 core/observer/index.js 文件内,

/**
 * In some cases we may want to disable observation inside a component's
 * update computation.
 */
export let shouldObserve: boolean = true

export function toggleObserving (value: boolean) {
 shouldObserve = value
}

这段代码定义了shouldObserve变量,初始化为true。接着定义了toggleObserving函数,该函数接收一个参数,这个参数用来更新shouldObserve的值。shouldObserve为true时可以进行观测,为false时将不会进行观测。

!isServerRendering()必须为true

isServerRendering函数用来判断是否是服务端渲染,只有当不是服务端渲染的时候才会进行观测
(Array.isArray(value) || isPlainObject(value)) 必须为真

只有当数据对象是数组或者纯对象时才进行观测

Object.isExtensible(value)必须为true

被观测的数据对象必须是可扩展的, 普通对象默认就是可扩展当。以下三个方法可以将对象变得不可扩展:

Object.preventExtensions()、 Object.freeze()、Object.seal()

!value._isVue必须为真

Vue实例含有_isVue属性,这个判断是为了防止Vue实例被观测

以上条件满足之后,就会执行代码ob = new Observer(value),创建一个Observer实例

Observer 构造函数

Observer也定义在core/observer/index.js文件中,它是一个构造函数,用来将数据对象转换成响应式的。

export class Observer {
 value: any;
 dep: Dep;
 vmCount: number; // number of vms that have this object as root $data

 constructor (value: any) {
 this.value = value
 this.dep = new Dep()
 this.vmCount = 0
 def(value, '__ob__', this)
 if (Array.isArray(value)) {
  if (hasProto) {
  protoAugment(value, arrayMethods)
  } else {
  copyAugment(value, arrayMethods, arrayKeys)
  }
  this.observeArray(value)
 } else {
  this.walk(value)
 }
 }

 /**
 * Walk through all properties and convert them into
 * getter/setters. This method should only be called when
 * value type is Object.
 */
 walk (obj: Object) {
 const keys = Object.keys(obj)
 for (let i = 0; i < keys.length; i++) {
  defineReactive(obj, keys[i])
 }
 }

 /**
 * Observe a list of Array items.
 */
 observeArray (items: Array<any>) {
 for (let i = 0, l = items.length; i < l; i++) {
  observe(items[i])
 }
 }
}

以上是Observer的全部代码,现在我们从constructor开始,来看一下实例化Observer都做了什么。

__ob__ 属性

constructor开始先初始化了几个实例属性

this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)

value就是实例化Observer时传递的参数,现在将它赋给了实例对象的value属性。dep属性指向实例化的Dep实例对象,它就是用来收集依赖的容器。vmCount属性被初始化为0.

接着使用def函数为数据对象添加了__ob__属性,它的值就是当前Observer实例对象。def定义在core/util/lang.js文件中,是对Object.defineProperty的封装。

export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
 Object.defineProperty(obj, key, {
 value: val,
 enumerable: !!enumerable,
 writable: true,
 configurable: true
 })
}

用def来定义__ob__属性是要把它定义成不可枚举的,这样遍历对象就不会遍历到它了。

假设我们的数据对象是

data = {
 a: 1
}

添加__ob__属性后变成

data = {
 a: 1,
 __ob__: {
  value: data, // data 数据对象本身
  dep: new Dep(), // Dep实例
  vmCount: 0
 }
}

处理纯对象

接下来是一个if...else判断, 来区分数组和对象,因为对数组和对象的处理不同。

if (Array.isArray(value)) {
 if (hasProto) {
  protoAugment(value, arrayMethods)
 } else {
  copyAugment(value, arrayMethods, arrayKeys)
 }
 this.observeArray(value)
} else {
  this.walk(value)
}

我们先来看是对象的情况,也就是执行this.walk(value)

walk函数就定义在constructor的下面

walk (obj: Object) {
 const keys = Object.keys(obj)
 for (let i = 0; i < keys.length; i++) {
  defineReactive(obj, keys[i])
 }
}

该方法就是用for循环遍历了对象的属性,并对每个属性都调用了defineReactive方法。

defineReactive 函数

defineReactive也定义在core/observer/index.js文件中,找到它的定义:

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
 obj: Object,
 key: string,
 val: any,
 customSetter?: ?Function,
 shallow?: boolean
) {
 const dep = new Dep()

 const property = Object.getOwnPropertyDescriptor(obj, key)
 if (property && property.configurable === false) {
 return
 }

 // cater for pre-defined getter/setters
 const getter = property && property.get
 const setter = property && property.set
 if ((!getter || setter) && arguments.length === 2) {
 val = obj[key]
 }

 let childOb = !shallow && observe(val)
 Object.defineProperty(obj, key, {
 enumerable: true,
 configurable: true,
 get: function reactiveGetter () {
  ...
 },
 set: function reactiveSetter (newVal) {
  ...
 }
 })
}

因代码太长,省略了部分内容,之后我们再具体看。该函数的主要作用就是将数据对象的数据属性转换为访问器属性

函数体内首先定义了dep常量,它的值是Dep实例,用来收集对应字段的依赖。

接下来是这样一段代码:

const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
 return
}

先通过Object.getOwnPropertyDescriptor获取字段的属性描述对象,再判断该字段是否是可配置的,如果不可配置,直接返回。因为不可配置的属性是不能通过Object.defineProperty改变其属性定义的。

再往下接着看:

// cater for pre-defined getter/setters
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
 val = obj[key]
}

先保存属性描述对象里面的get和set方法。如果这个属性已经是访问器属性了,那它就存在get或set方法了,下面的操作会使用Object.defineProperty重写get和set方法,为了不影响原来的读写操作,就先缓存setter/getter。

接下来是一个if判断,如果满足条件的话,就读取该属性的值。

再下面是这样一句代码:

let childOb = !shallow && observe(val)

因为属性值val也可能是一个对象,所以调用observe继续观测。但前面有一个条件,只有当shallow为假时才会进行深度观测。shallow是defineReactive的第五个参数,我们在walk中调用该函数时并没有传递该参数,所以这里它的值是undefined。!shallow的是true,所以这里会进行深度观测。

不进行深度观测的我们在initRender函数中见过:

defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, () => {
 !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
}, true)
defineReactive(vm, '$listeners', options._parentListeners || emptyObject, () => {
 !isUpdatingChildComponent && warn(`$listeners is readonly.`, vm)
}, true)

在Vue实例上定义属性$attrs和$listeners时就是非深度观测。

在get中收集依赖

接下来就是使用Object.defineProperty设置访问器属性,先看一下get函数:

get: function reactiveGetter () {
 const value = getter ? getter.call(obj) : val
 if (Dep.target) {
  dep.depend()
  if (childOb) {
   childOb.dep.depend()
   if (Array.isArray(value)) {
   dependArray(value)
   }
  }
 }
 return value
},

get函数首先是要返回属性值,还有就是在这里收集依赖。

第一行代码就是获取属性值。先判断了getter是否存在,getter就是属性原有的get函数,如果存在的话调用该函数获取属性值,否则的话就用val作为属性值。

接下来是收集依赖的代码:

if (Dep.target) {
 dep.depend()
 if (childOb) {
  childOb.dep.depend()
  if (Array.isArray(value)) {
   dependArray(value)
  }
 }
}

首先判断Dep.target是否存在,Dep.target就是要收集的依赖,如果存在的话,执行if语句块内的代码。

dep.depend()dep对象的depend方法执行就是收集依赖。

然后判断了childOb是否存在,存在的话执行childOb.dep.depend(). 那么childOb的值是谁呢?

如果我们有个数据对象:

data = {
 a: {
  b: 1
 }
}

经过observe观测之后,添加__ob__属性,变成如下模样:

data = {
 a: {
  b: 1,
  __ob__: { value, dep, vmCount }
 },
 __ob__: { value, dep, vmCount }
}

对于属性a来说,childOb === data.a.__ob__, 所以childOb.dep.depend()就是data.a.__ob__.dep.depend()

在if语句里面又一个if判断:

if (Array.isArray(value)) {
 dependArray(value)
}

如果属性值是数组,调用dependArray函数逐个触发数组元素的依赖收集

在set函数中触发依赖

set: function reactiveSetter (newVal) {
  const value = getter ? getter.call(obj) : val
  /* eslint-disable no-self-compare */
  if (newVal === value || (newVal !== newVal && value !== value)) {
  return
  }
  /* eslint-enable no-self-compare */
  if (process.env.NODE_ENV !== 'production' && customSetter) {
  customSetter()
  }
  // #7981: for accessor properties without setter
  if (getter && !setter) return
  if (setter) {
  setter.call(obj, newVal)
  } else {
  val = newVal
  }
  childOb = !shallow && observe(newVal)
  dep.notify()
}

set函数主要是设置属性值和触发依赖。

const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
 return
}

首先也是获取原来的属性值。为什么有这一步呢?因为要跟新值做比较,如果新旧值相等,就可以直接返回不用接下来的操作了。在if条件中,newVal === value这个我们都明白,那后面这个(newVal !== newVal && value !== value)条件是什么意思呢?

这是因为一个特殊的值NaN

NaN === NaN // false

如果newVal !== newVal,说明新值是NaN;如果value !== value,那么旧值也是NaN。那么新旧值也是相等的,也不需要处理。

/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
 customSetter()
}

在非生产环境下,如果customSetter函数存在,将执行该函数。customSetter是defineReactive的第四个参数,上面我们看initRender的时候有传过这个参数:

defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, () => {
  !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
}, true)

第四个参数是一个箭头函数,当修改vm.$attrs时,会打印警告信息$attrs是只读的。所以customSetter的作用就是打印辅助信息。

if (getter && !setter) return
if (setter) {
 setter.call(obj, newVal)
} else {
 val = newVal
}

如果存在getter不存在setter的话,直接返回。getter和setter就是属性自身的get和set函数。

下面就是设置属性值。如果setter存在的话,调用setter函数,保证原来的属性设置操作不变。否则用新值替换旧值。
最后是这两句代码:

childOb = !shallow && observe(newVal)
dep.notify()

如果新值也是一个数组或纯对象的话,这个新值是未观测的。所以在需要深度观测的情况下,要调用observe对新值进行观测。最后调用dep.notify()触发依赖。

处理数组

看完了纯对象的处理,再来看一下数组是怎么转换为响应式的。数组有些方法会改变数组本身,我们称之为变异方法,这些方法有:push pop shift unshift reverse sort splice,如何在调用这些方法的时候触发依赖呢?看一下Vue的处理。

if (hasProto) {
 protoAugment(value, arrayMethods)
} else {
 copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)

首先是一个if...else 判断,hasProto定义在core/util/env.js文件中。

// can we use __proto__?
export const hasProto = '__proto__' in {}

判断当前环境是否可以使用对象的 __proto__ 属性, 该属性在IE11及更高版本中才能使用。

如果条件为true的话,调用protoAugment方法, 传递了两个参数,一个是数组实例本身,一个是arrayMethods(代理原型)。

/**
 * Augment a target Object or Array by intercepting
 * the prototype chain using __proto__
 */
function protoAugment (target, src: Object) {
 /* eslint-disable no-proto */
 target.__proto__ = src
 /* eslint-enable no-proto */
}

该方法的作用就是将数组实例的原型指向代理原型。这样当数组实例调用变异方法的时候就能先走代理原型重定义的方法。我们看一下arrayMethods的实现,它定义在core/observer/array.js文件中:

import { def } from '../util/index'

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
 'push',
 'pop',
 'shift',
 'unshift',
 'splice',
 'sort',
 'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
 // cache original method
 const original = arrayProto[method]
 def(arrayMethods, method, function mutator (...args) {
 const result = original.apply(this, args)
 const ob = this.__ob__
 let inserted
 switch (method) {
  case 'push':
  case 'unshift':
  inserted = args
  break
  case 'splice':
  inserted = args.slice(2)
  break
 }
 if (inserted) ob.observeArray(inserted)
 // notify change
 ob.dep.notify()
 return result
 })
})

这是这个文件的全部内容,该文件只做了一件事,就是导出arrayMethods对象。

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

arrayMethods是以数组的原型为原型创建的对象。

const methodsToPatch = [
 'push',
 'pop',
 'shift',
 'unshift',
 'splice',
 'sort',
 'reverse'
]

这是定义了数组的变异方法。

接着for循环遍历变异方法,用def在代理原型上定义了与变异方法同名的方法。

methodsToPatch.forEach(function (method) {
 // cache original method
 const original = arrayProto[method]
 def(arrayMethods, method, function mutator (...args) {
 const result = original.apply(this, args)
 const ob = this.__ob__
 let inserted
 switch (method) {
  case 'push':
  case 'unshift':
  inserted = args
  break
  case 'splice':
  inserted = args.slice(2)
  break
 }
 if (inserted) ob.observeArray(inserted)
 // notify change
 ob.dep.notify()
 return result
 })
})

首先缓存了数组原本的变异方法

const original = arrayProto[method]

然后用def在arrayMethods对象上定义了与变异方法同名的函数。函数内首先调用了original原来的函数获取结果

const result = original.apply(this, args)

并在函数末尾返回result。保证了拦截函数的功能与原来方法的功能是一致的。

const ob = this.__ob__
...
ob.dep.notify()

这两句代码就是触发依赖。当变异方法被调用时,数组本身就被改变了,所以要触发依赖。

再看其余的代码:

let inserted
switch (method) {
 case 'push':
 case 'unshift':
  inserted = args
  break
 case 'splice':
  inserted = args.slice(2)
  break
}
if (inserted) ob.observeArray(inserted)

这段代码的作用就是收集新添加的元素,将其变成响应式数据。

push和unshift方法的参数就是要添加的元素,所以inserted = args。splice方法从第三个参数到最后一个参数都是要添加的新元素,所以inserted = args.slice(2)。最后,如果存在新添加的元素,调用observeArray函数对其进行观测。

以上是支持__proto__属性的时候,那不支持的时候呢?调用copyAugment方法,并传递了三个参数。前两个跟protoAugment方法的参数一样,一个是数组实例本身,一个是arrayMethods代理原型,还有一个是arrayKeys,

const arrayKeys = Object.getOwnPropertyNames(arrayMethods)

它的值就是定义在arrayMethods对象上的所有的键,也就是所要拦截的变异方法的名称。函数定义如下:

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])
 }
}

这个方法的作用就是在数组实例上定义与变异方法同名的函数,从而实现拦截。

if else代码之后,调用了observeArray方法this.observeArray(value), 并将数组实例作为参数。

observeArray方法的定义如下:

/**
* Observe a list of Array items.
*/
observeArray (items: Array<any>) {
 for (let i = 0, l = items.length; i < l; i++) {
  observe(items[i])
 }
}

循环遍历数组实例,并对数组的每一项再进行观测。这是因为如果数组元素是数组或纯对象的话不进行这一步数组元素就不是响应式的,这是为了实现深度观测。比如:

const vm = new Vue({
 data: {
  a: [[1,2]]
 }
})
vm.a.push(1); // 能够触发响应
vm.a[1].push(1); // 不能触发响应

所以需要递归观测数组元素。

Vue.set($set) 和 Vue.delete($delete) 的实现

我们知道,为对象或数组直接添加或删除元素Vue是拦截不到的。我们需要使用Vue.set、Vue.delete去解决,Vue还在实例对象上定义了$set $delete方便我们使用。其实不管是实例方法还是全局方法它们的指向都是一样的。我们来看以下它们的定义。

$set $delete定义在core/instance/state.js文件中的stateMixin方法中

export function stateMixin (Vue: Class<Component>) {
 ...

 Vue.prototype.$set = set
 Vue.prototype.$delete = del

 ...
}

Vue.set和Vue.delete定义在core/global-api/index.js文件中的initGlobalAPI函数中:

export function initGlobalAPI (Vue: GlobalAPI) {
 ...

 Vue.set = set
 Vue.delete = del

 ...
}

可以看到它们的函数值是相同的。 set和del定义在core/observer/index.js文件中。我们先来看一下set的定义

set

从上到下来看set的函数体,显示这个if判断:

if (process.env.NODE_ENV !== 'production' &&
 (isUndef(target) || isPrimitive(target))
) {
 warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
}

isUndef

export function isUndef (v: any): boolean %checks {
 return v === undefined || v === null
}

判断变量是否是未定义,或者值为null。

isPrimitive

export function isPrimitive (value: any): boolean %checks {
 return (
 typeof value === 'string' ||
 typeof value === 'number' ||
 // $flow-disable-line
 typeof value === 'symbol' ||
 typeof value === 'boolean'
 )
}

判断变量是否是原始类型。

所以这个if语句的作用就是,如果target是undefined或者null或者它的类型是原始类型,在非生产环境下打印警告信息。
再看下一个if语句:

if (Array.isArray(target) && isValidArrayIndex(key)) {
 target.length = Math.max(target.length, key)
 target.splice(key, 1, val)
 return val
}

isValidArrayIndex

export function isValidArrayIndex (val: any): boolean {
 const n = parseFloat(String(val))
 return n >= 0 && Math.floor(n) === n && isFinite(val)
}

判断变量是否是有效的数组索引。

如果target是一个数组,并且key是一个有效的数组索引,就执行if语句块内的代码

我们知道splice变异方法是可以触发响应的,target.splice(key, 1, val) 就利用了替换元素的能力,将指定位置元素的值替换为新值。所以数组就是利用splice添加元素的。另外,当要设置的元素的索引大于数组长度时 splice 无效,所以target的length取两者中的最大值。

if (key in target && !(key in Object.prototype)) {
 target[key] = val
 return val
}

这个if条件的意思是该属性已经在target对象上有定义了,那么只要重新设置它的值就行了。因为在纯对象中,已经存在的属性就是响应式的了。

const ob = (target: any).__ob__
if (target._isVue || (ob && ob.vmCount)) {
 process.env.NODE_ENV !== 'production' && warn(
  'Avoid adding reactive properties to a Vue instance or its root $data ' +
  'at runtime - declare it upfront in the data option.'
 )
 return val
}

target._isVue
拥有_isVue属性说明这是一个Vue实例‘

(ob && ob.vmCount)
ob就是target.__ob__,ob.vmCount也就是target.__ob__.vmCount。来看一下这段代码:

export function observe (value: any, asRootData: ?boolean): Observer | void {
 if (asRootData && ob) {
  ob.vmCount++
 }
}

asRootData表示是否是根数据对象。什么是根数据对象呢?看一下哪里调用observe函数的时候传递了第二个参数:

function initData (vm: Component) {
 ...

 // observe data
 observe(data, true /* asRootData */)
}

在initData中调用observe的时候传递了第二个参数为true,那根数据对象也就是data。也就是说当使用 Vue.set/$set 函数为根数据对象添加属性时,是不被允许的。

所以当target是Vue实例或者是根数据对象时,在非生产环境会打印警告信息。

if (!ob) {
  target[key] = val
  return val
}

当!ob为true时,说明不存在__ob__属性,那target也就不是响应式的,直接变更属性值就行。

defineReactive(ob.value, key, val)
ob.dep.notify()

这里就是给对象添加新的属性,并保证新添加的属性是响应式的。

ob.dep.notify()触发响应。

del

看完了set,再来看delete操作。

if (process.env.NODE_ENV !== 'production' &&
  (isUndef(target) || isPrimitive(target))
 ) {
  warn(`Cannot delete reactive property on undefined, null, or primitive value: ${(target: any)}`)
}

这个if判断跟set函数的一样。如果target是undefined、null或者原始类型值,在非生产环境下打印警告信息。

if (Array.isArray(target) && isValidArrayIndex(key)) {
  target.splice(key, 1)
  return
}

当target是数组类型并且key是有效的数组索引值时,也是使用splice来进行删除操作,因为该变异方法可以触发拦截操作。

const ob = (target: any).__ob__
if (target._isVue || (ob && ob.vmCount)) {
  process.env.NODE_ENV !== 'production' && warn(
   'Avoid deleting properties on a Vue instance or its root $data ' +
   '- just set it to null.'
  )
  return
}

这一段if判断也是一样的,如果target是Vue实例或者是根数据对象,在非生产环境下打印警告信息。也就是不能删除Vue实例对象的属性,也不能删除根数据对象的属性,因为data本身不是响应式的。

if (!hasOwn(target, key)) {
  return
}

如果target对象上没有key属性,直接返回。

delete target[key]

进行到这里就说明target是一个纯对象,并且有key属性,直接删除该属性。

if (!ob) {
  return
}

如果ob对象不存在,说明target不是响应式的,直接返回。

ob.dep.notify()

如果ob对象存在,说明target是响应式的,触发响应。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • 浅谈Vue数据响应思路之数组

    之前梳理Vue数据响应思路时没有考虑数组的情况. js 中数组有很多实例方法,其中有一部分会改变数组本身的值,比如 push pop shift unshift 等,这些方法被称为变异方法,这些变异方法也是 Vue 开发中常用的数组操作方法.那么要实现对数组的观测,首先要考虑的就是如何截获这些变异方法的调用. 简单来说,Vue 是通过保持这些数组变异方法原有功能不变的前提下,对其功能进行扩展来实现拦截的.具体怎么操作,可以先看一下例子: function add10(num) { return

  • 浅谈Vue 数据响应式原理

    前言 Vue的数据响应主要是依赖了Object.defineProperty(),那么整个过程是怎么样的呢?以我们自己的想法来走Vue的道路,其实也就是以Vue的原理为终点,我们来逆推一下实现过程. 本文代码皆为低配版本,很多地方都不严谨,比如 if(typeof obj === 'object')这是在判断obj是否为为一个对象,虽然obj也有可能是数组等其他类型的数据,但是本文为了简便,就直接这样写来表示判断对象,对于数组使用Array.isArray(). 改造数据 我们先来尝试写一个函数

  • Vue.js实现数据响应的方法

    许多前端JavaScript框架(例如Angular,React和Vue)都有自己的数据相应引擎.通过了解相应性及其工作原理,您可以提高开发技能并更有效地使用JavaScript框架.在视频和下面的文章中,我们构建了您在Vue源代码中看到的相同类型的Reactivity. 如果您观看此视频而不是阅读文章,请观看系列中的下一个视频,与Vue的创建者Evan You讨论反应性和代理.

  • 浅谈Vue数据响应

    Vue 中可以用 $watch 实例方法观察一个字段,当该字段的值发生变化时,会执行指定的回调函数(即观察者),实际上和 watch 选项作用相同.如下: vm.$watch('box', () => { console.log('box变了') }) vm.box = 'newValue' // 'box变了' 以上例切入,我想实现一个功能类似的方法 myWatch. 如何知道我观察的属性被修改了? -- Object.defineProperty 方法 该方法可以为指定对象的指定属性设置 g

  • Vue源码解析之数据响应系统的使用

    接下来重点来看Vue的数据响应系统.我看很多文章在讲数据响应的时候先用一个简单的例子介绍了数据双向绑定的思路,然后再看源码.这里也借鉴了这种方式,感觉这样的确更有利于理解. 数据双向绑定的思路 1. 对象 先来看元素是对象的情况.假设我们有一个对象和一个监测方法: const data = { a: 1 }; /** * exp[String, Function]: 被观测的字段 * fn[Function]: 被观测对象改变后执行的方法 */ function watch (exp, fn)

  • 从vue源码解析Vue.set()和this.$set()

    前言 最近死磕了一段时间vue源码,想想觉得还是要输出点东西,我们先来从Vue提供的Vue.set()和this.$set()这两个api看看它内部是怎么实现的. Vue.set()和this.$set()应用的场景 平时做项目的时候难免不会对 数组或者对象 进行这样的骚操作操作,结果发现,咦~~,他喵的,怎么页面没有重新渲染. const vueInstance = new Vue({ data: { arr: [1, 2], obj1: { a: 3 } } }); vueInstance.

  • vue 源码解析之虚拟Dom-render

    vue 源码解析 --虚拟Dom-render instance/index.js function Vue (options) { if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue) ) { warn('Vue is a constructor and should be called with the `new` keyword') } this._init(options) } renderMixin

  • vue3.x源码剖析之数据响应式的深入讲解

    目录 前言 什么是数据响应式 数据响应式的大体流程 vue2.x数据响应式和3.x响应式对比 大致流程图 实现依赖收集 代码仓库 结尾 前言 如果错过了秋枫和冬雪,那么春天的樱花一定会盛开吧.最近一直在准备自己的考试,考完试了,终于可以继续研究源码和写文章了,哈哈哈.学过vue的都知道,数据响应式在vue框架中极其重要,写代码也好,面试也罢,数据响应式都是核心的内容.在vue3的官网文档中,作者说如果想让数据更加响应式的话,可以把数据放在reactive里面,官方文档在讲述这里的时候一笔带过,笔

  • Vue源码学习之数据初始化

    目录 初始化数据 创建Vue实例 构造函数扩展方法 初始化状态 调用initData方法对数据进行代理 初始化数据 环境搭建:菜鸟学Vue源码第一步之rollup环境搭建步 响应式数据的核心就是,数据变化了可以监听到数据变化了,数据的取值和更改值可以监测到,首先第一步需要创建一个Vue实例 创建Vue实例 //dist/index.html //用Vue创造一个实例 const vm = new Vue({ data(){ return { name:'i东东', age:18 } } }) 创

  • Vue源码解析之数组变异的实现

    力有不逮的对象 众所周知,在 Vue 中,直接修改对象属性的值无法触发响应式.当你直接修改了对象属性的值,你会发现,只有数据改了,但是页面内容并没有改变. 这是什么原因? 原因在于: Vue 的响应式系统是基于Object.defineProperty这个方法的,该方法可以监听对象中某个元素的获取或修改,经过了该方法处理的数据,我们称其为响应式数据.但是,该方法有一个很大的缺点,新增属性或者删除属性不会触发监听,举个栗子: var vm = new Vue({ data () { return

  • Vue源码解析之Template转化为AST的实现方法

    什么是AST 在Vue的mount过程中,template会被编译成AST语法树,AST是指抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式. Virtual Dom Vue的一个厉害之处就是利用Virtual DOM模拟DOM对象树来优化DOM操作的一种技术或思路. Vue源码中虚拟DOM构建经历 template编译成AST语法树 -> 再转换为render函数 最终返回一个VNode(VNod

  • vue源码解析之事件机制原理

    上一章没什么经验.直接写了组件机制.感觉涉及到的东西非常的多,不是很方便讲.今天看了下vue的关于事件的机制.有一些些体会.写出来.大家一起纠正,分享.源码都是基于最新的Vue.js v2.3.0.下面我们来看看vue中的事件机制: 老样子还是先上一段贯穿全局的代码,常见的事件机制demo都会包含在这段代码中: <div id="app"> <div id="test1" @click="click1">click1<

  • vue数据控制视图源码解析

    分析vue是如何实现数据改变更新视图的. 前记 三个月前看了vue源码来分析如何做到响应式数据的, 文章名字叫vue源码之响应式数据, 最后分析到, 数据变化后会调用Watcher的update()方法. 那么时隔三月让我们继续看看update()做了什么. (这三个月用react-native做了个项目, 也无心总结了, 因为好像太简单了). 本文叙事方式为树藤摸瓜, 顺着看源码的逻辑走一遍, 查看的vue的版本为2.5.2. 我fork了一份源码用来记录注释. 目的 明确调查方向才能直至目标

  • Vue源码学习之响应式是如何实现的

    目录 前言 一.一个响应式系统的关键要素 1.如何监听数据变化 2.如何进行依赖收集--实现 Dep 类 3.数据变化时如何更新--实现 Watcher 类 二.虚拟 DOM 和 diff 1.虚拟 DOM 是什么? 2.diff 算法--新旧节点对比 三.nextTick 四.总结 前言 作为前端开发,我们的日常工作就是将数据渲染到页面➕处理用户交互.在 Vue 中,数据变化时页面会重新渲染,比如我们在页面上显示一个数字,旁边有一个点击按钮,每次点击一下按钮,页面上所显示的数字会加一,这要怎么

随机推荐