模拟Vue响应式原理--Vue

271 阅读2分钟

继上次所写

模拟Vue响应式原理--Vue

当我们在使用Vue的时候,首先会根据Vue类来创建Vue的实例。

那么Vue类主要的功能如下:

  1. 负责接收初始化的参数(选项)
  2. 负责把data中的属性注入到Vue实例,转换成getter/setter(可以通过this来访问data中的属性)
  3. 负责调用observer监听data中所有属性的变化(当属性值发生变化后更新视图)
  4. 负责调用compiler解析指令/差值表达式 Vue中包含了_proxyData这个私有方法,该方法的作用就是将data中的属性转换成getter/setter并且注入到Vue的实例中。

模拟Vue/js/vue.js基本代码实现如下:

目录结构 image.png

class Vue {
constructor(options) {

    this.$options = options || {};   // options表示在创建vue实例时传递过来的参数
    this.$data = options.data || {};// 获取参数中的data属性,保存到$data上
    // 获取模板的Dom元素,放到$el上
    this.$el = typeof options.el === "string" ? document.querySelector(options.el) : options.el
    // 把data中的数据转化成getter和setter,并且注入到vue实例中
    // 通过——proxyData,在控制台上,可以通过vm.msg直接获取数据  而不是通过vm.$data.msg获取数据
    this._proxyData(this.$data)
}
_proxyData(data) {
    Object.keys(data).forEach(key => {  // 遍历data中所有属性
        // 把data中所有的属性,注入到vue实例中
        // key =>使用的时还是vm实例箭头函数,this
        Object.defineProperty(this, key, {
            enumerable: true,configurable: true,
            get() {return data[key] },
            set(newValue) {
                if (newValue === data[key]) {return }
                data[key] = newValue
            }
        })
    })
}

}

image.png 在Vue类中,我们主要实现四项内容:

1、通过属性保存选项的数据

2、把data中的成员转换成getter和setter,注入到vue实例中.

 3、调用observer对象,监听数据的变化

 4、调用compiler对象,解析指令和差值表达式

在下面的代码中,我们首先实现了前两项内容。

小胡子语法

{{msg}}

{{count}}

image.png

在模板中添加了差值表达式,同时导入了我们自己创建的vue,并且创建了Vue的实例。在浏览器的控制台中查看对应效果,如下:

image.png

Observer

Observer的功能

  1. 负责把data选项中的属性转换成响应式数据
  2. data中的某个属性也是对象,把该属性转换成响应式数据(例如data中的某个属性为Student对象,也要将Student对象中的属性转换成响应式)
  3. 数据变化发送通知 observer.js文件中的基本代码如下:

class Observer { constructor(data) { this.walk(data); } walk(data) { // 判断data是否是对象,以及data是否为空 // typeof 操作符返回一个字符串,typeof operand,typeof(operand),// 参数operand,一个表示对象或原始值的表达式,其类型将被返回。

    if (!data || typeof data !== 'object') { return;   }
    Object.keys(data).forEach(key => {  // data是一个对象
        this.defineReactive(data, key, data[key]);
    })
}
defineReactive(obj, key, val) {
    // Object.defineProperty() 方法会直接在一个对象上定义一个新属性,
    // 或者修改一个对象的现有属性,并返回此对象。
    Object.defineProperty(obj, key, {
        enumerable: true, configurable: true, key,
        get() {return val; },
        set(newval) {
            if (newval === val) {  return   }
            val = newval; 
        }
    })
}

} image.png 下面对以上代码进行测试。如下: image.png 在Vue类的构造方法中,创建Observer的实例,同时传递data数据。

observer.js文件

class Observer { constructor(data) { this.walk(data); } walk(data) { // 判断data是否是对象,以及data是否为空 //typeof 操作符返回一个字符串,typeof operand,typeof(operand),参数operand,一个表示对象或原始值的表达式,其类型将被返回。

