Vue的响应式
用过Vue这个框架的人应该都知道,数据驱动是Vue框架的核心,数据双向绑定是它的一大特色,根据官方的解释,我们可以比较清晰地去知道响应式的简单原理。
Vue2的响应式原理
当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter。Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。
这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property 被访问和修改时通知变更。这里需要注意的是不同浏览器在控制台打印数据对象时对 getter/setter 的格式化并不同,所以建议安装 vue-devtools 来获取对检查数据更加友好的用户界面。
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
Vue3的响应式原理
实现原理:
通过Proxy(代理): 拦截对象中任意属性的变化,包括:属性值的读写,属性的增加,属性的删除等。
通过Reffect(反射): 对源对象的属性进行操作
new Proxy(data,{ //拦截读取属性值 get(target, prop){ return Reflect.get(target, prop) }, //拦截设置属性值或添加新属性 set(target, prop, value){ return Reflect.set(target, prop, value) }, //拦截删除属性 deleteProperty(target, prop){ return Reflect.deleteProperty(target, prop) } })
Vue2和Vue3的响应式原理其实有异曲同工之妙,但是Vue3的proxy封装性和独立性相对更强更灵活一些,但是我们看到的这些只是最简单的,也是最基础的一个响应式原理,如果要更深入地去了解Vue是如何利用这一原理去实现框架中的各种双向绑定和数据渲染操作,我们可以对它的源码进行分析。
深入理解响应式
1.数据初始化
new Vue({ el: "#app", router, store, render: (h) => h(App), });
这段代码,大家一定非常熟悉。这就是 Vue 实例化的过程 从 new 操作符,咱们可以看出 Vue 其实就是一个构造函数,没啥特别的,传入的参数就是一个对象,我们叫做 options(选项)。
// src/index.js import { initMixin } from "./init.js"; // Vue就是一个构造函数 通过new关键字进行实例化 function Vue(options) { // 这里开始进行Vue初始化工作 this._init(options); } // _init方法是挂载在Vue原型的方法 通过引入文件的方式进行原型挂载需要传入Vue // 此做法有利于代码分割 initMixin(Vue); export default Vue;
因为在 Vue 初始化可能会处理很多事情,比如数据处理,事件处理,生命周期处理等等,所以划分不同文件引入利于代码分割。
// src/init.js import { initState } from "./state"; export function initMixin(Vue) { Vue.prototype._init = function (options) { const vm = this; // 这里的this代表调用_init方法的对象(实例对象) // this.$options就是用户new Vue的时候传入的属性 vm.$options = options; // 初始化状态 initState(vm); }; }
initMixin 把_init 方法挂载在 Vue 原型 供 Vue 实例调用。
// src/state.js import { observe } from "./observer/index.js"; export function initState(vm) { // 获取传入的数据对象 const opts = vm.$options; if (opts.props) { initProps(vm); } if (opts.methods) { initMethod(vm); } if (opts.data) { // 初始化data initData(vm); } if (opts.computed) { initComputed(vm); } if (opts.watch) { initWatch(vm); } } // 初始化data数据 function initData(vm) { let data = vm.$options.data; // 实例的_data属性就是传入的data // vue组件data推荐使用函数 防止数据在组件之间共享 data = vm._data = typeof data === "function" ? data.call(vm) : data || {}; // 把data数据代理到vm 也就是Vue实例上面 我们可以使用this.a来访问this._data.a for (let key in data) { proxy(vm, `_data`, key); } // 对数据进行观测 --响应式数据核心 observe(data); } // 数据代理 function proxy(object, sourceKey, key) { Object.defineProperty(object, key, { get() { return object[sourceKey][key]; }, set(newValue) { object[sourceKey][key] = newValue; }, }); }
①通过这段代码,就可以得到一个平时开发Vue项目的时候对于我们有很大帮助的信息,即关于数据初始化的顺序依次是 prop>methods>data>computed>watch。关于我们能否在data里面去调用prop的值的问题,如果知道数据渲染的顺序,就迎刃而解了。
②另外通过这段源码,我们还可以获得一个信息,data是用了函数function封装,而不是对象Object,就是为了避免数据在组件间共享,这样我们每个组件才能有独立的变量作用域。
2.对象的数据劫持
对象数据的劫持,其实很好理解,代码中通过递归的方式,把对象中的每个参数都添加了对应的监听器,所以当对象数据发生变化的时候自然就会触发监听器。
这里我们可以得到一个信息,对象只有在初始化阶段的时候进行了监听标记,当我们后续为对象新增参数的时候,必须通过Vue提供的内置函数 s e t 和 set和 set和delete才能对对象参数进行动态操作,不然直接通过Object.xxx去新增参数,这个时候是不具备双向绑定的效果的。
// src/obserber/index.js class Observer { // 观测值 constructor(value) { this.walk(value); } walk(data) { // 对象上的所有属性依次进行观测 let keys = Object.keys(data); for (let i = 0; i < keys.length; i++) { let key = keys[i]; let value = data[key]; defineReactive(data, key, value); } } } // Object.defineProperty数据劫持核心 兼容性在ie9以及以上 function defineReactive(data, key, value) { observe(value); // 递归关键 // --如果value还是一个对象会继续走一遍odefineReactive 层层遍历一直到value不是对象才停止 // 思考?如果Vue数据嵌套层级过深 >>性能会受影响 Object.defineProperty(data, key, { get() { console.log("获取值"); return value; }, set(newValue) { if (newValue === value) return; console.log("设置值"); value = newValue; }, }); } export function observe(value) { // 如果传过来的是对象或者数组 进行属性劫持 if ( Object.prototype.toString.call(value) === "[object Object]" || Array.isArray(value) ) { return new Observer(value); } }
数组的监听
// src/obserber/index.js import { arrayMethods } from "./array"; class Observer { constructor(value) { if (Array.isArray(value)) { // 这里对数组做了额外判断 // 通过重写数组原型方法来对数组的七种方法进行拦截 value.__proto__ = arrayMethods; // 如果数组里面还包含数组 需要递归判断 this.observeArray(value); } else { this.walk(value); } } observeArray(items) { for (let i = 0; i < items.length; i++) { observe(items[i]); } } }
数组的监听,是对数组的每个元素进行判断,如果数组中还包含数组则需要递归进行监听,如果非数组元素则直接对数组进行监听设置的操作。
因为对数组下标的拦截太浪费性能 对 Observer 构造函数传入的数据参数增加了数组的判断。
// src/obserber/index.js class Observer { // 观测值 constructor(value) { Object.defineProperty(value, "__ob__", { // 值指代的就是Observer的实例 value: this, // 不可枚举 enumerable: false, writable: true, configurable: true, }); } }
最后为了方便我们对数组的操作,Vue对数组的一些常用方法进行了重写,当我们调用这些方法,Vue底层会为我们自动添加对应的监听器,不用让我们再次去对元素进行数据渲染和绑定。
// src/obserber/array.js // 先保留数组原型 const arrayProto = Array.prototype; // 然后将arrayMethods继承自数组原型 // 这里是面向切片编程思想(AOP)--不破坏封装的前提下,动态的扩展功能 export const arrayMethods = Object.create(arrayProto); let methodsToPatch = [ "push", "pop", "shift", "unshift", "splice", "reverse", "sort", ]; methodsToPatch.forEach((method) => { arrayMethods[method] = function (...args) { // 这里保留原型方法的执行结果 const result = arrayProto[method].apply(this, args); // 这句话是关键 // this代表的就是数据本身 比如数据是{a:[1,2,3]} 那么我们使用a.push(4) this就是a ob就是a.__ob__ 这个属性就是上段代码增加的 代表的是该数据已经被响应式观察过了指向Observer实例 const ob = this.__ob__; // 这里的标志就是代表数组有新增操作 let inserted; switch (method) { case "push": case "unshift": inserted = args; break; case "splice": inserted = args.slice(2); default: break; } // 如果有新增的元素 inserted是一个数组 调用Observer实例的observeArray对数组每一项进行观测 if (inserted) ob.observeArray(inserted); // 之后咱们还可以在这里检测到数组改变了之后从而触发视图更新的操作--后续源码会揭晓 return result; }; });