《工欲善其事,必先利其器》
既然点进来了,麻烦你看下去,希望你有不一样的收获。
大家好,我是vk,好久不见,今天我们一起来盘一盘关于 Vue2.0 的响应式原理。注意,响应式才是 Vue 的核心,而双向绑定则是指 v-model 指令。所以一般面试时候面试官都会问你,能否讲讲响应式原理,或者简单实现一个数据的双向绑定。而不是让你实现一个双向绑定原理。
所以这时候,如果面试官问你,能否简单实现一下 Vue 的响应式?
你应该答:好的,等我10分钟,我先去看一下 vk 哥写的文章。(狗头)
所谓
MVVM
框架,即是数据驱动视图模型,分为Model
、View
和ViewModel
。目前市场上三大框架,只有Vue
是百分之百应用了MVVM
框架,所以它的核心实现和思想值得我们学习。
有的小年轻这时候就要肝了,你个辣鸡,Vue3.0 都出了那么久了,现在才来讲 Vue2.0 ,有个屁用?出来混,是要讲…
我只能说,小年轻,你还小,有些事,你不懂…
好的,其实一方面是由于自己个人原因,没办法经常写文章。其次,不管是 Vue2.0 还是 3.0,只要是源码,它就有学习的价值。不是说,你用什么,就学什么;而是,我想学什么,我就去研究什么。这是一个主观的意向,我的建议就是不要被动的去学习,那样成长是很缓慢的,而且很容易记不住。
另外,更完这篇 2.0 的原理,下一篇就研究更新 3.0 的原理,这样一有对比,岂不美哉?(狗头)
我们都知道,Vue
在改变数据时,会自动刷新页面的 DOM
。同样,我们在页面输入数据,Vue
的 Model
层数据也会随之变化,这就是 Vue
的特性 —— 响应式原理。
最经典的例子就是输入框输入数据和其数据回显,任何一个地方改变数据,对应的数据都会发生改变,实现了数据的双向绑定。那么这到底是怎么做到的呢?我们来看一张图:
通过上面官方的图解我们可以理解并得到以下几点结论,我先把结论给你放出来了,尝试理解一下。如果不懂,没关系,我们后面会继续剖析它的原理乃至实现它:
DOM
时,如果需要访问我们 Data
中的数据 a
,那么我们就会先 new Watcher
一个实例,在 Watcher
实例中获取这个 a
属性的值,并进行观察,这个过程就叫 Touch
我们的 getter
以获取数据。此时设置 Dep.target = this
,即指向该 Watcher
,保持全局唯一性。Dep.target = this
的全局唯一性,我们使用 Object.defineProperty
对数据进行拦截,设置我们的 getter
和 setter
,如上图。此时 getter
里面,若 Dep.target
为 true
,我们通知收集器 Dep
把当前 this
(即当前 Watcher
)收集起来,以通知更新备用,同时返回该属性的值。Watcher
,值返回以后,为防止其他依赖(即其他 Watcher
)触发 getter
的同时把我们这个 Watcher
又收集回去,我们需要把 Dep.target
设置为 null
。这就避免了不停的绑定 Watcher
与 Dep
,造成代码死循环。鉴于 Dep
和 Watcher
两者之间这种微妙的关系,其实我们可以发现,这就是典型的应用了 —— 发布/订阅者设计模式
。
设计模式的本质就是使代码解耦,实现低耦合,形成代码的高可读性、高可重用性和高度扩展性;
这些特点对于一个库或者框架来说显得尤为重要。
以上就是响应式的收集依赖的过程了,这时候你千万不要懵,好戏才刚刚开始,我们开始剖析 —— 响应式。
先通过几段了解一下 Object.defineProperty
这个 API:
const data = {}
cosnole.log(data)
// 输出 {Prototype: object}
我们根据输出的结果,可以看到 data
里面现在只有一个原型对象。当我们为 data
添加或修改属性时:
let name = '张三'
data.name = name
console.log(data)
// 输出 {name: '张三', Prototype: object},这个也是很明显就可以理解的
对于添加或修改对象属性,有时候我们也可以用到 Object.defineProperty
这个 API。先看一下官方的定义:
那我们按照 MDN
文档,应用一下:
const data = {}
let person = '张三'
Object.defineProperty(data, "name", {
get: function() {
console.log("get")
return person
},
set: function(newValue) {
console.log("set")
value = newValue
}
})
console.log(data)
// 输出 {name: '张三', get: function() {}, set: function(newValue) {}, Prototype: object}
通过该 API 新增对象属性,我们可以观察到,跟直接添加对象属性相比较,多了 getter
和 setter
两个内置函数,分别用来拦截调用属性值和修改操作属性。
// 根据上面的 API,这时我们来修改 data 的属性
data.name = "李四"
console.log(data.name)
// 输出 set,说明修改属性值触发了 set 方法
// 输出 get,说明调用属性值触发了 get 方法
// 输出 李四,说明属性值已被修改
这说明,Object
的可能性一下子就被打开了,利用这个 API 可以达到我们前面提到的设计模式的特点。
插一句题外话,在 Vue2.0 发布的时候,Proxy 其实已经诞生了。很多人疑惑为什么尤大大不使用 Proxy?其实是因为当时的前端环境还并没有完全支持这个 API。很多浏览器除了几个主流的,基本上都还没有适配上。所以,尤大大为了用户群体考虑,选择了 Object.defineProperty,而放弃了 Proxy。直到今天,前端环境对 Proxy 友好了,Vue3.0 也就天然适配了 Proxy。
由此可见,我们可以往 Object.defineProperty
里面添加很多东西,例如:数据的监听、数据的加工、数据的计算、数据的判断等等非常非常多的工作。这也被业界称之为非常经典的 —— 《Vue
的数据劫持》。
但是,该API有弊端。
对于已进行数据劫持的对象,他在新增属性时候,并不会为新属性绑定setter
和getter
。
对于已进行数据劫持的对象,他在删除属性的时候,并不会触发setter
通过上面的分析我们大概了解到,Vue
的数据劫持是怎么实现的。
这个时候其实很重要昂,数据劫持只是响应式的其中一环罢了。不过现在需要继续摸索,层层递进,我带你模拟一下响应式的简易的过程(由于篇幅原因,就不做太多的引导了,直接全部代码展示,希望你多跟着敲几遍,把它理解透):
observe.js
文件:import Observer from "Observer.js";
// 监听对象属性
export default function observe(value) {
// 判断是基本数据类型或者引用数据类型 object
if (typeof value != "object") return;
let ob;
// 判断这个对象或属性是否携带有响应式的标识
if (typeof value.__ob__ != "undefined") {
ob = value.__ob__;
} else {
ob = new Observer(value);
}
return ob;
}
类(class)
是不二之选。不过在这之前,我们需要新增一个工具函数文件,用来给对象或其属性添加 __ob__
响应式标识。创建 utils.js
文件:/**
* @param {Object} obj 需要绑定的对象
* @param {String} key 需要绑定的属性名
* @param {Any} value 需要绑定的属性值
* @param {Boolean} enumerable 绑定的属性是否可枚举
*/
export default function def(obj, key, value, enumerable) {
Object.defineProperty(obj, key, {
value,
enumerable,
writable: true,
configurable: true
})
}
Observer.js
文件,设置颗粒度拦截:import { def } from "utils.js";
import observe from "observe.js";
import defineReactive$$1 from "defineReactive.js";
export default class Observer {
constructor(value) {
// 绑定响应式标识 __ob__ 属性
def(value, "__ob__", this., false);
// 实现拦截
this.walk(value);
}
walk(data) {
for (let k in data) {
defineReactive$$1(data, k);
}
}
}
defineReactive.js
文件,实现数据的拦截:/**
* defineReactive.js
* 该函数用于实现数据劫持
* @param {Object} data 需要实现数据劫持的对象
* @param {String} key 被劫持对象的属性名
* @param {Any} value 被劫持对象的属性值
* @return {Any} value 被劫持对象添加监听后的属性值
*/
export default function defineReactive$$1(target, key, value) {
if (arguments.length == 2) value = target[key];
// 深度遍历监听,因为对象的子属性也可能是一个 object
observer(value);
// 调用核心 API
Object.defineProperty(target, key, {
enumerable: true,
configurable: true,
get() {
return value;
},
set(newValue) {
if (newValue !== value) {
value = newValue;
// 同样的也是深度遍历监听,判断新的属性值是否也是 object
observer(newValue);
// 打印一下,方便我们后面监听数组
console.log("视图更新");
}
}
})
}
然后,利用 observer
测试监听一个对象:
const obj = {
name: '张三',
age: 20
}
observer(obj);
console.log(obj);
// 输出 {name: '张三', age: 20, get age fn(), set age fn(), get name fn(), set name fn(), Prototype: object}
我们观察到对象分别添加了 age > getter
、age > setter
、name > getter
和 name > setter
,说明我们针对 obj
的数据劫持已经成功监听到了。
不过,接下来,我们还需要测试一下,当我们分别新增属性、修改属性和删除属性,是否会触发视图刷新函数:
// 测试新增属性
obj.idcard = 123456
// 测试修改属性
obj.age = 18
// 测试删除属性
delete obj.age
console.log(obj)
// 输出 视图更新
// 输出 {name: '张三', idcard: 123456, get name fn(), set name fn(), Prototype: object}
现在我们可以观察到,执行完语句的对象 obj
,只剩下了 name > getter
和 name > setter
。而且,触发视图刷新的,是我们在修改 age
属性的过程中触发的。这就说明,新增的属性,并不会触发视图刷新,也不会被劫持数据监听。删除属性,也不会触发视图刷新。
看到这里,我相信你应该已经挺兴奋的了。因为你已经距离能自己手动实现一个双向绑定不远了。但,我希望细心的朋友可以发现,整个数据劫持的过程中,利用
setter
来触发视图刷新,这种设计手法相当于什么?
没错,它就是我们平常所了解的 ——观察者模式
。
这时候,有人问了:你这写的不严谨。你的属性都是基本数据类型,根本没提到引用数据类型数组要怎么处理啊!你个辣鸡!!!
小伙子,我很佩服你的勇气。
紧接着,我们继续测试,如果对象属性的值是引用类型的情况下,observe
的表现如何:
const obj = {
name: '张三',
age: 20,
hobby: ['唱', '跳', 'rap'],
address: {
province: '广东省',
city: '深圳市',
district: '福田区'
}
}
observe(obj);
obj.address.district = '南山区'
obj.hobby.push('篮球')
console.log(obj);
// 输出 视图更新
咦?!为什么只输出了一个视图刷新???明明 hobby
也生成了 setter
和 getter
啊!不应该是刷新两次吗???
原因就是,虽然我们封装的 defineReactive$$1
可以监听到这个属性值,但是,并不具备监听数组更新的能力。
得,又是一个坑。
其实,
Vue2.0
通过这个API实现响应式,还是不尽如人意的。
但是,尤大大还是为我们提供了Vue.set
和Vue.delete
,供我们新增属性,删除属性。
那咋办呢?总不能写一半去跟面试官说,剩下的你来?
我们可以通过改写 Object.defineProperty
来拦截数组及其属性,但是我们并不知道数组一开始的长度是多少。因此,为了性能着想,尤大大可以说是另辟蹊径,开辟了一个新思路。
这时候就大胆一点啦,我们的思维不妨狂野一点。都自己手动实现响应式原理了,不如再动动脑筋,发散一下思维,接着处理一下 Array
的原型 :
// 重新定义数组原型
import { def } from "utils.js";
// 复制 Array 的原型
const arrayPrototype = Array.prototype;
// 重塑新的 Array 原型
const arrayMethods = Object.create(arrayPrototype);
// 列举出影响属性变化以及需要改写原型的方法名
const methodsNeedChange = ["push","pop","shift","unshift","splice","reverse","sort"];
// 遍历方法名
methodsNeedChange.forEach(methodName => {
let original = arrayPrototype[methodName];
def(arrayMethods, methodName, function() {
//这个this是指调用该方法的实例对象,即数组对象arr
const result = original.apply(this, arguments);
//把类数组对象变为数组,从而在Observe中判断为数组,从而实现对元素的监视
const args = [...arguments];
//push , unshift, splice能增加新项,故也要变为observe
const ob = this.__ob__;
let inserted = []; // 保存新增的数组元素,用于设置响应式
switch(methodName) {
case 'push':
case 'unshift':
inserted = args; //指def形参的第三个function的参数
break;
case 'splice':
inserted = args.slice(2); //slice(start,end,newvalue) 开始结束时全闭区间
break;
}
//判断inserted是否为空,让新增的项也成为响应式
if (inserted) {
// ob就是OBserve类的实例对象
ob.arrayOberver(inserted)
}
// 工具函数的第三个参数是值,所以我们把方法处理完的结果返回即可
return result;
}, false)
})
// 修改Observer类
export default class Observer {
constructor(value) {
// 绑定响应式标识 __ob__ 属性
def(value, "__ob__", this, false);
// 判断是否为数组
if (Array.isArray(value)) {
// 实现拦截
Object.setPrototypeOf(value, arrayMethods);
// 数组的子项也可能是数组, 故也要调用 observe
this.arrayOberver(value);
} else {
this.walk(value);
}
}
walk(data) {
for (let k in data) {
defineReactive$$1(data, k);
}
}
arrayOberver(arr) {
for(let i = 0, l = arr.length; i < l; i++) {
observe(arr[i]);
}
}
}
这样,我们就能实现,既能劫持属性值为数组的变化,又不影响原来对数组的响应式的监听。
是吧,看到这里,你就会发现该API其实很拉垮,需要不断完善,才能勉强实现响应式。反之你也可以理解的尤大大的思维是有多么狂野。
但在Vue3.0
中,改用了proxy
处理响应式,实现了更完美的响应式。
经过前面的分析,我相信你现在应该对实现2.0版本的双向绑定应该有思路了,让我们梳理一下:
data
对属性进行数据劫持;Dep
调度中心,准备收集观察者依赖;getter
函数埋入观察者;setter
函数通知 Watcher
更新数据;Model
层数据变动,利用调度中心通知观察者更新视图;View
层操控数据,利用调度中心通知观察者更新 data
对应的属性。这里插一句,这篇文章代码部分是在 node 环境下示例的,也就是我可以使用 import 和 export 的关键。因为在这种开发环境下我的工作模式可以很单一,每一个文件都有它们自己的职责,而且每一个文件也只会注重自己需要做的事情。当然
Vue
源码也是这么做的,除了几份编译版的代码,但也是使用rollup
打包出来的。
OK,现在我们从头到尾,循序渐进的,完整的实现一下整个响应式的过程:
Dep
和 Watcher
收集依赖import Observer from "Observer.js";
/**
* observe.js
* 此方法用于判断数据是否为对象以及挂载拦截
* @param {Object} value 需要监听的对象
* @return {Object} ob 响应式的标识
*/
export default function observe(value) {
// 判断是基本数据类型或者引用数据类型 object
if (typeof value != "object") return;
let ob;
// 判断这个对象或属性是否携带有响应式的标识
if (typeof value.__ob__ != "undefined") {
ob = value.__ob__;
} else {
ob = new Observer(value);
}
return ob;
}
/**
* utils.js
* 该工具函数用于给对象属性绑定属性
* @param {Object} obj 需要绑定的对象
* @param {String} key 需要绑定的属性名
* @param {Any} value 需要绑定的属性值
* @param {Boolean} enumerable 绑定的属性是否可枚举
*/
export const function def(obj, key, value, enumerable) {
Object.defineProperty(obj, key, {
value,
enumerable,
writable: true,
configurable: true
});
}
// Observer.js
import { def } from "utils.js";
import Dep from "Dep.js";
import { arrayMethods } from "array.js";
import observe from "observe.js";
import defineReactive$$1 from "defineReactive.js";
export default class Observer {
constructor(value) {
// 绑定响应式标识 __ob__ 属性
def(value, "__ob__", this, false);
// 埋入该属性的收集者
this.dep = new Dep();
// 判断是否为数组
if (Array.isArray(value)) {
// 实现拦截
Object.setPrototypeOf(value, arrayMethods);
// 数组的子项也可能是数组, 故也要调用 observe
this.arrayOberver(value);
} else {
this.walk(value);
}
}
walk(data) {
for (let k in data) {
defineReactive$$1(data, k);
}
}
arrayOberver(arr) {
for(let i = 0, l = arr.length; i < l; i++) {
observe(arr[i]);
}
}
}
/**
* defineReactive.js
* 该函数用于实现数据劫持
* @param {Object} data 需要实现数据劫持的对象
* @param {String} key 被劫持对象的属性名
* @param {Any} value 被劫持对象的属性值
* @return {Any} value 被劫持对象添加监听后的属性值
*/
import Dep from "Dep.js";
import observe from "observe.js";
export default function defineReactive$$1(data, key, value) {
// 判断参数有没有传入 value,如果没有传入则需要给其赋值,否则报错
if (arguments.length == 2) value = data[key];
// 埋入收集依赖执行过程的收集者,用于触发收集或触发更新
const dep = new Dep();
// 其子属性也有可能是对象,故也要监听
let childOb = observe(value);
Object.defineProperty(data, key {
enumerable: true,
configurable: true,
get() {
// Watcher 获取属性值的时候判断 Dep.target
if (Dep.target) {
// 添加到需要通知更新的数组里面
dep.depend();
if (childOb) {
// 如果子属性有响应式也要添加
childOb.dep.depend();
}
}
return value;
},
set(newValue) {
if (newValue == value) return;
value = newValue;
// 新值子属性也可能是对象,故也要监听
childOb = observe(newValue);
// 更新值的时候,通知 Watcher 更新
dep.notify();
}
})
}
// 重新定义数组原型
import { def } from "utils.js";
// 复制 Array 的原型
const arrayPrototype = Array.prototype;
// 重塑新的 Array 原型
export const arrayMethods = Object.create(arrayPrototype);
// 列举出影响属性变化以及需要改写原型的方法名
const methodsNeedChange = ["push","pop","shift","unshift","splice","reverse","sort"];
// 遍历方法名
methodsNeedChange.forEach(methodName => {
let original = arrayPrototype[methodName];
def(arrayMethods, methodName, function() {
// 这个this是指调用该方法的实例对象,即数组对象arr
const result = original.apply(this, arguments);
// 把类数组对象变为数组,从而在Observe中判断为数组,从而实现对元素的监视
const args = [...arguments];
// push , unshift, splice能增加新项,故也要变为observe
const ob = this.__ob__;
let inserted = []; // 保存新增的数组元素,用于设置响应式
switch(methodName) {
case 'push':
case 'unshift':
inserted = args; //指def形参的第三个function的参数
break;
case 'splice':
inserted = args.slice(2); //slice(start,end,newvalue) 开始结束时全闭区间
break;
}
// 判断inserted是否为空,让新增的项也成为响应式
if (inserted) {
// ob就是OBserve类的实例对象
ob.arrayOberver(inserted)
}
// push , unshift, splice能增加新项,故需要通知 Watcher 更新
ob.dep.notify();
// 工具函数的第三个参数是值,所以我们把方法处理完的结果返回即可
return result;
}, false)
})
Dep
类:// 定义起始ID
let depid = 0;
export default class Dep {
constructor() {
// 自增ID
this.id = depid++;
this.subs = [];
}
addSubs(sub) {
this.subs.push(sub);
}
depend() {
if (Dep.target) {
this.addSubs(Dep.target);
}
}
notify() {
const subs = this.subs.slice();
for(let i = 0, l = subs.length; i < l; i++) {
// 循环通知 Watcher 更新
subs[i].update();
}
}
}
Watcher
类:import Dep from "Dep.js";
/**
* Watcher.js
* 该类用于实例观察者依赖
* @param {Node} node 依赖的对象属性
* @param {String} key 对象数据的属性名
* @param {Object} vm 实例化的 Vue 数据对象
*/
// 定义起始ID
let watchId = 0;
export default class Watcher {
constructor(node, key, vm) {
// 自增ID
this.id = watchId++;
// 实例化的时候指向自己,方便 Dep 收集依赖
Dep.target = this;
// 依赖的对象属性,用于判断数据以什么方式更新
this.node = node;
this.key = key;
// vm 实例化的 Vue 对象,包含 data
this.vm = vm;
// 获取实例化的属性值,驱动 Dep 收集依赖
this.getValue();
}
getValue() {
try {
this.value = this.vm.$data[this.key];
} finally {
// 设置为 null,防止死循环
Dep.target = null;
}
}
update() {
this.getAndInvoke();
}
getAndInvoke() {
this.getValue();
if (this.node.nodeType === 1) {
this.node.value = this.value;
} else if (this.node.nodeType === 3) {
this.node.textContent = this.value;
}
}
}
DOM
import Watcher from "Watcher.js";
/**
* render.js
* 该方法用于实现虚拟 DOM 和挂载节点
* @param {DOM} el 需要挂载的节点
* @param {Object} vm 实例化的 Vue 数据对象
*/
export default function nodeToFragment(el, vm) {
// 创建文档碎片
let fragment = document.createDocumentFragment();
let child;
// 循环生成文档碎片
while(child = el.firstChild) {
compiler(child, vm); // 模板编译
fragment.appendChild(child); // 将节点添加到文档碎片中
}
// 挂载文档
el.appendChild(fragment);
}
function compiler(node, vm) {
// 每个节点都有个节点类型属性 nodeType 对应的值分别是 1.元素 2.文本 8.注释 9.根节点
if (node.nodeType === 1) {
// 如果是元素节点
// 遍历所有的属性,判断是否有 v-model 指令
[...node.attributes].forEach(item => {
if (/^v-/.test(item.nodeName)) {
new Watcher(node, item.nodeValue, vm);
// nodeName 就是属性名
node.value = vm.$data[item.nodeValue];
node.addEventListener('input', () => {
console.log(vm.$data[item.nodeValue]);
vm.$data[item.nodeValue] = node.value;
})
}
});
// 元素节点还可能有很多子节点或孙子节点,因此需要递归处理
[...node.childNodes].forEach(item => {
compiler(item, vm);
})
} else if (node.nodeType === 3) {
// 如果是文本节点
// 检测该文本中是否包含胡须语法
if (/\{\{\w+\}\}/.test(node.textContent)) {
// 将胡须语法换为数据
node.textContent = node.textContent.replace(/\{\{(\w+)\}\}/, function(a, b) {
new Watcher(node, b, vm);
return vm.$data[b];
})
}
}
}
Vue
函数import observe from "observe.js";
import nodeToFragment from "render.js";
/**
* core.js
* 该方法用于生成 Vue 实例
* @param {Object} options 实例化所需的参数
*/
export default function Vue(options) {
this.$data = options.data;
this.$el = document.querySelector(options.el);
observe(this.$data);
nodeToFragment(this.$el, this);
}
DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>vue2实现双向绑定title>
head>
<body>
<div id="app">
<input type="text" v-model="name">
<h2>{{name}}h2>
div>
<script src="xuni/bundle.js">script>
<script>
const vm = new Vue({
data: { name: 'vk是铁憨憨', hobby: ['唱','跳','rap'] },
el: '#app'
})
script>
body>
html>
看一下效果:
data
DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>vue2实现双向绑定title>
head>
<body>
<div id="app">
<input type="text" v-model="name">
<h2>{{name}}h2>
div>
<script src="xuni/bundle.js">script>
<script>
const vm = new Vue({
data: { name: 'vk是铁憨憨', hobby: ['唱','跳','rap'] },
el: '#app'
})
setTimeout(() => {
vm.$data.name = "vk是大帅逼";
}, 3000)
script>
body>
html>
最后,感谢你的阅读,码字真的很辛苦,给个三连吧!!!
代码已上传至码云,有需要的小伙伴自行下载吧 —— 《下载地址》
参考文献