Vue 响应式原理(MVVM)模拟实现
Vue 响应式原理模拟
1、学习目的
-
模拟一个最小版本的 Vue。
-
响应式原理是面试的常问问题。
-
学习别人优秀的经验,转换成自己的经验。
-
实际项目中出问题的原理层面的解决。
- 给 Vue 实例新增一个成员是否是响应式的?
- 给属性重新赋值成对象,是否是响应式的?
-
为学习 Vue 源码做铺垫。
Vue基础结构:
<!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>Vue 基础结构</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="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 20,
items: ['a', 'b', 'c']
}
})
</script>
</body>
</html>
我们学习的目标就是自己实现一个vue来模拟实现上面基础结构中的内容。
2、数据驱动
在开始模拟vue响应式原理之前,还需要学习三个概念,分别是:
- 数据驱动
- 响应式的核心原理
- 发布订阅模式和观察者模式
数据驱动
在学习vue的过程中,我们经常会看到三个词,分别是:
1、数据响应式。2、双向绑定。3、数据驱动。
- 数据响应式,数据响应式中的数据指的是数据模型。我们基于vue开发的时候,数据模型就是普通的 JavaScript 对象。数据响应式的核心是指当我们修改数据时,视图会自动进行更新,不需要我们做任何的DOM操作。这和我们使用jQuery的时候完全不同,我们使用jQuery的核心就是进行DOM操作,而vue内部帮我们封装了复杂的DOM操作,避免了我们再进行繁琐的 DOM 操作,从而提高了开发效率。
- 双向绑定:指当数据发生改变时,视图同时会发生变化,视图改变,数据也会随之发生变化。在双向绑定中包含了数据响应式,因双向绑定包含了视图变化,所以双向绑定针对的是可以和用户交互的表单元素,我们可以使用v-model在表单元素上创建双向数据绑定。
- 数据驱动:是Vue最独特的特性之一。在使用vue开发的时候,只需要关注数据本身(业务本身),而不需要关注数据是如何渲染到视图上的。因为现在主流的MVVM框架内部都已经帮我们实现了数据响应式以及双向绑定,所以将来我们在开发的时候只需要关注业务本身,而不需要再考虑如何把数据渲染到DOM上。
3、数据响应式原理-Vue2
在Vue2.x中的响应式原理是基于Object.defineProperty实现的。
- 深入响应式原理 — Vue.js (vuejs.org)
- Object.defineProperty() - JavaScript | MDN (mozilla.org)
- 注意
Object.defineProperty
是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。
接下来我们通过一段代码来演示一下Object.defineProperty
方法是如何使用的。
<!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>defineProperty</title>
</head>
<body>
<div id="app">
hello
</div>
<script>
// 模拟 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)
// 我们真正的数据不是在vm.msg中,还是在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 = 'Hello World'
console.log(vm.msg)
</script>
</body>
</html>
用浏览器打开上面的页面还可以在控制台中修改vm.msg进行测试。
那么当有一个对象中多个属性需要转换getter/setter时需要如何处理呢?答案是我们应该遍历data中的所有属性,把他们转换为getter和setter。
<!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>defineProperty 多个成员</title>
</head>
<body>
<div id="app">
hello
</div>
<script>
// 模拟 Vue 中的 data 选项
let data = {
msg: 'hello',
count: 10
}
// 模拟 Vue 的实例
let vm = {}
proxyData(data)
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]
}
})
})
}
// 测试
vm.msg = 'Hello World'
console.log(vm.msg)
</script>
</body>
</html>
注意在上面代码中,我们此处仅仅只是展示为多个属性添加响应式,更改任何属性都是修改#app
中的内容。对于如何把其他属性也展示到界面中将会在后续实现。
4、数据响应式原理-Vue3
Vue3中的数据劫持使用的是ES6中新增的Proxy代理对象,可以通过MDN查看具体的使用。
- Proxy - JavaScript | MDN (mozilla.org)
- Proxy直接监听对象,而非属性。故把多个属性转换为getter和setter的时候,不需要循环。
- ES 6中新增,IE 不支持,性能由浏览器优化,比Object.defineProperty要好。
下面我们通过代码来演示proxy对象最基本的使用方式。
<!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>Proxy</title>
</head>
<body>
<div id="app">
hello
</div>
<script>
// 模拟 Vue 中的 data 选项
let data = {
msg: 'hello',
count: 0
}
// 模拟 Vue 实例
// Proxy 是一个构造函数我们通过new Proxy创建一个代理对象,
// 当我们想要访问data中属性的时候,我们使用vm代理对象来访问。
let vm = new Proxy(data, {
// 执行代理行为的函数
// 当访问 vm 的成员会执行
// target是我们代理的目标对象,第二个参数是我们要访问的哪个属性。
// 这两个参数不需要我们传递,我们只需要按照文档中的方式去实现即可。
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)
</script>
</body>
</html>
我们可以发现使用Proxy
要比使用Object.defineProperty
代码简洁得多,我们使用Proxy
是代理整个对象,而使用Object.defineProperty
处理多个属性还需要循环。另外Proxy
还由浏览器进行性能优化,性能也会更好一些。
5、发布订阅模式
我们假定存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做"发布/订阅模式"(publish-subscribe pattern)。
发布/订阅模式 一般包含三个部分 :1、订阅者。2、发布者。3、信号中心。
- 兄弟组件通信过程就是典型的发布订阅模式
// eventBus.js
// 事件中心
let eventHub = new Vue()
// ComponentA.vue
// 发布者
addTodo: function () {
// 发布消息(事件)
eventHub.$emit('add-todo', { text: this.newTodoText })
this.newTodoText = ''
}
// ComponentB.vue
// 订阅者
created: function () {
// 订阅消息(事件)
eventHub.$on('add-todo', this.addTodo)
}
还有vue中的自定义事件及node中的事件机制都是基于发布订阅模式。
let vm = new Vue()
vm.$on('dataChange', () => {
console.log('dataChange')
})
vm.$on('dataChange', () => {
console.log('dataChange1')
})
vm.$emit('dataChange')
但在上面的代码中我们很难分析出来哪个是发布订阅中的订阅者,发布者以及信息中心。实际上这三者都是vm。
接下来我们就模拟一下 Vue 自定义事件的简单实现。
首先我们通过自定义事件分析一下其内部是如何工作的,然后再通过代码去模拟。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue 自定义事件</title>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
// 首先定义了vue实例
let vm = new Vue()
// 然后调用$on方法注册事件(订阅消息)
vm.$on('dataChange', () => {
console.log('dataChange')
})
vm.$on('dataChange', () => {
console.log('dataChange1')
})
// 最后调用$emit触发事件(发布消息)
vm.$emit('dataChange')
// 我们先来思考一下$on内部是如何工作的,$on仅仅是注册事件,此时,事件处理函数并没有执行,
// 所以在vm里我们需要定义一个内部的变量来存储我们注册的事件名称以及事件处理函数。
// 注册事件的时候,我们可以注册多个事件名称,也可以给同一个事件注册多个处理函数。
// 存储事件的时候,我们需要记录所有的事件名称以及对应的处理函数,也即键值对的形式。
// 所以vm内部的变量应该是对象的形式。
// 对象形式应该像这样:{ 'click': [fn1, fn2], 'change': [fn] }
// 对象的属性应该是事件的名称,对象的值应该是事件处理函数
// 而$emit内部会通过传入的事件名称,在对象里找对应的属性,然后获取到对应的事件处理函数,
// 最后依次执行这些处理函数。另外这里为了简化过程,我们不考虑事件处理函数有参数的情况。
</script>
</body>
</html>
之后我们可以根据上面的分析,使用发布订阅模式模拟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 () {
// { 'click': [fn1, fn2], 'change': [fn] }
// Object.create 的参数是用来设置对象的原型,而设置为null表示该对象无原型。
// 因现在我们定义的对象只需要存储键值对的形式,不需要原型,设置为null可以提升性能。
this.subs = Object.create(null)
}
// 注册事件
// $on的第一个参数是事件类型,第二个参数是处理函数
$on (eventType, handler) {
this.subs[eventType] = this.subs[eventType] || []
this.subs[eventType].push(handler)
}
// 触发事件
// $emit的参数是事件类型,通过事件类型到事件中心找相关的处理函数去执行。
$emit (eventType) {
if (this.subs[eventType]) {
this.subs[eventType].forEach(handler => {
handler()
})
}
}
}
// 测试
let em = new EventEmitter()
em.$on('click', () => {
console.log('click1')
})
em.$on('click', () => {
console.log('click2')
})
em.$emit('click')
</script>
</body>
</html>
6、观察者模式
Vue的响应式机制中使用了观察者模式,所以我们要先了解一下观察者模式是如何实现的。
观察者模式和发布订阅模式的区别是没有事件中心,只有发布者和订阅者,并且发布者需要知道订阅者的存在。
观察者模式中,订阅者就叫做观察者,所有的订阅者自身都有一个update方法,当事件发生的时候会调用所有订阅者的update方法。
在vue的响应式机制中,当数据变化的时候会调用观察者的update方法,update方法内部去更新视图。在观察者模式中,订阅者(观察者)的update方法是由发布者调用的,发布者就叫做目标,在发布者内部记录所有的订阅者。
当事件发生的时候,是由发布者去通知所有的订阅者,发布者内部需要有一个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) {
// 传递的对象需要有update方法才认为是观察者
if (sub && sub.update) {
this.subs.push(sub)
}
}
// 发布通知
notify () {
this.subs.forEach(sub => {
sub.update()
})
}
}
// 订阅者-观察者
class Watcher {
// update是当事件发生的时候由发布者调用的,
// 在update里我们我们可以去更新视图等,这里我们只是简单打印一下
update () {
console.log('update')
}
}
// 测试
let dep = new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()
</script>
</body>
</html>
总结
- 观察者模式是由具体目标调度,比如当事件触发,Dep 就会去调用观察者的方法,所以观察者模 式的订阅者与发布者之间是存在依赖的。
- 发布/订阅模式由事件中心调用,因此发布者和订阅者不需要知道对方的存在。事件中心的作用是隔离发布者和订阅者,减少发布者和订阅者之间的依赖关系,会更灵活。
7、模拟Vue响应式原理-分析
现在我们开始模拟vue的响应式原理,模拟最小版本的vue。
首先我们分析一下我们需要做什么事情。
- 我们需要先回顾一下vue的基本结构以及我们要实现的功能。
- 然后打印vue实例观察我们要模拟vue实例中的哪些成员。
- 最后再来看一下我们要模拟的最小版本的vue的整体结构。
接下来我们回顾一下vue的基本结构。
<!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>Mini Vue</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="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
// 首先,我们调用了Vue的构造函数,构造函数接收一个对象参数,
// 在对象里我们设置了el和data,el中设置了选择器,data中就是我们需要使用的数据。
// 然后在模板中我们可以通过差值表达式v-text和v-model去绑定数据
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 100,
}
})
</script>
</body>
</html>
打开控制台,打印vm对象,可以看到Vue中有很多成员,我们只关注我们要模拟的成员。
我们可以看到count和msg以及其getter和setter,所以vue构造函数内部需要把data中的成员转换成getter和setter,注入到vue实例上。这样做的目的是在其他地方使用的时候,我们可以直接通过this.msg或this.count来使用。
count和msg之后是$data,data选项中的成员被记录在$data中,并转换成了setter和getter。$data中的setter是真正监视数据变化的地方。
$options可以简单认为是把Vue构造函数的参数记录到了$options中。
_data
和$data
指向的是同一个对象,_data
是私有成员$data
是公共成员,我们只需要模拟$data
即可。
$el
对应的是我们选项中的el,我们设置el选项的时候,可以是选择器,也可以是一个DOM对象。如果是选择器的话,vue构造函数内部需要把选择器转换成对应的DOM对象。
我们最小版本的vue中需要模拟vue中的$data,$el,$options
,还要把data中的成员注入到vue实例中来。
下面我们再来分析一下我们需要实现的最小版本vue的整体结构。
我们要实现的最小版本vue由五种类型组成。
- Vue:把 data 中的成员注入到 Vue 实例,并且把 data 中的成员转成 getter/setter。
- Observer:能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知 Dep。
- Compile:解析每个元素中的指令/插值表达式,并替换成相应的数据。
- Dep:添加观察者(watcher),当数据变化通知所有观察者。
- Watcher:内部有update方法,负责数据变化时更新视图。
8、Vue
下面我们来实现五种类型中的第一种Vue。可以用构造函数或类来实现,这里我们采用类的方式实现。
首先我们来观察一下vue中要实现的功能,再观察一下类内部的结构。
功能:
- 负责接收初始化的参数(选项),在内部通过属性的方式记录下来el和data选项。
- 负责把 data 中的属性注入到 Vue 实例,转换成 getter/setter。
- 负责调用 observer 监听 data 中所有属性的变化,当属性变化时更新视图。
- 负责调用 compiler 解析指令/插值表达式,在视图中绑定数据。
结构:
_proxyData的功能是把data中的属性转换为getter和setter,注入到vue实例中。
代码:
js/vue.js
class Vue {
constructor (options) {
// 1. 通过$options属性保存options参数传入的数据
this.$options = options || {}
this.$data = options.data || {}
// 如果el传入的是字符串,就认为是一个选择器,用querySelector获取到对应DOM对象
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
// 2. 把data中的成员转换成getter和setter,注入到vue实例中
// 通过_proxyData方法实现,把代码尽可能拆分,防止constructor过重
this._proxyData(this.$data)
// 3. 调用observer对象,监听数据的变化(暂未完成)
// 4. 调用compiler对象,解析指令和差值表达式(暂未完成)
}
_proxyData (data) {
// 遍历data中的所有属性
// 因这里用的是箭头函数,所以下面this指向vue实例。
// 注意如果这里用了function,则this会指向window。
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
}
})
})
}
}
index.html
<!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>Mini Vue</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>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 100,
}
})
</script>
</body>
</html>
打开控制台打印vm,我们可以观察到vm就是vue的一个实例。
vm对象中也存在msg和count及其setter和getter。这是我们通过_proxyData把data中的属性注入到vue实例上并转换成了getter和setter。
$options就是记录Vue构造函数中的参数。
$data里记录的就是option中的data选项,但此时的$data中我们还没有把msg和count转换为getter和setter,这也是我们下一步要完成的事。
最后是$el,$el是一个DOM对象,而我们传入的options.el是一个选择器,我们在vue的构造函数中把他转换为了DOM对象。
9、Observer
下面我们继续完成五个类中的第二个——Observer,Observer的作用是数据劫持,也即监听data中属性的变化,并做处理。
功能:
- 首先,他负责把 data 选项中的属性转换成响应式数据,也即getter和setter。
- 如果 data 中的某个属性也是对象,把该属性也转换成响应式数据。
- 数据变化时发送通知(结合观察者模式实现)。
结构:
Observer类中只有两个方法,方法的名字和vue源码中是一样的。
walk方法的作用是用来遍历data中的所有属性,故参数是data对象。
defineReactive即定义响应式数据,是通过调用Object.defineProperty来把属性转换为getter和setter。walk方法在循环的过程中会调用defineReactive。
代码:
js/observer.js
class Observer {
// 在构造函数中调用walk方法,当类创建完对象之后,
// 立即把data中的所有属性转换为getter和setter
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的核心作用是调用Object.defineProperty把属性转换为getter和setter。
// 注意这里的第三个参数val就是obj[key]的值。因在下面我们用到了Object.defineProperty,
// 而在设置getter时,若直接将obj[key]返回的话就相当于又触发了getter,出现了死递归。
// 而val能在外部访问是因这里的obj就是vue的$data属性,而$data中引用了get方法,
// 也即外部对get方法有引用,而get中返回了val,发生闭包,扩展了val的作用域。
defineReactive (obj, key, val) {
// 先保存this为了在下面的set方法中使用
let that = this
// 如果val是对象,把val内部的属性转换成响应式数据
this.walk(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
return val
},
set (newValue) {
if (newValue === val) {
return
}
val = newValue
// 如果赋值为对象的话,还要把对象中的所有属性都转换为响应式数据,
// 注意此时是在set方法中,存在新的作用域,this会指向data对象,
// 所以需要先保存之前的this并在此处使用。
that.walk(newValue)
// 之后发送通知(暂未完成)
}
})
}
}
在js/vue.js
中使用:
class Vue {
constructor (options) {
// 1. 通过$options属性保存options参数传入的数据
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对象,监听数据的变化
new Observer(this.$data)
// 4. 调用compiler对象,解析指令和差值表达式(暂未完成)
}
_proxyData (data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get () {
return data[key]
},
set (newValue) {
if (newValue === data[key]) {
return
}
data[key] = newValue
}
})
})
}
}
index.html
<!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>Mini Vue</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/observer.js"></script>
<script src="./js/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 100,
// 注意person中的属性也需要存在setter和getter(响应式)
person: { name: 'czs' }
}
})
console.log(vm.msg)
// 将msg赋值为新对象,其内部的属性也需要是响应式的
vm.msg = { test: 'Hello' }
</script>
</body>
</html>
10、Compiler
上面内容中,我们已经把data中的成员注入到vue实例中,并在Observer中把data的成员转换成了响应式数据,下面我们就要创建Compiler类。
首先我们观察一下Compiler有什么功能:
- 负责编译模板,解析模板中的指令/插值表达式。
- 负责页面的首次渲染。
- 当数据变化后重新渲染视图。
我们用一句话来总结就是:操作DOM。注意这里和vue中是不同的,我们做了简化,没有使用虚拟DOM,而只是做DOM操作。
下面我们再观察一下Compiler的结构:
Compiler类中有两个属性el和vm。其中el就是Vue构造函数传过来的options.el,并且把他转换成了DOM对象,我们后面要用到这个DOM对象,也就是我们的模板。而vm就是vue的实例,我们后续的方法要用到vm实例中的数据,所以我们先通过属性的方式把el和 vue实例全都记录下来。
Compiler类里面还定义了很多方法,不过这些方法其实都是在做DOM操作。因为如果我们把所有的DOM操作都写在一个位置的话,代码量会非常多,所以我们需要把DOM操作的一些代码都拆分出来,方便以后的维护。
compile方法传入一个el,也就是一个DOM对象,compile内部会遍历这个DOM对象的所有节点并且判断这个节点类型,如果是文本节点的话,解析插值表达式,如果是元素节点的话,就去解析指令。而结构图中最底下的两个方法isTextNode和isElementNode的作用就是判断当前节点是文本节点还是元素节点。
compile下面的两个方法compileElement和compileText就是去解析插值表达式和指令,如果是文本节点的话,就会调用compileText,这个方法内部去解析插值表达式。如果是元素节点的话,就会调用compileElement,这个方法用来解析元素中的指令。
另外还有一个方法isDirective就是用来判断当前属性是否是指令,是在compileElement中调用的。
以上就是Compiler类的一个结构。下面我们来用代码去实现:
页面结构还是原来的 index.html
<!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>Mini Vue</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>
<!-- 引入compiler.js -->
<script src="./js/compiler.js"></script>
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 100,
// 注意person中的属性也需要存在setter和getter(响应式)
person: { name: 'czs' }
}
})
console.log(vm.msg)
// 将msg赋值为新对象,其内部的属性也需要是响应式的
vm.msg = { test: 'Hello' }
</script>
</body>
</html>
js/compiler.js
class Compiler {
constructor (vm) {
// 把数据存储到这两个属性中的目的是为了在以后的方法中去使用。
// 其中el就是vm中的$el即我们的模板,vm就是vue实例。
this.el = vm.$el
this.vm = vm
// 当调用构造函数创建Compiler对象时,立即调用compile编译模板。
this.compile(this.el)
}
// 编译模板,处理文本节点和元素节点。
// 在上面的index.html中,el就是id为app的div标签,
// 要去遍历div中的所有节点(不是元素,因为我们还要去处理插值表达式,而插值表达式是文本节点。)
compile (el) {
// el.childNodes是节点的所有子节点,是一个伪数组。
let childNodes = el.childNodes
// 将childNodes转换为数组进行遍历。注意这里用了箭头函数,this还是当前的compiler实例
Array.from(childNodes).forEach(node => {
// 处理文本节点
if (this.isTextNode(node)) {
this.compileText(node)
} else if (this.isElementNode(node)) {
// 处理元素节点
this.compileElement(node)
}
// 判断node节点是否有子节点,如果有子节点,要递归调用compile
if (node.childNodes && node.childNodes.length) {
this.compile(node)
}
})
}
// 编译元素节点,处理指令,这里我们只模拟v-text和v-html。
// v-text和v-model本质上就是DOM元素的属性,所以我们首先要遍历DOM元素的所有属性,
// 找到所有以v-开头的属性,再进行处理。
// 在处理的过程中,我们需要知道这个属性的名称也即这个指令的名称以及对应的值。
// 最终把数据展示到指令指定的位置处。
compileElement (node) {
// console.log(node.attributes)
// 用node.attributes获取DOM元素的所有属性节点(伪数组)转换为数组后进行遍历
Array.from(node.attributes).forEach(attr => {
// 获取属性名称并判断是否是指令
let attrName = attr.name
if (this.isDirective(attrName)) {
// v-text --> text
attrName = attrName.substr(2)
let key = attr.value
// 调用辅助方法而非通过if-else来判断不同指令
this.update(node, key, attrName)
}
})
}
update (node, key, attrName) {
// 通过传入的attrName拼接出指令对应的方法名并调用,如v-text指令为textUpdater方法
let updateFn = this[attrName + 'Updater']
updateFn && updateFn(node, this.vm[key])
}
// 将方法名和指令关联起来,如v-text指令为textUpdater方法
// 处理 v-text 指令的方法
textUpdater (node, value) {
node.textContent = value
}
// v-model
modelUpdater (node, value) {
node.value = value
}
// 编译文本节点,处理差值表达式
compileText (node) {
// 注意当使用console.log打印文本节点时,会做特殊处理(输出字符串),
// 可以使用console.dir来打印文本节点
// console.dir(node)
// 编写正则表达式匹配类似 {{ msg }} 的插值表达式模式,并能提取插值表达式中的变量名
let reg = /{{(.+?)}}/
// 可以使用node.textContent或nodeValue来获取文本节点内容,这里使用textContent
let value = node.textContent
if (reg.test(value)) {
// 获取正则表达式中匹配到的变量名
let key = RegExp.$1.trim()
// 将文本节点原来内容中的插值表达式替换为date中变量对应的值,
// 可以通过this.vm获取vue实例中的对应值
node.textContent = value.replace(reg, this.vm[key])
}
}
// 判断元素属性是否是指令
isDirective (attrName) {
// 这里约定指令都是以v-开头
return attrName.startsWith('v-')
}
// 判断节点是否是文本节点(当nodeType为3的时候代表这个节点是文本节点。)
isTextNode (node) {
return node.nodeType === 3
}
// 判断节点是否是元素节点(当nodeType为1的时候代表这个节点是元素节点。)
isElementNode (node) {
return node.nodeType === 1
}
}
另外还需要在js/vue.js
中调用Compiler对象。
js/vue.js
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对象,监听数据的变化
new Observer(this.$data)
// 4. 调用compiler对象,解析指令和差值表达式
new Compiler(this)
}
_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
}
})
})
}
}
以上,我们完成了页面首次渲染并更新到视图的功能。但响应式处理还没实现,当数据改变的时候,视图还不会更新,所以接下来我们要实现Vue的响应式机制。
11、Dep
接下来,我们会模拟Vue中的响应式机制。现在,先对照下图回顾一下,
我们已经实现了这里的三个类:Vue、Observer以及Compiler。Vue负责把data中的属性注入到Vue实例并调用Observer和Compiler。Observer的作用是负责数据劫持,也即监听数据的变化,把data中的属性转化为getter和setter。Compiler则负责解析插值表达式和指令。
在Vue的响应式机制中,我们要使用到观察者模式来监听数据的变化。首先,我们要创建Dep类,也即观察者模式中的发布者(目标),Dep是Dependencies的缩写,意思是依赖。
Dep类的作用是在getter方法中收集依赖。收集依赖就是指收集依赖于该属性的Watcher对象,每一个响应式的属性将来都会创建一个对应的Dep对象,负责收集所有依赖于该属性的地方,而所有依赖于该属性的位置都会创建一个watcher对象。
我们会在setter方法通知依赖,当属性值发生变化的时候,我们会调用Dep的notify发送通知,调用Watcher对象的update方法。
经过分析,我们可以知道Dep的功能:
- 在getter中收集依赖即添加观察者(watcher)
- 在setter中去通知依赖即通知观察者(watcher)
Dep类的结构:
Dep类的结构不复杂,其中subs属性是一个数组,用来存储Dep中所有的Watcher。接下来是两个方法,addSub作用是添加watcher。最后是notify方法,当数据发生变化的时候,就会调用notify,在notify中去通知所有的观察者。
代码:
js/dep.js
class Dep {
constructor () {
// 初始化subs数组,用于存储所有的观察者
this.subs = []
}
// 添加观察者
addSub (sub) {
// 约定所有观察者都必须要有update方法
if (sub && sub.update) {
this.subs.push(sub)
}
}
// 发送通知
notify () {
// 调用所有观察者的update方法
this.subs.forEach(sub => {
sub.update()
})
}
}
Dep类的作用是收集依赖和发送通知,我们需要为每一个响应式数据创建一个Dep对象,在使用响应式数据的时候收集依赖,也即创建观察者对象。当数据变化的时候去通知所有的观察者,调用观察者的update方法来更新视图。
所以我们需要在Observer中来创建Dep对象:
js/observer.js
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方法中我们把data中的每个属性都转换为getter和setter。
// 同时,我们还要为每一个属性创建一个dep对象,让dep对象来收集依赖。
// 并在setter方法中去发送通知,也即调用dep对象的notify方法
defineReactive (obj, key, val) {
let that = this
// 负责收集依赖,并发送通知
let dep = new Dep()
// 如果val是对象,把val内部的属性转换成响应式数据
this.walk(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
// 收集依赖
// 注意收集依赖时比较特殊,需要判断Dep类是否设置静态属性target也即观察者对象。
// 但我们在定义Dep类的时候并没有给这个类设置target属性,
// target属性是我们在watcher对象中添加的。我们需要在编写Watcher类时再回头看。
// 如果 Dep.target 存在的话,target里存储的就是观察者对象。
Dep.target && dep.addSub(Dep.target)
return val
},
set (newValue) {
if (newValue === val) {
return
}
val = newValue
that.walk(newValue)
// 发送通知
dep.notify()
}
})
}
}
注意Dep类完成后,我们还需要编写完成Watcher类才能测试。
12、Watcher
本节中我们要完成Watcher类,也即观察者。我们先通过下面的图来观察一下Dep对象和Watcher对象的关系:
在data属性的getter方法中,我们通过dep对象来收集依赖。在data属性的setter方法中,我们通过dep对象来触发依赖。所以data中的每一个属性都要创建一个对应的dep对象,在收集依赖的时候把依赖该数据的所有watcher,也即观察者对象添加到dep对象的subs数组中。在setter中触发依赖,发送通知,即调用dep对象的notify方法通知所有关联的watcher对象,watcher对象负责更新对应的视图。
Watcher功能:
- 当数据变化触发依赖, dep 通知所有的 Watcher 实例更新视图
- 自身实例化的时候往 dep 对象中添加自己
Watcher结构:
之前我们约定过,所有的watcher对象都需要有update方法,在update方法里更新视图。但watcher对象有很多个,不同的watcher对象更新视图的时候所做的事情是不太一样的,所以watcher对象里有一个属性叫cb,也即callback,回调函数。当new一个Watcher的时候需要传入一个回调函数,这个回调函数里就应当指明如何去更新视图。
在更新视图的时候还需要数据,所以这里还需要一个key属性,即data中的属性名称。有了这个属性名称后,再拿到vm中保存的vue实例,我们就可以拿到属性对应的值。
oldValue是用来记录数据变化之前的值,在update触发的时候,update内部可以获取到这个数据最新的值。当拿新值和旧值进行比较时,若值发生变化,就调用cb方法去更新视图。
Watcher代码:
js/watcher.js
class Watcher {
constructor (vm, key, cb) {
this.vm = vm
// data中的属性名称
this.key = key
// 回调函数负责更新视图
this.cb = cb
// 把watcher对象记录到Dep类的静态属性target
Dep.target = this
// 访问vm[key]会触发observer中的get方法,在get方法中会调用addSub去收集依赖
this.oldValue = vm[key]
// 当把当前的target添加到dep中之后,我们还要置空,防止将来重复添加
Dep.target = null
}
// 当数据发生变化的时候更新视图
update () {
// 当触发update的时候数据已经发生变化,所以this.vm[this.key]即为新值
let newValue = this.vm[this.key]
if (this.oldValue === newValue) {
return
}
// 更新视图时需要把新值传入cb
this.cb(newValue)
}
}
13、创建watch对象
watch类创建完成后,下面我们还要思考在哪里创建对应的watch对象。
首先,来回顾一下watch的作用,第一是创建watch对象的时候,要把watch对象添加到dep的subs数组中,这件事我们在上面的代码中已经完成了。第二是当数据改变的时候,更新视图,发送通知,在observer的set中我们调用了dep的notify方法完成了当数据改变时更新视图并发送通知。在notify方法中,遍历所有的watch对象,调用所有对象的update去更新视图。
而在update中,是调用cb回调函数来更新视图的,而cb回调函数则是在构造函数中传递的,所以我们要在创建watch对象的时候传递这个回调函数。最终调用这个回调函数的时候,会把newValue传递给cb。
我们更新视图的操作,也即操作DOM,而所有的DOM操作,都在compiler中,所以我们需要在compiler.js中创建watch对象。
js/compiler.js
class Compiler {
constructor (vm) {
this.el = vm.$el
this.vm = vm
this.compile(this.el)
}
compile (el) {
let childNodes = el.childNodes
Array.from(childNodes).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)
}
})
}
compileElement (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) {
let updateFn = this[attrName + 'Updater']
// 通过call来改变this的指向,此处this即为compiler对象
updateFn && updateFn.call(this, node, this.vm[key], key)
}
// 处理 v-text 指令,需要传入key参数
textUpdater (node, value, key) {
node.textContent = value
// 创建watcher对象,当数据改变更新视图
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
// v-model,需要传入key参数
modelUpdater (node, value, key) {
node.value = value
// 创建watcher对象,当数据改变更新视图
new Watcher(this.vm, key, (newValue) => {
node.value = newValue
})
}
// 编译文本节点,处理差值表达式
compileText (node) {
let reg = /{{(.+?)}}/
let value = node.textContent
if (reg.test(value)) {
let key = RegExp.$1.trim()
node.textContent = value.replace(reg, this.vm[key])
// 创建watcher对象,当数据改变更新视图
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
}
isDirective (attrName) {
return attrName.startsWith('v-')
}
isTextNode (node) {
return node.nodeType === 3
}
isElementNode (node) {
return node.nodeType === 1
}
}
接下来在index.html
中引入相应文件,进行测试。
<!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>Mini Vue</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/dep.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compiler.js"></script>
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 100,
person: { name: 'zs' }
}
})
console.log(vm.msg)
// vm.msg = { test: 'Hello' }
vm.test = 'abc'
</script>
</body>
</html>
14、双向绑定
在上文中,我们已经完成了创建watch对象并能在数据变化的时候更新视图,但还没有完全实现双向绑定,即在视图改变时还需去更新数据。
js/compiler.js
class Compiler {
constructor (vm) {
this.el = vm.$el
this.vm = vm
this.compile(this.el)
}
compile (el) {
let childNodes = el.childNodes
Array.from(childNodes).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)
}
})
}
compileElement (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) {
let updateFn = this[attrName + 'Updater']
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
})
// 双向绑定,即给表单元素(node)注册一个input事件,当视图变化,去更新数据
node.addEventListener('input', () => {
this.vm[key] = node.value
})
}
compileText (node) {
let reg = /{{(.+?)}}/
let value = node.textContent
if (reg.test(value)) {
let key = RegExp.$1.trim()
node.textContent = value.replace(reg, this.vm[key])
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
}
isDirective (attrName) {
return attrName.startsWith('v-')
}
isTextNode (node) {
return node.nodeType === 3
}
isElementNode (node) {
return node.nodeType === 1
}
}
注意当触发了input事件后(视图改变),在事件处理函数中,会把文本框的值取出,并重新赋值给vm的对应变量,且当给vm的对应变量赋值的时候,又会触发响应式机制,即当数据变化时又去更新其他绑定对应数据的视图。
15、总结
先来回答开篇提出的问题:
- 给属性重新赋值成对象,是否是响应式的?
是的,因为在observer.js的set方法中,会调用walk方法,在walk方法中会遍历对象的所有属性,重新定义为响应式数据。
- 给 Vue 实例新增一个成员是否是响应式的?
不是的,因为data属性是在创建Vue对象时在Vue构造函数的创建Observer对象的时候转换为响应式数据的。这也在官方文档中有相应说明,且也提供了解决方案:深入响应式原理 — Vue.js (vuejs.org)。
接下来我们再通过下图来回顾整体流程:
-
Vue
- 记录传入的选项,设置 data/data/data/el。
- 把 data 的成员注入到 Vue 实例。
- 负责调用 Observer 实现数据响应式处理(数据劫持)。
- 负责调用 Compiler 编译指令/插值表达式等。
-
Observer
-
数据劫持
- 负责把 data 中的成员转换成 getter/setter。
- 负责把多层属性转换成 getter/setter。
- 如果给属性赋值为新对象,把新对象的成员设置为 getter/setter。
-
添加 Dep 和 Watcher 的依赖关系。
-
数据变化发送通知。
-
-
Compiler
- 负责编译模板,解析指令/插值表达式。
- 负责页面的首次渲染过程。
- 当数据变化后重新渲染。
-
Dep
- 收集依赖,添加订阅者(watcher)。
- 通知所有订阅者。
-
Watcher
- 自身实例化的时候往dep对象中添加自己。
- 当数据变化dep通知所有的 Watcher 实例更新视图。
转载自:https://juejin.cn/post/7182188577948696632