javascript基础修炼(9)——MVVM中双向数据绑定的基本原理

开发者的javascript造诣取决于对【动态】和【异步】这两个词的理解水平。javascript

一. 概述

1.1 MVVM模型

MVVM模型是前端单页面应用中很是重要的模型之一,也是Single Page Application的底层思想,若是你也由于本身学习的速度拼不过开发框架版本迭代的速度,或许也应该从更高的抽象层次去理解现代前端开发,由于其实最核心的经典思想几乎都是不怎么变的。关于MVVM的文章已经很是多了,本文再也不赘述。html

笔者以前听过一种很形象的描述以为有必要提一下,Model能够想象成HTML代码ViewModel能够想象成浏览器,而View能够想象成咱们最终看到的页面, 那么各个层次所扮演的角色和所须要处理的逻辑就比较清晰了。前端

1.2 数据绑定

数据绑定,就是将视图层表现和模型层的数据绑定在一块儿,关于MVVM中的数据绑定,涉及两个基本概念单向数据绑定双向数据绑定,其实二者并无绝对的优劣,只是适用场景不一样,现×××发框架都是同时支持两种形式的。vue

双向数据绑定由Angularjs1.x发展起来,在表单等用户体验高度依赖于即时反馈的场景中很是便利,但并非全部场景下都适用的,Angularjs中也能够经过ng-bind=":expr"的形式来实现单向绑定;在Flux数据流架构的影响下,更加易于追踪和管理的单向数据流思想出现了,各主流框架也进行了实现(例如redux,vuex),在单向数据绑定的框架中,开发者仍然能够在须要的地方监听变化来手动实现双向绑定。java

关于Angularjs1.x中如何经过脏检查机制来实现双向数据绑定和管理,能够参见《构建本身的AngularJS,第一部分:Scope和Digest》一文,讲述得很是详细。node

二. 基于数据劫持的绑定

2.1 Vue2.0源码的学习困惑

Vue2.0版本中的双向数据绑定,不少开发者都知道是经过劫持属性的get/set方法来实现的,上图已经展现了双向数据绑定的代码框架,分析源码的文章也很是多,许多文章都将重点放在了发布订阅模式的实现上,笔者本身阅读时有两大困扰点:git

第一,即便经过defineProperty劫持了属性的get/set方法,不知道数据模型和页面之间又是如何联系起来的。(不少文章都是顺带一提而没有详述,实际上这部分对于总体理解MVVM数据流很是重要)github

第二,Vue2.0在实现发布订阅模式的时候,使用了一个Dep类做为订阅器来管理发布订阅行为,从代码的角度讲这样作是很好的实践,它能够将订阅者管理(例如避免重复订阅)这种与业务无关的代码解耦出来,符合单一职责的开发原则。但这样作对于理清代码逻辑而言会形成困扰,让发布-订阅相关的代码段变得模糊,实际上将Dep类与发布者类合并在一块儿,绑定原理会更加清晰,而在代码迭代中,考虑到更多更复杂的状况时,即便你是框架的设计者,也会很天然地选择将Dep抽象成一个独立的类。vuex

若是你也在阅读博文的时候出现一样的困惑,强烈建议读完本篇后本身动手实现一个MVVM的双向绑定,你会发现不少时候你不理解一些代码,是由于你不知道做者面对了怎样的实际问题编程

2.2 从标签开始的代码推演

ps:下文说起的观察者类和发布者类是指同一个类。

2.2.1 示例代码

咱们先来写几个包含自定义指令的标签:

<div id="app" class="container">
        <input type="text" d-model="myname">
        <br>
        输入的是:<span d-bind="myname"></span>
        <br>
        <button d-click="alarm()">广播报警</button>
</div>
<script>
       var options = {
            el:'app',
            data:{
                myname:'僵尸'
            },
            methods:{
                alarm:function (node,event) {
                    window.alert(`一大波【${this.data.myname}】正在靠近!`);
                }
            }
        }
        //初始化
        var vm = new Dash(options);
</script>

