使用Object.observe实现简单的双向绑定

使用Object.observe实现简单的双向绑定

双向数据绑定极大的简化了 web 应用中 view 层的编写,但是不幸的是,直到现在,依然需要通过第三方库才能实现模型驱动试图。

Backbone.js 对 model 包装了一层变化通知机制,这样就可以在 view 层去改变 DOM 元素。Angular使用了一个纯 JS 对象作为 model,然后在 digest 阶段定期的去检查该 model 是否有变化。

Object.observe 方法使得编写数据绑定更加容易。该方法会在 ES7 中被添加进来,它的实现原理就是对纯 JS 对象增加一个监听,当对象的属性取值发生变化时候会进行通知。

这篇文章中会使用 Object.observe,用大概一百行代码实现一个双向绑定。

Chrome 36+ 支持 object.observe。其他浏览器不支持

译者注 这篇文章没有太多讲双向绑定的实现思路,大家要了解双向绑定的概念是是从界面的操作能实时反映到数据,数据的变更能实时展现到界面。 文中有两块实现 binder 跟 withBinders ,这两块的解释我会在下文补充。

Demo

下面的HTML模板使用两个特殊属性:

data-bind属性用于让DOM元素跟对象属性进行绑定。data-repeat用来把dom元素做成一个模板,这些模板用于展示数组里的所有元素(类似ng-repeat)。

<ul>
    <li>人员总数:</li>
    <li data-repeat="people">
        <input data-bind="value name">
        ≡
        <input data-bind="value name">
        <ol>
            <li data-repeat="hobbies">
                <input data-bind="value label">
                (exactly <span data-bind="count label"></span> letters)
            </li>
            <li>
                <a href="#" data-bind="click addHobby">Add hobby…</a>
            </li>
        </ol>
    </li>
</ul>
<a href="#" data-bind="click showStructure">Show structure</a>

JSON 结构的 JavaScript 代码作为 model ,跟上面的模板连接绑定:

var root = {
    people: [ {
        name: 'Ashley',
        hobbies: [
            { label: 'programming' },
            { label: 'poetry' },
            { label: 'knitting' }
        ],
        addHobby: addHobby
    }, {
        name: 'Noor',
        hobbies: [],
        addHobby: addHobby
    } ],
    showStructure: alertJSON
};
​
function addHobby() {
    this.hobbies.push({
        label: ''
    });
}
​
function alertJSON() {
    alert(JSON.stringify(root, null, 4));
}
​
withBinders(binders).bind(document.querySelector(".template"), root);

demo可以访问这个地址:JS Bin


绑定器 binder

  使用指定的对象来连接 DOM 元素,用于处理数据的双向绑定。但是我们也需要一个叫做绑定器 binder 的东西,来告诉js,如何把数据跟 dom 元素进行绑定。
  data-bind 的书写方式,则声明了绑定器要做什么的,我们这边定义了三种绑定器:

  value——把 input 的输入跟对象属性绑定在一起
  count——把元素的 textContent 属性与对象属性绑定在一起,用于计算元素内字符的个数。
  click——把元素的单击事件,跟对象属性(一般是一个函数)绑定在一起。

译者注:这里的 binder 定义了一些函数,这些函数的作用如下:
如果页面操作会影响数据层,则把页面操作转化为数据改变,如果页面的操作不会影响原始数据,则不会做这一步。
返回一个当数据改变时,让页面改变的函数 updateProperty ,这个函数就是本文的核心,是 Object.observe 的回调函数。
var binders = {
    value: function(node, onchange) {
        node.addEventListener('keyup', function() {
            onchange(node.value);
        });
        return {
            updateProperty: function(value) {
                if (value !== node.value) {
                    node.value = value;
                }
            }
        };
    },
    count: function(node) {
        return {
            updateProperty: function(value) {
                node.textContent = String(value).length;
            }
        };
    },
    click: function(node, onchange, object) {
        var previous;
        return {
            updateProperty: function(fn) {
                var listener = function(e) {
                    fn.apply(object, arguments);
                    e.preventDefault();
                };
                if (previous) {
                    node.removeEventListener(previous);
                    previous = listener;
                }
                node.addEventListener('click', listener);
            }
        };
    }
};

注意我们上面的绑定器,最终都会暴露一个方法 updateProperty ,来告诉双向绑定中,对象的属性改变后如何去更新对应的 dom 元素。


把属性跟 DOM 元素连接起来

withBinders 功能分为三个部分:

  • bindObject 把上文所讲的绑定器跟对象的属性相连接,而且会被每个 data-bind 来调用。而 data-bind 属性为 value 的值则表示应用 value 这种 binder,并绑定到后面对应名称的对象属性上。

  • bindCollection 则会被 data-repeat 属性调用。他会把对应的 dom 节点作为模板,呈现一个数组的每个元素。它也会建立观察者,来观察数组的添加,更新或删除行为。

  • bindModel 会被以上两个部分调用,会查询 data-bind 跟 data-repeat 属性所对应的节点以及他的子结点。这些节点会被过滤,只有不在 data-repeat 模板中的节点会被处理。

