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

Addy Osmani
Addy Osmani

简介

一场革命即将来临。JavaScript 中新增了一个功能,它将改变您认为自己对数据绑定的所有了解。同时,这还会改变 MVC 库中用于观察模型修改和更新内容的 MVC 库的数量。您是否已准备好为注重属性观察的应用大幅提升性能?

好的,好的。现在,我很高兴地宣布,Object.observe() 已发布到 Chrome 36 稳定版。[哇哦。THE CROWD GOES WILD]

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 毫秒,而 O.o() 每次更新需要 1-2 毫秒(速度提高了 20-40 倍)。

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

如果您已经在使用 O.o() 了,请跳至功能简介部分,或者继续阅读,详细了解它可以解决的问题。

我们要观察什么?

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

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

数据绑定的重要性

当您关注模型视图控件分离时,数据绑定就变得非常重要。HTML 是一种非常出色的声明式机制,但它是完全静态的。理想情况下,您只需声明数据与 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 对象数据,这种数据使用起来很方便,而且组合效果非常好。其缺点是算法行为不佳,并且费用可能非常高昂。

脏值检查。

此操作的费用与观察到的对象的总数成正比。我可能需要进行大量脏值检查。此外,可能还需要一种在数据可能发生更改时触发脏检查的方法。框架有很多巧妙的技巧来实现这一点。目前还不确定能否做到完美。

Web 生态系统应有更强的能力创新和进化自己的声明机制,例如

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

容器对象是框架创建对象的位置,这些对象在内部存储数据。他们可以访问这些数据,也可以捕获您设置的或获取的内容,并在内部广播。这种方法非常有效。它具有较高的性能,并且具有良好的算法行为。下面是一个使用 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()

理想情况下,我们希望两全其美:如果我们选择 AND 而不对所有内容进行脏检查,则可以支持原始数据对象(常规 JavaScript 对象)来观察数据。具有良好算法行为的内容。可很好地组合并内置到平台中的内容。这就是 Object.observe() 的优势所在。

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

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);

如下所示,运行后对对象所做的任何更改都不会再导致返回更改记录列表。

变更

指定感兴趣的更改

我们已经了解了如何获取观察对象的更改列表背后的基本原理。如果您只对对象所做的部分更改感兴趣,而不是所有更改,该怎么办?每个人都需要垃圾内容过滤器。观察者通过接受列表,只能指定他们想要了解的更改类型。您可以使用 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() 还附带有通知的概念。它们与手机上令人讨厌的广告完全不同,而是非常实用的。通知类似于更改观察器。它们发生在微任务结束时。在浏览器上下文中,这几乎总是位于当前事件处理程序的结尾。

这个时间点非常合适,因为通常一个工作单元已完成,现在观察器可以开始执行自己的工作。这是一个很好的回合制处理模型。

使用通知器的工作流程大致如下所示:

通知

我们来看一个示例,了解在实践中如何使用通知器来定义在获取或设置对象上的属性时发送的自定义通知。请留意此处的评论:

// 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())。

多年来在 Web 平台上的经验告诉我们,同步方法是最先尝试的方法,因为它最容易理解。问题在于,它会创建一个从根本上危险的处理模型。如果您正在编写代码并说更新对象的属性,那么您实际上应该不希望在更新该对象的属性时,会邀请任意代码执行所需的任何操作。在运行函数的过程中,让假设失效并不理想。

如果您是观察者,最好不要在其他人正在处理其他事务时被调用。您不想被迫去为不一致的地方工作。最终,您需要进行更多的错误检查。尝试容忍更多糟糕的情况,通常来说,这种模型很难使用。异步处理起来更难,但最终还是更好的模型。

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

合成更改记录

基本上,如果您希望拥有存取器或计算属性,则需要负责在这些值发生变化时发出通知。这需要额外的工作,但它被设计为此机制的一项重要功能,这些通知将与来自底层数据对象的其他通知一起传送。来自数据属性。

合成更改记录

观察存取器和计算的属性可通过 notifier.notify(O.o() 的另一部分)解决。大多数观察系统都希望以某种形式观察派生值。您可以通过多种方式来实现此目的。O.o 不会对“正确”的方式做出判断。计算属性应是当内部(私有)状态发生变化时发送通知的访问器。

再次强调,Web 开发者应该期望库能够帮助简化通知和各种计算属性方法(并减少样板代码)。

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

跳过代码,看看它在开发者工具中的运行情况。

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() 支持的另一种模式是单个回调观察器的概念。这样,单个回调就可以用作许多不同对象的“观察器”。回调将在“微任务结束时”向其观察到的所有对象传递全部更改(请注意,这与变更观察器类似)。

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

大规模更改

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

O.o() 通过两种特定的实用程序来帮助实现此目的:notifier.performChange()notifier.notify()(我们已经引入)。

大规模更改

我们来看一个如何描述大规模变化的示例,在这个示例中,我们使用一些数学实用程序(乘法、增量、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() 是一种方法,用于将对自身进行的大规模更改(例如,接合、unshift 或任何隐式更改其长度的操作)视为“接合”更改记录。在内部,它使用 notifier.performChange("splice",...)

下面的示例展示了如何观察模型“array”,并在基础数据发生任何更改时同样会返回更改列表:

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() 适用于 1) 等用例。

脏值检查需要保留您正在观察的所有数据的副本。这意味着,进行脏值检查会产生结构内存开销,而 O.o() 不会产生这种开销。虽然脏值检查是一种不错的权宜解决方案,但从根本上讲,它是一种漏洞性抽象,可能会给应用带来不必要的复杂性。

为什么呢?只要数据可能发生变化,就必须执行脏检查。根本没有非常可靠的方法来做到这一点,而且任何方法都存在明显的缺点(例如,通过轮询间隔进行检查可能会导致视觉伪影和代码问题之间的竞态条件)。脏值检查还需要一个全局观察器注册表,这会造成内存泄露风险和拆解开销,而 O.o() 可以避免这些问题。

我们来看一些数字。

通过以下基准测试(可在 GitHub 上找到),我们可以比较脏值检查与 O.o() 的效果。这些测试的结构为“观察到的对象集大小”与“变更次数”的图表。一般而言,脏值检查性能在算法上与观察对象的数量成正比,而 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 的反馈意见和评价。