Skip to content

Vue2 data 响应式处理深入思考 #35

@EmiyaGm

Description

@EmiyaGm

有如下情形

<template>
	<div>
		<el-form :model="form">
			<el-form-item label="选择供应商" required>
				<el-select
	        v-model="form.supplierId"
          filterable
          remote
          reserve-keyword
          placeholder="请输入关键词"
          :remote-method="remoteMethod"
          :loading="loading"
         >
	         <el-option
             v-for="item in supplier"
             :key="item.id"
             :label="item.name"
             :value="item.id"
           />
        </el-select>
			</el-form-item>
		</el-form>
	</div>
</template>

<script>
export default {
	data() {
		return {
			form: {},
			supplier: [],
			loading: false
		}
	},
	created() {
     this.getSupplier()
   },
	methods: {
		getSupplier() {
			const self = this
			self.$app.rpc(1, 'Supply/suppliers', [{}, { start: 0, size: 99 }], (result, error) => {
        if (!error) {
           if (result) {
              self.supplier = result.list || []
              self.form.supplierId = 1
           }
        }
      })
		}
	}
}
</script>

这段代码也很简单,使用了 element-ui 中的 select 组件,在初始化的时候,通过接口获取到了该组件的所有选项,并且还为该组件设置了一个初始值,这样这个组件在一开始的时候就选择了一个 id 为 1 的选项,这些都是很好理解的

但是这个代码在之后的执行就有些问题,问题是在重新切换该组件的选择项的时候, self.form.supplierId 的值确实是修改了,但是,该组件的 label 的展现却没有发生变化,并且不管切换多少个,都是不变的。

有经验的我们其实很快就能判断的出,其实就是 form 的修改,修改了内部的属性,在 Vue2 基于 Object.defineProperty(obj, key, descriptor) 来定义响应式对象的前提下,内部属性的修改,不会触发其 setter,因为其内部属性不是响应式的。这个问题很常见,解决方法大家也都清楚。

但是如果换一种写法,这种问题将不会出现

<template>
	<div>
		<el-form :model="form">
			<el-form-item label="选择供应商" required>
				<el-select
	        v-model="form.supplierId"
          filterable
          remote
          reserve-keyword
          placeholder="请输入关键词"
          :remote-method="remoteMethod"
          :loading="loading"
         >
	         <el-option
             v-for="item in supplier"
             :key="item.id"
             :label="item.name"
             :value="item.id"
           />
        </el-select>
			</el-form-item>
		</el-form>
	</div>
</template>

<script>
export default {
	data() {
		return {
			form: {
				supplierId: ''
			},
			supplier: [],
			loading: false
		}
	},
	created() {
     this.getSupplier()
   },
	methods: {
		getSupplier() {
			const self = this
			self.$app.rpc(1, 'Supply/suppliers', [{}, { start: 0, size: 99 }], (result, error) => {
        if (!error) {
           if (result) {
              self.supplier = result.list || []
              self.form.supplierId = 1
           }
        }
      })
		}
	}
}
</script>

这段代码与上门那段代码唯一的不同就在于,在 data 刚开始的时候,就给 form 赋予了一个初始值,即 { supplierId: '' } 这也是本次我们要探究的问题,为什么这么写就可以了。

这时候我们就要去看 Vue2 的源码了,先从 data 处理的部分看起。

不过首先,我们先要对 Object.defineProperty 有个简单的认知

let val = 'msg'
const reactiveObj = {}
 Object.defineProperty(reactiveObj, msg, {
   get: function () {
     // 当访问reactiveObj.msg时被调用
     return val
   },
   set: function (newVal) {
     // 当设置reactiveObj.msg时被调用
     val = newVal
   }
 })

Vue的响应式对象中,它会在getter收集依赖、在setter派发更新,我们会在之后的章节中分别对getter收集依赖setter派发更新做单独的讲解。

在介绍完Object.defineProperty,我们来回答一个问题,什么是响应式对象?在Vue.js中对于什么是响应式对象,我们可以简单的理解成:用Object.defineProperty()方法定义时同时提供了getset选项,我们就可以将其称之为响应式对象。

Vue.js实例化时,会把propsdatacomputed等变成响应式对象,在介绍响应式对象时,我们会重点介绍propsdata的处理过程,这个过程发生在this._init()方法中的initState(vm)中。

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.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)
  }
}

这里我们来看一下 initData 是如何处理 data 相关的逻辑的:

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 */)
}

这个逻辑很清楚,首先获取到 data 的值,然后做相关的校验,其中包括 data 应是一个普通对象,还有就是其中的 keys 不与 propsmethods 里面的重名。校验都完成之后,就开始做最重要的两个逻辑,一个是 data 代理,一个是 data 的响应式。

proxy()方法是定义在src/core/instance/state.js文件中:

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

代码分析:

  • noop:它代表空函数,空函数代表什么都不做。
  • target:它是目标代理对象,在Vue.js中就是Vue实例。
  • sourceKey:它是源属性,在props代理中传递的是_props内部私有属性。
  • key:它是要代理的属性,在props中就是我们撰写的各种props属性。
  • sharedPropertyDefinition:它就是Object.defineProperty(obj, key, descriptor)方法的descriptor参数,可以从上面代码中看到,在props代理中它提供了enumerableconfigurablegetset这几个选项。

假设我们有如下Vue实例:

export default {
  data() {
		return {
			a: '123',
			b: {}
		}
	}
}

proxy代理后,我们就能通过this.athis.b代替this._data.athis._data.b的形式直接访问或者设置值

