Vue的响应式原理 -- 实现一个指令解析器Compile

190 阅读4分钟

前言提示

实现过程非常啰嗦,几乎每行都有注释。

响应式原理脑图

        vue是采用数据劫持配合发布订阅者模式,通过Object.definerProperty()来劫持各个属性的getter和setter,在数据变动时,发布消息给依赖收集器,去通知观察者,作出对应的回调函数,去更新视图。

        MVVM作为绑定的入口,整合Observer,Compile和Watcher三者,通过Observer来监听model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起了Observer,Compile之间的通信桥梁,达到数据变化 =》视图更新,视图交互变化 =》 数据model变更的双向绑定效果。

        那么我们就从实现一个Compile指令解析器开始。

-------------------------------------------------------------------------------------------

源码实现

构造一个MVue类

        在这个MVue类里,我们要实现一个Compile指令解析器,一个Observer数据观察者,然后通过 Watcher 去更新视图。

class MVue {
    constructor(options) {
            
    }
}

在MVue类里,我们会存放包括el,data,methods,components等。

class MVue {
    constructor(options) {
        this.$el = options.el;
        this.$data = options.data;
        this.$options = options; 
        if(this.$el) {
            //1.实现一个Compile指令解析器
            //把节点和这个实例作为参数
            new Compile(this.$el,this)
            //2.实现一个Oberver数据观察者            	        
        }           
    }
}

构造Compile 类

然后我们要实现一个Compile指令解析器,首先获取到页面中的所有节点,保存到文档碎片对象中,然后放入内存中,这样做可以减少页面的回流和重绘造成的页面性能损耗。

class Compile {
    constructor(el,vm) {
        //首先判断当前el是不是一个元素节点
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        //然后我们来获取文档碎片对象
        const fragment = this.node2Fragment(this.el);
    }
}
node2Fragment(el) {
    //创建文档碎片
    const f = document.createDocumentFragment();
    //通过对el下所有子元素的遍历,把他们都放在"f"这个文档碎片对象中
    let firstchild;
    while (firstChild = el.firstChild) {
        //如果子元素存在,就放进f里
    	f.appendChild(firstChild)	            
    }
    //然后我们把这个文档碎片对象"f"返回出去
    return f;
}

定义文档碎片对象

我们获取到文档碎片对象后,我们要对它里面的所有节点进行编译。

class Compile {
    constructor(el,vm) {
        //首先判断当前el是不是一个元素节点
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        //然后我们来获取文档碎片对象
        const fragment = this.node2Fragment(this.el);
        //接着我们来对这个对象进行编译
        this.compile(fragment)
    }
}
//node2Fragment(el)这里是省略掉的获取文档碎片对象的方法
compile(fragment) {
    //我们要对这个文档碎片对象的所有节点进行编译\
    //获取子节点
    const childNodes = fragment.childNodes;
    [...childNodes].foreach(child => {
        if(this.isElementNode(child)) {
            //是元素节点,编译元素节点
            this.compileElement(child)        
        }else {
            //是文本节点,编译文本节点
            this.compileText(child)        
        }
    })
}
//接下来是对节点的具体编译
compileElement(node) {
    
}
compileText(node) {
    
}

编译元素节点

在上面我们要用这个节点编译方法对节点进行编译,并将这个子节点作为参数传入。