译者注 : withBinder函数的实现思路是这样的: Object.observe 监听到数据改变时,就会调用对应上文 binder 的 updateProperty 来改变页面。接下来需要扫描整个 html,会将 html 中的绑定属性进行转换,并交给对应的 binder 去处理。
  • function withBinders(binders) {
        function bindObject(node, binderName, object, propertyName) {
            var updateValue = function(newValue) {
                object[propertyName] = newValue;
            };
            var binder = binders[binderName](node, updateValue, object);
            binder.updateProperty(object[propertyName]);
            var observer = function(changes) {
                var changed = changes.some(function(change) {
                    return change.name === propertyName;
                });
                if (changed) {
                    binder.updateProperty(object[propertyName]);
                }
            };
            Object.observe(object, observer);
            return {
                unobserve: function() {
                    Object.unobserve(object, observer);
                }
            };
        }
    ​
        function bindCollection(node, array) {
            function capture(original) {
                var before = original.previousSibling;
                var parent = original.parentNode;
                var node = original.cloneNode(true);
                original.parentNode.removeChild(original);
                return {
                    insert: function() {
                        var newNode = node.cloneNode(true);
                        parent.insertBefore(newNode, before);
                        return newNode;
                    }
                };
            }
    ​
            delete node.dataset.repeat;
            var parent = node.parentNode;
            var captured = capture(node);
            var bindItem = function(element) {
                return bindModel(captured.insert(), element);
            };
            var bindings = array.map(bindItem);
            var observer = function(changes) {
                changes.forEach(function(change) {
                    var index = parseInt(change.name, 10), child;
                    if (isNaN(index)) return;
                    if (change.type === 'add') {
                        bindings.push(bindItem(array[index]));
                    } else if (change.type === 'update') {
                        bindings[index].unobserve();
                        bindModel(parent.children[index], array[index]);
                    } else if (change.type === 'delete') {
                        bindings.pop().unobserve();
                        child = parent.children[index];
                        child.parentNode.removeChild(child);
                    }
                });
            };
            Object.observe(array, observer);
            return {
                unobserve: function() {
                    Object.unobserve(array, observer);
                }
            };
        }
    ​
        function bindModel(container, object) {
            function isDirectNested(node) {
                node = node.parentElement;
                while (node) {
                    if (node.dataset.repeat) {
                        return false;
                    }
                    node = node.parentElement;
                }
                return true;
            }
    ​
            function onlyDirectNested(selector) {
                var collection = container.querySelectorAll(selector);
                return Array.prototype.filter.call(collection, isDirectNested);
            }
    ​
            var bindings = onlyDirectNested('[data-bind]').map(function(node) {
                var parts = node.dataset.bind.split(' ');
                return bindObject(node, parts[0], object, parts[1]);
            }).concat(onlyDirectNested('[data-repeat]').map(function(node) {
                return bindCollection(node, object[node.dataset.repeat]);
            }));
    ​
            return {
                unobserve: function() {
                    bindings.forEach(function(binding) {
                        binding.unobserve();
                    });
                }
            };
        }
    ​
        return {
            bind: bindModel
        };
    }
    

      这个解决方案的主要问题是,它依赖 Object.observe 函数,目前的支持度并不好。 withBinders 当前的实现也有几个缺点:

    • 它不能直接绑定到原始数据——比如 hobbies 是一个字符串数组(而不是对象),则不能绑定到这个数组。
    • 无法绑定到嵌套属性——比如 name 属性是一个对象(例如:{
      firstName:FN,lastName:LN的})然后没有办法与 name.firstName 绑定。
    • 当一个 binder 触发 onchange 函数后,同时使用相同的值进行更新。这会导致一些上下文信息丢失。 你可以尝试删除( value!== node.value ),并观察当你在 input 区域内输入后,光标位置是如何改变的。

译者注:大家可以尝试解决这些问题,来完成一个完善的双向绑定框架。

原文链接:curiosity-driven.org/ob

外刊君推荐阅读:

Model-driven Views: Design
Backbone.js
Backbone.Events — Backbone.js
Data Binding in Angular Templates — Developer Guide — Angularjs
Scope Life Cycle — Developer Guide — Angularjs
updates.html5rocks.com/
Object.observe — ECMAScript compatibility table
Object.observe: Key Algorithms and Semantics — ES Wiki
Object.observe() and AngularJS — ES Discuss
Object.observe — Chromium Features
Implement Object.observe — Bugzilla@Mozilla

编辑于 2015-05-29

文章被以下专栏收录