使用 Object.observe() 实现数据绑定革新

艾迪·奥斯曼尼
Addy Osmani

简介

一场革命即将到来。JavaScript 中新增了一个新功能,它将改变您认为关于数据绑定的一切。而且还会改变 MVC 库用于观察模型以进行编辑和更新的数量。准备好迎接一项甜蜜的性能提升,为那些关注属性观察的应用开启了吗?

好的。接下来,我很高兴地宣布,Object.observe() 已登陆 Chrome 36 稳定版。THE CROWD GONGING]

Object.observe() 是未来推出的 ECMAScript 标准的一部分,是一种异步观察 JavaScript 对象更改的方法,无需单独的库。它允许观察者接收按时间排序的变更记录序列,这些记录描述对一组观察对象发生的更改集。

// Let's say we have a model with data
var model = {};

// Which we then observe
Object.observe(model, function(changes){

    // This asynchronous callback runs
    changes.forEach(function(change) {

        // Letting us know what changed
        console.log(change.type, change.name, change.oldValue);
    });

});

每当您做出了更改,系统都会报告:

已报告更改。

借助 Object.observe()(我喜欢将其称为 O.o() 或 Oooooooo),您可以实现双向数据绑定,而无需框架

这并不是说您不能使用该功能。对于具有复杂业务逻辑的大型项目,固有框架非常有用,您应继续使用这些框架。这种模式简化了新开发者的学习方向,需要的代码维护工作量更少,并提供了执行常见任务的模式。当您不需要时,可以使用更小、更有针对性的库,例如 Polymer(已利用 O.o())。

即使您发现自己频繁使用框架或 MV* 库,O.o() 也有可能为他们提供一些健康的性能改进,在保持相同 API 的同时实现更快、更简单的实现。例如,去年 Angular 发现,在对模型进行更改的基准测试中,每次更新需要 40 毫秒进行脏检查,而每次更新需要 1-2 毫秒(速度提高了 20-40 倍)。

数据绑定无需大量复杂代码,也意味着您无需再轮询更改,从而延长电池续航时间!

如果您已销售 O.o(),请直接跳到该功能简介,或继续阅读下文,详细了解它所能解决的问题。

我们想要观察什么?

当我们谈论数据观察时,通常是指关注某些特定类型的变化:

  • 原始 JavaScript 对象的更改
  • 添加、更改或删除房源的时间
  • 当数组中有元素接合到一起时
  • 对对象原型的更改

数据绑定的重要性

如果您注重模型与视图控件的分离,数据绑定便开始变得至关重要。HTML 是一种强大的声明式机制,但它是完全静态的。理想情况下,您只需声明数据与 DOM 之间的关系,并使 DOM 保持最新状态。这不但可以提高您的利用效率,还能为您节省大量时间,让您在编写非常重复的代码时,只需在应用内部状态或服务器之间向 DOM 发送数据或从 DOM 发送数据即可。

如果您的界面较为复杂,您需要将数据模型中多个属性之间的关系与视图中的多个元素关联起来,那么数据绑定尤为有用。这在我们目前构建的单页应用中很常见。

我们开发了一种在浏览器中原生观察数据的方法,为 JavaScript 框架(以及您编写的小型实用程序库)提供了一种方法来观察模型数据的变化,而无需依赖目前世界上使用的一些慢速黑客手段。

当今世界是什么样的

脏污检查

您以前在哪里见过数据绑定?如果您使用现代 MV* 库来构建 Web 应用(例如 Angular、Knockout),则可能已经习惯将模型数据绑定到 DOM。我们先来回顾一下,下面是一个“电话”列表应用的示例,在该示例中,我们将 phones 数组(在 JavaScript 中定义)中每部手机的值绑定到一个列表项,以便我们的数据和界面始终保持同步:

<html ng-app>
  <head>
    ...
    <script src='angular.js'></script>
    <script src='controller.js'></script>
  </head>
  <body ng-controller='PhoneListCtrl'>
    <ul>
      <li ng-repeat='phone in phones'>
        
        <p></p>
      </li>
    </ul>
  </body>
</html>

以及控制器的 JavaScript:

var phonecatApp = angular.module('phonecatApp', []);