compileElement(node) {//node就是我们的节点,我们可以试试打印node看看
    //comsole.log(node)
    //然后我们要获得这个节点上的内联样式上的指令
    const attributes = node.attributes;
    //我们知道内联样式上都是name: value的形式,例如v-text = "msg",
    //那么我们可以通过遍历attributes中的每一项来获得它上面的name和value
    [...attributes].forEach((attr) => {
        //解构赋值,把name和value放在这个attr对象中
        const { name, value } = attr; 
        //接着我们就要判断,这个内联样式的name是不是一个指令
        if(this.isDirective(name)) {
            //是一个指令 v-text v-html v-model v-on:click
            //我们用数组的split方法对指令进行处理,拿到v-后面的部分,赋值给directive
            const [,directive] = name.split("-")	//text html model on:click
            //然后我们要注意处理on:click这类的事件,拿到:前面的部分赋值给dirName,拿到:后面的部分,赋值给eventName
            const [dirName,eventName] = directive.split(":")
            //我们拿到这个指令的关键字了,然后我们可以通过一个处理器对象来进行处理
            //我们把上面获得的关键字作为参数传递给这个处理器对象
            compileUtil[dirName](node,value,this.vm,eventName);
            //把节点上的指令删除掉
            node.removeAttribute("v-" + dirctive);    
        } 
    })
}
isDirective(attrName) {
    //判断一下是不是 vue 指令
    return attrName.startsWith("v-");
}

定义处理器对象compileUtil

处理v-text指令

现在我们拿到了节点上的指令,要根据指令来处理节点的渲染,我们来定义一个处理器对象。

//我们在Compile类的上面定义一下这个处理器对象
//在这个对象上有对应的text,html,model,on的方法,对应compileUtil[dirName]里的dirName
const compileUtil = {
    text(node,expr,vm) {
        //expr : msg
        //我们要做v-text指令下完成的效果是把它的value渲染到我们的节点上
        const value = vm.$data[expr];//获取到value
        //然后我们通过updater对象上的方法改变这个节点上的textContent值
        this.updater.textUpdater(node,value);    
    },
    html(node,expr,vm){},
    model(node,expr,vm){},
    on(node,expr,vm){},
    updater: {
        textUpdater(node,value) {
            node.textContent = value;
            //做到这一步我们就已经把v-text="msg"这个指令初步渲染完成了
            //我们把$data.msg的值渲染到这个节点上了        
        }    
    }
}

这样做可以实现 v-text = "msg" 这个指令,可是如果 v-text = "person.fav" 这样的形式,那么 vm.$data[expr] 获取到的 value 值应该是 person.fav ,那么就要进行处理。

//我们在Compile类的上面定义一下这个处理器对象
//在这个对象上有对应的text,html,model,on的方法,对应compileUtil[dirName]里的dirName
const compileUtil = {
    //定义 getVal 方法对可能出现的 v-text = "person.fav" 这样形式的指令进行处理
    getVal(expr, vm) {
        return expr.spilt('.').reduce((data, currentVal) => {	// 得到 [ person, fav ]
        	return data[currentVal];				// 通过 person[fav] 拿到值
        }, vm.$data)
    }
    text(node,expr,vm) {
        //expr : msg
        //我们要做v-text指令下完成的效果是把它的value渲染到我们的节点上
        const value = this.getVal(expr, vm);//获取到value
        //然后我们通过updater对象上的方法改变这个节点上的textContent值
        this.updater.textUpdater(node,value);    
    },
    html(node,expr,vm){},
    model(node,expr,vm){},
    on(node,expr,vm){},
    updater: {
        textUpdater(node,value) {
            node.textContent = value;
            //做到这一步我们就已经把v-text="person.fav"这个指令初步渲染完成了
            //我们把$data.person[fav] 的值渲染到这个节点上了        
        }    
    }
}

做好了 v-text 的渲染,接着来做剩下的。

处理v-html,v-model,v-on指令

