Javascript 实现数据双向绑定的三种方式

2016/03/02 angularmvvm

# 前端数据的双向绑定方法

前端的视图层和数据层有时需要实现双向绑定(two-way-binding),例如 mvvm 框架,数据驱动视图,视图状态机等,研究了几个目前主流的数据双向绑定框架,总结了下。目前实现数据双向绑定主要有以下三种。

# 1、手动绑定

比较老的实现方式,有点像观察者编程模式,主要思路是通过在数据对象上定义 get 和 set 方法(当然还有其它方法),调用时手动调用 get 或 set 数据,改变数据后出发 UI 层的渲染操作;以视图驱动数据变化的场景主要应用于 input、select、textarea 等元素,当 UI 层变化时,通过监听 dom 的 change,keypress,keyup 等事件来出发事件改变数据层的数据。整个过程均通过函数调用完成。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>data-binding-method-set</title>
</head>
<body>
    <input q-value="value" type="text" id="input">
    <div q-text="value" id="el"></div>
    <script>
        var elems = [document.getElementById('el'), document.getElementById('input')];

        var data = {
            value: 'hello!'
        };

        var command = {
            text: function(str){
                this.innerHTML = str;
            },
            value: function(str){
                this.setAttribute('value', str);
            }
        };

        var scan = function(){
            /**
             * 扫描带指令的节点属性
             */
            for(var i = 0, len = elems.length; i < len; i++){
                var elem = elems[i];
                elem.command = [];
                for(var j = 0, len1 = elem.attributes.length; j < len1; j++){
                    var attr = elem.attributes[j];
                    if(attr.nodeName.indexOf('q-') >= 0){
                        /**
                         * 调用属性指令,这里可以使用数据改变检测
                         */
                        command[attr.nodeName.slice(2)].call(elem, data[attr.nodeValue]);
                        elem.command.push(attr.nodeName.slice(2));
                    }
                }
            }
        }

        /**
         * 设置数据后扫描
         */
        function mvSet(key, value){
            data[key] = value;
            scan();
        }
        /**
         * 数据绑定监听
         */
        elems[1].addEventListener('keyup', function(e){
            mvSet('value', e.target.value);
        }, false);

        scan();

        /**
         * 改变数据更新视图
         */
        setTimeout(function(){
            mvSet('value', 'fuck');
        },1000)

    </script>
</body>
</html>

# 2、脏检查机制

以典型的 mvvm 框架 angularjs 为代表,angular 通过检查脏数据来进行 UI 层的操作更新。关于 angular 的脏检测,有几点需要了解些:

  • 脏检测机制并不是使用定时检测

  • 脏检测的时机是在数据发生变化时进行

  • angular 对常用的 dom 事件,xhr 事件等做了封装, 在里面触发进入 angular 的 digest 流程

  • 在 digest 流程里面, 会从 rootscope 开始遍历, 检查所有的 watcher

关于 angular 的具体设计可以看其他文档,这里只讨论数据绑定,那我们看下脏检测该如何去做:主要是通过设置的数据来寻找与该数据相关的所有元素,然后再比较数据变化,如果变化则进行指令操作

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>data-binding-drity-check</title>
</head>
<body>
    <input q-event="value" ng-bind="value" type="text" id="input">
    <div q-event="text" ng-bind="value" id="el"></div>
    <script>

        var elems = [document.getElementById('el'), document.getElementById('input')];

        var data = {
            value: 'hello!'
        };

        var command = {
            text: function(str) {
                this.innerHTML = str;
            },
            value: function(str) {
                this.setAttribute('value', str);
            }
        };

        var scan = function(elems) {
            /**
             * 扫描带指令的节点属性
             */
            for (var i = 0, len = elems.length; i < len; i++) {
                var elem = elems[i];
                elem.command = {};
                for (var j = 0, len1 = elem.attributes.length; j < len1; j++) {
                    var attr = elem.attributes[j];
                    if (attr.nodeName.indexOf('q-event') >= 0) {
                        /**
                         * 调用属性指令
                         */
                        var dataKey = elem.getAttribute('ng-bind') || undefined;
                        /**
                         * 进行数据初始化
                         */
                        command[attr.nodeValue].call(elem, data[dataKey]);
                        elem.command[attr.nodeValue] = data[dataKey];
                    }
                }
            }
        }

        /**
         * 脏循环检测
         * @param  {[type]} elems [description]
         * @return {[type]}       [description]
         */
        var digest = function(elems) {
            /**
             * 扫描带指令的节点属性
             */
            for (var i = 0, len = elems.length; i < len; i++) {
                var elem = elems[i];
                for (var j = 0, len1 = elem.attributes.length; j < len1; j++) {
                    var attr = elem.attributes[j];
                    if (attr.nodeName.indexOf('q-event') >= 0) {
                        /**
                         * 调用属性指令
                         */
                        var dataKey = elem.getAttribute('ng-bind') || undefined;

                        /**
                         * 进行脏数据检测,如果数据改变,则重新执行指令,否则跳过
                         */
                        if(elem.command[attr.nodeValue] !== data[dataKey]){

                            command[attr.nodeValue].call(elem, data[dataKey]);
                            elem.command[attr.nodeValue] = data[dataKey];
                        }
                    }
                }
            }
        }

        /**
         * 初始化数据
         */
        scan(elems);

        /**
         * 可以理解为做数据劫持监听
         */
        function $digest(value){
            var list = document.querySelectorAll('[ng-bind='+ value + ']');
            digest(list);
        }

        /**
         * 输入框数据绑定监听
         */
        if(document.addEventListener){
            elems[1].addEventListener('keyup', function(e) {
                data.value = e.target.value;
                $digest(e.target.getAttribute('ng-bind'));
            }, false);
        }else{
            elems[1].attachEvent('onkeyup', function(e) {
                data.value = e.target.value;
                $digest(e.target.getAttribute('ng-bind'));
            }, false);
        }

        setTimeout(function() {
            data.value = 'fuck';
            /**
             * 这里问啥还要执行$digest这里关键的是需要手动调用$digest方法来启动脏检测
             */
            $digest('value');
        }, 2000)

    </script>