须要实现的功能就如同你在全部框架中见到的那样:input标签的值经过d-model指令和数据模型中的myname进行双向绑定,span标签的值经过d-bind指令从myname单向获取,button标签的点击响应经过d-click绑定数据模型中的*alarm()*方法。初始化所用到的方法已经提供好了,假如咱们要在一个叫作DashMVVM框架中实现数据绑定,那么第一步要作的,是模板解析

2.2.2 模板解析

DOM标签自身是一个树形结构,因此须要从最外层的*<div>*为起点以递归的方式来进行解析。

compiler.js——模板解析器类

/**
 * 模板编译器
 */
class Compiler{
    constructor(){
       this.strategy = new Strategy();//封装的策略类,下一节描述
       this.strategyKeys = Object.keys(this.strategy);
    }

    /**
    *编译方法
    *@params vm Dash类的实例(即VisualModel实例)
    *@params node 待编译的DOM节点
    */
    compile(vm, node){
        if (node.nodeType === 3) {//解析文本节点
            this.compileTextNode(vm, node);
        }else{
            this.compileNormalNode(vm, node);
        }
    }

    /**
    *编译文本节点,此处仅实现一个空方法,实际开发中多是字符串转义过滤方法
    */
    compileTextNode(vm, node){}

    /**
    *编译DOM节点,遍历策略类中支持的自定义指令,若是发现某个指令dir
    *则以this.Strategy[str]的方式取得对应的处理函数并执行。
    */
    compileNormalNode(vm, node){
         this.strategyKeys.forEach(key=>{
            let expr = node.getAttribute(key);
            if (expr) {
                this.strategy[key].call(vm, node, expr);
            }
        });
        //递归处理当前DOM标签的子节点
        let childs = node.childNodes;
        if (childs.length > 0) {
            childs.forEach(subNode => this.compile(vm, subNode));
        }
    }
}
//为方便理解,此处直接在全局生成一个编译器单例,实际开发中请挂载至适当的命名空间下。
window.Compiler = new Compiler();

2.2.3 策略封装

咱们使用策略模式实现一个单例的策略类Strategy,将全部指令所对应的解析方法封装起来并传入解析器,当解析器递归解析每个标签时,若是遇到能够识别的指令,就从策略类中直接取出对应的处理方法对当前节点进行处理便可,这样Strategy类只须要实现一个Strategy.register( customDirective, options)方法就能够暴露出将来用以添加自定义指令的接口。(细节可参考附件中的代码)

strategy.js——指令解析策略类

