准备工作
先了解一下这些:
- 数据驱动
- 响应式核心原理
- 发布订阅模式和观察者模式
数据驱动
- 数据响应式:数据模型是普通的js对象,但是当修改数据时,视图也会随之更新,避免开发者手动进行
DOM
操作 - 双向数据绑定:当数据发生变化时,视图也会发生变化;视图发生变化时,数据也会发生变化。体现就是对于表单元素,可以使用
v-model
指令创建双向数据绑定 - 数据驱动:开发时只需要关注数据本身业务本身,而不需要考虑数据该如何渲染视图
响应式核心原理
在vue2中,响应式是基于Object.defineProperty
实现的:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">msg</div>
<script>
let data = {
msg: 'hello world'
}
let vm = {}
Object.defineProperty(vm, 'msg', {
// 可枚举
enumerable: true,
// 可配置
configurable: true,
// 读vm.msg时会触发这个,读取到的是data.msg
get() {
return data.msg
},
// 设置vm.msg的值时会触发这个,同时可以在这里进行dom操作来修改视图
set(v) {
if (v === data.msg) return
data.msg = v
document.getElementById('app').textContent = data.msg
}
})
// 给vm.msg重新赋值时,视图也会重新渲染出新值
</script>
</body>
</html>
这种方法需要遍历对象中的属性来设置好getter
和setter
在vue3中,使用的是proxy
,这个方法是监听整个对象而不是分别给每个属性添加getter
和setter
。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">msg</div>
<script>
const data = {
msg: 'hello world',
count: 0
}
// proxy是个构造函数
// 参数第一项是被代理的对象,第二项是handler
// new完返回的即是代理对象
const vm = new Proxy(data, {
get(target, key) {
return target[key]
},
set(target, key, value) {
if (target[key] === value) return
target[key] = value
document.getElementById('app').textContent = target[key]
}
})
</script>
</body>
</html>
发布订阅模式和观察者模式
- 发布订阅模式:这个模式有三个对象:发布者、订阅者和消息中心(事件中心)。当一个事件触发时,发布者会向消息中心传递一个信号,所有在消息中心订阅该消息任务会在接收到该信号后开始执行。
在vue中,可以通过自定义事件来体验发布订阅模式:
// 创建一个空的vue实例
// 消息中心
const vm = new Vue()
// 订阅者
vm.$on('change', () => {
console.log('Event triggerred')
})
//发布者
vm.$emit('change')
vue中的兄弟组件可以通过此类方法进行通信
可以简单模拟一下自定义事件的机制:
class EventEmitter {
// 该类内部有$emit和$on两个方法,还有一个能保存发布的事件的对象
constructor() {
// 创建这个保存事件的对象
this.subs = {}
}
// $on-订阅者
// Type为事件名称,handler为事件处理函数
$on(type, handler) {
// 判断subs中有没有该事件,如果没有就将它赋值为一个空数组用于存储多个事件
this.subs[type] = this.subs[type] || []
this.subs[type].push(handler)
}
// $emit-发布者
$emit(type, ...args) {
// 如果有该事件,就执行
if (this.subs[type]) {
this.subs[type].forEach(handler => {
handler(...args)
})
}
}
}
再来测试一下:
const event = new EventEmitter()
event.$on('change', (arg1, arg2) => {
console.log(arg1, arg2)
})
event.$emit('change', 1, 2)
控制台也会有打印
- 观察者模式:与发布订阅模式相比,观察者模式少了消息中心。发布者(目标)内有
subs
数组来存储所有的订阅者(观察者)、addSub
函数添加观察者、notify
函数来在事件触发时调用订阅者(观察者)中的update
函数来执行相应的任务。也就是说,不同于发布订阅模式下发布者和订阅者的“零耦合”,观察者模式中目标和观察者是有依赖关系的。
简单实现(不考虑传参问题)
// 目标-发布者
class Dep {
constructor() {
// 初始化一个subs数组以存储观察者
this.subs = []
}
// addSub方法-将观察者添加进subs中
addSub(sub) {
if (sub && sub.update) {
this.subs.push(sub)
}
}
// notify方法-调用所有观察者的update方法
notify() {
this.subs.forEach(sub => {
sub.update()
})
}
}
// 观察者-订阅者
class Watcher {
update() {
// 观察者要执行的相应的任务
console.log('updated')
}
}
测试一下:
const dep = new Dep()
const watcher = new Watcher()
dep.addSub(watcher)
dep.notify()
总结一下就是:
观察者模式是由目标调度,观察者和目标之间是有依赖关系的;发布订阅模式是由消息中心统一调度,发布者和订阅者之间没有联系。
模拟Vue响应式原理
整体结构如下
现在一步一步实现这五个方面(迷你版)
Vue
Vue的主要功能:
- 接收初始化的参数
- 把data中的数据注入vue实例中并转换成getter和setter
- 调用observer监听data中数据的变化
- 调用compiler解析差值表达式和指令
看一下类图:
按照功能实现一下:
class Vue {
constructor(options) {
// 一、将options中的数据保存起来
// 1.将options保存到$options上
this.$options = options || {}
// 2.将options.data保存到$data上
this.$data = options.data || {}
// 3.将options.el保存到$el上
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
// 二、将data中的数据转换成getter和setter,注入vue实例中
this._proxyData(this.$data)
// 三、调用observer监听数据变化
// 四、调用compiler解析指令和差值表达式
}
_proxyData(data) {
// 遍历data
Object.keys(data).forEach(key => {
// 将data中的属性注入vue实例中
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return data[key]
},
set(newVal) {
if (newVal === data[key]) return
data[key] = newVal
}
})
})
}
}
同时在模板HTML文件里面引用一下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">
<h1>差值表达式</h1>
<h3>{{ msg }}</h3>
<h3>{{ count }}</h3>
<h1>v-text</h1>
<div v-text="msg"></div>
<h1>v-model</h1>
<input type="text" v-model="msg">
<input type="text" v-model="count">
</div>
<script src="./js/vue.js"></script>
<script>
const vm = new Vue({
el: '#app',
data: {
msg: 'hello',
count: 1
}
})
console.log(vm)
</script>
</body>
</html>
打印结果为
看到$data
、$el
、$options
和getter
、setter
都已经在vue实例中了。
Observer
Observer的主要功能:
- 把data中的属性转换成响应式数据(如果某个属性也是对象,将其转换成响应式对象)
- 在数据发生变化时发送通知(需要配合观察者模式)
observer的类图:
实现:
class Observer {
constructor(data) {
// 调用walk转换getter setter
this.walk(data)
}
walk(data) {
// 先判断一下data是不是对象
if (!data || typeof data !== 'object') return
// 遍历data中的属性
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key])
})
}
defineReactive(obj, key, val) {
// 对值调用walk方法,以解决当val为对象时不能将其内部属性转换成getter setter的bug
this.walk(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
// 当给data中属性进行赋值时,如果值为对象,将其内部属性转换成getter setter
that.walk(newVal)
// 发送通知
}
})
}
}
defineReactive
方法中转换getter
时直接return val
是因为如果返回obj[key]
,这个时候就会产生递归调用产生死循环:
同时在vue.js里面创建一个Observer
的实例,传入$data
constructor(options) {
// 一、将options中的数据保存起来
// 1.将options保存到$options上
this.$options = options || {}
// 2.将options.data保存到$data上
this.$data = options.data || {}
// 3.将options.el保存到$el上
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
// 二、将data中的数据转换成getter和setter,注入vue实例中
this._proxyData(this.$data)
// 三、调用observer监听数据变化
new Observer(this.$data)
// 四、调用compiler解析指令和差值表达式
}
html文件中,引入vue.js之前引入observer.js,打开浏览器打印一下vm
可以看到$data
中的属性已经被转换成了getter
和setter
Compiler
Compiler的主要功能:
- 编译模板、解析指令与差值表达式
- 渲染页面并在数据更新后再次渲染数据
类图:
实现:
class Compiler {
// 构造器接收vue实例
constructor(vm) {
this.el = vm.$el
this.vm = vm
this.compiler(this.el)
}
// 编译模板,处理文本节点以及元素节点
compiler(el) {
const childNodes = el.childNodes
Array.from(childNodes).forEach(node => {
// 判断节点类型并对相应的节点进行操作
if (this.isTextNode(node)) {
this.compilerText(node)
} else if (this.isElementNode(node)) {
this.compilerElement(node)
}
// 判断是否有子节点,若有则递归
if (node.childNodes && node.childNodes.length) {
this.compiler(node)
}
})
}
// 处理元素节点
compilerElement(node) {
// 遍历所有的属性
Array.from(node.attributes).forEach(attr => {
let attrName = attr.name
// 判断该属性是否为指令
if (this.isDirective(attrName)) {
attrName = attrName.substr(2)
let key = attr.value
this.update(node, key, attrName)
}
})
}
update(node, key, attrName) {
const updateFn = this[attrName + 'Updater']
updateFn && updateFn(node, this.vm[key])
}
textUpdater(node, value) {
node.textContent = value
}
modelUpdater(node, value) {
node.value = value
}
// 处理文本节点
compilerText(node) {
const reg = /\{\{(.+?)\}\}/
let value = node.textContent
if (reg.test(value)) {
let key = RegExp.$1.trim()
// {{ xxx }} 替换成变量的值
node.textContent = value.replace(reg, this.vm[key])
}
}
// 判断是否为指令
isDirective(attrName) {
return attrName.startsWith('v-')
}
// 判断是否为文本节点
isTextNode(node) {
return node.nodeType === 3
}
// 判断是否为元素节点
isElementNode(node) {
return node.nodeType === 1
}
}
Dep
功能:
- 收集依赖,添加观察者
- 通知所有观察者
类图
实现
class Dep {
constructor() {
// subs用于存储所有的观察者
this.subs = []
}
// 添加观察者
addSubs(sub) {
if (sub && sub.update) {
this.subs.push(sub)
}
}
// 发送通知
notify() {
this.subs.forEach(sub => {
sub.update()
})
}
}
在html文件引入js文件的最上方引入Dep.js,然后在Observer
类中的defineReactive
方法中创建一个dep
对象,并在get
和set
方法中收集依赖和发送通知
defineReactive(obj, key, val) {
// 对值调用walk方法,以解决当val为对象时不能将其内部属性转换成getter setter的bug
this.walk(val)
const that = this
// 创建dep对象来收集依赖+发送通知
const dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 收集依赖
Dep.target && dep.addSub(Dep.target)
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
// 当给data中属性进行赋值时,如果值为对象,将其内部属性转换成getter setter
that.walk(newVal)
// 发送通知
dep.notify()
}
})
}
watcher
watcher
和dep
的关系图:
功能:
- 当数据变化触发依赖,dep通知所有的watcher实例更新视图
- 自身实例化的时候往dep对象中添加自己
类图
实现
class Watcher {
constructor(vm, key, cb) {
this.vm = vm
// data中的属性名
this.key = key
// 更新视图的回调函数
this.cb = cb
// 把watcher实例记录到Dep类的静态属性target上
Dep.target = this
// 旧值--此时会触发get方法,调用addSubs
this.oldValue = vm[key]
// 清空target
Dep.target = null
}
// 更新视图
update() {
let newValue = this.vm[this.key]
if (newValue === this.oldValue) return
this.cb(newValue)
}
}
然后修改Compiler
类中的文本节点处理方法以及指令处理方法:
// 处理文本节点
compilerText(node) {
const reg = /\{\{(.+?)\}\}/
let value = node.textContent
if (reg.test(value)) {
let key = RegExp.$1.trim()
// {{ xxx }} 替换成变量的值
node.textContent = value.replace(reg, this.vm[key])
// 创建watcher实例
new Watcher(this.vm, key, newValue => node.textContent = newValue)
}
}
update(node, key, attrName) {
const updateFn = this[attrName + 'Updater']
// 使用call改变方法调用时其内部this的指向,将其指向当前compiler实例
updateFn && updateFn.call(this, node, this.vm[key], key)
}
textUpdater(node, value, key) {
node.textContent = value
new Watcher(this.vm, key, newValue => node.textContent = newValue)
}
modelUpdater(node, value, key) {
node.value = value
new Watcher(this.vm, key, newValue => node.value = newValue)
}
这样当数据发生变化时,视图也会随之更新:
更新后:
双向绑定
效果:当文本框内数据发生变化时,vm内的数据和视图也要更新
在v-model
的处理函数中,给node
增加input
事件:在触发input
后,将vm
中对应的属性的值更改为当前的值:
modelUpdater(node, value, key) {
node.value = value
new Watcher(this.vm, key, newValue => node.value = newValue)
// 实现双向绑定
node.addEventListener('input', () => {
this.vm[key] = node.value
})
}
重新赋值时会触发
set
方法,这时会再调用Dep
实例的notify
方法来更新相应的视图