mvvm数据响应实现

1,052 阅读6分钟

为什么实现数据响应式

当前vue、react等框架流行。无论是vue、还是react框架大家最初的设计思路都是类似的。都是以数据驱动视图,数据优先。希望能够通过框架减少开发人员直接操作节点,让开发人员能够把更多的精力放在业务上而不是过多的放在操作节点上。另一方面,框架会通过虚拟dom及diff算法提高页面性能。这其中需要数据优先最根本的思路就是实现数据响应式。so,本次来看下如何基于原生实现数据响应式。

vue中的数据响应

vue中会根据数据将数据通过大胡子语法及指令渲染到视图上,这里我们以大胡子语法为例。如下:

<div id="app">
        {{message}}
</div>
let vm = new Vue({
    el:"#app",
    data:{
        message:"测试数据"
    }
})
setTimeout(()=>{
    vm.message = "修改的数据";
},1000)

如上代码,很简单 。vue做了两件事情。一、把message数据初次渲染到视图。二、当message数据改变的时候视图上渲染的message数据同时也会做出响应。以最简单的案例。带着问题来看,通过原生js如何实现??这里为了简化操作便于理解,这里就不去使用虚拟dom。直接操作dom结构。

实现数据初次渲染

根据vue调用方式。定义Vue类来实现各种功能。将初次渲染过程定义成编译compile函数渲染视图。通过传入的配置以及操作dom来实现渲染。大概思路是通过正则查找html 里 #app 作用域内的表达式,然后查找数据做对应的替换即可。具体实现如下:

class Vue {
    constructor(options) {
        this.opts = options;
        this.compile();
    }
    compile() {
        let ele = document.querySelector(this.opts.el);
        // 获取所有子节点
        let childNodes = ele.childNodes;
        childNodes.forEach(node => {
            if (node.nodeType === 3) {
                // 找到所有的文本节点
                let nodeContent = node.textContent;
                // 匹配“{{}}”
                let reg = /\{\{\s*([^\{\}\s]+)\s*\}\}/g;
                if (reg.test(nodeContent)) {
                    let $1 = RegExp.$1;
                    // 查找数据替换 “{{}}”
                    node.textContent = node.textContent.replace(reg, this.opts.data[$1]);
                }
            }
        })
    }
}

如上完成了初次渲染,将message数据渲染到了视图上。但是会返现并没对深层次的dom结构做处理也就是如下情况:

  <div id="app">
        1{{ message }}2
        <div>
            hello , {{ message }}
        </div>
    </div>

渲染结果如下: 发现结果并没有达到预期。so,需要改下代码,让节点可以深层次查找就可以了。代码如下:

   compile() {
        let ele = document.querySelector(this.opts.el);
        this.compileNodes(ele);
    }
    compileNodes(ele) {
        // 获取所有子节点
        let childNodes = ele.childNodes;
        childNodes.forEach(node => {
            if (node.nodeType === 3) {
                // 找到所有的文本节点
                let nodeContent = node.textContent;
                // 匹配“{{}}”
                let reg = /\{\{\s*([^\{\}\s]+)\s*\}\}/g;
                if (reg.test(nodeContent)) {
                    let $1 = RegExp.$1;
                    // 查找数据替换 “{{}}”
                    node.textContent = node.textContent.replace(reg, this.opts.data[$1]);
                }
            } else if (node.nodeType === 1) {
                if (node.childNodes.length > 0) {
                    this.compileNodes(node);
                }
            }
        })
    }

上述代码通过递归查找节点 实现深层次节点的渲染工作。如此,就实现了视图的初次渲染。

数据劫持

回过头来看下上面说的第二个问题:当message数据改变的时候视图上渲染的message数据同时也会做出响应。如何实现数据响应式?简而言之就是数据变动影响视图变动?再将问题拆分下 1. 如何知道数据变动了? 2.如何根据数据变动来更改视图?

  • 如何知道数据变动了? 这里就需要用到数据拦截了,或者叫数据观察。把会变动的data数据观察起来。当他变动的时候我们可以做后续的渲染事情。如何拦截数据呢 ?vue2里采取的是definePrototype。
let obj = {
   myname:"张三"
}
Object.defineProperty(obj,'myname',{
   configurable:true,
   enumerable:true,
   get(){
       console.log("get.")
       return "张三";
   },
   set(newValue){
       console.log("set")
       console.log(newValue);
   }
})
console.log(obj);

上述代码会发现,通过defineProperty劫持的对象属性下都会有get及set方法。那么当我们获取或者设置数据的时候就能出发对应的get及set 。这样就能拦截数据做后续操作。

还有没有其他方式达到数据劫持的效果呢?ES6中出现了Proxy 代理对象同样也可以达到类似劫持数据的功能。如下代码:

let obj = {
    myname:"张三"
}
let newObj = new Proxy(obj,{
    get(target,key){
        console.log("get...")
        return "张三"
    },
    set(target,name,newValue){
        console.log("set...");
       return Reflect.set(target,name,newValue);
    }
})

两种方式都可以实现数据劫持。proxy功能更加强大,很多方法是defineProperty所不具备的。且proxy直接拦截的是对象而defineProperty拦截的是对象属性。so,可以利用上述方式将data数据做劫持,代码如下:

observe(data){
        let keys = Object.keys(data);
        keys.forEach(key=>{
            let value = data[key];
            Object.defineProperty(data,key,{
                configurable:true,
                enumerable:true,
                get(){
                    return value;
                },
                set(newValue){
                    value = newValue;
                }
            });
        })
 }

观察者模式实现数据响应