    if (!data || typeof data !== 'object') { return; }
    Object.keys(data).forEach(key => {  // data是一个对象
        this.defineReactive(data, key, data[key]);
    })
}
defineReactive(obj, key, val) {
    // Object.defineProperty() 方法会直接在一个对象上定义一个新属性,
    // 或者修改一个对象的现有属性,并返回此对象。
    Object.defineProperty(obj, key, {
        enumerable: true, configurable: true,key,
        get() { return val;},
        set(newValue) {
            if (newValue === val) { return;}
            val = newValue;
        }
    })
}

} 在01-vue的基本使用.html文件中,导入observer.js文件,如下:

image.png

注意:导入顺序不能变

下面我们修改一下代码,看一下效果:

image.png 我们打印输出了vm中的msg的值,如下: image.png 这时候,会执行vue.js文件中的get方法,也会执行observer.js 文件中的get方法。如下: image.png

如果将observer.js文件中的get方法修改成如下形式,如下: image.png image.png

以上错误信息的含义为:堆栈溢出 为什么会出现以上错误呢?

因为obj就是data对象,而通过obj[key]的方式来获取值,还是会执行get方法,所以这里形成了死循环。 所以在observer.js中,不能写obj[key],应该返回val

完善defineReactive方法

现在data中的数据如下:

image.png msg和count是响应式,如下: image.png

如果,我们在data中添加一个对象,那么对象中的属性是否为响应式的呢?如下:

image.png 在浏览器的控制台中,输出的person对象是响应式的,如下: image.png 但是其内部属性并不是响应式的,如下: image.png 下面处理一下这块内容。而Vue中的对象是响应式的,对象中的属性也是响应式的。关于这个问题的解决,非常的简单。在observer.js文件中的defineReactive方法中,调用一次walk方法就可以了。如下代码所示:

this.walk(val) image.png

在上面的代码中,首先调用了this.walk(val)方法,同时传递了val这个参数。这样在所调用walk方法的内部,会先判断传递过来的参数的类型,如果不是对象,就停止执行,walk方法总的循环,而这时候会Object.defineProperty,但是如果传递过来的参数就是一个对象,那么会进行循环遍历,取出每一个属性,为其添加getter/setter,如下:

image.png

下面,我们在看另外一个问题,现在对01-vue的基本使用.html中vue对象中data中的msg属性重新赋值,并且赋值为一个对象,那么新赋值的这个对象的成员是否为响应式的呢?下面我们来测试一下:

image.png 通过上图,可以发现新赋值给msg属性的对象中的属性并不是响应式的,所以接下来,我们需要为其改造成响应式的。当我们给msg属性赋值的时候,就会执行observer.js文件中的defineReactive方法中的set操作,在这里我们可以将传递过来的值再次调用walk方法,这样又会对传递过来的值,进行判断是否为对象,然后进行遍历,同时为其属性添加getter/setter。如下: let that = this; that.walk(newValue) image.png 通过上面的代码可以看到,在defineReactive方法中的set操作中,又调用了walk方法,但是要注意的就是,这里需要处理this指向的问题。如上: image.png

Compiler

现在模板如下:

小胡子语法

{{msg}}

{{count}}

v-text

v-model

image.png 效果如下:

image.png

Compiler功能

  1. 负责编译模板,解析指令/差值表达式
  2. 负责页面的首次渲染
  3. 当数据变化后重新渲染视图 通过以上功能的描述,可以总结出Compiler主要就是对Dom进行操作 在js目录下面创建compiler.js文件,实现代码如下:

