手写Vue.js响应式原理,实现一个自己的Vue响应式框架

1.简述功能

我们实现的一个带有响应式功能的简单Vue框架。

功能:

  • 负责接收初始化的参数(options)
  • 负责把data中的属性注入到Vue实例,转换成getter/setter
  • 负责调用observer监听data属性中的变化
  • 负责调用compiler解析指令/差值表达式

Vue类的结构:

在这里插入图片描述
前三个是Vue的属性,最后一个是Vue的方法,用于将data设置为setter/getter,并纳入Vue实例中。

项目初始结构:
在这里插入图片描述

2.创建Vue

index.html:

<!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>
    <div id="app">
        <h1>差值表达式</h1>
        <h3>{{msg}}</h3>
        <h3>{{count}}</h3>
        <h1>Vue指令(v-text)</h1>
        <div v-text="msg"></div>
        <h1>Vue指令(v-model)</h1>
        <label for="msg">msg:</label><input type="text" v-model="msg">
        <label for="count">count:</label><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:11
            }
        })
        console.log(vm);
    </script>
</body>
</html>

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)
    }

    _proxyData(data){
        Object.keys(data).forEach((key)=>{
            //这里使用箭头函数,可以不改变this的指针,这里的this指向Vue实例,因为上面是使用this._proxyData调用
            //确保第一个参数this指向Vue实例
            //Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
            //下面就是将data属性注入到vue实例中
            Object.defineProperty(this,key,{
                configurable:true,
                enumerable:true,
                set(newValue){
                    if(data[key]===newValue){
                        return
                    }else{
                        data[key]=newValue;
                    }
                },
                get(){
                    return data[key];
                }
            })
        })
    }
}

我们看看我们打印出来的vm:

在这里插入图片描述

左边的视图我们先不管,因为我们还没有实现v-text、v-model、差值表达式以及编译等功能。打印出来的vm是我们预期的Vue结构。

上面我们之所以将$data中的属性注入Vue实例中,是为了调用方便:

<template></template>
<script>
	let this = new Vue();
	this.msg;
	this.count;
</script>
<style></style>

我们在Vue模板中就是通过this指向Vue实例,然后直接使用this.xxx调用data中的数据的。

3.Observer 实现响应式数据

刚刚我们调用Object.defineProperty()只是将data对象中的属性注入到Vue实例中,是Vue实例中的msg和count属性有了setter和getter。下面我们开始实现$data对象中的属性实现数据响应式。

Observer.js的功能:

  • 负责把data选项中的属性转换成响应式数据
  • data中的某个属性也是对象,则把该属性也转换成响应式数据
  • 数据变化发送通知(结合观察者模式实现)

Observer.js结构:

在这里插入图片描述
这两个方法名也是Vue源码中的方法名。

下面看看observer.js的源码(未完全实现上述功能):

class Observer{

    constructor(data){
        //在新建对象时,就调用this.walk
        this.walk(data);
    }

    //walk()用于遍历data中的所有属性,在这里调用defineReactive
    walk(data){
        //1.判断data是否是对象
        if(!data||typeof data !=='object'){
            return
        }
        Object.keys(data).forEach(key=>{
            this.defineReactive(data,key,data[key]);
        })
    }

    //定义响应式数据
    defineReactive(obj,key,val){
        Object.defineProperty(obj,key,{
            enumerable:true,
            configurable:true,
            set(newValue){
                if(newValue===val){
                    return
                }
                val=newValue;
            },
            get(){
                return val;
            }
        })
    }
}

修改 index.html ,引入observer.js:

    <script src="./js/observer.js"></script>
    <script src="./js/vue.js"></script>

因为在vue.js中使用了observer,所以得在vue.js前面引入observer.js。

此时$data中的属性也是响应式的了,有了setter、getter:

在这里插入图片描述
上面我们之所以说功能未完成,是因为还没考虑下面两种情况:

  • 如果data的属性也是一个对象,那么这个属性内的属性还不是响应式的。
  • 如果data中某属性初始化是字符串,但后面改为了对象,这时这对象就不是响应式的了。

完善observer.js:

class Observer{

    constructor(data){
        //在新建对象时,就调用this.walk
        this.walk(data);
    }

