本任务目标是模拟一个最小版本的Vue,目的是:
- 了解响应式原理。
- 学习别人优秀的经验,转换成自己的经验。
- 实际项目中出现问题,可从原理层面解决。
- 为学习Vue源码做准备。
前置概念
数据驱动
数据响应式:
数据模型为普通JS对象,修改数据时会刷新视图,避免繁琐的DOM操作,提高开发效率。
双向绑定:
数据改变,会使试图改变;视图改变,数据也随之改变。其大量用于表单元素,例如v-model。
数据驱动:
Vue最独特的特性之一,仅需关注数据本身,不用关心数据是如何渲染到视图。也就是声明式。
响应式的核心原理
Vue2.X响应式原理
Vue官方解释:
当你把一个普通的 JavaScript 对象传入 Vue 实例作为data选项,Vue 将遍历此对象所有的 property,并使用Object.defineProperty把这些 property 全部转为getter/setter。Object.defineProperty是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。
shim是无法降级的意思。
下面来段代码示例:
// 模拟 Vue 中的 data 选项
let data = {
msg: 'hello'
}
// 模拟 Vue 的实例
let vm = {}
// 数据劫持:当访问或者设置 vm 中的成员的时候,做一些干预操作
Object.defineProperty(vm, 'msg', {
// 可枚举(可遍历)
enumerable: true,
// 可配置(可以使用 delete 删除,可以通过 defineProperty 重新定义)
configurable: true,
// 当获取值的时候执行
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
}
})
当我们对vm上的msg属性进行赋值时,就会触发data对象上的对应属性更新,以及视图的更新。
思考:如果一个对象中有多个属性要处理成getter、setter该如何处理呢?
function proxyData(data) {
// 遍历 data 对象的所有属性
Object.keys(data).forEach(key => {
// 把 data 中的属性,转换成 vm 的 setter/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]
}
})
})
}
Vue2.X的响应式是基于defineProperty来做的。
Vue3.X响应式原理
基于ES6的Proxy,它是直接监听对象而非属性。IE不支持,同时它有浏览器优化,所以性能比defineProperty好。我们可以使用它创建一个代理对象,当想要对目标对象进行增删改查时,可以通过代理对象来进行操作。
// 模拟 Vue 中的 data 选项
let data = {
msg: 'hello',
count: 0
}
// 模拟 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]
}
})
// 测试
vm.msg = 'Hello World'
console.log(vm.msg)
发布订阅模式和观察者模式
这两种模式在Vue中,有各自的应用场景,它俩本质相同,但也有区别,不能混为一谈。
发布/订阅模式,有这几个概念:
- 订阅者
- 发布者
- 事件中心
展示一段代码来示意一个最简单的发布订阅模式代码:
// 事件触发器
class EventEmitter {
constructor() {
// { 'click': [fn1, fn2], 'change': [fn] }
this.subs = Object.create(null)
}
// 注册事件
$on(eventType, handler) {
this.subs[eventType] = this.subs[eventType] || []
this.subs[eventType].push(handler)
}
// 触发事件
$emit(eventType, ...args) {
if (this.subs[eventType]) {
this.subs[eventType].forEach(handler => {
handler(...args)
})
}
}
}
其本质就是订阅者将回调存入eventMap中,当触发对应事件时则从eventMap中找到对应的事件数组,遍历执行一次。
观察者模式,Vue的响应式机制使用了观察者模式,它和发布订阅模式区别是没有事件中心, 只有发布者和订阅者。
- 观察者(订阅者)--Watcher
-
- update:当事件发生时,具体要做的事。
- deps:可以记录多个发布者,循环遍历deps,集中进行绑定。
- 目标(发布者)--Dep
-
- subs数组:存储所有的观察者。
- addSub:添加观察者。
- notify:当事件发生时,调用所有观察者的update方法。
- 没有事件中心
观察者模式简单源码展示:
// 发布者-目标
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')
}
}
// 测试
let dep = new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()
在这对两者进行一下总结:
- 发布订阅模式是有一个事件中心,将各类型的事件及其回调函数都收集在一个对象中。观察者模式则没有事件中心,它是每个事件都有一个回调数组,对应的回调事件都存储在对应的事件发布者中。
- 观察者模式是由具体目标调度,比如当事件触发,Dep就会去调用观察者的方法,该模式下订阅者与发布者之间是存在依赖的。
- 发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在。事件中心可以隔离发布者和订阅者,减少它们之间依赖关系,更为灵活。
Vue响应式原理模拟
在该节我们会实现一个最小版本的Vue,接下来先整体分析一下Vue。
整体分析
Vue基本结构
Vue实例观察
我们需要模拟Vue中如下属性:
- vm实例-$data:真正监视data数据变化的地方,data对象的get、set方法有它进行监测。
- vm实例-options中。
- vm实例-$el:可是选择器或是DOM对象,如果是选择器则需在内部将其转为DOM对象。
在vm实例中,_开头的是私有成员,$开头的是公共成员。我们还要把data中的成员注入到Vue实例中来。
整体结构
- 创建Vue类型,把data中的成员注入到Vue实例,并且将其成员转换成getter、setter。
-
- Vue内部调用Observer方法,进行数据劫持,对数据进行监听。
- Compiler负责解析模板中的指令和插值表达式,并替换成对应的数据。
- Dep、Watcher很熟悉了,属于观察者模式中的概念。
Vue类的实现
这里使用ES6中类的方式实现,它简单包含这些功能:
- 负责接收初始化的参数(选项),通过构造函数接收。
- 负责把data中的属性注入到Vue实例,转换成getter、setter。
- 负责调用Observer监听data中所有属性的变化。
- 负责调用Compiler解析指令、插值表达式。
Vue类的实例代码如下所示:
class Vue {
constructor(options) {
// 1. 通过属性保存选项的数据
this.$options = options || {}
this.$data = options.data || {}
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
// 2. 把data中的成员转换成getter和setter,注入到vue实例中,方便后续使用
this._proxyData(this.$data)
// 3. 调用observer对象,监听数据的变化
// 4. 调用compiler对象,解析指令和差值表达式
}
// 使Vue代理data中的数据
_proxyData(data) {
// 遍历data中的所有属性
Object.keys(data).forEach(key => {
// 把data的属性注入到vue实例中
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return data[key]
},
set(newValue) {
if (newValue === data[key]) {
return
}
data[key] = newValue
}
})
})
}
}
Observer类的实现
它主要负责如下几个功能:
- 负责把data选项中的属性转换成响应式数据。
- 如果data中某个属性是对象,也应该将其转换为响应式数据,对于数组我们应该要重写数组方法。
- 数据变化时,发送通知。结合观察者模式实现。
它也有两个方法,我们命名与Vue源码保持一致:
- walk:遍历data数据
- defineReactive:定义响应式数据
为什么defineReactive要传第三个参数--val
因为此处如果直接返回obj[key]则会无限触发在observer中所定义的get方法,导致代码的死循环,解决该问题的方法就是返回val。
同时val会在此处形成闭包,所以val也不会被释放掉。
defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
console.log("在Observer中被get获取")
// 这里为什么不返回obj[key]
return obj[key]; // 这会导致无限触发observer中的get方法
},
set: (newVal) => {
console.log("在Observer中被set设置")
if (newVal === val) return
val = newVal
// 发送通知
}
})
}
在Vue3中使用了函数式编程,来减少了this的使用场景,减少this指向问题所带来的困惑。在这将完整代码贴在这:
class Observer {
constructor(data) {
this.walk(data)
}
walk(data) {
// 1. 判断data是否是对象
if (!data || typeof data !== 'object') {
return
}
// 2. 遍历data对象的所有属性
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key])
})
}
// 为什么defineReactive要传第三个参数--val
defineReactive(obj, key, val) {
// 如果val是对象,把val内部的属性转换成响应式数据
this.walk(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
console.log("在Observer中被get获取")
// 这里为什么不返回obj[key]
return val;
},
set: (newVal) => {
console.log("在Observer中被set设置")
if (newVal === val) return
val = newVal
this.walk(newVal)
// 发送通知
}
})
}
}
在这里,当属性值或新设置的值是对象时,都会将它重新设置为响应式的属性。
Compiler类的实现
它主要负责如下几个功能:
- 负责模板编译,解析指令和插值表达式
- 负责页面首次渲染
- 当数据变化后重新渲染视图
简而言之,它就是负责操作DOM。它有2个属性和6个方法:
- el:DOM对象,也就是模板
- vm:实例,会用到里面的数据
而它的方法都是在做DOM操作:
- compile(el):进行节点的遍历,针对不同节点类型做不同的解析。
- isTextNode(node):判断是否为文本节点。
- isElementNode(node):判断是否为元素节点。
- compileElement(node):对于元素节点,解析元素中指令
- compileText(node):对于文本节点,解析差值表达式
- isDirective(attrName):判断是否是指令,在compileElement中调用
compileText方法实现
// {{ msg }}
const reg = /{{(.+?)}}/;
简单说说这里的正则表达式,{{}}是固定的,同时在正则中需要转义;.+是匹配任意字符,多个长度;?是代表非贪婪匹配。同时我们需要拿到属性的名称,可以通过添加()的方式去提取,它在正则中是分组的含义,通过RegExp.$1可以获取第一个分组的内容。
compileElement方法实现
通过遍历所有的属性节点,来找出v-开头的属性,并根据这些属性指令,对其执行不同的指令方法。在attributes属性中能获得属性名称及其值,从而拿到所对应的data值。
同时为方便处理不同指令的方法,这里没有采用if else或者switch的方式,还是采用函数名拼接,精简了代码。
const updateFn = this[attrName + 'Updater']
Vue响应式机制的实现
先来对Vue响应式机制的整体流程图分析下,目前已实现了Vue类、Observer类、Compiler类
Dep类的实现
这里的作用是创建一个实例,在getter中收集依赖,添加观察者;在setter中根据依赖变化,调用notify方法通知观察者。
class Dep {
constructor() {
// 存储所有的观察者
this.subs = []
}
// 添加观察者
addSub(sub) {
if (sub && sub.update) {
this.subs.push(sub)
}
}
// 发送通知
notify(...args) {
this.subs.forEach(sub => {
sub.update(...args)
})
}
}
Watcher类的实现
- 当数据变化时触发监听,dep通知所有的Watcher实例更新视图。
- 自身实例化时,往dep对象的subs中将自己添加进去。
class Watcher {
constructor(vm, key, cb) {
this.vm = vm;
// data中的属性名称
this.key = key;
// 回调函数负责更新视图
this.cb = cb;
// 把watcher对象记录到Dep类的静态属性target,
Dep.target = this
// 使用oldValue存储实例中对应属性的内存地址,这里会触发get方法,而此时在get方法中则会触发addSub方法,将该Watcher添加到dep的subs中
this.oldValue = vm[key]
// 在此处释放,防止被多次重复添加
Dep.target = null
}
// 当数据发生变化的时候更新视图
update() {
let newValue = this.vm[this.key]
if (this.oldValue === newValue) {
return
}
this.cb(newValue)
}
}
何处创建Watcher对象
我们需要在涉及页面数据改变的地方创建Watcher对象。它的本质是操作DOM,所以我们在textUpdater、modelUpdater、compileText等涉及文本操作的地方创建了Watcher对象。
// 处理 v-text 指令
textUpdater(node, value, key) {
node.textContent = value
// 创建watcher对象,当数据改变更新视图
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
// 处理 v-model 指令
modelUpdater(node, value, key) {
node.value = value
// 创建watcher对象,当数据改变更新视图
new Watcher(this.vm, key, (newValue) => {
node.value = newValue
})
// 双向绑定
node.addEventListener('input', () => {
this.vm[key] = node.value
})
}
// 编译文本节点,处理差值表达式
compileText(node) {
const reg = /{{(.+?)}}/;
const value = node.textContent;
if (reg.test(value)) {
const key = RegExp.$1.trim();
node.textContent = value.replace(reg, this.vm[key])
// 创建watcher对象,当数据改变更新视图
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
}
同时我们给绑定v-model的文本输入框添加了input事件,完善双向绑定机制。
调试-首次渲染
接下来通过调试来加深对代码的理解:
- 调试页面首次渲染的过程。
- 调试数据改变更新视图的过程。
- 我们对vm实例已有属性重新赋值为对象,这个对象内部属性是响应式的。
- 我们给vm实例添加新属性,这个新属性不是响应式的,Vue官方解决方案是使用
Vue.set实例方法来在vm创建一个对象,增加相应式数据。