phonecatApp.controller('PhoneListCtrl', function($scope) {
  $scope.phones = [
    {'name': 'Nexus S',
     'snippet': 'Fast just got faster with Nexus S.'},
    {'name': 'Motorola XOOM with Wi-Fi',
     'snippet': 'The Next, Next Generation tablet.'},
    {'name': 'MOTOROLA XOOM',
     'snippet': 'The Next, Next Generation tablet.'}
  ];
});

每当底层模型数据发生变化时,DOM 中的列表都会更新。Angular 是如何做到这一点的?它在后台执行一项名为“脏检查”的操作。

脏污检查

脏检查的基本思路是,每当数据可能发生变化时,库都必须检查数据是否通过摘要或更改周期进行了更改。在 Angular 的示例中,摘要循环会标识已注册要监控的所有表达式,以了解是否有变化。它可以了解模型之前的值,如果这些值已更改,则会触发更改事件。对于开发者来说,这样做的主要好处是,您可以使用原始 JavaScript 对象数据,这些数据易于使用且组合效果相当出色。其缺点是算法行为不佳,并且成本可能非常高昂。

脏污的检查。

此操作的费用与观察到的对象总数成正比。我可能需要进行很多脏话检查。此外,您可能需要通过某种方式在数据可能发生更改时触发脏位检查。框架可以使用许多聪明的技巧来完成此操作。现在还不清楚这是否能够达到完美的效果。

网络生态系统应具有更强的创新能力和能力,可以改进自己的声明式机制,例如

  • 基于约束条件的模型系统
  • 自动持久化系统(例如,持久保留对 IndexedDB 或 localStorage 的更改)
  • 容器对象(Ember、Backbone)

框架使用 Container 对象创建对象,并在内部保存数据。它们可以访问数据,可以捕获您设置的或获取的内容,并在内部广播。这样做效果很好。它性能相对良好,并且具有良好的算法行为。以下是使用 Ember 的容器对象的示例:

// Container objects
MyApp.president = Ember.Object.create({
  name: "Barack Obama"
});
 
MyApp.country = Ember.Object.create({
  // ending a property with "Binding" tells Ember to
  // create a binding to the presidentName property
  presidentNameBinding: "MyApp.president.name"
});
 
// Later, after Ember has resolved bindings
MyApp.country.get("presidentName");
// "Barack Obama"
 
// Data from the server needs to be converted
// Composes poorly with existing code

发现此处发生变化的内容所花费的成本与发生变化的内容的数量成正比。另一个问题是,您要使用这种不同类型的对象。一般来说,您必须将从服务器获取的数据转换为这些对象,才能对其进行观察。

这与现有 JS 代码组合的效果并不好,因为大多数代码都假定它可以对原始数据进行操作。这些特殊类型的对象不可以。

Introducing Object.observe()

理想情况下,我们希望实现两全其美 - 在我们选择且无需始终检查所有内容的情况下,通过支持原始数据对象(常规 JavaScript 对象)来观察数据。算法表现良好。平台中融入了构思巧妙的组件。这就是 Object.observe() 带来的美妙体验。

它允许我们观察对象、更改属性以及查看更改内容的更改报告。关于该理论,我们来看一些代码!

Object.observe()

Object.observe() 和 Object.unobserve()

假设我们有一个简单的原版 JavaScript 对象,用来表示一个模型:

// A model can be a simple vanilla object
var todoModel = {
  label: 'Default',
  completed: false
};

然后,我们可以为每次对象更改(更改)指定回调:

function observer(changes){
  changes.forEach(function(change, i){
      console.log('what property changed? ' + change.name);
      console.log('how did it change? ' + change.type);
      console.log('whats the current value? ' + change.object[change.name]);
      console.log(change); // all changes
  });
}

然后我们可以使用 O.o() 观察这些更改,传入对象作为第一个参数,传入回调作为第二个参数:

Object.observe(todoModel, observer);

让我们开始对 Todos 模型对象进行一些更改:

todoModel.label = 'Buy some more milk';

通过查看控制台,我们获得了一些实用信息!我们知道更改了什么属性、如何更改以及新值是什么。

控制台报告

哇!再见,脏脏检查!你的墓碑应该雕刻在 Comic Sans 中。让我们更改另一个属性。这次 completeBy

todoModel.completeBy = '01/01/2014';

可以看到,我们再次成功获得了更改报告:

更改报告。

