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

5 年前

双向数据绑定极大的简化了 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 区域内输入后,光标位置是如何改变的。

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

原文链接:https://curiosity-driven.org/object-observe-data-binding

外刊君推荐阅读:

Model-driven Views: Design Backbone.js Backbone.Events — Backbone.js Data Binding in Angular Templates — Developer Guide — Angularjs Scope Life Cycle — Developer Guide — Angularjs http://updates.html5rocks.com/2012/11/Respond-to-change-with-Object-observe 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

0
推荐阅读