</body>
</html>

# 3、前端数据劫持(Hijacking)

第三种方法则是 avalon 等框架使用的数据劫持方式。基本思路是使用 Object.defineProperty 对数据对象做属性 get 和 set 的监听,当有数据读取和赋值操作时则调用节点的指令,这样使用最通用的=等号赋值就可以了。具体实现如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>data-binding-hijacking</title>
</head>
<body>
    <input q-value="value" type="text" id="input">
    <div q-text="value" id="el"></div>
    <script>
        var elems = [document.getElementById('el'), document.getElementById('input')];

        var data = {
            value: 'hello!'
        };

        var command = {
            text: function(str) {
                this.innerHTML = str;
            },
            value: function(str) {
                this.setAttribute('value', str);
            }
        };

        var scan = function() {
            /**
             * 扫描带指令的节点属性
             */
            for (var i = 0, len = elems.length; i < len; i++) {
                var elem = elems[i];
                elem.command = [];
                for (var j = 0, len1 = elem.attributes.length; j < len1; j++) {
                    var attr = elem.attributes[j];
                    if (attr.nodeName.indexOf('q-') >= 0) {
                        /**
                         * 调用属性指令
                         */
                        command[attr.nodeName.slice(2)].call(elem, data[attr.nodeValue]);
                        elem.command.push(attr.nodeName.slice(2));

                    }
                }
            }
        }

        var bValue;
        /**
         * 定义属性设置劫持
         */
        var defineGetAndSet = function(obj, propName) {
            try {
                Object.defineProperty(obj, propName, {

                    get: function() {
                        return bValue;
                    },
                    set: function(newValue) {
                        bValue = newValue;
                        scan();
                    },

                    enumerable: true,
                    configurable: true
                });
            } catch (error) {
                console.log("browser not supported.");
            }
        }
        /**
         * 初始化数据
         */
        scan();

        /**
         * 可以理解为做数据劫持监听
         */
        defineGetAndSet(data, 'value');

        /**
         * 数据绑定监听
         */
        if(document.addEventListener){
            elems[1].addEventListener('keyup', function(e) {
                data.value = e.target.value;
            }, false);
        }else{
            elems[1].attachEvent('onkeyup', function(e) {
                data.value = e.target.value;
            }, false);
        }

        setTimeout(function() {
            data.value = 'fuck';
        }, 2000)
    </script>
</body>
</html>

defineProperty 支持 IE8 及以上的浏览器(MDN (opens new window)),IE8 只支持 DOM 对象的 defineProperty,要兼容低版本浏览器,参考如下代码(来源 (opens new window)):

/*
    * Super amazing, cross browser property function, based on http://thewikies.com/
    */
function addProperty(obj, name, onGet, onSet) {
    /**
     * wrapper functions
     */
    var oldValue = obj[name],
        getFn = function () {
            return onGet.apply(obj, [oldValue]);
        },
        setFn = function (newValue) {
            return oldValue = onSet.apply(obj, [newValue]);
        };
 
    /**
     * Modern browsers, IE9+, and IE8 (must be a DOM object)
     */
    if (Object.defineProperty) {
        Object.defineProperty(obj, name, {
            get: getFn,
            set: setFn
        });
    }
    /**
     * Older Mozilla
     */
    else if (obj.__defineGetter__) {
 
        obj.__defineGetter__(name, getFn);
        obj.__defineSetter__(name, setFn);

    }
    /**
     * IE6-7
     * must be a real DOM object (to have attachEvent)
     * and must be attached to document (for onpropertychange to fire)
     */
    else {
 
        var onPropertyChange = function (e) {
 
            if (event.propertyName == name) {
                // temporarily remove the event so it doesn't fire again and create a loop
                obj.detachEvent("onpropertychange", onPropertyChange);
 
                // get the changed value, run it through the set function
                var newValue = setFn(obj[name]);
 
                // restore the get function
                obj[name] = getFn;
                obj[name].toString = getFn;
 
                // restore the event
                obj.attachEvent("onpropertychange", onPropertyChange);
            }
        }; 
 
        obj[name] = getFn;
        obj[name].toString = getFn;
 
        obj.attachEvent("onpropertychange", onPropertyChange);
 
    }
}
 
/**
 * must be a DOM object (even if it's not a real tag) attached to document
 */
var myObject = document.createElement('fake');
document.body.appendChild(myObject);
 
// create property
myObject.firstName = 'John';
myObject.lastName = 'Dyer';
addProperty(myObject, 'fullname', function() {
        return this.firstName + ' ' + this.lastName;
    },
    function(value) {
        var parts = value.split(' ');
        this.firstName = parts[0];
        this.lastName = (parts.length > 1) ? parts[1] : '';
    });
 
console.log(myObject.fullname); // returns 'John Dyer'

# 4、 小结

首先这里的例子只是简单的实现,读者可以深入感受三种方式的异同点,复杂的框架也是通过这样的基本思路滚雪球滚大的。

# 参考资料 && 相关资料:

本文转载自:http://ouvens.github.io/frontend-javascript/2015/11/29/js-data-two-ways-binding.html (opens new window)

上次更新: 2024/4/15 02:28:03