有了劫持数据方式后,接下来需要实现的就是当修改数据的时候将新数据渲染到视图。如何办到呢?会发现,需要在data设置的时候触发视图的compile编译。二者之间互相影响,此时可以想到利用观察者模式,通过观察者模式让二者产生关联,如下:

图略小,代码也贴上吧。

class Vue extends EventTarget {
    constructor(options) {
        super();
        this.opts = options;
        this.observe(this.opts.data);
        this.compile();
    }
    observe(data){
        let keys = Object.keys(data);
        let _this = this;
        keys.forEach(key=>{
            let value = data[key];
            Object.defineProperty(data,key,{
                configurable:true,
                enumerable:true,
                get(){
                    return value;
                },
                set(newValue){
                    _this.dispatchEvent(new CustomEvent(key,{
                        detail:newValue
                    }));
                    value = newValue;
                }
            });
        })
    }
    compile() {
        let ele = document.querySelector(this.opts.el);
        this.compileNodes(ele);
    }
    compileNodes(ele) {
        // 获取所有子节点
        let childNodes = ele.childNodes;
        childNodes.forEach(node => {
            if (node.nodeType === 3) {
                // 找到所有的文本节点
                let nodeContent = node.textContent;
                // 匹配“{{}}”
                let reg = /\{\{\s*([^\{\}\s]+)\s*\}\}/g;
                if (reg.test(nodeContent)) {
                    let $1 = RegExp.$1;
                    // 查找数据替换 “{{}}”
                    node.textContent = node.textContent.replace(reg, this.opts.data[$1]);
                    this.addEventListener($1,e=>{
                        let oldValue = this.opts.data[$1];
                        let newValue =  e.detail;
                        let reg =  new RegExp(oldValue);
                        node.textContent = node.textContent.replace(reg,newValue);
                    })
                }
            } else if (node.nodeType === 1) {
                if (node.childNodes.length > 0) {
                    this.compileNodes(node);
                }
            }
        })
    }
}

如上,成功的通过观察者模式实现了数据的响应。但是会发现data与compile之间需要通过键名来进行关联。如果data数据结构嵌套关系复杂后面会比较难处理。有没有一种方式让二者松解耦呢?这时候可以用发布订阅模式来进行改造。

发布订阅模式改造响应式

还是略小,也还是贴上代码:

class Vue {
    constructor(options) {
        this.opts = options;
        this.observe(this.opts.data);
        this.compile();
    }
    observe(data){
        let keys = Object.keys(data);
        let _this = this;
        keys.forEach(key=>{
            let value = data[key];
            let dep = new Dep();
            Object.defineProperty(data,key,{
                configurable:true,
                enumerable:true,
                get(){
                    if(Dep.target){
                        dep.addSub(Dep.target); 
                    }
                    return value;
                },
                set(newValue){
                    dep.notify(newValue);
                    value = newValue;
                }
            });
        })
    }
    compile() {
        let ele = document.querySelector(this.opts.el);
        this.compileNodes(ele);
    }
    compileNodes(ele) {
        // 获取所有子节点
        let childNodes = ele.childNodes;
        childNodes.forEach(node => {
            if (node.nodeType === 3) {
                // 找到所有的文本节点
                let nodeContent = node.textContent;
                // 匹配“{{}}”
                let reg = /\{\{\s*([^\{\}\s]+)\s*\}\}/g;
                if (reg.test(nodeContent)) {
                    let $1 = RegExp.$1;
                    // 查找数据替换 “{{}}”
                    node.textContent = node.textContent.replace(reg, this.opts.data[$1]);
                    new Watcher(this.opts.data,$1,(newValue)=>{
                        let oldValue = this.opts.data[$1];
                        let reg =  new RegExp(oldValue);
                        node.textContent = node.textContent.replace(reg,newValue);
                    })
                }
            } else if (node.nodeType === 1) {
                if (node.childNodes.length > 0) {
                    this.compileNodes(node);
                }
            }
        })
    }
}

class Dep{
    constructor(){
        this.subs = [];
    }
    addSub(sub){
        this.subs.push(sub);
    }
    notify(newValue){
        this.subs.forEach(sub=>{
            sub.update(newValue);
        })
    }
}

class Watcher{
    constructor(data,key,cb){
        Dep.target = this;
        data[key];
        this.cb = cb;
        Dep.target = null;
    }
    update(newValue){
        this.cb(newValue);
    }
}

如上代码思路是 针对每个数据会生成一个dep(依赖收集器)在数据get的时候收集watcher,将watcher 添加到dep里保存。数据一旦有改变触发notify发布消息从而影响compile编译更新视图。这个流程也可以参看下图:

如上就完成了视图响应。通过上述代码,我们可以看出实现数据响应两个核心点1.数据劫持。2.观察者和发布订阅。在这我们可以思考一个问题,2个设计模式都是可以实现的但是有什么区别呢?

观察者与发布订阅

这里需要从概念来看

  • 观察者模式:定义一个对象与其他对象之间的一种依赖关系,当对象发生某种变化的时候,依赖它的其它对象都会得到更新,一对多的关系。
  • 发布订阅模式:是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。

不难看出,观察者是两者之间关系,发布订阅是三者之间关系。发布订阅会多了一个关系器来组织主题和观察者之间的关系。这样做的好处就是松解耦。看上面响应式例子可以看出观察者需要通过事件名称来进行关联。发布订阅定义dep管理器之后data和compile彻底解耦,让二者松散解耦。在处理多层数据结构上发布订阅会更清晰。松解耦能够应对更多变化,把模块之间依赖降到最低。发布订阅广义上是观察者模式

好了 暂时先over 。 如果觉得有收获的话可以点个赞,赠人玫瑰,手有余香!!!!


  • 这是我们团队的开源项目 element3
  • 一个支持 vue3 的前端组件库