    //walk()用于遍历data中的所有属性,在这里调用defineReactive
    walk(data){
        //1.判断data是否是对象
        if(!data||typeof data !=='object'){
            return
        }
        Object.keys(data).forEach(key=>{
            this.defineReactive(data,key,data[key]);
        })
    }

    //定义响应式数据
    defineReactive(obj,key,val){
        //假如key的属性值也是对象,则再调用walk
        //这里之所以没有判断val是不是对象,是因为walk()内部会判断val是不是对象
        //如果不是对象会直接return
        this.walk(val);
        let that=this;
        Object.defineProperty(obj,key,{
            enumerable:true,
            configurable:true,
            set(newValue){
                if(newValue===val){
                    return
                }
                val=newValue;
                //之所以使用that,是因为这里地set()是一个新的作用域,this不是指向observer实例
                that.walk(val);
            },
            get(){
                return val;
            }
        })
    }
}

上面更改的地方是在defineReactive中多调用了两次walk,这解决了上面提出来的两个问题。

4.Compiler

上面我们html中的标签内容不能正常显示,是因为我们没有实现编译功能。在这里我们没有使用VNode,直接渲染真实的DOM。

功能:

  • 负责编译模板,解析指令/差值表达式
  • 负责页面的首次渲染
  • 当数据变化后重新渲染视图

结构:

在这里插入图片描述

在编写compiler.js时,先在Vue.js中引入Compiler实例:

    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.编译模板,this就是vm,即Vue实例
        new Compiler(this);
    }

同时在index.html中引入compiler.js文件:

//注意三者关系和顺序
    <script src="./js/compiler.js"></script>
    <script src="./js/observer.js"></script>
    <script src="./js/vue.js"></script>

compiler.js:

class Compiler{
    //vm即是vue实例
    constructor(vm){
        this.el=vm.$el;
        this.vm=vm;
        this.compiler(this.el)
    }

    //编译模板,处理文本节点和元素节点
    compiler(el){
        let childNodes=el.childNodes;
        Array.from(childNodes).forEach((node)=>{
            if(this.isTextNode(node)){
                //如果是文本节点则编译文本
                this.compilerText(node);
            }else if(this.isElementNode(node)){
                //如果是元素节点,则编译元素
                this.compilerElement(node);
            }

            //还需判断node还有没有子节点,元素节点内的文本节点也是相当于子节点
            if(node.childNodes&&node.childNodes.length){
                //递归处理子节点
                this.compiler(node);
            }
        })
    }

    //编译元素节点。处理指令
    compilerElement(node){
        //通过node的attributes可以获取元素内的属性
        //遍历节点的所有属性,判断是否是指令
        Array.from(node.attributes).forEach((attr)=>{
            //获取属性名
            let attrName=attr.name;
            if(this.isDirective(attrName)){
                //对v-model和v-text分别进行处理
                //从下标为2处,开始截取字符串,attrName为model或text
                attrName=attrName.substr(2);
                //获取指令绑定的属性名
                let key=attr.value;
                //将updateFunc指向textUpdater或modelUpdater函数
                //使用了这种方法就不用if进行判断是调用哪个处理函数了
                let updateFunc=this[attrName+'Updater'];
                //之所以用“&&”,是因为我们这里只处理了v-text、v-model指令
                //如果出现其它指令,那么updateFunc则为undefined,那么再执行updateFunc()就会出错,应该排除这种情况
                //调用textUpdater或modelUpdater函数
                updateFunc&&updateFunc(node,this.vm[key]);
            }
        })
    }

    //处理v-text指令
    textUpdater(node,value){
        node.textContent=value;
    }

    //处理v-model指令
    modelUpdater(node,value){
        node.value=value;
    }
    
    //编译文本节点,处理差值表达式
    compilerText(node){
        //正则表达式匹配双花括号
        let reg=/\{\{(.+?)\}\}/;
        let value=node.textContent;
        if(reg.test(value)){
            //获取正则表达式里面的内容,即data属性
            let key=RegExp.$1.trim();
            //将属性对应的值插入DOM中
            node.textContent=value.replace(reg,this.vm[key]);
        }
    }