//策略类的基本结构
class Strategy{
    constructor(){
        let strategy = {
            'd-bind':function(){//...},
            'd-model':function(){//...},
            'd-click':function(){//...}
        }
        return strategy;
    }
    
    //注册新的指令
    register(customDir,options){
        ...
    }
}

模板解析的工做就比较清晰了,至关于带着一本《解析指南》去遍历处理DOM树,不难看出,实际上绑定的工做就是在策略对应的方法里来实现的,在MVVM结构种,这一步被称为**“依赖收集”**。

2.2.4 订阅数据模型变化

以最基本的d-bind指令为例,经过使用strategy['d-bind']方法处理节点后,被处理的节点应该具有感知数据模型变化的能力。以上面的模板为例,当this.data.myname发生变化时,就须要将被处理节点的内容改成对应的值。此处就须要用到发布-订阅模式。为了实现这个方法,须要一个观察者类Observer,它的功能是观察数据模型的变化(经过数据劫持实现),管理订阅者(维护一个回调队列管理订阅者添加的回调方法), 变化发生时通知订阅者(依次调用订阅者注册的回调方法),同时将提供回调方法并执行视图更新行为的逻辑抽象为一个订阅者类Subscriber,订阅者实例拥有一个update方法,当该方法被观察者(同时也是发布者)调用时,就会刷新对应节点的视图,很明显,subscriber实例须要被添加至指定的观察者类的回调队列中才可以生效。

//发布订阅模式的伪代码
//...
'd-bind':function(node, expr){
    //实例化订阅者类
    let sub = new Subscriber(node, 'myname',function(){
        //更新视图
        node.innerHTML = VM.data['myname'];
    });
    //当观察者实例化时,须要将这个sub实例的update方法添加进
},
//...

subscriber.js——订阅者类

class Subscriber{
    constructor(vm, exp, callback){
        this.vm = vm;
        this.exp = exp;
        this.callback = callback;
        this.value = this.vm.data[this.exp];
    }

    /**
     * 提供给发布者调用的方法
     */
    update(){
        return this.run();
    }

    /**
     * 更新视图时的实际执行函数
     */
    run(){
        let currentVal = this.vm.data[this.exp];
        if (this.value !== currentVal) {
            this.value = currentVal;
            this.callback.call(this.vm, this.value);
        }
    }
}

2.2.5 数据劫持

在生成一个subscriber实例后,还要实现一个observer实例,而后才可以经过调用observer.addSub(sub)方法来将订阅者添加进观察者的回调队列中。先来看一下Observer这个类的定义:

observer.js——观察者类

/**
 * 发布者类,同时为一个观察者
 * 功能包括:
 * 1.观察视图模型上数据的变化
 * 2.变化出现时发布变化消息给订阅者
 */
class Observer{
    constructor(data){
        this.data = data;
        this.subQueue = {};//订阅者Map
        this.traverse();
    }

    //遍历数据集中各个属性并添加观察器具
    traverse(){
        Object.keys(this.data).forEach(key=>{
            defineReactive(this.data, key, this.data[key], this);
        });
    }

    notify(key){
        this.subQueue[key].forEach(fn=>fn.update());
    }
}

//修改对象属性的get/set方法实现数据劫持
function defineReactive(obj, key, val, observer) {
    //当键的值仍然是一个对象时,递归处理,observe方法定义在dash.js中
    let childOb = observe(val);

    //数据劫持
    Object.defineProperty(obj, key, {
        enumerable:true,
        configurable:true,
        get:()=>{
            if (window.curSubscriber) {
                 if (observer.subQueue[key] === undefined) {observer.subQueue[key] = []};
                 observer.subQueue[key].push(window.curSubscriber);
            }
            return val;
        },
        set:(newVal)=>{
            if (val === newVal) return;
            val = newVal;
            //监听新值
            childOb = observe(newVal);
            //通知全部订阅者
            observer.notify(key);
        }
    })
}

观察者类实例化时,传入一个待观察的数据对象,构造器调用遍历方法来改写数据集中每个键的get/set方法,在读取某个键的值时,将订阅者监听器(细节下一节讲)添加进回调队列,当set改变数据集中某个键的值时,调用观察者的notify( )方法找到对应键的回调队列并以此触发。

上面的代码能够应付通常状况,但存在一些明显的问题就是集中式的回调队列管理,subQueue其实是一个HashMap结构:

subQueue = {
    'myname':[fn1, fn2, fn3],
    'otherAttr':[fn11,fn12, fn13],
        //...
}

不难看出这种管理回调的方式存在不少问题,遇到嵌套或重名结构就会出现覆盖,这个时候就不难理解Vue2.0源码中的作法了,在进行数据劫持时生成一个Dep实例,实例中维护一个回调队列用来管理发布订阅,当数据模型中的属性被set修改时,调用dep.notify( )方法来依次调用订阅者添加的回调,当属性被读取而触发get方法时,向dep实例中添加订阅者的回调函数便可。

2.2.6 发布订阅的链接

截止目前为止,还有最后一个问题须要处理,就是订阅者实例sub和发布订阅管理器实例dep存在于两个不一样的做用域里,那么要怎么经过调用dep.addSub(sub)来实现订阅动做呢?换个问法或许你就发现这个问题其实并不难回答,在SPA框架中,兄弟组件之间如何通讯呢?一般都是借助数据上浮(公用数据提高到共同的父级组件中)或者EventBus来实现的。

这里的作法是一致的,在策略类中某个指令对应的处理方法中,当咱们准备从数据模型this.data中读取对应的初值前,先将订阅者实例sub挂载到一个更高的层级(附件的demo中简单粗暴地挂载到全局,Vue2.0源码中挂载到Dep.target),而后再去读取this.data[expr],这个时候在expr属性被劫持的get方法中,不只能够访问到属于本身的订阅管理器dep实例,也能够经过Dep.target访问到当前节点所对应的订阅者实例,那么完成对应的订阅逻辑就易如反掌了。

2.2.7 逻辑整合

了解了上述细节,咱们整理一下思路,总体看一下数据绑定所经历的各个环节:

2.2.8 Demo

有关上面示例中d-modeld-click指令绑定的实现,本文再也不赘述,笔者提供了包含详细注释的完整Demo,有须要的读者能够直接从附件中取用,最后Demo也会存放在个人github仓库

2.2.9 Vue2.0中有关双向绑定的源码

了解了上述细节,能够阅读《vue的双向绑定原理及实现》来看看 Vue2.0的源代码中是如何更加规范地实现双向数据绑定的。

2.3 数据劫持绑定存在的问题

基于劫持的数据绑定方法是没法感知数组方法的,Vue2.0中使用了Hack的方法来实现对于数组元素的感知,其基本原理依旧是经过代理模式实现,在此直接给出源码Vue源码连接

//Vue2.0中有关数组方法
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// hack 如下几个函数
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
methodsToPatch.forEach(function (method) {
  // 得到原生函数
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    // 调用原生函数
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 触发更新
    ob.dep.notify()
    return result
  })
})

大体的思路是为Array.prototype上几个原生方法设置了访问代理,并将订阅管理器的消息发布方法混入其中,实现了对特定数组方法的监控。

三. 基于Proxy的数据绑定

Vue官方已经确认3.0版本重构数据绑定代码,改成Proxy实现。

Proxy对象是ES6引入的原生化的代理对象,和基于defineProperty实现数据劫持在思路上其实并无什么本质区别,都是使用经典的**“代理模式”**来实现的,只是原生支持的Proxy编写起来更简洁,整个自然支持对数组变化的感知能力。ProxyReclect对象基本是成对出现使用的,属于元编程范畴,能够从语言层面改变原有特性,Proxy能够拦截对象的数十种方法,比手动实现的代理模式要清晰不少,也要方便不少。

基本实现以下:

//使用Proxy代理数据模型对象
let watchVmData = (obj, setBind, getLogger) => {
    let handler = {
        get(target, property, receiver){
            getLogger(target, property);
            return Reflect.get(target, property, receiver);
        },
        set(target, property, value, receiver){
            setBind(value);
            return Reflect.set(target, property, value);
        }
    };
    return new Proxy(obj, handler);
};

//使用Proxy代理
let data = { myname : 1 };
let value;
let vmproxy = watchVmData(obj, (v) => {
    value = v;
},(target, property)=>{
    console.log(`Get ${property} = ${target[property]}`);
});

四. What's next

数据绑定只是MVVM模型中的冰山一角,若是你本身动手实现了上面说起的Demo,必定会发现不少明显的问题,例如订阅者刷新函数是直接修改DOM的,稍有开发经验的前端工程师都会想到须要将变化收集起来,尽量将高性能消耗的DOM操做合并在一块儿处理来提高效率,这就引出了一系列咱们经常听到的Virtual-DOM(虚拟DOM树)Life-Cycle-Hook(生命周期钩子)等等知识点,若是你对三大框架的底层原理感兴趣,能够继续探索,那是一件很是有意思的事情。

五. 总结

经过原理的学习就会发现学习**【设计模式】的重要性,不少时候别人用设计模式的术语交流并非在装X,而是它真的表明了一些久经验证的思想,仅仅是数据绑定这样一个小小的知识点,就包含了类模式代理模式,原型模式,策略模式,发布订阅模式的运用,代码的实现中也涉及到了单一职责**,开放封闭等等开发原则的考量,框架编写是一件很是考验基本功的事情,在基础面前,技巧只能是浮云。

相关文章
相关标签/搜索