Vue2.0 —— 由设计模式切入,实现响应式原理
《工欲善其事,必先利其器》
既然点进来了,麻烦你看下去,希望你有不一样的收获。
大家好,我是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
先通过几段了解一下 Object.defineProperty 这个 API:
const data = {}
cosnole.log(data)
我们根据输出的结果,可以看到 data 里面现在只有一个原型对象。当我们为 data 添加或修改属性时:
let name = '张三'
data.name = name
console.log(data)
对于添加或修改对象属性,有时候我们也可以用到 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)
通过该 API 新增对象属性,我们可以观察到,跟直接添加对象属性相比较,多了 getter 和 setter 两个内置函数,分别用来拦截调用属性值和修改操作属性。
data.name = "李四"
console.log(data.name)
这说明,Object 的可能性一下子就被打开了,利用这个 API 可以达到我们前面提到的设计模式的特点。
插一句题外话,在 Vue2.0 发布的时候,Proxy 其实已经诞生了。很多人疑惑为什么尤大大不使用 Proxy?其实是因为当时的前端环境还并没有完全支持这个 API。很多浏览器除了几个主流的,基本上都还没有适配上。所以,尤大大为了用户群体考虑,选择了 Object.defineProperty,而放弃了 Proxy。直到今天,前端环境对 Proxy 友好了,Vue3.0 也就天然适配了 Proxy。
由此可见,我们可以往 Object.defineProperty 里面添加很多东西,例如:数据的监听、数据的加工、数据的计算、数据的判断等等非常非常多的工作。这也被业界称之为非常经典的 —— 《Vue 的数据劫持》。
但是,该API有弊端。 对于已进行数据劫持的对象,他在新增属性时候,并不会为新属性绑定 setter 和 getter 。 对于已进行数据劫持的对象,他在删除属性的时候,并不会触发 setter
三、浅析 Vue2.0 的响应式
通过上面的分析我们大概了解到,Vue 的数据劫持是怎么实现的。
这个时候其实很重要昂,数据劫持只是响应式的其中一环罢了。不过现在需要继续摸索,层层递进,我带你模拟一下响应式的简易的过程(由于篇幅原因,就不做太多的引导了,直接全部代码展示,希望你多跟着敲几遍,把它理解透):
- 我们需要一个入口,来传入以及分析数据类型,创建
observe.js 文件:
import Observer from "Observer.js";
export default function observe(value) {
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 文件:
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) {
def(value, "__ob__", this., false);
this.walk(value);
}
walk(data) {
for (let k in data) {
defineReactive$$1(data, k);
}
}
}
- 创建
defineReactive.js 文件,实现数据的拦截:
export default function defineReactive$$1(target, key, value) {
if (arguments.length == 2) value = target[key];
observer(value);
Object.defineProperty(target, key, {
enumerable: true,
configurable: true,
get() {
return value;
},
set(newValue) {
if (newValue !== value) {
value = newValue;
observer(newValue);
console.log("视图更新");
}
}
})
}
然后,利用 observer 测试监听一个对象:
const obj = {
name: '张三',
age: 20
}
observer(obj);
console.log(obj);
我们观察到对象分别添加了 age > getter 、age > setter 、name > getter 和 name > setter ,说明我们针对 obj 的数据劫持已经成功监听到了。
不过,接下来,我们还需要测试一下,当我们分别新增属性、修改属性和删除属性,是否会触发视图刷新函数:
obj.idcard = 123456
obj.age = 18
delete obj.age
console.log(obj)
现在我们可以观察到,执行完语句的对象 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";
const arrayPrototype = Array.prototype;
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() {
const result = original.apply(this, arguments);
const args = [...arguments];
const ob = this.__ob__;
let inserted = [];
switch(methodName) {
case 'push':
case 'unshift':
inserted = args;
break;
case 'splice':
inserted = args.slice(2);
break;
}
if (inserted) {
ob.arrayOberver(inserted)
}
return result;
}, false)
})
export default class Observer {
constructor(value) {
def(value, "__ob__", this, false);
if (Array.isArray(value)) {
Object.setPrototypeOf(value, arrayMethods);
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 处理响应式,实现了更完美的响应式。
四、实现 Vue2.0 的双向绑定原理
经过前面的分析,我相信你现在应该对实现2.0版本的双向绑定应该有思路了,让我们梳理一下:
- 组件挂载的时候,必须先遍历
data 对属性进行数据劫持; - 生成
Dep 调度中心,准备收集观察者依赖; - 利用
getter 函数埋入观察者; - 利用
setter 函数通知 Watcher 更新数据; - 如果
Model 层数据变动,利用调度中心通知观察者更新视图; - 如果
View 层操控数据,利用调度中心通知观察者更新 data 对应的属性。
这里插一句,这篇文章代码部分是在 node 环境下示例的,也就是我可以使用 import 和 export 的关键。因为在这种开发环境下我的工作模式可以很单一,每一个文件都有它们自己的职责,而且每一个文件也只会注重自己需要做的事情。当然 Vue 源码也是这么做的,除了几份编译版的代码,但也是使用 rollup 打包出来的。
OK,现在我们从头到尾,循序渐进的,完整的实现一下整个响应式的过程:
- 实现数据劫持
- 处理数组原型
Dep 和 Watcher 收集依赖- 修改数据更新视图
import Observer from "Observer.js";
export default function observe(value) {
if (typeof value != "object") return;
let ob;
if (typeof value.__ob__ != "undefined") {
ob = value.__ob__;
} else {
ob = new Observer(value);
}
return ob;
}
export const function def(obj, key, value, enumerable) {
Object.defineProperty(obj, key, {
value,
enumerable,
writable: true,
configurable: true
});
}
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) {
def(value, "__ob__", this, false);
this.dep = new Dep();
if (Array.isArray(value)) {
Object.setPrototypeOf(value, arrayMethods);
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]);
}
}
}
import Dep from "Dep.js";
import observe from "observe.js";
export default function defineReactive$$1(data, key, 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() {
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
}
}
return value;
},
set(newValue) {
if (newValue == value) return;
value = newValue;
childOb = observe(newValue);
dep.notify();
}
})
}
import { def } from "utils.js";
const arrayPrototype = Array.prototype;
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() {
const result = original.apply(this, arguments);
const args = [...arguments];
const ob = this.__ob__;
let inserted = [];
switch(methodName) {
case 'push':
case 'unshift':
inserted = args;
break;
case 'splice':
inserted = args.slice(2);
break;
}
if (inserted) {
ob.arrayOberver(inserted)
}
ob.dep.notify();
return result;
}, false)
})
let depid = 0;
export default class Dep {
constructor() {
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++) {
subs[i].update();
}
}
}
import Dep from "Dep.js";
let watchId = 0;
export default class Watcher {
constructor(node, key, vm) {
this.id = watchId++;
Dep.target = this;
this.node = node;
this.key = key;
this.vm = vm;
this.getValue();
}
getValue() {
try {
this.value = this.vm.$data[this.key];
} finally {
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;
}
}
}
import Watcher from "Watcher.js";
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) {
if (node.nodeType === 1) {
[...node.attributes].forEach(item => {
if (/^v-/.test(item.nodeName)) {
new Watcher(node, item.nodeValue, vm);
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];
})
}
}
}
import observe from "observe.js";
import nodeToFragment from "render.js";
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>
看一下效果:
<!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>
最后,感谢你的阅读,码字真的很辛苦,给个三连吧!!!
代码已上传至码云,有需要的小伙伴自行下载吧 —— 《下载地址》
参考文献
|