目标:实现一个最小版本的Vue.js
一、响应式核心原理
(一)Vue2.x
Vue2.x响应式原理
Object.defineProperty()
1、 Vue2.x
的响应式使用的是Object.defineProperty()
方法。Object.defineProperty()
静态方法会直接在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。接收三个参数:参数一:目标对象;参数二:目标属性名称;参数三:要定义或修改的属性的描述符。
2、描述符是一个对象,分为两种:数据描述符和访问器描述符。数据描述符关注的是一个值在对象上的存储行为,包括 writable
、value
和 enumerable
等属性;访问器描述符则关注的是 value
的获取 (get
) 和设置 (set
) 行为,并且不具备可写性。当目标属性是一个普通的数据的时候使用数据描述符,当目标属性在赋值期间需要进行一些特殊操作(比如通知视图更新)的时候需要使用访问器描述符。
它们公用的可选键:
① configurable
当设置为false
时,目标属性类型不能更改;目标属性不能被删除;描述符的其他属性也不能更改(例外:如果目标属性writable: true
,则value
能被修改,并且writable
可以被修改)。默认为false
。
② enumerable
目标属性能否在对象中被枚举。默认为false
数据描述符的私有键:
① value
目标属性的值。默认为undefined
② writable
是否能被重新赋值。默认为false
。
访问器描述符的私有键:
① get
是一个函数,目标属性的获取器,在属性被访问的时候执行这个方法。
② set
是一个函数,目标属性的设置器,在属性被赋值的时候执行这个方法。设置的目标属性的新值会作为参数传递进来,set
方法中的this
指向目标对象。
3、响应式核心原理模拟
- 使用一个对象
vm
模拟Vue
实例,vm
中的数据存储在data
中,获取vm
上的数据(get()
)其实返回的是data
上的数据,设置vm
上的数据(set()
)其实设置的是data
上的数据。data
上的属性需要遍历并且转化为vm
的getter/setter
。在数据set
阶段,需要操作dom
,模拟数据驱动视图更新的操作。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Page Title</title>
</head>
<body>
<div id="app">
hello
</div>
<script>
const app = document.getElementById('app')
let vm = {};
let data = {
msg: 'hello',
count: 100
}
// 遍历data中的属性
Object.keys(data).forEach(key => {
setProperty(key);
})
function setProperty(key){
Object.defineProperty(vm, key, {
get() {
console.log('触发get方法');
return data[key];
},
set(newVal) {
console.log('触发set方法');
if(newVal === data[key]) return;
// 数据还是存储在data中的
data[key] = newVal;
// 触发视图更新
app.innerHTML = newVal;
}
})
}
// 给vm中的属性赋值会触发set方法
vm.msg = 'hello world';
// 获取vm中的属性会触发get方法
console.log(`获取count属性:${vm.count}`);
</script>
</body>
</html>
在控制台查看vm
,可以看出,使用setter/getter
增加的属性并不是vm
的真实属性。
(二)Vue3.x
Vue3.x中的响应式使用的是Proxy对象
Proxy
对象可以创建一个对象的代理。使用new
关键字创建Proxy
对象,接收两个参数,参数一是目标对象;参数二是包含set
和get
拦截器函数的对象。将一个对象定义成Proxy
对象,就是在操作对象属性的时候,不操作自身,而是对目标对象进行操作,相当于给Proxy
对象找了一个经纪人。Proxy
是监听对象的变化,而非监听属性的变化,比defineProperty()
写法简单并且更高效。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Page Title</title>
</head>
<body>
<div id="app">
hello
</div>
<script>
const app = document.getElementById('app')
let data = {
msg: 'hello',
count: 100
}
let vm = new Proxy(data, {
// target=>data目标对象
get(target, key) {
console.log(`触发get操作,获取${key}属性`);
return target[key]
},
set(target, key, value) {
console.log(`触发set操作,设置${key}属性`);
if(value === target[key]) return
target[key] = value
app.innerHTML = value
}
})
// test
vm.msg = 'hello world';
console.log(`获取count属性:${vm.count}`);
</script>
</body>
</html>
查看vm
是一个Proxy
对象
二、设计模式
(一)发布订阅模式
1、释义
在软件架构中,发布-订阅(publish–subscribe)是一种消息传播模式,消息的发送者(发布者)不会将消息直接发送给特定的接收者(订阅者)。而是将发布的消息按特征分类,无需对订阅者(如果有的话)有所了解。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需对发布者(如果有的话)有所了解。
发布订阅模式有三个关键的概念:
- 发布者
- 订阅者
- 消息中心
回顾兄弟组件传值的代码
// eventBus.js
import Vue from 'vue';
export const EventBus = new Vue();
// Component A - 发送事件
import { EventBus } from './eventBus.js';
methods: {
sendEvent() {
EventBus.$emit('my-event', data);
}
}
// Component B - 监听事件
import { EventBus } from './eventBus.js';
created() {
EventBus.$on('my-event', this.handleMyEvent);
},
methods: {
handleMyEvent(data) {
// 处理事件数据
}
}
在这个例子中,发布者就是Component A,将消息发送给消息中心;订阅者就是Component B,从消息中心订阅消息;消息中心就是EventBus这个类。因为发送者和接收者都不需要知道彼此的存在,所以这种方式可以显著减少组件之间的耦合程度,提高系统的可维护性和可扩展性。
2、模拟实现
定义一个类,实现$emit()
和$on()
方法。
$on()
需要接收两个参数,参数一是事件名称,参数二是事件处理函数;当执行$on()
方法的时候,接收到这两个数据后还需要将这两个数据存储起来,存储成键值对的形式;另外,同一个事件可能对应好几个事件处理函数,所以键值对键是事件名称,值是事件处理函数组成的数组。
$emit()
需要接收两个参数,第一个参数是事件名称,第二个参数是传给事件处理函数的参数,当执行$emit()
方法的时候,要在键值对中找对应事件名称的处理函数,并且把参数传递给处理函数并执行。
class EventEmmiter{
constructor(){
this.subs = {}
}
$on(event,cb){
this.subs[event] = this.subs[event] || []
this.subs[event].push(cb)
}
$emit(event,params){
if(this.subs[event]){
this.subs[event].forEach(cb => cb(params))
}
}
}
const event = new EventEmmiter()
event.$on('test',function (params) {
console.log(`第一次:${params}`)
})
event.$on('test',function (params) {
console.log(`第二次:${params}`)
})
event.$emit('test','hello')
(二)观察者模式
1、释义
观察者模式与发布订阅模式的区别在于没有消息中心,并且发布者要知道观察者的存在,并且保存所有的观察者;观察者必须有一个名为'update'
的函数,用来定义接收到消息的行为。当发布者发布消息的时候,调用所有观察者的update()
函数。
2、实现
// 观察者
class Watcher {
update () {
console.log('update')
}
}
// 发布者
class Dep {
constructor () {
// 观察者列表
this.subs = []
}
addSub (watcher) {
this.subs.push(...watcher)
}
notify () {
// 发布消息
this.subs.forEach(watcher => {
watcher.update()
})
}
}
const dep = new Dep()
const watcher1 = new Watcher()
const watcher2 = new Watcher()
dep.addSub([watcher1, watcher2])
dep.notify()
可以看到两个观察者都接收到了消息
- 观察者模式是由具体目标调度,比如当事件触发,
Dep
就会去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的。 - 发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在。
三、模拟Vue响应式原理
(一)功能分析
先回顾一下一个简单的Vue应用的基本结构
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Page Title</title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body>
<div id="app">
<p v-text="message"></p>
<p>{{message}}</p>
<input type="number" v-model="count">
</div>
<script>
new Vue({
el: '#app',
data: {
message: 'Hello Vue!',
count: 0
}
})
</script>
</body>
</html>
上述代码中,script
中通过new Vue()
创建了一个Vue
实例,并且传递了一个对象作为参数,所以Vue
构造函数应该接受一个对象作为参数。在控制台中查看Vue
实例,重点关注$el
:一个DOM
元素;$options
:创建实例时传过来的原始参数都保存在其中(比如el
、data
);count
和message
:data
上的属性被挂载到了Vue
实例上;$data
保存原始数据,并且作为Vue
实例的代理,实现数据更改和获取。以_
开头的是私有变量,只能在对象内部访问,先不考虑其实现,以$
开头的是公有变量。
并且count
和message
是getter/setter
类型的数据:
根据以上分析,我们可以得知,模拟的Vue
中需要有三个属性:
① $options
:保存构造函数传递过来的数据
② $el
:传递过来的el
如果是选择器,需要转换为DOM
元素;如果是元素则直接保存
③ $data
:作为Vue
实例的代理,需要实现属性的set
和get
方法
Vue
实例要实现的功能:
① Vue
类:存储原始数据;维护Vue
实例的属性;将data
中的数据代理到Vue
实例上。
② Observer
类:将data
中的属性转换为getter/setter
;监听data
属性变化;当变化时发送通知。
③ Dep
类:订阅者,发布消息;维护观察者队列;调用观察者的update()
方法。
④ Watcher
类:观察者,在update()
方法中更新视图。
⑤ Compiler
类:解析指令和插值表达式。
文件目录说明:
index.html
作为主页面,里面通过script
标签引入依赖的js
文件;一个类定义为一个js
文件
(二)Vue类
在构造函数中需要实现$options
、$data
、$el
属性的初始化。
vue.js
class Vue{
constructor(options) {
this.$options = options;
// 如果el是字符串,那么就是一个选择器,需要获取到dom元素,赋值给$el
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el;
this.$data = options.data;
this.proxyData(this.$data);
}
// 将data中的数据代理到Vue实例上,可以通过this.xxx访问到data中的数据
proxyData(data) {
Object.keys(this.$data).forEach(key => {
// 使用箭头函数,保证this指向Vue实例
// 如果不使用箭头函数,那么this指向的是window
Object.defineProperty(this, key, {
enumerable: true, // 可枚举
configurable: true, // 可配置
get() {
return data[key]
},
set(newVal) {
if(newVal !== data[key]) data[key] = newVal;
}
})
})
}
}
(二)Observer类
可以先大致浏览下vue
的相关源码,会更好理解。其中省略了很多内容,大致展现了Observer
类的结构:
vue.runtime.esm.js
var Observer = function Observer (value) {
// ...
this.walk(value);
};
Observer.prototype.walk = function walk (obj) {
var keys = Object.keys(obj);
for (var i = 0; i < keys.length; i++) {
defineReactive$$1(obj, keys[i]);
}
};
function defineReactive$$1 (obj,key,val) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
return value
},
set: function reactiveSetter (newVal) {
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
val = newVal;
}
});
}
Observer
类需要实现的功能:
1、将data
中的属性转换为getter/setter
。
2、如果某个属性是对象的形式,需要将其内部的属性也转换为getter/setter
。
3、在数据变化的时候发布消息。
Observer
类有两个方法:
① walk()
:负责遍历对象属性
② defineReactive()
:将属性转换为getter/setter
1、将data中的属性转换为getter/setter
observer.js
class Observer {
constructor(data) {
this.walk(data)
}
// walk:遍历对象的每一个属性
walk(obj) {
Object.keys(obj).forEach(key => {
this.defineReactive(obj, key, obj[key])
})
}
defineReactive(obj, key, val) {
Object.defineProperty(obj,key,{
enumerable: true,
configurable: true,
get: () => {
console.log('observer defineReactive get')
return val
},
set: newVal => {
if(newVal === val) return
val = newVal
// 发布消息
}
})
}
}
测试:
在vue
类的构造函数中,创建一个observer
:
vue.js
constructor(options) {
this.$options = options;
// 如果el是字符串,那么就是一个选择器,需要获取到dom元素,赋值给$el
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el;
this.$data = options.data;
const observer = new Observer(this.$data);
this.proxyData(this.$data);
}
在index.html
中,获取vm.message
console.log(vm.message)
控制台查看函数的调用路线,先执行了vue
实例的proxyData()
中定义的getter
,在这个getter
中,return
了this.$data[key]
,所以后续执行了observer
中的getter
,最终将vm.$data.message
输出
再看一下此时的vm.$data
,两个属性都拥有了各自的getter/setter
2、data中属性为对象的情况
如果属性是一个对象,就需要使用walk()
方法,对其中的属性再次遍历。并且在walk()
方法中要增加判断,如果参数不存在或者不是对象,直接return
。
walk(obj) {
if (!obj || typeof obj !== 'object') return
Object.keys(obj).forEach(key => {
this.defineReactive(obj, key, obj[key])
})
}
defineReactive(obj, key, val) {
// 递归遍历
this.walk(val)
Object.defineProperty(obj,key,{
// ...
})
}
3、将data中的属性改为对象的情况
index.html
vm.count = {
time: 'today',
number:3
}
修改属性值会发生在oberver
对象的set
阶段,所以在set()
方法中,再次调用walk()
方法,如果是对象,对其属性遍历并且转换为getter/setter
observer.js
set: newVal => {
if(newVal === val) return
val = newVal
this.walk(newVal)
// 发布消息
}
4、注意点
defineReactive
接受三个参数:obj
, key
, val
。思考一个问题,为什么不能通过obj[key]
来拿到val
?也就是写成vue
中的getter/setter
那种形式:
defineReactive(obj, key) {
Object.defineProperty(obj,key,{
// ...
get: () => {
return obj[key]
},
set: newVal => {
if(newVal === obj[key]) return
obj[key] = newVal
}
})
}
我们先运行一下,看看有什么问题:
在observer
的get()
方法中,发生了堆栈溢出。
原因是:在get()
中执行return obj[key]
,也是一个获取的行为,会再一次进入get()
方法中,发生了死循环。
(三)Compiler类
1、功能分析和基本结构
Compiler
类主要负责操作DOM
。这里进行了简化,没有使用虚拟DOM
,直接操作真实的DOM
。功能可以分为以下三个:
① 编译模版,解析指令和{{}}
② 页面的首次渲染
③ 数据变化时更新视图
Compiler
需要接收vm
实例,并且需要初始化el
,后续都是操作el
中的节点。类图如下图所示:
这几个方法的使用过程是这样的:
Compiler
类基础代码:
class Compiler {
constructor(vm) {
this.vm = vm;
this.el = vm.$el;
this.compile(this.el)
}
compile(el){
// 遍历节点
}
compileElement(node){
// 处理元素节点
}
compileText(node){
// 处理文本节点
}
isElementNode(node){
// 判断节点是否是元素节点
return node.nodeType === 1;
}
isTextNode(node){
// 判断节点是否是文本节点
return node.nodeType === 3;
}
isDirective(attrName){
// 指令都是以'v-'开头
return attrName.startsWith('v-')
}
}
2、compile()方法
在compile()
方法中,对el
中的节点进行遍历,判断节点类型,对不同的节点类型执行不同的操作。如果节点还有子节点,需要递归调用compile()
方法。
compile(el){
// 编译模版
el.childNodes.forEach(node=>{
if(this.isElementNode(node)){
this.compileElement(node)
}else if(this.isTextNode(node)){
this.compileText(node)
}
if(node.childNodes && node.childNodes.length > 0){
this.compile(node)
}
})
}
3、compileText()方法
compileText()
方法用于处理文本节点,需要检测文本中有没有插值表达式。需要使用正则匹配{{ 任意字符 }}
的结构,这样的正则表达式定义为:/\{\{(.+?)\}\}/g
;通常匹配任意内容都会使用.*?
,+
表示出现一次或多次,*
表示出现0次或多次,插值表达式中放置属性名,所以一定会有内容,这里使用+
。这里的?
表示非贪婪模式,尽可能少的匹配字符。如果匹配上正则表达式,需要将文本节点中对应的数据替换,需要使用matchAll()方法
。
compilerText(node){
// 文本节点需要处理插值表达式
const reg = /\{\{(.+?)\}\}/g
let text = node.textContent;
if(text.match(reg)){
[...text.matchAll(reg)].forEach(result=>{
let key = result[1].trim();
text = text.replace(result[0],this.vm[key])
node.textContent = text.replace(result[0],this.vm[key])
})
}
}
思考:
if(text.match(reg)){
能不能使用test
?
答案是不能,如果正则表达式设置了全局标志,test()
的执行会改变正则表达式 lastIndex
属性,后续的执行将会从 lastIndex
处开始匹配字符串,matchAll
会匹配不上。神奇的是match()
是可以匹配上的,并且会将lastIndex
属性变为0。所以,如果在matchAll()
之前写一句text.match(reg)
就能匹配上了😂
扩展:
matchAll()方法
返回的是一个类数组对象,需要使用...
转换为数组,再使用forEach()
遍历。数组的元素也是一个数组,其中
result[0]
表示匹配上的字符串,例如:信息:{{ message }}
中,result[0]
就是{{ message }}
;
后面的元素序列分别是匹配上正则表达式小括号分组的内容的字符串。例如:信息:{{ message }}
中,result[1]
就是 message
,注意两边有空格,所以要trim()
去除空格。
看一下MDN
的示例:
测试:
在index.html
中引入compiler.js
;在Vue
的构造函数中创建一个compiler
constructor(options) {
// ...
const observer = new Observer(this.$data);
const compiler = new Compiler(this);
}
页面中的插值表达式已经被正确渲染了:
4、compileElement()方法
compileElement()
方法用来处理元素节点。获取元素节点的属性,判断属性是不是指令。vue
中的指令有很多,这里只处理v-text
和v-model
。如果属性是指令,需要对不同的指令进行不同的处理。用if
或者switch
?这个方法里的代码就需要写的很长,并且耦合性强,复用性不高。
一个更好的解决思路:不同的指令其实就是名称字符串不一样,我们可以将一个指令的处理封装为一个处理函数,这些处理函数都以指令的名称作为前缀,决定要调用哪个处理函数时,只需要根据指令名称找到对应的处理函数即可。
compileElement(node) {
// node.attributes是一个伪数组,需要转换为真实的数组
const attrs = Array.from(node.attributes);
attrs.forEach(attr => {
// 其中name是属性名,value是属性值
// 这里如果使用console.log打印会是一个字符串:v-text="message"这种形式
// 需要使用console.dir以对象形式打印
let attrName = attr.name
if (!this.isDirective(attrName)) {
return
}
// 获取属性的值
const key = attr.value
attrName = attrName.substr(2)
this.update(node, attrName, key)
})
}
update(node, attrName, key) {
// 通过字符串拼接获取指令对应的处理函数
const updateFn = this[attrName + 'Updater']
// 先判断处理函数是否存在,如果存在就进行调用,也是一个免用if的小妙招
updateFn && updateFn(node,this.vm[key])
}
textUpdater(node, value) {
// v-text用于给元素增加文本
node.textContent = value;
}
modelUpdater(node, value) {
// v-model用于表单元素
node.value = value;
}
查看指令渲染效果
扩展
textContent
和 innerText
都是用于获取或设置元素的文本内容的属性,但它们有一些区别:
textContent
返回元素所有的文本内容,包括隐藏的文本,而innerText
只返回可见的文本内容。例如,如果一个元素的文本内容中有一部分被CSS
隐藏了,那么textContent
会返回全部的文本内容,而innerText
则只会返回可见的文本内容。textContent
会返回元素中的所有文本,包括空格和换行符,而innerText
会忽略空格和换行符。textContent
是一个W3C
标准属性,而innerText
是一个非标准属性,只受部分浏览器支持,并且在不同浏览器中的实现可能会有所不同。
总的来说,如果需要获取元素的全部文本内容,包括隐藏的文本和空格等字符,应该使用textContent
,如果只需要获取可见的文本内容,则应该使用innerText
。
示例
<div id="ppppp">nkjnkjnk
njknjknk
mlknlknlknlnk
<span style="visibility: hidden">我被隐藏了</span>
<span style="display: none">我被隐藏了</span>
</div>
分别打印上述div
的textContent
和innerText
:
(四)Dep类
dep
是dependency
依赖的意思。dep
的作用是保存所有的观察者,并且在数据变化的时候发布消息,调用观察者的update()
方法进行视图更新。也就是在vm.$data
中的属性的set
阶段调用观察者的update()
方法。Dep
类的定义和上面提到的观察者模式中的发布者是几乎一样的。
dep.js
class Dep {
constructor () {
// 观察者列表
this.subs = []
}
addSub (sub) {
// 判断是不是观察者
if(sub && sub.update){
this.subs.push(sub)
}
}
notify () {
// 发布消息
this.subs.forEach(sub => {
sub.update()
})
}
}
dep
对象的创建位置:每一个响应式对象都需要一个dep
来处理数据更新。所以在observer.js
中定义响应式数据的方法里,创建dep
对象。
addSub()
方法的执行位置:addSub()
方法需要把观察者放到dep
中,怎么知道目前加入的观察者是谁?就需要在观察者的constructor
构造函数中,将新建的watcher
放到Dep
的静态属性target
上。在watcher
里面,我们需要拿到响应式数据,就必定会触发get()
方法,所以在响应式数据的get()
方法中执行addSub(Dep.target)
。
notify()
方法的执行位置:notify()
方法是在数据更新时候通知watchers
进行视图更新的,所以在响应式数据的set
阶段进行
Dep
和Watcher
大致工作流程如下:
在初始化阶段,每一个响应式数据都会新建一个dep
,在compiler
中(解析{{}}
和指令),给所有需要依赖数据的DOM
创建一个watcher
(插值表达式、v-text
、v-model
),在此时,我们可以知道这个watcher
具体要做什么DOM
操作(差值表达式、v-text
、v-model
对应不同的DOM
操作),所以给watcher
传递一个操作DOM
的回调函数。在watcher
的constructor
中,执行Dep.target = this;
,然后获取当前数据,触发响应式数据的get()
方法,在get()
方法中将watcher
加入dep.subs
中。
初始化阶段对于每一个响应式数据,创建了一个
dep
发布者和一个watcher
观察者队列。
在数据更新阶段,会触发响应式数据的set()
方法,调用所有watcher
的update()
方法。在update()
方法中,执行创建watcher
时传递的回调函数。
Dep
的使用:
defineReactive(obj, key, val) {
let dep = new Dep()
// 递归遍历
this.walk(val)
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
this.walk(newVal)
// 发布消息
dep.notify()
}
})
}
(五)Watcher类
Watcher
类的作用是在数据变化的时候更新DOM
。数据变化的时候,会触发watcher
对象的update()
方法。在watcher
里面我们不知道具体要进行什么操作,所以在watcher
创建的时候需要接收一个回调函数,在update()
里面调用这个回调函数。还需要接收当前vm
实例和响应式数据的key
用来获取响应式数据。
在构造函数中,
① 需要执行Dep.target = this;
,使得当前watcher
可以被dep
识别到并且加入dep.subs
队列中,
② 需要获取该数据的旧值,一是用来与新值作对比,二是可以触发响应式数据的get()
方法,进而执行dep.addSub()
在update()
方法中,
① 获取修改后的值:由于此方法是在响应式对象的set()
方法中被调用的,所以此时this.vm[this.key]
已经是新值了
② 如果数据更新,执行回调函数,并且把新值传递过去。
watcher.js
class Watcher {
constructor(vm,key,cb) {
this.vm = vm;
this.key = key;
this.cb = cb;
// 把watcher对象记录到Dep类的静态属性target
Dep.target = this;
// 触发get方法,在get方法中会调用addSub
this.oldValue = vm[key];
Dep.target = null;
}
update(){
const newValue = this.vm[this.key]
if(newValue === this.oldValue) return
this.cb(newValue)
}
}
watcher
对象的创建:
① 在compileText()
中:
compilerText(node){
// 文本节点需要处理插值表达式
const reg = /\{\{(.+?)\}\}/g
let text = node.textContent;
if(text.match(reg)){
[...text.matchAll(reg)].forEach(result=>{
let key = result[1].trim();
text = text.replace(result[0],this.vm[key])
node.textContent = text.replace(result[0],this.vm[key])
const oldValue = this.vm[key]
new Watcher(this.vm, key,newValue=>{
const value = node.textContent
node.textContent = value.replace(oldValue,newValue)
oldValue = newValue // 每次修改值之后,都需要把oldValue存为最新的值,下次再修改就跟这个值进行比较
})
})
}
}
② 在指令处理函数中:
new Watcher()
需要接收三个参数:vm
对象、响应式数据的key
、回调函数。vm
使用this.vm
获取,key
需要从指令处理函数中传过来。
textUpdater(node, value, key) {
// v-text用于给元素增加文本
node.textContent = value;
new Watcher(this.vm, key, newValue => {
node.textContent = newValue
})
}
modelUpdater(node, value, key) {
// v-model用于表单元素
node.value = value;
new Watcher(this.vm, key, newValue => {
node.value = newValue
})
}
在调用指令处理函数的时候,需要使用call
将this
指向改为当前的Vue
实例,并且需要传递key
。
update(node, attrName, key) {
// 通过字符串拼接获取指令对应的处理函数
const updateFn = this[attrName + 'Updater']
// 先判断处理函数是否存在,如果存在就进行调用,也是一个免用if的小妙招
updateFn && updateFn.call(this, node, this.vm[key], key)
}
扩展
浏览器断点调试快捷键:
① F8:进入下一个断点
② F10:单步实施,不进入子函数
③ F11:单步实施,遇到子函数会进入子函数
管理所有断点:
四、总结
整体流程
在使用new Vue()
创建Vue
实例的时候,Observer
将数据转换为响应式数据,每一个响应式数据都创建一个Dep
对象,用来维护订阅者,在数据变化的时候发布消息;同时,Compiler
进行DOM
的解析,将插值表达式和vue
指令解析为对应数据,每一个需要依赖数据的DOM
都需要创建一个watcher
,等数据变化的时候,可以接收到Dep
对象发布的消息,进行DOM
元素的更新。在数据更新时,响应式数据的set
阶段会调用watchers
的update()
方法,进行DOM
更新。
参考文章:
发布-订阅模式[译自维基百科]