写点什么

vue 实战 - 深入响应式数据原理

作者:yyds2026
  • 2023-03-01
    浙江
  • 本文字数:6697 字

    阅读完需:约 22 分钟

本文将带大家快速过一遍 Vue 数据响应式原理,解析源码,学习设计思路,循序渐进。

数据初始化

_init

在我们执行new Vue创建实例时,会调用如下构造函数,在该函数内部调用this._init(options)


import { initMixin } from "./init.js";
// 先创建一个Vue类,Vue就是一个构造函数(类) 通过new关键字进行实例化function Vue(options) { // 这里开始进行Vue初始化工作 this._init(options);}// _init方法是挂载在Vue原型的方法,每一个new 实例可以调用, 由initMixin方法挂载
// 将不同的操作拆分成不同的模块,导入后对Vue类做一些处理,此做法更利于维护initMixin(Vue); // 定义原型方法_initstateMixin(Vue) //定义 $set $get $delete $watch 等eventsMixin(Vue) // 定义事件 $on $once $off $emitlifecycleMixin(Vue) // 定义 _update $forceUpdate $destroyrenderMixin(Vue) // 定义 _render 返回虚拟dom
export default Vue;
复制代码


initMixin函数里面定义了原型方法_init_init调用了initState(vm)等方法,_init里做了很多初始化工作,我们重点关注initState


import { initState } from "./state";
export function initMixin(Vue) { Vue.prototype._init = function (options) { const vm = this; // 这里的this指向调用_init方法的对象(即 new的实例) // this.$options就是用户new Vue的时候传入的属性 vm.$options = options; ... initLifecycle(vm); initEvents(vm); initRender(vm); callHook(vm, 'beforeCreate'); initInjections(vm); // resolve injections before data/props // 初始化状态,在beforeCreate之前,created之后 initState(vm); initProvide(vm); // resolve provide after data/props callHook(vm, 'created'); ...
};}
复制代码

initState

initState 函数按顺序初始化$options的数据,顺序为 prop>methods>data>computed>watch