class Compiler { constructor(vm) { this.el = vm.$el // 通过vm实例得到模板的DOM元素,放到compiler对象上的el属性上 this.vm = vm; // 把vm实例放到compiler对象上的vm属性上 }

compile(el) {   // 编译模板,处理文本节点和元素节点
}
compileElement(node) { }  // 编译元素节点  处理指令
compileText(node) {  }  // 编译文本节点  处理小胡子语法
isDirective(attrName) {  // 判断元素属性是否是指令  v-html
    return attrName.startsWith("v-")
}
isElementNode(node) {   // 判断节点是否是元素节点
    // nodeType  1表示元素节点   3表示文本节点
    return node.nodeType === 1;
}
isTextNode(node) {    // 判断节点是否是文本节点
    // nodeType  1表示元素节点   3表示文本节点
    return node.nodeType === 3;
}

} image.png

image.png

image.png

compile方法实现

在调用compile方法的时候传递过来的参数el就是模板,也就是01-vue的基本使用.html中的

中的内容。所以我们在compile方法中要遍历模板中的所有节点。如下

compile(el) { // console.log(el); // 获取所有的子节点 伪数组 需要转成真实数组 let childNodes = el.childNodes; // console.log(childNodes); Array.from(childNodes).forEach(node=>{ if(this.isTextNode(node)){ // 是文本节点 // console.log(node,"是文本节点"); this.compileText(node) }else if(this.isElementNode(node)){ // 是元素节点// console.log(node,"是元素节点"); this.compileElement(node) } // 判断node节点是否还有子节点,如果有子节点,还需要调用compile递归编译 if(node.childNodes && node.childNodes.length){ this.compile(node) // 说明节点有子节点 } }) }

image.png

浏览器测试如下:

image.png

然后就需要遍历模板,实现compile方法,如下:

image.png

浏览器中测试如下:

image.png

以上就是compile方法的基本实现.

compileText方法实现

compileText方法的作用就是对对插值表达式进行解析,在编写compileText方法之前,我们先测试一下前面写的代码。

在compiler.js文件中的comileText方法中可以先打印一下文本节点,看一下具体的文本节点。如下:

image.png

浏览器中测试如下:

image.png

下面完善一下compileText方法的实现如下:

compileText(node) { // console.log(node); let reg = /{{(.+)}}/; // (.+)表示分组 let value = node.textContent; // 获取文本节点中的数据 // console.log(value); if(reg.test(value)){ // console.log(value); // {{msg}} => 'hello' // console.log(RegExp.1);//RegExp.1); // RegExp.1表示获取第1个分组的内容 let key = RegExp.$1.trim() // console.log(key); node.textContent = value.replace(reg, this.vm[key]); } }

image.png

这时刷新浏览器,就可以看到对应效果。如下:

image.png

compileElement方法实现

compileElement方法,就是完成指令的解析。

在这里我们重点解析的指令为v-text与v-model,如下:

这些指令本身就是html标签的属性。

通过以上的代码,我们可以看到,如果想以后在处理其它的指令,只需要添加方法就可以了,方法的名字后缀一定要有Updater。这比写很多的判断语句方便多了。compiler.js文件完整代码,如下:

当页面首次渲染的时候,把数据更新到视图的功能,我们已经完成了,但是还没有实现对应的响应式,也就是数据更改后,视图也要进行更新。

下面我们就来实现对应的响应式机制

Dep类

该类的功能:

  1.  收集依赖,添加观察者(watcher)
  2.  通知所有观察值 什么时候收集依赖呢? 答:在getter中收集依赖,添加观察者

什么时候通知观察者呢? 答:在setter中通知依赖,通知观察者

在dep.js文件中编写如下代码:

class Dep { constructor(vm) { this.subs = [] //所有的观察者 } addSub(sub){ // 添加观察者 // 判断传递过来的sub必须有值,还必须是观察者 // 观察者有一个update方法 ,在update方法中更新视图 if(sub&& sub.update){ this.subs.push(sub) } } notify(){ // 数据变了,通知所有的观察者 this.subs.forEach(sub=>{ sub.update() }) } }

image.png

修改Observer类中的代码,如下:

image.png

首先针对每一个响应式数据添加了一个Dep对象(发布者),然后在set方法中,当数据发生了变化后,会调用dep中的notify方法,完成更新视图的操作。在get方法中添加依赖,也就是将watcher观察者添加到了Dep中的subs数组中。以上代码无法进行测试,完成Watcher类可以进行测试

Watcher类

Watcher类创建

通过前面的学习,我们知道在Observer类中为每一个响应式的数据创建了Dep对象,而且在getter 中会收集依赖,所谓收集依赖就是将watcher观察者添加到subs数组中

而在setter中会触发依赖,其实就是调用Dep对象中notify方法,该方法会获取subs数组中的所有的watcher,然后执行watcher中的update方法来更新对应的视图。

Watcher 类的代码如下: class Watcher { constructor(vm,key,cb) { this.vm=vm //vm是vue实例 this.key=key //就是data中的数据 msg count this.cb=cb //cb回调函数,负责更新视图 this.oldValue=vm[key] //数据更新前的旧值 } update(){ // 当数据发生了变化,更新视图 let newValue=this.vm[this.key] if(newValue===this.oldValue){ return } this.cb(newValue) // 调用cb回调函数更新视图,将新值传递到回调函数中 } }

image.png

接下来还有一件事情需要处理一下,当创建了Watcher对象后,需要将当前创建的Watcher对象添加到Dep中的subs数组中。我们可以查看Observer类,在get方法中已经写过将Watcher对象添加到Dep中的subs数组中了( Dep.target && dep.addSub(Dep.target);),但是问题是,我们并没有创建target属性,所以下面我们创建一下target属性。下面在Watcher类的构造方法中,添加给Dep添加target属性,用来保存Watcher的实例。如下:

image.png

创建Watcher对象

image.png

下面要在xxx.html文件中导入相关的js文件。如下:

image.png

下面可以进行测试了

image.png

对应的页面视图中的内容也发生了变化。这也就实现了响应式机制,所谓响应式就是当数据变化了,对应的视图也会进行更新。所以需要在textUpdater和modelUpdater方法中完成Watcher对象的创建。如下:

获取每一个元素节点上的所有的属性节点如果是指令,需要获取指令名与对应的值不同的指令处理方案不一样

image.png

image.png

定义一个方法传参

image.png

分别处理不同的指令 image.png

再次测试之,如下:

image.png

双向数据绑定

双向数据绑定包含两部分内容,数据变化更新视图,视图变化更新数据。

怎样实现双向绑定呢?

基本的思路就是,我们可以给文本框(第一个文本框)添加一个input事件,在输入完数据后触发该事件,同时将用户在文本框中输入的数据赋值给data中的属性(视图变化,更新数据,而当数据变化后,会执行行observer.js中的set方法,更新视图,也就是触发了响应式的机制)。

那么我们应该在哪实现数据的双向绑定呢?

我们知道,这里是对文本框的操作,所以需要compiler.js文件中的modelUpdater方法中,实现双向绑定。因为modelUpdater方法就是处理v-model,如下:

image.png

测试如下:

image.png

现在整个Vue的模拟实现,我们就完成了。

当然,我们这里只是模拟了最核心的内容也就是数据响应式与双向绑定。

总结首先我们先来看一下最开始提出的问题。

 第一个:给属性重新赋值成对象,是否是响应式的?答案:是响应式的。

 应当我们给data中的属性进行重新赋值的时候,会执行Observer类中的defineReactive方法的set方法

在set方法中,调用了walk方法,该方法中判断重新给data属性中赋的值是否为对象,如果是对象,会将对象中的每个属性都修改成响应式的。

第二个问题:给Vue实例新增一个成员是否是响应式的?

例如如下代码:

image.png

image.png

给Vue实例新增一个成员是否是响应式的?答:不是响应式的

因为,我们所有的操作都是在创建Vue的实例的时候完成的,也就是在Vue类的构造函数中完成的。在Vue类的构造函数中,创建了Observer的实例,完成了监听数据的变化。所以当Vue的实例创建完成后,在为其添加属性,该属性并不是一个响应式的。当然,为了解决这个问题,Vue中也给出了相应的解决方案,可以查看官方的文档。

现在有一个问题?后期给data中添加一个响应式数据,如何添加?

正确的添加方式:$set()

image.png

image.png