知其不可奈何而安之若命
——— <<庄子>>
前言
这是一个Vue源码系列文章,建议从第一篇文章 Vue源码系列(一):Vue源码解读的正确姿势 开始阅读。 文章是我个人学习源码的一个历程,这边分享出来希望对大家有所帮助。
本文是上一篇文章:Vue源码系列(三):数据响应式原理的补充。主要内容是Vue2.X、Vue3.X的数据响应式原理和区别,同时补充了发布订阅模式和观察者模式,最后手撸一个min-vue的数据响应式。
在上一篇文章中,对于响应式的源码解析咱们说到了一个方法:defineReactive(),它就是Vue对响应式处理的核心代码。不记得的小伙伴可以点击Vue源码系列(三):数据响应式原理回顾一下,对 defineReactive() 的解释在文章中的 代码块 7 。
接下来进入文章内容 👇 👇
VueJs的核心包括一套“响应式系统”。 “响应式”,是指当数据改变后,Vue会通知到使用该数据的代码。例如,视图渲染中使用了数据,数据改变后,视图也会自动更新。
接下来咱们将从浅到深,了解一下Vue2.X 和 Vue3.X 的响应式原理。
Vue2.X响应式原理
首先咱们先说一下 Vue2.X 的响应式原理吧。
人狠话不多,直接上代码 😂 😂
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title> Vue2.X 单属性的数据响应式 </title>
</head>
<body>
<div id="app"></div>
<script>
// 模拟Vue中的data选项
let data = {
msg: 'hello world'
}
// 模拟Vue的实例
let vm = {}
// 数据劫持:当访问或者设置vm中的成员时,做一些劫持后操作
Object.defineProperty(vm, 'msg', {
// 当获取值的时候执行
get () {
console.log('get: ', data.msg)
return data.msg
},
// 当设置值的时候执行
set (newValue) {
console.log('set: ', newValue)
if (newValue === data.msg) {
return
}
data.msg = newValue
// 数据更改时更新DOM的值
document.querySelector('#app').textContent = data.msg
}
})
// 测试一下 o(* ̄︶ ̄*)o
vm.msg = 'Hello VueJs'
console.log(vm.msg)
</script>
</body>
</html>
运行代码咱们可以看到,改变vm.msg的值,触发了数据劫持
还可以试着改变看一下具体怎么触发数据劫持的
以上只支持一个属性的响应式,如果想支持多个属性又该怎么办呢?直接上代码。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title> Vue2.X 多属性的数据响应式 </title>
</head>
<body>
<div id="app"></div>
<script>
// 模拟Vue中的data选项
let data = {
msg: 'hello vue',
value: 7
}
// 模拟Vue的实例
let vm = {}
proxyData(data)
function proxyData(data) {
// 遍历data对象中的所有属性
Object.keys(data).forEach(key => {
// 把data中的属性,转换成vm的setter
Object.defineProperty(vm, key, {
enumerable: true,
configurable: true,
get () {
console.log('get: ', key, data[key])
return data[key]
},
set (newValue) {
console.log('set: ', key, newValue)
if (newValue === data[key]) {
return
}
data[key] = newValue
// 数据更改,使DOM的值更新
document.querySelector('#app').textContent = data[key]
}
})
})
}
// 测试一下 o(* ̄︶ ̄*)o
vm.msg = 'Hello Vue'
console.log(vm.msg)
</script>
</body>
</html>
试一下是否支持多个属性的响应式
Vue3.X响应式原理
Vue3.X和Vue2.X的响应式实现不同,Vue3.X是实现原理是用ES6中的Proxy方法来实现的。这里有的小伙伴可能对Proxy这个方法不是太熟悉,所以咱们先简单说一下:
Proxy对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)
语法
const p = new Proxy(target, handler)
参数
target: 要使用Proxy包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。handler: 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理p的行为。
想详细了解的可以戳 MDN详解说明 详细了解一下。
具体怎么用Proxy实现Vue3.X响应式原理的,咱们还是人狠话不多,直接上代码 😂 😂
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Vue3.X 数据响应式</title>
</head>
<body>
<div id="app"> </div>
<script>
// 模拟Vue中的data选项
let data = {
msg: 'hello vue',
value: 7
}
// 模拟Vue实例
let vm = new Proxy(data, {
// 执行代理行为的函数 当访问vm的成员会执行
get (target, key) {
console.log('get, key: ', key, target[key])
return target[key]
},
// 当设置vm的成员会执行
set (target, key, newValue) {
console.log('set, key: ', key, newValue)
if (target[key] === newValue) {
return
}
target[key] = newValue
document.querySelector('#app').textContent = target[key]
}
})
// 测试一下 o(* ̄︶ ̄*)o
vm.msg = 'Hello Vue'
console.log(vm.msg)
</script>
</body>
</html>
试验一下,看看效果如何。
👌🏻 完美! 看一下区别吧,虽然已经很明了了,但是还总结一下。
Vue2.X 和 Vue3.X 的响应式原理
首先是Vue 2.x
- 底层原理:Object.defineProperty
- 直接监听属性
- 浏览器兼容IE8以上(不兼容IE8)
其次是Vue3.X
- 底层原理:Proxy
- 直接监听对象,而非属性
- ES6中新增方法,不支持IE浏览器
发布订阅模式和观察者模式
接下来我们继续深入Vue的响应式原理,那么就不得不说一下发布订阅模式和观察者模式了。
发布订阅模式
什么是发布订阅模式呢?
基于一个事件中心,接收通知的对象是订阅者,需要先订阅某个事件,触发事件的对象是发布者,发布者通过触发事件,通知各个订阅者。
举个例子🌰 :大家都订阅过公众号吧?比如:微信开发者啊、奇舞精选啊之类的。这里就涉及到两个角色:公众号(事件中心) 和 订阅了公众号的大家(订阅者)。然后当公众号的作者发布了文章以后,订阅了公众号的大家就会收到消息,这里又涉及到一个角色:公众号的作者(发布者)。
vue中的事件总线就是使用的发布订阅模式。
接下来就模拟实现Vue中的自定义事件,依旧人狠话不多,直接上代码
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>发布订阅模式</title>
</head>
<body>
<script>
// 事件触发器
class EventEmitter {
// 事件中心
constructor () {
// 创建的对象原型属性为null
this.subs = Object.create(null)
}
// 注册事件
$on (eventType, handler) {
this.subs[eventType] = this.subs[eventType] || []
this.subs[eventType].push(handler)
}
// 触发事件
$emit (eventType) {
if (this.subs[eventType]) {
this.subs[eventType].forEach(handler => {
handler()
})
}
}
}
// 测试一下 o(* ̄︶ ̄*)o
let em = new EventEmitter()
// 注册事件(订阅消息)
em.$on('click', () => {
console.log('click1')
})
em.$on('click', () => {
console.log('click2')
})
// 触发事件(发布消息)
em.$emit('click')
</script>
</body>
</html>
校验一下,大家也可以复制代码体验一下:
是不是感觉通俗易懂 😆 😆 😆
观察者模式
目标者对象和观察者对象有相互依赖的关系,观察者对某个对象的状态进行观察,如果对象的状态发生改变,就会通知所有依赖这个对象的观察者。
观察者模式相比发布订阅模式少了个事件中心,订阅者和发布者不是直接关联的。
- 目标者对象 [Subject] : 拥有方法:[ 添加 / 删除 / 通知 ] Observer;
- 观察者对象 [Observer] : 拥有方法:接收 Subject 状态变更通知并处理;
目标对象 [Subject] 状态变更时,通知所有观察对象[Observer]。
Vue中响应式数据变化是观察者模式,在上一篇源码解析文章中咱们已经了解了,每个响应式属性都有dep,dep存放了依赖这个属性的watcher(watcher是观测数据变化的函数),如果数据发生变化,dep就会通知所有的观察者watcher去调用更新方法。因此, 观察者需要被目标对象收集,目的是通知依赖它的所有观察者。这里可能有的小伙伴会问:为什么watcher中也要存放dep呢?原因是因为当前正在执行的watcher需要知道此时是哪个dep通知了自己。
观察者(订阅者)- Watcher
- update(): 当事件发生时,具体要做的事情
目标者(发布者) - Dep
- subs数组:存储所有的观察者
- addSub(): 添加观察者
- notify(): 当事件发生后调用所有观察者的update()
没有事件中心
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>观察者模式</title>
</head>
<body>
<script>
// 目标者(发布者)
class Dep {
constructor () {
// 记录所有的订阅者
this.subs = []
}
// 添加订阅者
addSub (sub) {
if (sub && sub.update) {
this.subs.push(sub)
}
}
// 发布通知
notify () {
this.subs.forEach(sub => {
sub.update()
})
}
}
// 观察者(订阅者)
class Watcher {
update () {
console.log('update')
}
}
// 测试一下 o(* ̄︶ ̄*)o
let dep = new Dep()
let watcher = new Watcher()
let watcher1 = new Watcher()
// 添加订阅
dep.addSub(watcher)
dep.addSub(watcher1)
// 开启通知
dep.notify()
</script>
</body>
</html>
虽然已经很明显了,但还是验证一下吧。
兄弟们 看到这里是不是恍然大悟的感觉?「哈哈。。。 是不是有点 🌬 🐂 🍺 的感觉?」
发布订阅模式和观察者模式的区别
从三个角度总结一下吧:
从结构上分析
- 观察者模式里,只有两个角色:观察者 和 目标者(也可以叫被观察者)
- 发布订阅模式里,不仅仅只有发布者和订阅者,还有一个事件中心(也可以叫控制中心)
从关系上分析
- 观察者和目标者,是松耦合的关系
- 发布者和订阅者,则完全不存在耦合
从使用角度分析
- 观察者模式,多用于单个应用内部(上面说过Vue中响应式数据变化就是观察者模式)
- 发布订阅模式,则更多应用于跨应用的模式,比如我们常用的 消息中间件
来个图片说明一下吧
简易的vue响应式
一切都准备好,那么就开始进入咱们今天的主题吧。依旧人狠话不多,直接上代码。
运用代码(index.html)
首先上运用代码。然后根据功能代码再上各个部分的代码。
<body>
<div id="app">
<div>
<input v-model="msg" />
<span> {{ msg }} </span>
<p v-text="msg"></p>
</div>
<br>
<div>
<input v-model="value" />
<span> {{ value }} </span>
<p v-text="value"></p>
</div>
</div>
<script src="./dep.js"></script>
<script src="./watcher.js"></script>
<script src="./compiler.js"></script>
<script src="./observer.js"></script>
<script src="./vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'hello vue',
value: 7,
},
})
</script>
</body>
vue类 「vue.js」
/**
* vue.js
*
* 属性
* - $el:挂载的dom对象
* - $data: 数据
* - $options: 传入的属性
*
* 方法:
* - _proxyData 将数据转换成getter/setter形式
*
*/
class Vue {
constructor(options) {
// 获取传入的对象 默认为空对象
this.$options = options || {}
// 获取 el (#app)
this.$el =
typeof options.el === 'string'
? document.querySelector(options.el)
: options.el
// 获取data 默认为空对象
this.$data = options.data || {}
// 调用_proxyData处理data中的属性
this._proxyData(this.$data)
// 使用Obsever把data中的数据转为响应式 并监测数据的变化,渲染视图
new Observer(this.$data)
// 编译模板 渲染视图
new Compiler(this)
}
// 把data中的属性注册到Vue
_proxyData(data) {
// 遍历data对象的所有属性 进行数据劫持
Object.keys(data).forEach((key) => {
// 把data中的属性,转换成vm的getter/setter
Object.defineProperty(this, key, {
// 可枚举(可遍历)
enumerable: true,
// 可配置(可以使用delete删除,可以通过defineProperty重新定义)
configurable: true,
// 获取值的时候执行
get() {
return data[key]
},
// 设置值的时候执行
set(newValue) {
// 若新值等于旧值则返回
if (newValue === data[key]) {
return
}
// 如新值不等于旧值则赋值
data[key] = newValue
},
})
})
}
}
Observer 「observer.js」
/**
* observer.js
*
* 功能
* - 把$data中的属性,转换成响应式数据
* - 如果$data中的某个属性也是对象,把该属性转换成响应式数据
* - 数据变化的时候,发送通知
*
* 方法:
* - walk(data) - 遍历data属性,调用defineReactive将数据转换成getter/setter
* - defineReactive(data, key, value) - 将数据转换成getter/setter
*
*/
class Observer {
constructor(data) {
this.walk(data)
}
// 遍历data转为响应式
walk(data) {
// 如果data为空或者或者data不是对象
if (!data || typeof data !== "object") {
return;
}
// 遍历data转为响应式
Object.keys(data).forEach((key) => {
this.defineReactive(data, key, data[key])
})
}
// 将data中的属性转为getter/setter
defineReactive(data, key, value) {
// 检测属性值是否是对象,是对象的话,继续将对象转换为响应式的
this.walk(value)
// 保存一下 this
const that = this;
// 创建Dep对象 给每个data添加一个观察者
let dep = new Dep();
Object.defineProperty(data, key, {
// 可枚举(可遍历)
enumerable: true,
// 可配置(可以使用delete删除,可以通过defineProperty重新定义)
configurable: true,
// 获取值的时候执行
get() {
// 在这里添加观察者对象 Dep.target 表示观察者
Dep.target && dep.addSub(Dep.target)
return value
},
// 设置值的时候执行
set(newValue) {
// 若新值等于旧值则返回
if (newValue == value) {
return;
}
// 如新值不等于旧值则赋值 此处形成了闭包,延长了value的作用域
value = newValue;
// 赋值以后检查属性是否是对象,是对象则将属性转换为响应式的
that.walk(newValue);
// 数据变化后发送通知,触发watcher的pudate方法
dep.notify();
},
})
}
}
Compiler 「compiler.js」
/**
* compiler.js
*
* 功能
* - 编译模板,解析指令/插值表达式
* - 负责页面的首次渲染
* - 数据变化后,重新渲染视图
*
* 属性
* - el -app元素
* - vm -vue实例
*
* 方法:
* - compile(el) -编译入口
* - compileElement(node) -编译元素(指令)
* - compileText(node) 编译文本(插值)
* - isDirective(attrName) -(判断是否为指令)
* - isTextNode(node) -(判断是否为文本节点)
* - isElementNode(node) - (判断是否问元素节点)
*/
class Compiler {
constructor(vm) {
// 获取vm
this.vm = vm
// 获取el
this.el = vm.$el
// 编译模板 渲染视图
this.compile(this.el)
}
// 编译模板渲染视图
compile(el) {
// 不存在则返回
if (!el) return;
// 获取子节点
const nodes = el.childNodes;
//收集
Array.from(nodes).forEach((node) => {
// 文本类型节点的编译
if (this.isTextNode(node)) {
// 编译文本节点
this.compileText(node)
} else if (this.isElementNode(node)) {
// 编译元素节点
this.compileElement(node)
}
// 判断是否还存在子节点
if (node.childNodes && node.childNodes.length) {
this.compile(node);
}
});
}
// 添加指令方法 并且执行
update(node, value, attrName, key) {
// 定义相应的方法 举个例子:添加textUpdater就是用来处理v-text的
const updateFn = this[`${attrName}Updater`];
// 若存在 则调用
updateFn && updateFn.call(this, node, value, key);
}
// 用来处理v-text
textUpdater(node, value, key) {
node.textContent = value;
}
// 用来处理v-model
modelUpdater(node, value, key) {
node.value = value;
// 用来实现双向数据绑定
node.addEventListener("input", (e) => {
this.vm[key] = node.value;
});
}
// 编译元素节点
compileElement(node) {
// 获取到元素节点上面的所有属性进行遍历
Array.from(node.attributes).forEach((attr) => {
// 获取属性名
let _attrName = attr.name
// 判断是否是 v- 开头
if (this.isDirective(_attrName)) {
// 删除 v-
const attrName = _attrName.substr(2);
// 获取属性值 并赋值给key
const key = attr.value;
const value = this.vm[key];
// 添加指令方法
this.update(node, value, attrName, key);
// 数据更新之后,通过wather更新视图
new Watcher(this.vm, key, (newValue) => {
this.update(node, newValue, attrName, key);
});
}
});
}
// 编译文本节点
compileText(node) {
// . 表示任意单个字符,不包含换行符、+ 表示匹配前面多个相同的字符、?表示非贪婪模式,尽可能早的结束查找
const reg = /\{\{(.+?)\}\}/;
// 获取节点的文本内容
var param = node.textContent;
// 判断是否有 {{}}
if (reg.test(param)) {
// $1 表示匹配的第一个,也就是{{}}里面的内容
// 去除 {{}} 前后空格
const key = RegExp.$1.trim();
// 赋值给node
node.textContent = param.replace(reg, this.vm[key]);
// 编译模板的时候,创建一个watcher实例,并在内部挂载到Dep上
new Watcher(this.vm, key, (newValue) => {
// 通过回调函数,更新视图
node.textContent = newValue;
});
}
}
// 判断元素的属性是否是vue指令
isDirective(attrName) {
return attrName && attrName.startsWith("v-");
}
// 判断是否是文本节点
isTextNode(node) {
return node && node.nodeType === 3;
}
// 判断是否是元素节点
isElementNode(node) {
return node && node.nodeType === 1;
}
}
Dep 「dep.js」
/**
* dep.js
*
* 功能
* - 收集观察者
* - 触发观察者
*
* 属性
* - subs: Array
* - target: Watcher
*
* 方法:
* - addSub(sub): 添加观察者
* - notify(): 触发观察者的update
*
*/
class Dep {
constructor() {
// 存储观察者
this.subs = []
}
// 添加观察者
addSub(sub) {
// 判断观察者是否存在、是否拥有update且typeof为function
if (sub && sub.update && typeof sub.update === "function") {
this.subs.push(sub);
}
}
// 发送通知
notify() {
// 触发每个观察者的更新方法
this.subs.forEach((sub) => {
sub.update()
})
}
}
Watcher 「watcher.js」
/**
* watcher.js
*
* 功能
* - 生成观察者更新视图
* - 将观察者实例挂载到Dep类中
* - 数据发生变化的时候,调用回调函数更新视图
*
* 属性
* - vm: vue实例
* - key: 观察的键
* - cb: 回调函数
*
* 方法:
* - update()
*
*/
class Watcher {
constructor(vm, key, cb) {
// 获取vm
this.vm = vm
// 获取data中的属性
this.key = key
// 回调函数(更新视图的具体方法)
this.cb = cb
// 将watcher实例挂载到Dep
Dep.target = this
// 缓存旧值
this.oldValue = vm[key]
// get值之后,清除Dep中的实例
Dep.target = null
}
// 观察者中的方法 用来更新视图
update() {
// 调用update的时候,获取新值
let newValue = this.vm[this.key]
// 新值和旧值相同则不更新
if (newValue === this.oldValue) return
// 调用具体的更新方法
this.cb(newValue)
}
}
验证一下吧。
到此完毕。觉得可以的欢迎点赞、收藏加关注 🙏 🙏 。