MVVM响应式原理

总述

vue是采用数据劫持配合发布者-订阅者的模式的方式,通过Object.defineProperty()来劫持各个属性的getter和setter,在数据变动时,set方法就会发布消息给依赖收集器(dep中的subs),去通知(notify)观察者,做出对应的回调函数,去更新视图

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

MVVM响应式原理

详细(以Vue为例,这里是一个Vue的简单实现)

class Vue {
    constructor(options) {
        this.$data = options.data;
        this.$el = options.el;
        this.$options = options;//属性中也会有methods
        // 如果这个根元素存在开始编译模板
        if (this.$el) {
            // 1.先实现一个数据监听器Observe, 对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅器Dep,触发一个通知的方法,通知观察者更新视图
            // Object.definerProperty()来定义
            new Observer(this.$data);

            // 把数据获取操作 vm上的取值操作 都代理到vm.$data上
            this.proxyData(this.$data);

            // 2.实现一个指令解析器Compile
            //(如:解析v-html="a.b"的标签(指令)和值(a.b)),数据监听绑定完成后, 
            //指令解析就会解析每个指令的值, 解析值(或代码中取值)的时候就会通过watcher的一个获取旧值的方法, 这个方法通过获取值触发了data属性的get方法, 给每个属性一个订阅器, 通过订阅器添加了观察者
            //每个观察者都有一个旧值, 每当属性值改变时,触发set方法,订阅器就会通知观察者去更新
            new Compile(this.$el, this);

        }
    }
    // 做个代理,代理的作用是在methods中修改data的属性只需要this.a=1就可更改而不是需要this.$data.a=1才能更改
    proxyData(data) {
        for (const key in data) {
            Object.defineProperty(this, key, {
                get() {
                    return data[key];
                },
                set(newVal) {
                    data[key] = newVal;
                }
            })
        }
    }
}

首先, 需要实现一个数据监听器类Observer

// 创建一个数据监听者  劫持并监听所有数据的变化
class Observer {
    constructor(data) {
        this.observe(data);
    }
    /**
     * 遍历data中的属性,设置每个属性的监听
     * @param {*} data 
     */
    observe(data) {
        // 如果当前data是一个对象才劫持并监听
        if (data && typeof data === 'object') {
            // 遍历对象的属性做监听
            Object.keys(data).forEach(key => {
                this.defineReactive(data, key, data[key]);
            })

        }
    }
    /**
     * 监听属性
     * @param {*} obj 对象
     * @param {*} key 
     * @param {*} value 
     */
    defineReactive(obj, key, value) {
        // 循环递归 对所有层的数据进行观察
        this.observe(value);//这样obj也能被观察了
        const dep = new Dep();//每一个属性都设置一个订阅器
        Object.defineProperty(obj, key, {
            get() {
                //订阅数据变化,往Dep中添加观察者,观察数据是否有变化
                Dep.target && dep.addSub(Dep.target);
                return value;
            },
            set: (newVal) => {
                //(即: data中的一个属性如果由一个值变为一个对象, 若不重新观察,则该对象的属性不会被监听到)
                this.observe(newVal);
                if (newVal !== value) {
                    // 如果外界直接修改对象 则对新修改的值重新观察

                    value = newVal;

                }
                // 通知变化
                dep.notify();
            }
        })
    }
}

这个数据监听器就实现了数据的劫持, 给data中的每一个层级属性都绑定了一个观察者, 当属性发生变化时, 订阅器(Dep)会有一个依赖收集器(Dep中的subs), 去通知(notify)观察者,做出对应的回调函数,去更新视图

数据监听器有了, 那么接下来就要实现下订阅器类和观察者