    //判断元素属性是否是指令
    isDirective(attrName){
        //如果是v-开头则是指令,返回true
        return attrName.startsWith("v-");
    }

    //判断节点是否是文本节点
    isTextNode(node){
        //根据节点的nodeType来判断是什么节点
        //nodeType=3则是文本节点
        return node.nodeType===3;
    }

    //判断节点是否是元素节点
    isElementNode(node){
        //nodeType=1则是元素节点
        return node.nodeType===1;
    }
}

在这里插入图片描述
现在模板上的数据都可以正常显示出来了,但是还没有实现MVVM。即更新数据,页面还不会更新。

5.Dep(发布者)

我们实现了图中的大部分代码:

在这里插入图片描述
现在剩下的是Dep和Watcher没有实现。

dep.js:

class Dep{
    constructor(){
        //记录所有订阅者
        this.subs=[];
    }
    addSub(sub){
        //sub必须有update才是合格的订阅者
        if(sub&&sub.update){
            this.subs.push(sub);
        }
    }
    notify(){
        //通知,即执行每个sub的update()
        this.subs.forEach(sub=>{
            sub.update();
        })
    }
  }

我们该怎么使用Dep类呢?这个类的主要功能是收集订阅者和发布时发布通知,我们可以在observer.js中的set()中为每个响应式数据设置一个Dep对象,在数据发生改变时,通知各个订阅者;在get()收集订阅者。

更改observer.js代码:加入Observer

class Observer{

    constructor(data){
        //在新建对象时,就调用this.walk
        this.walk(data);
    }

    //walk()用于遍历data中的所有属性,在这里调用defineReactive
    walk(data){
        //1.判断data是否是对象
        if(!data||typeof data !=='object'){
            return
        }
        Object.keys(data).forEach(key=>{
            this.defineReactive(data,key,data[key]);
        })
    }

    //定义响应式数据
    defineReactive(obj,key,val){
        //假如key的属性值也是对象,则再调用walk
        //这里之所以没有判断val是不是对象,是因为walk()内部会判断val是不是对象
        //如果不是对象会直接return
        this.walk(val);
        let that=this;
        //为对象的每个属性创建依赖,并发送通知
        let dep=new Dep();
        Object.defineProperty(obj,key,{
            enumerable:true,
            configurable:true,
            set(newValue){
                if(newValue===val){
                    return
                }
                val=newValue;
                //之所以使用that,是因为这里地set()是一个新的作用域,this不是指向observer实例
                that.walk(val);
                //set说明数据发生变化,通知订阅者
                dep.notify();
            },
            get(){
                //收集依赖,只有当存在target时才添加依赖
                //target存储的就是观察者对象
                //先不用管Dep.target是如何而来,它是在Watcher类中添加的
                Dep.target&&dep.addSub(Dep.target);
                return val;
            }
        })
    }
}

此时页面初始化每个引用data中的属性时,还是无法将watcher添加到this.subs中的,因为现在的Dep.target是未定义的。

6.Watcher

在这里插入图片描述
功能:

  • 当数据变化,触发依赖,dep会通知所有watcher实例更新视图
  • watcher自身实例化的时候往dep对象中添加自己

结构:
在这里插入图片描述
在index.html引入js文件:

//它们有依赖,注意顺序不可变
    <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>

Watcher类:

class Watcher{
    constructor(vm,key,cb){
        this.vm=vm;
        this.key=key;
        this.cb=cb;

        //下面三行代码实现了将watcher添加到了this.subs中
        //把watcher对象记录到Dep类的静态属性target属性
        //当触发observer.js中的get()时,get()会调用addSub(Dep.target)
        Dep.target=this;
        //下面的vm[key]就触发了get(),这时watcher就被加入了this.subs中
        this.oldValue=vm[key];
        //当上面的vm[key]触发get()后,为了不重复添加设置为null
        //因为此时为null,“&&”就不会再执行addSub()
        Dep.target=null;
    }

    //当数据发生变化时,更新视图
    update(){
        let newValue=this.vm[this.key];
        if(this.oldValue===newValue){
            return;
        }
        //如果不相同,调用回调函数
        this.cb(newValue);
    }
}