好极了。如果我们现在决定从对象中删除“completed”属性,该怎么办?

delete todoModel.completed;
已完成

如我们所见,返回的更改报告包含与删除有关的信息。与预期一样,属性的新值现在未定义。因此,我们现在已经知道你何时添加了属性。删除日期。从根本上来说,就是对象的属性集(“新”、“已删除”、“重新配置”)及其原型更改 (proto)。

与任何观察系统中一样,也存在一个用于停止监听变化的方法。在本例中,它是 Object.unobserve(),其签名与 O.o() 相同,但可以按如下方式进行调用:

Object.unobserve(todoModel, observer);

如下所示,在执行上述操作后,对该对象所做的任何更改都不会再导致返回更改记录列表。

Mutations

指定感兴趣的更改

我们已经了解了有关如何获取被观察对象的更改列表的基础知识。如果您只关注对某个对象做出的部分更改,而不是全部更改,该怎么办?每个人都需要一个垃圾邮件过滤器。观察者可以通过接受列表仅指定他们希望了解的更改类型。这可以使用 O.o() 的第三个参数指定,如下所示:

Object.observe(obj, callback, optAcceptList)

下面我们通过一个示例来了解一下它的使用方法:

// Like earlier, a model can be a simple vanilla object

var todoModel = {
  label: 'Default',
  completed: false

};


// We then specify a callback for whenever mutations 
// are made to the object
function observer(changes){
  changes.forEach(function(change, i){
    console.log(change);
  })

};

// Which we then observe, specifying an array of change 
// types we're interested in

Object.observe(todoModel, observer, ['delete']);

// without this third option, the change types provided 
// default to intrinsic types

todoModel.label = 'Buy some milk'; 

// note that no changes were reported

但是,如果我们现在删除该标签,则会发现系统确实会报告此类更改:

delete todoModel.label;