//观察者类
class Watcher {
    constructor(vm, expr, cb) {
        // 观察新值和旧值的变化,如果有变化 更新视图
        this.vm = vm;
        this.expr = expr;//指令内容
        this.cb = cb;//callback
        // 先把旧值存起来  
        this.oldVal = this.getOldVal();
    }
    //获取旧值
    getOldVal() {
        Dep.target = this;//Watcher挂载到订阅器中
        let oldVal = compileUtil.getVal(this.expr, this.vm);
        Dep.target = null;//挂载后target删除掉,如果target还有值, 则一个属性多次改变就会有多个watcher,会难以维护
        return oldVal;
    }
    // 更新操作 数据变化后 Dep会发生通知 告诉观察者更新视图
    update() {
        console.log('旧值', this.oldVal)
        let newVal = compileUtil.getVal(this.expr, this.vm);//获取新值
        if (newVal !== this.oldVal) {//如果旧值!==新值,则触发callback
            this.cb(newVal);
        }
    }
}
// 订阅器
class Dep {
    constructor() {
        this.subs = [];//依赖收集器
    }
    // 搜集观察者
    addSub(watcher) {
        this.subs.push(watcher);

    }
    // 通知观察者去更新
    notify() {
        // 观察者中的update方法 来更新视图
        console.log("观察者", this.subs)
        this.subs.forEach(w => {//遍历watcher,去更新
            w.update()
        });
    }
}

这里, 订阅器的作用是收集watcher依赖, 

待data中数据发生变化时, 触发属性的set方法, 然后会调用Dep的 notify方法去通知观察者更新

观察者是怎么添加的, 什么啥时候添加的观察者? 这里就需要指令解析类(Compile)

Vue中的模板指令 即 v-html, v-model等, 都是一个个自定义属性, Vue将这些指令做了解析, 解析时获取了data中的属性, 触发了get方法, 在yi'la中添加了观察者

// 指令解析器
class Compile {
    constructor(el, vm) {
        // 判断el参数是否是一个元素节点,如果是直接赋值,如果不是 则获取赋值
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        // 因为每次匹配到进行替换时,会导致页面的回流和重绘,影响页面的性能
        // 所以需要创建文档碎片来进行缓存,减少页面的回流和重绘
        // 1.获取文档碎片对象
        const fragment = this.node2Fragment(this.el);
        // 2.编译模板
        this.compile(fragment)

        // 3.把子元素的所有内容添加到根元素中
        this.el.appendChild(fragment);

    }
    compile(fragment) {
        // 1.获取子节点
        const childNodes = fragment.childNodes;
        // 2.遍历子节点
        [...childNodes].forEach(child => {

            // 3.对子节点的类型进行不同的处理
            if (this.isElementNode(child)) {
                // 是元素节点
                // 编译元素节点
                // console.log('我是元素节点',child);
                this.compileElement(child);
            } else {
                // console.log('我是文本节点',child);
                this.compileText(child);
                // 剩下的就是文本节点
                // 编译文本节点
            }
            // 4.一定要记得,递归遍历子元素
            if (child.childNodes && child.childNodes.length) {
                this.compile(child);
            }
        })
    }
    // 编译文本的方法
    compileText(node) {
        const content = node.textContent;
        // 匹配{{xxx}}的内容
        if (/\{\{(.+?)\}\}/.test(content)) {
            // 处理文本节点
            compileUtil['text'](node, content, this.vm)
        }

    }
    //获取文档碎片
    node2Fragment(el) {
        const fragment = document.createDocumentFragment();
        // console.log(el.firstChild);
        let firstChild;
        将dom对象的节点加入到文档碎片中,从dom元素的第一个节点开始加入到文档碎片中, 直到没有为止
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment
    }
    //判断el是否是一个dom对象
    isElementNode(el) {
        return el.nodeType === 1;
    }
    //编译元素节点
    compileElement(node) {
        // 获取该节点的所有属性
        const attributes = node.attributes;
        // 对属性进行遍历
        [...attributes].forEach(attr => {
            const { name, value } = attr; //v-text v-model   v-on:click  @click 
            // 看当前name是否是一个指令
            if (this.isDirective(name)) {
                //对v-text进行操作
                const [, directive] = name.split('-'); //text model html
                // v-bind:src
                const [dirName, eventName] = directive.split(':'); //对v-on:click 进行处理
                // 更新数据
                compileUtil[dirName] && compileUtil[dirName](node, value, this.vm, eventName);
                // 移除当前元素中的属性
                node.removeAttribute('v-' + directive);

            } else if (this.isEventName(name)) { // 对事件进行处理 在这里处理的是@click
                let [, eventName] = name.split('@');
                compileUtil['on'](node, value, this.vm, eventName)
            }

        })

    }
    // 是否是@click这样事件名字
    isEventName(attrName) {
        return attrName.startsWith('@')
    }
    //判断是否是一个指令
    isDirective(attrName) {
        return attrName.startsWith('v-')
    }
}

