前言
随着互联网行业的飞速发展,互联网所携带的产业也迎来了春天,尤其是前端行业更为突出,各种框架层出不穷,Vue React Angular Solid 等优秀框架的出现,不仅使得前端行业发生了翻天覆地的变化,还大大减轻了开发者的学习成本,但是随着框架的不断优化和版本升级,我们不在只追逐如何去使用这些框架,而是把目光留在了框架本身,比如框架的设计思想、框架的实现方案、以及框架的设计模式等等
为什么介绍Vue2
Vue3的组合式API,和hooks设计已经成熟,可以说是对Vue2进行了彻底的颠覆- 比如
Vue2中,响应式拦截采用的是Object.defineProperty而Vue3中采用的是Proxy- 对于小白是学习
Vue2还是Vue3,尤大在 Vue Toronto 的主题演讲中也回答了Vue3是向下兼容的,可以直接学习Vue3即可 那么为什么还要介绍Vue2,我认为虽然Vue2已经要新版本取代,但是Vue2的设计思想和实现原理值得我们去深度刨析,框架的设计思想是不会过期的,这仍是值得我们学习的地方
观察者模式
Vue2的响应式开发,采用的就是设计模式中的观察者模式,这个设计模式在开发过程中也是用的最多的一种,下面我们一起来看一下观察者模式的实现原理
观察者模式下有两个重要的角色,分别是发布者与订阅者,通常情况下,发布者只有一个,而订阅者有很多个,
当状态发生改变(发布者发布信息),会通知所有订阅者进行更新处理,那么这个过程就是观察者模式的实现
发布者类
class Publisher {
constructor() {
// 订阅者 依赖存储器 初始化
this.observers = [];
}
// 增加订阅者
add(observer) {
this.observers.push(observer);
}
// 删除订阅者
remove(observer) {
const eq = this.observers.findIndex(observer);
if (eq) {
this.observers.splice(eq, 1);
}
}
// 通知订阅者更新
notify() {
this.observers.forEach((item) => {
item?.update(this);
});
}
}
我们看一下这段代码,这是一个发布者类,主要的方法有,添加订阅者add、删除订阅者remove、通知订阅者notify
订阅者类
class Observer {
constructor() {
console.log("订阅者 创建了");
}
update() {
console.log("更新");
}
}
订阅者类的解构很简单,只有一个 update方法, 订阅者 通过调用 Publisher.add(订阅者) 当发布者发布通知后,会逐个调用 notify 进行调用
现学现用
最近
华为mate50和iphone14发布会已经结束,大家都在挣钱恐后的抢购,导致服务器爆炸,特喵的我根本就挤不进去,跑偏了不好意思,哈哈哈。那么我们现在有个需求,有一个手机厂商(phoneFactory),他会不定时的发布一款新的手机,而我们有A、B、C三位同学(studentObserver)着急换手机,所以就一直在等待这个厂商的新品发布,那么我们如何实现一个当phoneFactory工厂发布新手机时,通知studentObserver购买
PhoneFactory
class PhoneFactory extends Publisher {
constructor() {
// 执行继承类的构造函数
super();
// 初始化手机 默认是没有
this.phone = null;
this.phonePrice = 0;
// 订阅者列表 存起来用于通知
this.observers = [];
}
// 获取当前的产品名称和价格
getProduct() {
return {
phone: this.phone,
phonePrice: this.phonePrice
};
}
// 修改产品信息
setPrd(phone, phonePrice) {
this.phone = phone;
this.phonePrice = phonePrice;
// 通知订阅者可以购买了
this.notify();
}
}
studentObserver
class StudentObserver extends Observer {
constructor(name, price) {
super();
// 订阅者姓名
this.name = name
// 资产
this.price = price;
// 当前手机型号
this.phoneModel = 'HUAWEI nova5 pro';
}
// update 购买
update(Publisher) {
// 更新需求文档
let foo, boo = false
foo = Publisher.getProduct();
if(foo.phonePrice < this.price) {
// 买得起
boo = true
// 换新手机了,修改手机的参数
this.price -= foo.phonePrice
this.phoneModel = foo.phone
}
// 其他逻辑
this.run(boo);
}
// 执行的逻辑
run(boo) {
console.log(
`${this.name} ${boo ? "购买了新手机" : "存款买不起"}`,
`存款剩余 ${this.price}`, `当前手机:${this.phoneModel}`
);
}
}
代码测试:
// 工厂
const Factory = new PhoneFactory()
// 学生A、B、C
// A同学有一万块钱
const A = new StudentObserver('A', 10000)
// B同学有四千块钱
const B = new StudentObserver('B', 4000)
// C同学有两千块钱
const C = new StudentObserver('C', 2000)
// 学生订阅到工厂
Factory.add(A)
Factory.add(B)
Factory.add(C)
// 工厂发布手机
Factory.setPrd('iphone14 pro max 远峰蓝', 9999)
我们运行代码查看输出的结果
我们可以看到,订阅者创建了三次,因为有三名同学订阅了这个工厂,当工厂发布手机时,三名同学会触发购买流程,但是很可惜B、C两位同学因为买不起错过了这次发布会
以上实例就是一个完整的观察者模式的示例,虽然有时候写法大同小异,但是设计思想都是一样的,无非就是通过订阅,然后发布者修改数据并通知订阅者完成具体的逻辑操作
Vue2 中响应式的实现
我们先来看一段代码
var vm = new Vue({
el: "#app",
data: {
name: "yunhe达摩院",
age: 16,
school: "郑州大学",
},
});
我们在使用Vue2时,首先会在data定义一个对象或者闭包, data 存放的是我们响应式处理的数据,当我们在模板中渲染data中的数据后,如果data里面的数据发生了改变,就会通知模板更新绑定的data
现在我们撸一撸思路Vue修改参数,通知模板更改,这用的不就是我们的观察者模式
- 模板调用
data,完成订阅 data.xxx = newXxx修改数据后,通知notify修改页面上的变量,完成响应式更新
那么怎么去用代码具体实现Vue2的响应式开发,下面让我们一起来看一下
文件目录结构如下
main.js为入口文件
index.js为vue文件
dep.js为订阅者文件
proxy.js为代理文件
watcher.js为订阅者文件
defineReactive.js为拦截器文件
mount.js为渲染文件
简单的实现Vue2的响应式,大致需要以上几个文件,现在都是空白文件,现在让我们一步一步的根据我们的思路,去实现一下Vue2的响应式处理
main.js
main.js是项目的入口文件,这里通常是使用Vue的地方,所以我们可以根据Vue2的使用原理去编写一个Vue2的解构代码
import Vue from "./index.js";
var vm = new Vue({
el: "#app",
data: {
name: "达摩院",
age: 16,
school: "郑州大学",
},
// data 还可以使用闭包
// data() {
// return {
// name: "达摩院",
// age: 16,
// school: '郑州大学'
// }
// }
});
// 将 $vm 绑定到window全局变量上,帮助我们在控制台可以操作修改变量
window.$vm = vm;
index.js
我们在
main.js中导入了import Vue from "./index.js",所以接下来我们要创建它
export default function Vue(options) {
// _init 用于初始化;
this._init(options);
}
// 初始化 把data绑定到
Vue.prototype._init = function (options) {
// 把数据配置挂载到Vue.$options 上
this.$options = options;
// 初始数据(data methods props computed等 这里制作简单的处理 data)
initData(this);
}
// 初始化数据 具体的方法实现
function initData(vm) {
const {
data,
el
} = vm.$options
// data 就是我们在首页 new Vue({data(){return {}}}) 的data
let _data
if (data) {
// 把data的值挂载到vm实例上
// data 有可能是个闭包方法 所以要判断如果是闭包执行以下获取一下里面的json
_data = vm._data = typeof data === 'function' ? data() : data
}
}
现在我们已经将 new Vue({data(){return {}}}) 的 data 绑定到了Vue示例上, 现在让我们打开控制台看一下
现在我们不仅可以查看绑定的数据,而且还可以进行修改,那么在Vue2的逻辑中,我们在进行修改属性时,会被系统劫持,也就是说我们所有的赋值操作,都应该被拦截到,这里我们使用的是 defineReactive.js
defineReactive.js
export default function defineReactive(target, key, val) {
Object.defineProperty(target, key, {
get() {
return val
},
set(value) {
console.log(val,'被修改了 ',value);
if (val === value) return
val = value
}
})
}
修改 index.js
import defineReactive from "./defineReactive.js";
export default function Vue(options) {
this._init(options);
}
// 初始化 把data绑定到
Vue.prototype._init = function (options) {
console.log("options ==> ", options);
// 把数据配置挂载到Vue.$options 上
this.$options = options;
// 初始数据 (data methods props computed等 这里制作简单的处理 data)
initData(this);
};
// 初始化数据 具体的方法实现
function initData(vm) {
const { data, el } = vm.$options;
// data 就是我们在首页 new Vue({data(){return {}}}) 的data
// console.log('data ==> ', data);
let _data;
if (data) {
// 把data的值挂载到vm实例上
// data 有可能是个闭包方法 所以要判断如果是闭包执行以下获取一下里面的json
_data = vm._data = typeof data === "function" ? data() : data;
}
// 给对象属性设置响应式 (只支持对象)
function walk(obj) {
for (let key in obj) {
// 依次给对象设置拦截
defineReactive(obj, key, obj[key]);
}
}
// 设置拦截
walk(_data);
}
运行示例查看效果
到目前为止,我们的程序已经可以监听到我们的修改了
index.html
通过上面的示例,我们已经创建了Vue文件,并设置了拦截修改的处理,但是我们发现,我们的数据只能在
console.log内运行,现在我们渲染一下我们的数据,看一下我们的效果
<!DOCTYPE html>
<html lang="en">
<body>
<div id="app">
<div>{{name}}</div>
<div>{{age}}</div>
<div>{{shops}}</div>
<div>{{school}}</div>
<div>{{name}}</div>
</div>
<script type="module" src="./main.js"></script>
</body>
</html>
mount.js
现在我们已经将变量放置在了模板块中,但是我们现在还无法渲染数据,因为我们的
mount挂载逻辑没有处理,现在让我们完善一下mount.js
// 把数据挂载到页面上
export default function mount(vm) {
// 获取根节点
let {
el
} = vm.$options;
el = document.querySelectorAll(el);
// el[0].childNodes 默认情况下是 NodeList类数组,输出可看,不能直接进行遍历需要转换为普通数组
// 把vm实例传下去 方便替换数据
compuleNode(Array.from(el[0].childNodes), vm)
}
// 查找变量并替换
function compuleNode(nodes, vm) {
// node 是我们拿到的所有根节点下的dom
// 遍历dom节点
for (const node of nodes) {
// 节点有区别:
// nodeType === 1 表示是元素div span这些元素等
// nodeType === 3 表示是文本 也就是最后一级
if (node.nodeType === 3 && node.textContent.match(/{{(.*)}}/)) {
// node.textContent.match(/{{(.*)}}/) 会对我们的结果进行分组 {{name}} {{age}}
// 通过正则 可以使用下标获取结果
// console.log('RegExp.$1 ==> ', RegExp.$1);
// 既然是文本 那么我们就可以直接查找替换元素即可
// <div>{{name}}</div> 我们要判断他是否有 {{}}
// 是文本节点 并且有 {{(.*)}} 因为我们的变量是通过双括号包含的
compuleTextNode(node, vm);
}
// 如果是dom元素 就递归遍历查找他的子元素
if (node.nodeType === 1) {
compuleNode(node.childNodes, vm)
}
}
}
// 替换数据
function compuleTextNode(node, vm) {
// 获取匹配的结果
// 替换dom得值
const key = RegExp.$1
// 写一个回调 专门修改当前这个节点的数据
// 如果 vm[key] 有数据 就返回数据 没有就返回 未找到
node.textContent = vm[key] ? JSON.stringify(vm[key]) : `${key} 未定义`;
}
修改 index.js
import mount from "./compiler/mount.js";
import defineReactive from "./defineReactive.js";
export default function Vue(options) {
this._init(options);
}
// 初始化 把data绑定到
Vue.prototype._init = function (options) {
console.log("options ==> ", options);
// 把数据配置挂载到Vue.$options 上
this.$options = options;
// 初始数据 (data methods props computed等 这里制作简单的处理 data)
initData(this);
// 挂在到dom
if (this.$options.el) {
this.$mount();
}
};
// 挂在实例
Vue.prototype.$mount = function () {
// 挂载到页面上
mount(this);
};
}
运行文件,我们会发现,所有的变量全部都报未定义
那是因为在这里,我是需要通过 _data.xx 来访问 xx 变量的,在Vue中,我们使用data中的变量的时候,直接使用的方式是 this.xxx,而不是 this.data.xxx, 当然,我们也可以渲染 _data 到页面查看
在 index.html 输出 {{_data}}
虽然使用_data.xxx的方式可行,但是我们总不能一直使用_data.为前缀吧,这样的话会使得我们的代码非常的不堪,那么这个地方,我们可以使用proxy代理,将 xxx 代理到 vm._data 上,这样当我们访问xxx时,会被拦截并代理到_data.xxx
proxy.js
export default function proxy(target, sourceKey, key) {
// 代理 当访问指定targert对象时 例:Obj.a 代理到 Obj.data.a
// proxy(Obj, data, a) 代理
Object.defineProperty(target, key, {
get() {
// this.a 访问 this._data.a
return target[sourceKey][key]
},
set(val) {
// this.a = 1 修改 this._data.a = 1
target[sourceKey][key] = val
}
})
}
在 index.js 中调用
import mount from "./compiler/mount.js";
import defineReactive from "./defineReactive.js";
import proxy from './proxy.js'
// 初始化数据 具体的方法实现
function initData(vm) {
const { data, el } = vm.$options;
// data 就是我们在首页 new Vue({data(){return {}}}) 的data
// console.log('data ==> ', data);
let _data;
if (data) {
// 把data的值挂载到vm实例上
// data 有可能是个闭包方法 所以要判断如果是闭包执行以下获取一下里面的json
_data = vm._data = typeof data === "function" ? data() : data;
}
// 给对象属性设置响应式 (只支持对象)
function walk(obj) {
for (let key in obj) {
// 依次给对象设置拦截
defineReactive(obj, key, obj[key]);
}
}
// 设置拦截
walk(_data);
// proxy 映射
for (const key in _data) {
// 把data数据代理到 _data 下 是他支持 this.xxx 调用
// 原理 当我们调用 this.xxx 时,会把我们的指向为 this._data.xxx
// props methods computed同理
proxy(vm, "_data", key);
}
}
完成代理后,我们在浏览器中重新执行,会发现,所有的数据都已经被渲染出来了
现在我们已经实现了一个很小的Vue模板渲染功能,但是,我们目前的功能还不能够进行响应式的实现,因为我们修改了数据,也仅仅只是修改了对象中的数据而已,并没有被重新渲染到页面上
重点来袭
在
vue中,发布者和订阅者的关系比较抽象
- 我们定义一个
Dep类用来订阅data,当data修改时通知Dep类更新,data和Dep关系是一对一的订阅模式,一个data只能被订阅一次,在这里data等于被Dep订阅- 在模板渲染中,我们定义一个
Watcher类,当我们在模板中渲染data时,我们将调用的地方传递给Watcher,并让Watcher订阅Dep,Dep修改后通知模板Watcher渲染模板,也就是说,Dep和Watcher的关系属于是一对多的关系, 一个Dep可以被多个Watcher调用
具体的流程图如下
当我们修改name 时,会触发更新Dep 、 Dep更新后会开始遍历更新Watcher,此时只会去更新name的订阅者,而不会影响其他
也就是说在这里,Dep不仅仅是订阅者,他也是发布者
Dep.js
理解完上面的原理,我们现在开始创建
Dep类
class Dep {
// Dep 不仅是订阅者 他订阅后还要收集watcher来更新模板 所以Dep也可以说是发布者
constructor() {
this.watcher = []
}
// target 表示节点, 当在模板中调用变量时,target指向哪个dom节点
static target = null
depend() {
// 主要用于保存 Watcher,Watcher 会在DOM调用时处理
// Dep.target 就是 当前模板中的 Watcher
this.watcher.push(Dep.target)
}
// 通知依赖 watcher 更新
notify() {
this.watcher.forEach(sub => {
sub.update()
})
}
}
export default Dep
处理完 Dep 我们开始订阅 data
修改 index.js
// 添加引用
import Dep from "./dep.js";
// 修改方法
function initData(vm) {
const { data, el } = vm.$options;
// data 就是我们在首页 new Vue({data(){return {}}}) 的data
// console.log('data ==> ', data);
let _data;
if (data) {
// 把data的值挂载到vm实例上
// data 有可能是个闭包方法 所以要判断如果是闭包执行以下获取一下里面的json
_data = vm._data = typeof data === "function" ? data() : data;
}
// proxy 映射
for (const key in _data) {
// 把data数据代理到 _data 下 是他支持 this.xxx 调用
// 原理 当我们调用 this.xxx 时,会把我们的指向为 this._data.xxx
// props methods computed同理
proxy(vm, "_data", key);
}
// 设置完代理以后 给data数据设置响应式更新 也就是 Dep订阅data
observe(vm._data);
}
// 响应式判断和设置响应式
function observe(data) {
// 如果数据已经是响应式 就不需要observe进行依赖收集 否则会造成 重复更新指定节点
if (data.__ob__) return value.__ob__;
return new Observer(data);
}
// 订阅
function Observer(data) {
// 给每个数据都加上一个 __ob__ 属性 表示已经处理了响应式拦截和更新
Object.defineProperty(data, "__ob__", {
value: this,
// 数据可枚举
enumerable: false,
// 数据可修改
writable: true,
// 数据可配置
configurable: true,
});
console.log("data ==> ", data);
// 对值进行依赖收集也就是订阅
data.__ob__.dep = new Dep();
// 给对象的每一项都设置响应式
this.walk(data);
}
// 给对象属性设置响应式 (只支持对象)
Observer.prototype.walk = function (obj) {
for (let key in obj) {
// 依次给对象设置拦截
defineReactive(obj, key, obj[key]);
}
};
添加完Dep订阅以后,我们在控制台输出查看我们的变量是否被Dep订阅
在这里Dep已经成功订阅了data
Watcher.js
Dep订阅了data之后,我们开始处理模板中使用data时,让Watcher订阅Dep
首先我们要知道 Watcher 其实就是更新Dom 操作的
我们修改一下mount.js下面的 ompuleTextNode法
// 新增引入
import Watcher from "../watcher.js";
function compuleTextNode(node, vm) {
// 获取匹配的结果
// 替换dom得值
const key = RegExp.$1
// 封装成一个方法 专门更新当前的Dom
function cb() {
// 如果 vm[key] 有数据 就返回数据 没有就返回 未找到
node.textContent = vm[key] ? JSON.stringify(vm[key]) : `${key} 未定义`;
}
// Watcher 是专门用来处理Dom节点更新的
// 我们把cb回调传递给Watcher,当更新至 执行这个cb() 就可以完成重新渲染
new Watcher(cb)
}
Wtcher.js
import Dep from "./dep.js"
class Watcher {
// cb 是修改指定dom的闭包回调,只修改某一个
constructor(cb) {
// Dep.target 是一个静态变量, this就是我们当前的Watcher,赋值给Dep后,Dep放入到订阅数组中
// 为什么要使用Dep.target 因为我们必须要知道我们调用data的时机
// js this.xxx调用时 我们不需要获取依赖,所以 Dep.target 主要适用于判断那我们的时机
Dep.target = this
// 这一步是将修改的回调函数存储起来
this._cb = cb
// 执行一下,因为初始化状态需要执行以下,将 {{name}} 转换为 data 的变量值
this._cb()
// 处理完毕后 要制空 否则会阻断后面的变量
Dep.target = null
}
// 执行更新
update() {
this._cb()
}
}
export default Watcher
创建完Dep和Watcher之后,我们开始处理最后一步,也就是拦截修改,通知Dep修改,并让Dep通知Watcher修改
修改defineReactive.js
当我们在模板中调用 data 时,我们需要将Dep订阅这个data
import Dep from "./dep.js";
// 拦截器
export default function defineReactive(target, key, val) {
// Dep 订阅 data 一对一
const dep = new Dep();
Object.defineProperty(target, key, {
get() {
// Dep.target 表示 Watcher,可以输出查看
// Dep.target 如果有值,表示是在模板中使用了data,需要订阅
// 如果只是在 js 中使用了,那么不进行订阅
if (Dep.target) {
// 订阅
dep.depend();
}
return val;
},
set(value) {
if (val === value) return;
val = value;
// dep 通知 watcher更新
// watcher 就是修改模板方法
dep.notify();
},
});
}
至此,我们已经实现了响应式的原理,我们在控制台更改一下数据,看看是否能够完成响应式的更改
总结
Dep是怎么与data进行绑定的
答:当我们在模板中调用data时,会触发拦截器的get()方法,在这个方法里,会去判断是否是模板文件调用的data,我们在new Watcher()时,会将 Dep.target 指向当前 Watcher,我们通过Dep.target来判断是否是模板调用的data,如果是那么就将Dep订阅到data
结尾
Vue的响应式设计原理,不论是Vue2还是Vue3,都是采用的观察者模式来实现的,这个设计模式不论是在工作中,还是在面试中,都非常的常见,而且非常的重要,如果感兴趣的话,可以去了解一下js中的设计模式,我想一定会对你的思路和间接有一定的提升