如果您未为 O.o() 指定接受类型列表,则默认为“固有”对象更改类型(addupdatedeletereconfigurepreventExtensions(适用于不可观察变为不可扩展的对象的情况)。

通知

O.o() 还附带通知的概念。它们和手机上那些令人厌烦的东西没有什么不同,反而有用。通知类似于 Mutation Observer。它们在微任务结束时发生。在浏览器上下文中,这几乎总是出现在当前事件处理脚本的末尾。

时机很合适,因为通常一个工作单元已经完成,现在观察者需要完成他们的工作。它是一个不错的回合制处理模型。

使用通知程序的工作流与以下内容类似:

通知

我们来看一个示例,说明在实际情况中,您可以如何使用通知程序来定义获取或设置对象的属性时所发出的自定义通知。请关注此处的评论:

// Define a simple model
var model = {
    a: {}
};

// And a separate variable we'll be using for our model's 
// getter in just a moment
var _b = 2;

// Define a new property 'b' under 'a' with a custom
// getter and setter

Object.defineProperty(model.a, 'b', {
    get: function () {
        return _b;
    },
    set: function (b) {

        // Whenever 'b' is set on the model
        // notify the world about a specific type
        // of change being made. This gives you a huge
        // amount of control over notifications
        Object.getNotifier(this).notify({
            type: 'update',
            name: 'b',
            oldValue: _b
        });

        // Let's also log out the value anytime it gets
        // set for kicks
        console.log('set', b);

        _b = b;
    }
});

// Set up our observer
function observer(changes) {
    changes.forEach(function (change, i) {
        console.log(change);
    })
}

// Begin observing model.a for changes
Object.observe(model.a, observer);
通知控制台

在本示例中,我们会报告数据属性的值何时发生更改(“更新”)。该对象的实现选择报告的任何其他内容 (notifier.notifyChange())。

多年来在网络平台上的经验告诉我们,首先尝试的就是同步方法,因为这是最容易引起警惕的。问题在于,这种方法会导致一个本质上非常危险的处理模型。如果您编写的是代码,比如说更新某个对象的属性,您一定不希望出现更新该对象属性的情况,可能会邀请一些任意代码执行它想要的任何操作。在运行函数中间时,让假设失效并不理想。

如果您是一名观察者,理想情况下您一定不希望有人在忙于某件事时接到电话。你可不想被要求去处理世界上不稳定状态的工作。最终需要执行更多的错误检查。试图容忍更多不良情况,通常来说,这是一个很难处理的模型。异步是更难以处理的,但在一天结束时它是一种更好的模型。

此问题的解决方案是综合变更记录。

综合更改历史记录

基本上,如果您想要添加访问器或计算属性,则您有责任在这些值发生更改时发出通知。这是一项额外的工作,但它被设计为这种机制的一流功能,这些通知将与来自底层数据对象的其余通知一起传递。来自数据属性。

综合更改历史记录

观察访问器和计算属性可以通过 notifier.notify(O.o() 的另一部分)来解决。大多数观察系统都需要某种形式的观察派生值。您可以采用多种方法。O.o 不会对“正确”的方式做出判断。计算属性应该是访问器,用于在内部(私有)状态发生变化时notify

同样,Webdevs 应该借助库来帮助简化计算属性的通知和各种方法(并减少样板代码)。

我们设置下一个示例,这是一个圆形类。这里的想法是,我们有这个圆形,并且有一个 radius 属性。在本例中,radius 是一个访问器,当其值发生变化时,它实际上会通知自己该值发生了更改。将随对此对象或任何其他对象的所有其他更改一起传送。从本质上讲,如果你要实现的对象需要具有合成或计算属性,或者你必须针对其运作方式选择策略。完成上述操作后,即可将其融入您的整个系统中。

跳过代码,即可看到在开发者工具中正常运行。

function Circle(r) {
  var radius = r;
 
  var notifier = Object.getNotifier(this);
  function notifyAreaAndRadius(radius) {
    notifier.notify({
      type: 'update',
      name: 'radius',
      oldValue: radius
    })
    notifier.notify({
      type: 'update',
      name: 'area',
      oldValue: Math.pow(radius * Math.PI, 2)
    });
  }
 
  Object.defineProperty(this, 'radius', {
    get: function() {
      return radius;
    },
    set: function(r) {
      if (radius === r)
        return;
      notifyAreaAndRadius(radius);
      radius = r;
    }
  });
 
  Object.defineProperty(this, 'area', {
    get: function() {
      return Math.pow(radius, 2) * Math.PI;
    },
    set: function(a) {
      r = Math.sqrt(a/Math.PI);
      notifyAreaAndRadius(radius);
      radius = r;
    }
  });
}
 
function observer(changes){
  changes.forEach(function(change, i){
    console.log(change);
  })
}
综合更改历史记录控制台

访问器属性

有关访问器属性的简要说明。我们之前提到过,对于数据属性,只能观察到值变化。不适用于计算属性或访问器。原因在于 JavaScript 并没有改变访问器的价值的概念。访问器只是一组函数的集合。

如果将 分配给访问器,JavaScript 仅会在访问器中调用函数,并且从它的角度来看,一切都未发生任何变化。它只是让一些代码有机会运行。

问题在于从语义上讲,我们可以查看上面对值 - 5 的赋值。我们应该知道这里发生了什么。这实际上是一个无法解决的问题。该示例说明了原因。实际上,任何系统都无从了解其含义,因为可能是任意代码。在这种情况下,它可以执行所需的任何操作。它在每次有用户访问时都会更新值,因此询问值是否发生更改没有太大意义。

使用一个回调观察多个对象

使用 O.o() 的另一种模式是单个回调观察者的概念。这允许将单个回调用作许多不同对象的“观察者”。回调将在“微任务结束时”传递给它观察到的所有对象的全部更改(请注意与 Mutation Observer 的相似之处)。

使用一个回调观察多个对象

大规模更改

或许,您正在开发一款超大的应用,并且经常需要处理大规模的变更。对象可能希望描述更大的语义更改,而这将以更紧凑的方式影响大量属性(而不是广播大量属性更改)。

O.o() 以两个特定的实用程序(即,我们已介绍的 notifier.performChange()notifier.notify())的形式提供这方面的帮助。

大规模更改

我们用一些数学实用程序(multiply、increment、incrementAndMultiply)定义一个 Thingy 对象,来看看如何描述大规模更改。每次使用实用程序时,它都会告知系统,工作集合包含特定类型的更改。

例如:notifier.performChange('foo', performFooChangeFn);

function Thingy(a, b, c) {
  this.a = a;
  this.b = b;
}

Thingy.MULTIPLY = 'multiply';
Thingy.INCREMENT = 'increment';
Thingy.INCREMENT_AND_MULTIPLY = 'incrementAndMultiply';


Thingy.prototype = {
  increment: function(amount) {
    var notifier = Object.getNotifier(this);

    // Tell the system that a collection of work comprises 
    // a given changeType. e.g
    // notifier.performChange('foo', performFooChangeFn);
    // notifier.notify('foo', 'fooChangeRecord');
    notifier.performChange(Thingy.INCREMENT, function() {
      this.a += amount;
      this.b += amount;
    }, this);

    notifier.notify({
      object: this,
      type: Thingy.INCREMENT,
      incremented: amount
    });
  },

  multiply: function(amount) {
    var notifier = Object.getNotifier(this);

    notifier.performChange(Thingy.MULTIPLY, function() {
      this.a *= amount;
      this.b *= amount;
    }, this);

    notifier.notify({
      object: this,
      type: Thingy.MULTIPLY,
      multiplied: amount
    });
  },

  incrementAndMultiply: function(incAmount, multAmount) {
    var notifier = Object.getNotifier(this);

    notifier.performChange(Thingy.INCREMENT_AND_MULTIPLY, function() {
      this.increment(incAmount);
      this.multiply(multAmount);
    }, this);

    notifier.notify({
      object: this,
      type: Thingy.INCREMENT_AND_MULTIPLY,
      incremented: incAmount,
      multiplied: multAmount
    });
  }
}

然后,我们为对象定义两个观察器:一个是更改无限量的观察器,另一个仅报告我们定义的特定接受类型(Thingy.INCREMENT、Thingy.MULTIPLY、Thingy.INCREMENT_AND_MULTIPLY)。

var observer, observer2 = {
    records: undefined,
    callbackCount: 0,
    reset: function() {
      this.records = undefined;
      this.callbackCount = 0;
    },
};

observer.callback = function(r) {
    console.log(r);
    observer.records = r;
    observer.callbackCount++;
};

observer2.callback = function(r){
    console.log('Observer 2', r);
}


Thingy.observe = function(thingy, callback) {
  // Object.observe(obj, callback, optAcceptList)
  Object.observe(thingy, callback, [Thingy.INCREMENT,
                                    Thingy.MULTIPLY,
                                    Thingy.INCREMENT_AND_MULTIPLY,
                                    'update']);
}

Thingy.unobserve = function(thingy, callback) {
  Object.unobserve(thingy);
}

现在,我们就可以开始使用此代码了。我们来定义一个新的 Thingy:

var thingy = new Thingy(2, 4);

观察它,然后进行一些更改。哇,太有趣了。东西太多了!

// Observe thingy
Object.observe(thingy, observer.callback);
Thingy.observe(thingy, observer2.callback);

// Play with the methods thingy exposes
thingy.increment(3);               // { a: 5, b: 7 }
thingy.b++;                        // { a: 5, b: 8 }
thingy.multiply(2);                // { a: 10, b: 16 }
thingy.a++;                        // { a: 11, b: 16 }
thingy.incrementAndMultiply(2, 2); // { a: 26, b: 36 }
大规模更改

“perform function”中的所有内容都被视为“big-change”的工作。接受“big-change”的观察者将仅收到“big-change”记录。根据“执行函数”所做的工作,不会收到底层更改的观察者。

观察数组

我们已经谈论了如何观察对象更改,但数组呢?!问得好。当有人告诉我:“好问题”。我从没听说过他们的回答,因为我正忙于恭喜自己问了这么好的问题,但却失望了。我们还提供用于处理数组的新方法!

Array.observe() 是将对自身的大规模更改(例如,接合、取消移位或任何隐式改变其长度的内容)视为“接合”变更记录。在内部,它使用 notifier.performChange("splice",...)

在下面的示例中,我们观察一个模型“数组”,同样地,当基础数据发生任何更改时,我们会返回更改列表:

var model = ['Buy some milk', 'Learn to code', 'Wear some plaid'];
var count = 0;

Array.observe(model, function(changeRecords) {
  count++;
  console.log('Array observe', changeRecords, count);
});

model[0] = 'Teach Paul Lewis to code';
model[1] = 'Channel your inner Paul Irish';
观察数组

性能

您可以将其视为读取缓存,了解 O.o() 对计算性能的影响。一般来说,缓存是在以下情况下的绝佳选择(按重要性排序):

  1. 读取频率占据了写入频率。
  2. 您可以创建一个缓存,它以写入期间涉及的固定工作量换取读取期间的算法更好的性能。
  3. 写入的持续速度减慢是可以接受的。

O.o() 是专为如下用例设计的。

脏话检查需要保留您所观察的所有数据的副本。这意味着,脏检查会产生结构内存成本,而脏检查是 O.o() 中所无法做到的。脏污检查虽然是一个不错的权宜之计,但也是一种从根本上泄漏的抽象,可能会给应用带来不必要的复杂性。

原因何在?在数据可能发生变化时,系统必须运行脏检查。并没有一种非常可靠的方法可以做到这一点,任何方法都存在严重的缺点(例如,检查轮询间隔会导致视觉伪影和代码问题之间的竞态条件产生)。脏检查还需要一个观察器的全局注册表,从而产生内存泄漏危险和 O.o() 可避免的拆解成本。

我们来看一些数字。

以下基准测试(可在 GitHub 上提供)让我们可以比较脏检查与 O.o()。它们以 Observed-Object-Set-Size 与 Number-Of-Mutations 图表的结构。一般结果是,脏检查性能在算法上与观察到的对象数量成正比,而 O.o() 性能与进行的变更数量成正比。

脏污检查

脏页检查性能

已开启 Object.observe() 的 Chrome

观察性能

Polyfilling Object.observe()

太棒了 - O.o() 可以在 Chrome 36 中使用,那么在其他浏览器中使用它呢?我们会为您提供帮助!Polymer 的 Observe-JS 是 O.o() 的 polyfill,它会使用原生实现(如果存在),否则会对其进行 polyfill 并在其顶部添加一些有用的糖分。它以汇总视图的方式呈现各种变化,并提供一份关于变化情况的报告。它揭示了两个非常强大的功能:

  1. 您可以观察路径。这意味着,您可以这样说:我想观察给定对象的“foo.bar.baz”,然后它们会告诉您该路径上的值何时发生了变化。如果无法到达路径,它会认为值未定义。

观察路径中给定对象的值的示例:

var obj = { foo: { bar: 'baz' } };

var observer = new PathObserver(obj, 'foo.bar');
observer.open(function(newValue, oldValue) {
  // respond to obj.foo.bar having changed value.
});
  1. 其中会介绍数组接合。数组接合基本上是为将旧版数组转换为新版数组而必须对数组执行的最小接合操作集。这是一种转换类型或数组的不同视图。它是从旧状态转换到新状态所需完成的最少工作量。

以最小接合集的形式报告数组更改的示例:

var arr = [0, 1, 2, 4];

var observer = new ArrayObserver(arr);
observer.open(function(splices) {
  // respond to changes to the elements of arr.
  splices.forEach(function(splice) {
    splice.index; // index position that the change occurred.
    splice.removed; // an array of values representing the sequence of elements which were removed
    splice.addedCount; // the number of elements which were inserted.
  });
});

框架和 Object.observe()

如上所述,O.o() 将为框架和库提供巨大的机会来提升其在支持该功能的浏览器中的数据绑定性能。

Ember 的 Yehuda Katz 和 Erik Bryn 确认了添加对 O.o() 的支持是 Ember 的近期路线图。Angular 的 Misko Hervy 撰写了一篇设计文档,讨论 Angular 2.0 中经过改进的更改检测功能。他们的长期方法是利用 Object.observe() 在 Chrome 稳定版中推出后加以利用,并在那之前选择使用 Watchtower.js,这是他们自己的更改检测方法。太好了。

总结

O.o() 是 Web 平台的强大补充,可立即直接使用。

我们希望该功能能够及时登陆更多浏览器,让 JavaScript 框架能够因使用原生对象观察功能而实现性能提升。以 Chrome 为目标平台的用户应该能够在 Chrome 36(及更高版本)中使用 O.o(),并且在未来的 Opera 版本中也应该提供该功能。

因此,请与 JavaScript 框架的作者讨论一下 Object.observe(),以及他们打算如何使用它来提高应用中数据绑定的性能。未来绝对是最激动人心的时刻!

资源

感谢 Rafael Weinstein、Jake Archibald、Eric Bidelman、Paul Kinlan 和 Vivian Cromwell 的意见和评价。