最后就是重中之重,observe()方法以及Observer类。observe()方法定义与defineReactive()方法定义在同一个文件中,其代码如下:

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  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 (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

代码分析:

  • 首先对传递的value进行了类型判断,不为对象或者是VNode实例时不进行任何操作,其中VNode是一个类,它会在生成虚拟DOM的时候使用到,我们会在后面进行介绍,isObject是一个定义在src/shared/utils.js文件中的工具方法。
export function isObject (obj: mixed): boolean {
  return obj !== null && typeof obj === 'object'
}
  • 然后对value使用hasOwn判断是否有__ob__属性且__ob__Observer实例,添加这个属性是为了防止重复观察(避免重复定义响应式),既:如果已经是响应式对象了,直接返回,否则才会进行下一步操作。hasOwn是一个定义在src/shared/utils.js文件中的工具方法:
const hasOwnProperty = Object.prototype.hasOwnProperty
export function hasOwn (obj: Object | Array<*>, key: string): boolean {
  return hasOwnProperty.call(obj, key)
}
  • 最后value又进行了一些条件判断,其中最重要的两个条件为Array.isArrayisPlainObject,它们分别判断value是否为数组,是否为普通对象,其它几个边界条件暂时不做介绍。其中isPlainObject是一个定义在src/shared/utils.js文件中的工具方法:
export function isPlainObject (obj: any): boolean {
  return _toString.call(obj) === '[object Object]'
}

接下来,我们需要看一下Observer类的实现:

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

代码分析:

  • def为定义在src/core/utils/lang.js文件中的一个工具方法,def本质上也是对Object.defineProperty()方法的一层包裹封装,使用def定义__ob__的目的是让__ob__在对象属性遍历的时候不可被枚举出来。
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}
  • Vue.js中对于纯对象和数组的响应式的处理方式是不同的,代码首先判断了value是否为数组。如果不是数组,则调用walk()方法。walk()方法实际上就是递归遍历对象属性,然后调用defineReactive()的过程,例如:
const nestedObj = {
  a: {
    b: {
      c: 'c'
    }
  }
}
// 递归调用
defineReactive(nestedObj)
defineReactive(a)
defineReactive(b)
defineReactive(c)

在这里其实我们就已经明白了,因为他会递归调用遍历里面的所有对象属性,然后将其都变成响应式的,所以,第二种写法就能使其发生变化。

如果是数组,则调用observeArray()方法,observeArray也是一个遍历递归调用的过程,只不过这里遍历的是数组,而不是对象的属性键。然后我们还发现,在observeArray()方法调用之前,还进行了hasProto判断,然后根据判断结果进行不同的操作。其中,hasProto是定义在src/core/util/env.js文件中的一个常量,它的目的就是为了判断当前浏览器是否支持__proto__属性:

export const hasProto = '__proto__' in {}

我们都知道因为原生API某些限制因素,Vue.js对数组七种可以改变自身数组的方法提供了变异方法支持,这七种方法分别为:

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

对这七种方法的变异处理逻辑在src/core/ovserver/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
  })
})

代码分析:

  • 首先以Array.prototype原型创建一个新的变量,这个变量会在protoAugment或者copyAugment方法的时候使用到。
  • 然后遍历七种方法,使用def来重新定义一个包裹方法。也就是说:当我们调用这七种任意一种方法的时候,首先调用我们的包裹方法,在包裹方法里面再调用原生对应的数组方法,这样做的目的是让我们可以在这个包裹方法中做我们自己的事情,例如notify,这个过程可以使用以下伪代码实例描述:
// Array.prototype.push方法为例
function mutatorFunc (value) {
  const result = Array.prototype.push(value)
  // do something
  return result
}
export default {
  data () {
    return {
      arr: []
    }
  },
  created () {
    this.arr.push('123')
    // 相当于
    mutatorFunc(123)
  }
}

然后我们接下来看一下protoAugmentcopyAugment的实现,首先是最简单的protoAugment

// 定义
const arr = []
export const arrayMethods = Object.create(arrayProto)
function protoAugment (target, src: Object) {
  target.__proto__ = src
}

// 调用
protoAugment(arr, arrayMethods)

// 调用后
arr.__proto__ = {
  // 省略其它
  push: function () {},
  pop: function () {},
  shift: function () {},
  unshift: function () {},
  splice: function () {},
  sort: function () {},
  reverse: function () {}
}
arr.push()
arr.pop()
arr.shift()
arr.unshift()
arr.splice()
arr.sort()
arr.reverse()

代码分析:当浏览器支持__proto__属性的时候,直接把__proto__指向我们创建的arrayMethods变量,这个包含我们在上面定义的七种变异方法。

当浏览器不支持__proto__属性的时候,我们就调用copyAugment方法:

// 定义
const arr = []
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
export const arrayMethods = Object.create(arrayProto)
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])
  }
}

// 调用
copyAugment(arr, arrayMethods, arrayKeys)

// 调用后
arr = {
  // 省略其它
  push: function () {},
  pop: function () {},
  shift: function () {},
  unshift: function () {},
  splice: function () {},
  sort: function () {},
  reverse: function () {}
}
arr.push()
arr.pop()
arr.shift()
arr.unshift()
arr.splice()
arr.sort()
arr.reverse()

代码分析:我们可以从代码中看到,当浏览器不支持__proto__的时候,会把我们创建的arrayMethods变量上所有的key,遍历赋值到value数组上。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions