17

一、Vue实现双向绑定的两大机制

Vue实现数据双向绑定主要利用的就是: 数据劫持发布订阅模式
所谓发布订阅模式就是,定义了对象间的一种一对多的关系让多个观察者对象同时监听某一个主题对象,当一个对象发生改变时,所有依赖于它的对象都将得到通知
所谓数据劫持,就是利用JavaScript的访问器属性,即Object.defineProperty()方法,当对对象的属性进行赋值时,Object.defineProperty就可以通过set方法劫持到数据的变化,然后通知发布者(主题对象)去通知所有观察者,观察者收到通知后,就会对视图进行更新。

vue双向绑定原理.png

如上图所示,View模板首先经过Compiler(编译器对象)进行编译,在编译的过程中,会分析模板中哪里使用到了Vue数据(Model中的数据)一旦使用到了Vue数据(Model中的数据),就会创建一个Water(观察者对象),并且将这个观察者对象添加到发布者对象的数组中,同时获取到Vue中的数据替换编译生成一个新的View视图。
在创建Vue实例的过程中,会对Vue data中的数据进行数据劫持操作,即将data上的属性都通过Object.definePropery()的方式代理到Vue实例上,当View视图或者Vue Model中发生数据变化的时候,就会被劫持,然后通知Dep发布者对象进行视图的更新,从而实现数据的双向绑定。

二、从零实现一个简易Vue

⓪ 项目初始化

// index.html

<body>
    <div id="app">
        <input type="text"  v-model="scholl.name">
        <div>{{scholl.name}} {{scholl.age}}</div>
    </div>
</body>
<script  src="./vue.js"></script>
<script>
let vm = new  Vue({
    el:  "#app",
    data :  {
        scholl :  {
            name:  "zf",
            age:  10
        }
    }
});
</script>
我们使用Vue的时候,是直接new一个Vue对象,并传入一个options配置对象,里面有el和data,先简单点只配置el和data两个属性,所以vue.js中存在一个Vue类,如:
// vue.js
class  Vue {
    constructor(options) {
        this.$el = options.el; // 保存传递的el属性
        this.$data = options.data; // 保存传入的data属性
    }
}

① 编译模板

要实现一个简易Vue,第一步就是要编译模板,那么我们该何时发起模板的编译操作呢?我们应该在创建Vue实例的时候,在其构造函数中就应该开始发起模板编译操作,如:
// vue.js
class  Vue {
    constructor(options) {
        this.$el = options.el; // 保存传递的el属性
        this.$data = options.data; // 保存传入的data属性
        new Complier(this.$el, this); // 在创建Vue实例的过程中立即发起模板编译操作
    }
}

1.1 劫持模板内容到内存

从上面可以看出Compiler也是一个类,传入了el和Vue实例对象,编译的第一步就是将View模板中的内容全部转换为文档片段进行操作,因为模板可能会非常的复杂,而模板的编译是一个频繁操作DOM的过程,如果直接操作真实的DOM会非常影响页面性能,因为文档片段存在于内存中并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流,从而可以提升页面性能,document.createDocumentFragment()方法可以创建文档片段,如:
class  Complier {
    constructor(el, vm) {
        // 因为配置options.el的时候el可以传入选择器还可以直接传入DOM元素
        this.el = this.isElementNode(el) ? el :  document.querySelector(el);
        this.vm = vm; // 将Vue实例保存到编译器对象上
        // 传入this.el,即el对应的DOM元素,也就是根节点DOM
        let fragment = this.node2fragment(this.el);
        // 编译模板,将真实DOM劫持到文档片段中后,就可以开始进行模板编译了,用Vue中的数据进行替换等
        this.compile(fragment);
        // 将编译好的模板添加回到页面中,以便在页面中显示出来
        this.el.appendChild(fragment);
    }
    isElementNode(node) { // 判断是否是DOM元素节点
        return  node.nodeType  ===  1;
    }
    node2fragment(node) { // 将真实DOM劫持到内存中
        let fragment =  document.createDocumentFragment(); // 创建一个文档片段
        let firstChild;
        while(firstChild = node.firstChild) { // 遍历传入节点中的所有子节点,然后依次添加到文档片段中
            // appendChild具有移动性,可以劫持页面中的真实DOM到内存中
            fragment.appendChild(firstChild);
        }
        return fragment;
    }
}

1.2 遍历节点,根据节点类型进行相应的编译

将el中的所以子节点劫持到内存中后,就可以开始在内存中进行编译操作了,从上面可以看到,是直接调用Compiler中的compile方法,所以接下来我们需要实现这个compile()方法,编译过程就是遍历文档片段中的所有子节点然后根据子节点的类型进行区分,如果是元素节点,那么进行元素节点编译,如果是文本节点,那么进行文本节点编译,并且,如果是元素节点,那么还有对该元素节点继续递归编译,即继续遍历该元素节点的子节点,如:
class Compiler {
    compile(node) {
        let childNodes = node.childNodes; // 获取传递节点的所有子节点
        [...childNodes].forEach((child) => { // 遍历传递节点的所有子节点
            if (this.isElementNode(child)) { // 如果是元素节点
                this.compileElement(child); // 编译元素节点,比如元素上面的指令等
                this.compile(child); // 递归编译元素节点
            } else {
                this.compileText(child); // 编译文本节点,即{{}}mustache表达式
            }

        });

    }
}

1.3 找到元素节点上的指令开始编译元素节点

接下来就是要实现对元素节点和文本节点的编译,即实现compileElement()和compileText()方法,对于元素节点,首先获取到元素节点上的所有属性,然后开始遍历属性判断是否有带"v-"的属性,如果有那么就是一个指令,然后对指令进行处理,指令的作用就是操作DOM,所以需要传入DOM节点,vm、指令表达式,如:
// 在Complier中添加一个compileElement()方法
class  Complier {
    compileElement(node){
        let attributes =  node.attributes; // 取出元素节点上的所有属性
        [...attributes].forEach((attr) => {
            let {name, value:expr} = attr; // 获取到带v-的指令名和指令表达式
            if (this.isDirective(name)) { // 如果该属性名是vue指令,即以v-开头
                let [, directive] =  name.split("-"); // 去除v-,获取带参数和修饰符的指令名
                let [directiveName, eventName] =  directive.split(":"); // 将指令名和事件名拆开,如v-on:click, 则分别为 on click
                CompileUtil[directiveName](node, expr, this.vm, eventName); // 传递DOM元素和指令表达式以及vm进行指令处理
            }
        });
    }
}
上面使用到了CompileUtil编译工具对象专门进行各种指令的具体处理,添加一个CompileUtil对象里面有各种工具方法,如model、text,由于指令的作用,主要就是操作DOM,所以里面主要就是根据指令表达式从vm中获取到数据,然后操作DOM进行值的设置,如:
// 添加一个CompileUtil工具对象
var CompileUtil = {
    getVal(vm, expr) { // 根据vm和指令表达式从vm中获取数据
        return  expr.split(".").reduce((data, current) => {
            return data[current];
        }, vm.$data);
    },
    model(node, expr, vm) {
        const value =  this.getVal(vm, expr); // 获取表达式的值
        node.value  = value; // 对于v-model指令,直接给DOM的value属性赋值即可
    }
}
这里主要理解getVal()方法即可,这里用到了reduce()进行累加操作,主要是因为表达式,如果是多个点的形式,如"scholl.name",那么可以以vm中的data作为最初数据,然后遍历每个属性名,进行"."的累加操作,即vm.$data.scholl.name进行获取值。

1.4 找到带mustache表达式的文本节点开始编译文本节点

可以通过/\{\{(.+?)\}\}/正则表达式检测是否存在{{}},然后对{{}}表达式进行替换即可,如:
// 在Complier中添加一个compileText()方法
class  Complier {
    compileText(node){
        const content =  node.textContent;
        if(/\{\{(.+?)\}\}/.test(content)) { // 检测文本节点中是否含有{{}}表达式
            CompileUtil["text"](node, content, this.vm);
        }
    }
}
将整个文本内容交给CompileUtil中的text方法进行处理,即将{{}}替换掉然后用替换后的值再替换DOM的文本内容,如:
var CompileUtil = {
    text(node, expr, vm) {
        let content =  expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            return this.getVal(vm, args[1]);
        });
        node.textContent  = content; // 替换文本节点的内容
    }
}
至此,编译已经完成,已经可以在页面上看到vue指令{{}}表达式编译后的数据了。

② 数据劫持

此时模板虽然编译成功了,但是当vue中data里的数据发生变化的时候,整个Vue对象并不能检测到数据发生了变化,因为vue中的data还没有添加数据劫持,即还没有通过Object.defineProperty()方法进行重新定义,所以需要在编译模板前对vue中data进行观察即数据劫持
class  Vue {
    constructor(options) {
        this.$el = options.el; // 保存传递的el属性
        this.$data = options.data; // 保存传入的data属性
        // 添加数据劫持,将数据全部转化成Object.defineProperty()来定义
        new Observer(this.$data);
        new Complier(this.$el, this); // 在创建Vue实例的过程中立即发起模板编译操作
    }
}
上面是直接创建Observer对象并传入data进行数据劫持的,所以需要创建一个Observer类,在其构造函数中进行数据劫持,如:
class Observer {
    constructor(data) {
        this.observer(data);
    }
    observer(data) { 
        if (data &&  typeof data \===  "object") { // 如果传入的data是一个对象,遍历data对象中的所有属性改成Object.defineProperty的形式
            for (let key in data) { 
                this.defineReactive(data, key, data[key]);
            }
        }
    }
    defineReactive(obj, key, value) {
        this.observer(value); // 递归观察数据,如果data中的某个属性的属性值为对象,则也要进行观察
        Object.defineProperty(obj, key, {
            get() {
                return value;
            },
            set: (newValue) => {
                if (newValue != value) {
                    this.observer(newValue); // 如果赋值的是对象那么也进行新数据监控
                    value = newValue;
                }
            }
        });
    }
}
这样,当vue中data数据发生变化的时候就会被get()和set()劫持到,从而可以进行视图的更新。

③ 发布订阅模式

此时虽然已经可以劫持到vue中data的数据变化了,但是还不能进行页面的更新,因为目前还不知道页面上有哪些地方用到了该数据,所以必须在编译的时候,如果发现有某个地方用到了vue中的数据,那么就注册一个Watcher观察者,然后检测到数据发生变化的时候,通过发布者去通知所有观察者,观察者收到通知后进行页面的更新即可实现数据的双向绑定。
// 添加Watcher观察者类
class  Watcher {
    constructor(vm, expr, cb) {
        Dep.target  =  this; // 每次创建Watcher对象的时候,将创建的Watcher对象在获取值的时候添加到dep中
        this.vm  = vm;
        this.expr = expr;
        this.cb = cb;
        // 默认先存放旧值
        this.oldValue = this.get();
        Dep.target = null; // 添加Watcher对象后清空,防止每次获取数据的时候都添加Watcher对象
    }
    get() {
        let value =  CompileUtil.getVal(this.vm, this.expr);
        return value;
    }
    update() {
        let newValue =  CompileUtil.getVal(this.vm, this.expr);
        if (newValue !==  this.oldValue) {
            this.cb(newValue);
        }
    }
}
// 添加Dep发布者类
class  Dep { 
    constructor() {
        this.subs  = []; // 存放所有的watcher
    }
    // 订阅
    addSub(watcher) { // 添加watcher
        this.subs.push(watcher);
    }
    // 发布,遍历所有的观察者,调用观察者的update进行页面的更新
    notify() {
        this.subs.forEach((watcher) => {
            watcher.update();
        });
    }
}
创建Watcher对象的时候,需要传递vm和表达式,为了获取到表达式的值,同时传递了一个回调函数,主要是为了把变化后的值传递出去以便更新视图。那么应该在什么时候创建Watcher对象呢?应该在模板编译的时候,当检测到元素上使用了vue指令绑定data中的数据或者使用mustache表达式绑定data中的数据的时候,就需要创建一个Watcher对象了,如:
CompileUtil  =  {
    model(node, expr, vm) {
        new Watcher(vm, expr, (newValue) => {
            node.value = newValue;
        });
        const value =  this.getVal(vm, expr); // 获取表达式的值
        node.value  = value; // 对于v-model指令,直接给DOM的value属性赋值即可
    },
    getContentValue(vm, expr) {
        return  expr.replace(/\{\{(.+?)\}\}/g,(...args) => {
            return  this.getVal(vm, args\[1\]); // 重新获取最新的值
        });
    },
    text(node, expr, vm) {
        let content =  expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            new  Watcher(vm, args[1], () => { //每次匹配到一个就创建一个Watcher对象
                node.textContent = this.getContentValue(vm, expr); 
            });
            return this.getVal(vm, args[1]);
        });
        node.textContent  = content; // 替换文本节点的内容
    }
}
Watcher对象创建好之后,那么又需要在什么时候添加到对应的发布对象中呢?当Watcher对象创建好之后,会立即去获取对应的值,从而会触发对应数据的getter方法,所以在调用getter方法的时候将创建的Watcher对象添加到发布者对象中,如:
class  Observer {
    defineReactive(obj, key, value) { // 每个key对应一个发布者对象
        let dep = new  Dep(); // 为data中的每一个属性创建一个发布者对象
        Object.defineProperty(obj, key, {
            get() {
                Dep.target  &&  dep.addSub(Dep.target); // 将创建的Watcher对象添加到发布者中
            }
        });
    }
}
至此,已经实现了Vue的数据双向绑定,但还不支持计算属性。

④ 实现Computed计算属性

比如有计算属性{{getNewName}}和普通表达式{{scholl.name}},那么二者有什么共同点呢?就是不给是计算属性还是普通表达式,都是要从vm.\$data中去取值,当我们给{{getNewName}}创建Watcher的时候,我们希望获取到vm.\$data.getNewName的值,要想从vm.\$data中获取到值,那么必须将getNewName代理到vm.$data,然后获取getNewName的值时,直接执行计算属性函数即可。如:
class  Vue {
    this.$el = options.el;
    this.$data = options.data;
    let computed = options.computed;
    let methods = options.methods;
    new Observer(this.$data);
    for (let key in computed) { // 计算属性代理到data上
        Object.defineProperty(this.$data, key, { // 需要从$data中取值,所以需要将计算属性定义到this.$data上而不是vm上
            get: () => {
                return computed[key].call(this);
            }
        }
    }
    for (let key in methods) { // 将methods上的数据代理到vm上
        Object.defineProperty(this, key, {
            get() {
                return methods[key];
            }
        });

    }
    // 为了方便,把数据获取操作,将data上的数据都代理到vm上
    this.proxyVm(this.$data);
    proxyVm(data) {
        for (let key in data) {
            Object.defineProperty(this, key, {
                get() {
                    return data[key];
                },
                set(newValue) {
                    data[key] = newValue;
                }
            });
        }
    }
}

三、总结

总之就是,在创建Vue实例的时候给传入的data进行数据劫持,同时视图编译的时候,对于使用到data中数据的地方进行创建Watcher对象,然后在数据劫持的getter中添加到发布者对象中,当劫持到数据发生变化的时候,就通过发布订阅模式以回调函数的方式通知所有观察者操作DOM进行更新,从而实现数据的双向绑定。

JS_Even_JS
2.6k 声望3.7k 粉丝

前端工程师