// 编译模板工具
const compileUtil = {
    /**
     * 获取指令标签的值的方法,此时触发了get方法,添加了观察者
     * @param {*} expr 指令标签的值:如 v-model="a.b", expr就是a.b
     * @param {*} vm vue实例
     */
    getVal(expr, vm) {
        return expr.split('.').reduce((data, currentVal) => {
            return data[currentVal]
        }, vm.$data)
    },
    /**
     * 获取指令标签的值的方法,视频的方法是错误的.....
     * @param {*} expr 指令标签的值:如 v-model="a.b", expr就是a.b
     * @param {*} vm vue实例
     */
    setVal(vm, expr, val) {
        return expr.split('.').reduce((data, currentVal, index, arr) => {
            if (index == arr.length - 1) {
                data[currentVal] = val;
            }
            return data[currentVal];
        }, vm.$data)
        // var value = vm.$data
        // expr.split('.').forEach(item => {
        //     value = value[item];
        // })
        // return value
    },
    //获取新值 对{{a}}--{{b}} 这种格式进行处理
    getContentVal(expr, vm) {
        return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            return this.getVal(args[1], vm);
        })
    },
    text(node, expr, vm) {
        let val;
        if (expr.indexOf('{{') !== -1) {//expr 可能是 {{obj.name}}--{{obj.age}} 
            val = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {//取到双花括号内部的data属性字符串
                console.log(args)
                //绑定watcher,当数据发生变化的时候, 触发回调, 从而更新视图
                new Watcher(vm, args[1], () => {//args[1]为比配到的字符串,即指令设置的data属性
                    this.updater.textUpdater(node, this.getContentVal(expr, vm));//若获取到的expr为{a}---{b},则进一步获取指令值
                })
                return this.getVal(args[1], vm);
            })
        } else { //也可能是v-text='obj.name' v-text='msg'
            val = this.getVal(expr, vm);
        }
        this.updater.textUpdater(node, val);
    },
    html(node, expr, vm) {
        // html处理 非常简单 直接取值 然后调用更新函数即可
        let val = this.getVal(expr, vm);
        // 订阅数据变化时 绑定watcher,从而更新函数
        new Watcher(vm, expr, (newVal) => {
            this.updater.htmlUpdater(node, newVal);
        })
        this.updater.htmlUpdater(node, val);
    },
    model(node, expr, vm) {
        const val = this.getVal(expr, vm);
        // 订阅数据变化时 绑定更新函数 更新视图的变化
        // 数据==>视图
        new Watcher(vm, expr, (newVal) => {
            this.updater.modelUpdater(node, newVal);
        })
        // 视图==>数据
        node.addEventListener('input', (e) => {
            // 设置值
            debugger
            this.setVal(vm, expr, e.target.value);

        }, false);
        this.updater.modelUpdater(node, val);
    },
    /**
     * 对事件进行处理
     * @param {*} node 节点
     * @param {*} expr 事件的函数(@click="handlerClick")中的 handlerClick
     * @param {*} vm vm实例
     * @param {*} eventName 事件名 
     */
    on(node, expr, vm, eventName) {
        // 获取事件函数
        let fn = vm.$options.methods && vm.$options.methods[expr];
        // 添加事件 因为我们使用vue时 都不需要关心this的指向问题,这是因为源码的内部帮咱们处理了this的指向
        node.addEventListener(eventName, fn.bind(vm), false);
    },
    // 绑定属性 简单的属性 已经处理 类名样式的绑定有点复杂 因为对应的值可能是对象 也可能是数组 大家根据个人能力尝试写一下
    bind(node, expr, vm, attrName) {
        let attrVal = this.getVal(expr, vm);
        this.updater.attrUpdater(node, attrName, attrVal);
    },
    updater: {
        attrUpdater(node, attrName, attrVal) {
            node.setAttribute(attrName, attrVal);
        },
        modelUpdater(node, value) {
            node.value = value;
        },
        textUpdater(node, value) {
            node.textContent = value;
        },
        htmlUpdater(node, value) {
            node.innerHTML = value;
        }
    }

}

 

上一篇:MySQL SELECT语法(一)SELECT语法详解


下一篇:使用 Linux 终端进行算术运算