//我们在Compile类的上面定义一下这个处理器对象
//在这个对象上有对应的text,html,model,on的方法,对应compileUtil[dirName]里的dirName
const compileUtil = {
    //定义 getVal 方法对可能出现的 v-text = "person.fav" 这样形式的指令进行处理
    getVal(expr, vm) {
        return expr.spilt('.').reduce((data, currentVal) => {	// 得到 [ person, fav ]
        	return data[currentVal];				// 通过 person[fav] 拿到值
        }, vm.$data)
    }
    text(node,expr,vm) {
        //expr : msg
        //我们要做v-text指令下完成的效果是把它的value渲染到我们的节点上
        const value = this.getVal(expr, vm);//获取到value
        //然后我们通过updater对象上的方法改变这个节点上的textContent值
        this.updater.textUpdater(node,value);    
    },
    html(node,expr,vm){
        const value = this.getVal(expr, vm);
        this.updater.htmlUpdater(node, value);
    },
    model(node,expr,vm){
        const value = this.getVal(expr, vm);
        this.modelUpdater(node, value);    
    },
    on(node,expr,vm){
        let fn = vm.$options.methods && vm.$options.methods[expr];
        node.addEventListener(eventName, fn.bind(vm), false);
        //做到这一步我们就已经把 v-on = "handleClick" 这个指令实现了,可是我们知道,在vue的语法中,我们还可以用 @ 来代替 :on 绑定事件,所以我们还要对这个情况进行处理
    },
    updater: {
        textUpdater(node,value) {
            node.textContent = value;
            //做到这一步我们就已经把v-text="person.fav"这个指令初步渲染完成了
            //我们把$data.person[fav] 的值渲染到这个节点上了        
        },
        htmlUpdater(node, value) {
            node.innerHTML = value;
            //做到这一步我们就已经把v-html = "msg" 这个指令初步渲染完成了        
        },
        modelUpdater(node, value) {
            node.value = value;
            //做到这一步我们就已经把 v-model = "msg" 这个指令初步渲染完成了        
        }
    }
}

我们已经把 v-on = "handleClick" 这个指令实现了,可是我们知道,在vue的语法中,我们还可以用 @ 来代替 :on 绑定事件,所以我们还要对这个情况进行处理。

compileElement(node) {
    const attributes = node.attributes;
    console.log(node.attributes);
    [...attributes].forEach((attr) => {
      //解构赋值
      const { name, value } = attr;
      if (this.isDirective(name)) {
        //是一个指令 v-text v-html v-model v-on:click
        const [, dirctive] = name.split("-"); //text html model on:click
        const [dirName, eventName] = dirctive.split(":");
        //更新数据 数据驱动视图
        compileUtil[dirName](node, value, this.vm, eventName);
        //删除有指令的标签上的属性
        node.removeAttribute("v-" + dirctive);
      } else if (this.isEventName(name)) {
        //判断一下是不是@符号开头的
        //@click="handleClick"
        let [, eventName] = name.split("@");
        //调用compileUtil类下的on方法进行编译
        compileUtil["on"](node, value, this.vm, eventName);
      }
    });
  }
  isEventName(attrName) {
  	 //判断一下是不是@符号开头的
    return attrName.startsWith("@");
  }

编译文本节点

现在我们处理完了文档碎片对象中的所有的元素节点,那么接着来处理 文档碎片对象上的文本节点。

compileText(node) {
    // {{}} 处理 console.log(node.textContent)
    const content = node.textContent;
    //通过正则表达式拿到双大括号里的插值
    if (/\{\{(.+?)\}\}/.test(content)) {
      //console.log(content);  		// {{person.name}} -- {{person.age}}
      compileUtil["text"](node, content, this.vm);		//这里我们调用 compileUtil[text] 的时候,传入的 content 是有双大括号包裹的,所以在这个方法里还要进行判断
    }
}

这里我们调用 compileUtil[text] 的时候,传入的 content 是有双大括号包裹的,所以在这个方法里还要进行判断

text(node,expr,vm) {
    let value;
    if(expr.indexOf('{{') !== -1) {
        //处理传入的expr是带有{{}}形式的	{{person.fav}}
        value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {	//通过正则表达式分割字符串, ['{{person.fav}}', 'person.fav', 0, '{{person.fav}}']
            return this.getVal(args[1], vm);
        });
    }else {
      const value = this.getVal(expr, vm);
    }
    this.updater.textUpdater(node,value);    
},

 这样我们就完成了一个指令解析器,通过指令解析器就可以把我们的指令包括 [ 'v-text', 'v-html', 'v-model', 'v-on', '@' ] 和双括号的插值表达式里的数据都渲染到页面上了。