现在我们创建好了Watcher,但是我们应该在哪里使用Watcher呢?Watcher主要是监听到数据变化就更新视图,而更新视图就是对真实DOM操作。而我们操作DOM都放在了compiler.js中,所以应该在compiler.js中创建watcher对象。

而我们compiler.js只是在下面三个方法中更新视图,这三种方法只是在页面初次渲染被调用,我们应该让它在数据更新时也能被调用:

	textUpdater(node,value){
	        node.textContent=value;
	    }
	    //处理v-model指令
	 modelUpdater(node,value){
	       node.value=value;
	   }
	    
    //编译文本节点,处理差值表达式
    compilerText(node){
        //正则表达式匹配双花括号
        let reg=/\{\{(.+?)\}\}/;
        let value=node.textContent;
        if(reg.test(value)){
            //获取正则表达式里面的内容,即data属性
            let key=RegExp.$1.trim();
            //将属性对应的值插入DOM中
            node.textContent=value.replace(reg,this.vm[key]);
        }
    }

修改四个函数:

    //编译元素节点。处理指令
    compilerElement(node){
        //通过node的attributes可以获取元素内的属性
        //遍历节点的所有属性,判断是否是指令
        Array.from(node.attributes).forEach((attr)=>{
            //获取属性名
            let attrName=attr.name;
            if(this.isDirective(attrName)){
                //对v-model和v-text分别进行处理
                //从下标为2处,开始截取字符串,attrName为model或text
                attrName=attrName.substr(2);
                //获取指令绑定的属性名
                let key=attr.value;
                //将updateFunc指向textUpdater或modelUpdater函数
                //使用了这种方法就不用if进行判断是调用哪个处理函数了
                let updateFunc=this[attrName+'Updater'];
                //之所以用“&&”,是因为我们这里只处理了v-text、v-model指令
                //如果出现其它指令,那么updateFunc则为undefined,那么再执行updateFunc()就会出错,应该排除这种情况
                //调用textUpdater或modelUpdater函数
                updateFunc&&updateFunc.bind(this,node,this.vm[key],key)();
            }
        })
    }

    //处理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;
        })
    }
    
    //编译文本节点,处理差值表达式
    compilerText(node){
        //正则表达式匹配双花括号
        let reg=/\{\{(.+?)\}\}/;
        let value=node.textContent;
        if(reg.test(value)){
            //获取正则表达式里面的内容,即data属性
            let key=RegExp.$1.trim();
            //将属性对应的值插入DOM中
            node.textContent=value.replace(reg,this.vm[key]);
            //创建watcher对象,当数据改变时更新视图
            new Watcher(this.vm,key,(newValue)=>{
                node.textContent=newValue;
            })
        }
    }

现在就实现了数据更新,视图也更新了:

在这里插入图片描述

由于新建Watcher需要三个参数,而vm要使用this.vm需要使用bind或call来绑定this指针指向Compiler实例,而key在compilerElement()方法中有,则在updateFunc中使用参数传递过来即可。

注意:可能有人会疑问,在编译中使用new Watcher()会不会给一个属性重复添加watcher。答案是不会,因为编译只会执行一次,而执行编译后,所有绑定到html的数据都是响应式的,在get()也不会重复执行dep.addSubs(),因为在Watcher内部中的静态属性Dep.target=null,确保了不会再执行dep.addSubs()。

7.双向绑定

下面我们要实现视图的输入框数据变化,我们的数据也随着变化,这就是双向数据绑定。我们要怎样实现呢?
我们需要知道表单数据的变化,那么我们就需要监听表单的input事件:

修改modelUpdater():

    //处理v-model指令
    modelUpdater(node,value,key){
        node.value=value;
        //创建watcher对象,当数据改变时更新视图
        new Watcher(this.vm,key,(newValue)=>{
            node.value=newValue;
        })

        //实现视图变化,数据也变化
        node.addEventListener("input",()=>{
            //从节点node中获取输入值,赋值给数据
            this.vm[key]=node.value;
        })
    }

在这里插入图片描述

到这里,简单的实现了Vue响应式。


版权声明:本文为weixin_43334673原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。