Vue.js 框架源码与进阶(二)--- 模拟Vue.js响应式原理

目标:实现一个最小版本的Vue.js

一、响应式核心原理

(一)Vue2.x

Vue2.x响应式原理
Object.defineProperty()
1、 Vue2.x的响应式使用的是Object.defineProperty()方法。Object.defineProperty()静态方法会直接在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。接收三个参数:参数一:目标对象;参数二:目标属性名称;参数三:要定义或修改的属性的描述符。
2、描述符是一个对象,分为两种:数据描述符访问器描述符。数据描述符关注的是一个值在对象上的存储行为,包括 writablevalue 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上的属性需要遍历并且转化为vmgetter/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对象,接收两个参数,参数一是目标对象;参数二是包含setget拦截器函数的对象。将一个对象定义成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)是一种消息传播模式,消息的发送者(发布者)不会将消息直接发送给特定的接收者(订阅者)。而是将发布的消息按特征分类,无需对订阅者(如果有的话)有所了解。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需对发布者(如果有的话)有所了解。
发布订阅模式有三个关键的概念:

  1. 发布者
  2. 订阅者
  3. 消息中心
    回顾兄弟组件传值的代码
// 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:创建实例时传过来的原始参数都保存在其中(比如eldata);countmessagedata上的属性被挂载到了Vue实例上;$data保存原始数据,并且作为Vue实例的代理,实现数据更改和获取。以_开头的是私有变量,只能在对象内部访问,先不考虑其实现,以$开头的是公有变量。
在这里插入图片描述

并且countmessagegetter/setter类型的数据:
在这里插入图片描述
根据以上分析,我们可以得知,模拟的Vue中需要有三个属性:
$options:保存构造函数传递过来的数据
$el:传递过来的el如果是选择器,需要转换为DOM元素;如果是元素则直接保存
$data:作为Vue实例的代理,需要实现属性的setget方法

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中,returnthis.$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
        }
    })
}

我们先运行一下,看看有什么问题:
在这里插入图片描述

observerget()方法中,发生了堆栈溢出。
原因是:在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-textv-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;
}

查看指令渲染效果
在这里插入图片描述

扩展
textContentinnerText都是用于获取或设置元素的文本内容的属性,但它们有一些区别:

  1. textContent 返回元素所有的文本内容,包括隐藏的文本,而 innerText 只返回可见的文本内容。例如,如果一个元素的文本内容中有一部分被 CSS 隐藏了,那么 textContent 会返回全部的文本内容,而 innerText 则只会返回可见的文本内容。
  2. textContent 会返回元素中的所有文本,包括空格和换行符,而 innerText 会忽略空格和换行符。
  3. textContent 是一个 W3C 标准属性,而 innerText 是一个非标准属性,只受部分浏览器支持,并且在不同浏览器中的实现可能会有所不同。
    总的来说,如果需要获取元素的全部文本内容,包括隐藏的文本和空格等字符,应该使用 textContent,如果只需要获取可见的文本内容,则应该使用 innerText
    示例
<div id="ppppp">nkjnkjnk
njknjknk
mlknlknlknlnk
    <span style="visibility: hidden">我被隐藏了</span>
    <span style="display: none">我被隐藏了</span>
</div>

分别打印上述divtextContentinnerText:
在这里插入图片描述

(四)Dep类

depdependency依赖的意思。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阶段进行
DepWatcher大致工作流程如下:
在这里插入图片描述
在初始化阶段,每一个响应式数据都会新建一个dep,在compiler中(解析{{}}和指令),给所有需要依赖数据的DOM创建一个watcher(插值表达式、v-textv-model),在此时,我们可以知道这个watcher具体要做什么DOM操作(差值表达式、v-textv-model对应不同的DOM操作),所以给watcher传递一个操作DOM的回调函数。在watcherconstructor中,执行Dep.target = this;,然后获取当前数据,触发响应式数据的get()方法,在get()方法中将watcher加入dep.subs中。

初始化阶段对于每一个响应式数据,创建了一个dep发布者和一个watcher观察者队列。

在数据更新阶段,会触发响应式数据的set()方法,调用所有watcherupdate()方法。在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
    })
}

在调用指令处理函数的时候,需要使用callthis指向改为当前的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阶段会调用watchersupdate()方法,进行DOM更新。
在这里插入图片描述

参考文章:
发布-订阅模式[译自维基百科]

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值