import { observe } from "./observer/index.js";
function initState (vm) { vm._watchers = []; const opts = vm.$options; // 按顺序初始化 prop>methods>data>computed>watch if (opts.props) { initProps(vm, opts.props); } if (opts.methods) { initMethods(vm, opts.methods); } if (opts.data) { // 初始化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

initData 做了什么事?


  1. vm.$options.data 赋值给vm._data

  2. 此处有个细节,vue 组件 data 推荐使用函数,防止数据在组件之间共享,原因是如果你定义的 data 是个对象的话,那所有的组件实例的 data 都会引用这个对象,一个组件更改了 data 别的组件也会发生变化,他们的 data 指向同一个内存地址。

  3. 判断方法和属性是否重名,以及是否有保留属性

  4. 没有问题就通过 proxy() 把 data 里的每一个属性都代理到当前实例上,就可以通过 this.xx 访问了

  5. 最后再调用 observe 监听整个 data,observe 方法用于创建监听器


import { observe } from "./observer/index.js";
function initState (vm) { ... initData(vm);}
function initData (vm: Component) { // 获取当前实例的 data let data = vm.$options.data // 判断 data 的类型 data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {} if (!isPlainObject(data)) { data = {} process.env.NODE_ENV !== 'production' && warn(`数据函数应该返回一个对象`) } // 获取当前实例的 data 属性名集合 const keys = Object.keys(data) // 获取当前实例的 props const props = vm.$options.props // 获取当前实例的 methods 对象 const methods = vm.$options.methods let i = keys.length while (i--) { const key = keys[i] // 非生产环境下判断 methods 里的方法是否存在于 props 中 if (process.env.NODE_ENV !== 'production') { if (methods && hasOwn(methods, key)) { warn(`Method 方法不能重复声明`) } } // 非生产环境下判断 data 里的属性是否存在于 props 中 if (props && hasOwn(props, key)) { process.env.NODE_ENV !== 'production' && warn(`属性不能重复声明`) } else if (!isReserved(key)) { // 都不重名的情况下,代理到 vm 上,可以让 vm._data.xx 通过 vm.xx 访问 proxy(vm, `_data`, key) } } // 监听 data observe(data, true /* asRootData */)}
复制代码

proxy 数据代理

proxy 函数中调用了Object.defineProperty_data中的每个 property 代理到了 vm 身上,作用就是,可以 vm._data.xx 通过 vm.xx 访问,当你访问 vm.a 的时候实际上是访问的 vm._data.a。


function proxy (target, sourceKey, key) {    sharedPropertyDefinition.get = function proxyGetter () {      return this[sourceKey][key]    };    sharedPropertyDefinition.set = function proxySetter (val) {      this[sourceKey][key] = val;    };    Object.defineProperty(target, key, sharedPropertyDefinition);  }
复制代码

observe 数据劫持

observe

该方法用于创建监听器实例


export function observe (value: any, asRootData: ?boolean): Observer | void {  // 如果不是'object'类型 或者是 vnode 的对象类型就直接返回  if (!isObject(value) || value instanceof VNode) {    return  }  let ob: Observer | void  // __ob__是监听器对象,如果存在的话说明已经被监听过,避免重复监听  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}
复制代码

Observer

监听器类,将数据转换为响应式数据


export class Observer {  value: any;  dep: Dep;  vmCount: number; // 根对象上的 vm 数量  constructor (value: any) {    this.value = value    this.dep = new Dep(); // 预先实例化一个dep,用于保存数组的依赖    this.vmCount = 0    // 给 value 添加 __ob__ 属性,值为为当前value 创建的 Observe 实例    // 表示已经变成响应式了,目的是对象遍历时就直接跳过,避免重复监听    def(value, '__ob__', this)    // 类型判断    if (Array.isArray(value)) {      // 判断数组是否有__proto__      if (hasProto) {        // 如果有就把它的原型设置为arrayMethods,arrayMethods对象拥有变异后的七个数组方法并且原型是原生数组Array的原型        protoAugment(value, arrayMethods); // 原型增强      } else {        // 没有就通过 def,也就是Object.defineProperty 去定义属性值        copyAugment(value, arrayMethods, arrayKeys)      }      this.observeArray(value)    } else {      this.walk(value)    }  }  // 如果是对象类型  walk (obj: Object) {    const keys = Object.keys(obj)    // 遍历对象所有属性,转为响应式对象,也是动态添加 getter 和 setter,实现双向绑定    for (let i = 0; i < keys.length; i++) {      defineReactive(obj, keys[i])    }  }  // 监听数组  observeArray (items: Array<any>) {    // 遍历数组,对每一个元素进行监听    for (let i = 0, l = items.length; i < l; i++) {      observe(items[i])    }  }}
复制代码


参考 前端进阶面试题详细解答


对于数组和对象有不同的处理,我们先来看处理对象响应式的方法,walk

walk

遍历对象所有属性,调用defineReactive方法转为响应式对象,


  walk (obj: Object) {    const keys = Object.keys(obj)    // 遍历对象所有属性,转为响应式对象,也是动态添加 getter 和 setter,实现双向绑定    for (let i = 0; i < keys.length; i++) {      defineReactive(obj, keys[i])    }  }
复制代码

defineReactive

定义响应式对象,getter 时收集依赖,setter 时触发依赖


export function defineReactive (  obj: Object,  key: string,  val: any,  customSetter?: ?Function,  shallow?: boolean) {
// 创建 dep 实例,保存属性的依赖,getter时添加依赖,setter时触发依赖 const dep = new Dep(); 这个是对象的依赖 // 拿到对象的属性描述符 const property = Object.getOwnPropertyDescriptor(obj, key) if (property && property.configurable === false) { return } // 获取自定义的 getter 和 setter const getter = property && property.get const setter = property && property.set if ((!getter || setter) && arguments.length === 2) { val = obj[key] } // 如果 val 是对象的话就递归监听 // 递归监听子属性,如果value还是一个对象会继续走一遍defineReactive 层层遍历一直到value不是对象才停止,所以如果对象层级过深,对性能会有影响 let childOb = !shallow && observe(val) // data = {a: {b: 3}, c: [1, 2]} 属性值如果是对象或数组会返回Observer实例 // 截持对象属性的 getter 和 setter Object.defineProperty(obj, key, { // 例如监听data.a,那val就是{b: 3} enumerable: true, configurable: true, // 拦截 getter,当取值时会触发该函数 get: function reactiveGetter () { const value = getter ? getter.call(obj) : val // 开始依赖收集 (在get中会收集属性的依赖,以及其属性值的依赖) // 初始化渲染 watcher 时访问到已经被添加响应式的对象,从而触发 get 函数 if (Dep.target) { // 如果现在处于依赖收集阶段 dep.depend(); // 添加当前属性的依赖 if (childOb) { // 数组会在此收集依赖,在数组被push等操作时调用保存的Observer实例触发依赖;对象会收集两次依赖,但是对象的第二次收集不会被setter触发 // childOb.dep 就是Observer 中 this.dep = new Dep() childOb.dep.depend(); // 父属性包含子属性,即访问了this.a,实际上也访问了this.a.b,this.a.b变了,this.a就变了,所以子属性也要收集依赖 if (Array.isArray(value)) { dependArray(value) } } } return value }, // 拦截 setter,当值改变时会触发该函数 set: function reactiveSetter (newVal) { const value = getter ? getter.call(obj) : val // 判断是否发生变化 if (newVal === value || (newVal !== newVal && value !== value)) { return } if (process.env.NODE_ENV !== 'production' && customSetter) { customSetter() } // 没有 setter 的访问器属性 if (getter && !setter) return if (setter) { setter.call(obj, newVal) } else { val = newVal } // 如果新值是对象的话递归监听 childOb = !shallow && observe(newVal) // 遍历通知储存在Dep实例中的所有依赖 dep.notify() } })}
复制代码

Object.defineProperty 定义响应式对象的缺点

  1. 监听嵌套层级过深的对象会影响性能

  2. 对象新增或者删除的属性无法被set 监听到 只有对象本身存在的属性修改才会被劫持,所以 Vue 设计了$set$delete方法,更新数据的同时手动触发通知依赖

  3. 如果用其来监听数组的话,无法监听数组长度动态变化,并且只能监听通过对已有元素下标的访问进行的修改,即arr[已有元素下标] = val


我们自己手写一个递归设置响应式的方法来试一下:


function defineProperty(obj, key, val){  observer(val);  Object.defineProperty(obj, key, {      enumerable: true,      configurable: true,      get() {        // 读取方法        console.log('读取', key, '成功')        return val      },      set(newval) {        // 赋值监听方法        if (newval === val) return        observer(newval)        console.log('监听赋值成功', newval)        val = newval      }    })}
function observer(obj) { if (typeof obj !== 'object' || obj == null) { return } for (const key of Object.keys(obj)) { // 给对象中的每一个方法都设置响应式 defineProperty(obj, key, obj[key]) }}
const arr = [{a:3}, 66, [4,5]];const obj = {a:1, b: [2]};
arr.length = 33; // 无法监听数组长度动态变化arr[2].push(22) // 只能监听通过对已有元素下标的访问进行的修改arr[2][0] = 5; // 访问已有元素的下标可以监听修改
obj.c = 6; // 无法监听新添加的属性delete obj.b // 无法监听属性被删除obj.b = 66; // 被删除后就失去响应式了
复制代码


虽然defineProperty可以监听通过对已有元素下标访问的修改,但是出于性能考虑,vue 并没有使用这一功能来使数组实现响应式,因为数组元素太多时耗费一定性能,要挨个遍历监听一遍数组的每一个属性,属性可能还会包含自己的嵌套属性,所以vue的做法是修改原生操作数组的方法,并且跟用户约定修改数组要用这些方法去操作。


尤大也做出了官方的解释:


数组的观测

数组元素添加或删除操作的观测通过创建一个以原生 Array 的原型为原型的新对象,为新对象添加数组的变异方法,将观察的对象的原型设置为这个新对象,被观察的对象调用数组方法时就会使用被重写后的方法。


记得我们在讲寄生式继承时说的么,寄生式继承的核心:使用原型式继承Object.create(parent)可以获得一份目标对象的浅拷贝,在这个浅拷贝对象上进行增强,添加一些方法属性。

vue 对重写数组方法的设计与寄生式继承类似,都是面向切面编程的思想(AOP),即不破坏原有功能封装的前提下,动态的扩展功能


import { TriggerOpTypes } from '../../v3'import { def } from '../util/index'
const arrayProto = Array.prototype // 用Array的原型创建一个新对象,arrayMethods.__proto__ === arrayProto,免得污染原生Arrayexport 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] // 给arrayMethods对象定义上述方法,使该对象拥有原生方法能力的同时添加响应式行为 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': // 可以监测数组长度变化 //splice格式是splice(下标,数量,插入的新项) inserted = args.slice(2); // 获取插入的新项 break } if (inserted) ob.observeArray(inserted) // notify change if (__DEV__) { ob.dep.notify({ type: TriggerOpTypes.ARRAY_MUTATION, target: this, key: method }) } else { ob.dep.notify() } return result })})
复制代码


因为出于性能考虑,vue 没有使用defineProperty劫持数组,所以要通过索引修改数组,我们需要使用$set

总结

以上就是Vue2的响应式数据原理,讲述了如何对数据进行响应式观测,核心就是通过Object.defineProperty对数据进行劫持,在getter中收集依赖,setter中派发依赖,完整的响应式原理,如修改数据后视图是如何更新视图的还需要结合 Dep 和 Watcher 来看,这段后续接着说,一点点地来消化。


用户头像

yyds2026

关注

还未添加个人签名 2022-09-08 加入

还未添加个人简介

评论

发布
暂无评论
vue实战-深入响应式数据原理_Vue_yyds2026_InfoQ写作社区