簡介
革命即將到來。JavaScript 新增了一個新功能,將改變您對資料繫結的一切認知。這也會改變許多 MVC 程式庫如何處理觀察模型的編輯和更新作業。您是否已準備好為關心屬性觀察的應用程式提供優異的效能提升功能?
好,好。我很高興宣布,Object.observe()
已在 Chrome 36 穩定版中推出。[WOOOO. 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 或從 DOM 傳送資料。
當您有複雜的使用者介面,需要將資料模型中的多個屬性與檢視畫面中的多個元素建立連結時,資料繫結就特別實用。這在我們目前建構的單頁應用程式中相當常見。
我們提供一種在瀏覽器中原生觀察資料的方式,讓 JavaScript 架構 (以及您編寫的小型公用程式庫) 能夠觀察模型資料的變更,而不需要依賴目前業界使用的某些緩慢的駭客攻擊。
現今世界概況
髒值檢查
您之前曾在哪裡看過資料繫結?如果您使用新式 MV* 程式庫建構 webapps (例如 Angular、Knockout),那麼您可能會將模型資料繫結至 DOM。為方便您複習,以下提供一個電話清單應用程式的範例,我們會將 phones
陣列 (在 JavaScript 中定義) 中每支電話的值繫結至清單項目,以便資料和 UI 保持同步:
<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)
容器物件是指架構建立的物件,這些物件會在內部儲存資料。這些類別具有資料存取權,可擷取您設定或取得的資料,並在內部播送。這麼做很有效。這類內容的效能相對較佳,且演算法行為良好。以下是使用 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.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);
我們現在開始對 Todo 模型物件進行一些變更:
todoModel.label = 'Buy some more milk';
查看控制台後,我們獲得了一些實用資訊!我們會知道哪些屬性已變更、變更方式為何,以及新值為何。

太棒了!再見,髒值檢查!您的墓碑應以 Comic Sans 字型刻字。接著,我們來變更其他屬性。這次 completeBy
:
todoModel.completeBy = '01/01/2014';
我們再次成功取得變更報告:

好的。如果我們現在決定從物件中刪除「completed」屬性,會發生什麼情況?
delete todoModel.completed;

如您所見,變更報告中包含刪除作業的相關資訊。如預期,屬性的新值現在未定義。我們知道您可以查看何時新增了屬性。已刪除。基本上,物件上的屬性集合 (「new」、「deleted」、「reconfigured」) 和原型變更 (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() 的接受類型清單,系統會預設為「內在」物件變更類型 (add
、update
、delete
、reconfigure
、preventExtensions
(如果物件變成不可擴充,則無法觀察))。
通知
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 不會判斷「正確」的做法。計算屬性應為存取工具,可在內部 (私人) 狀態變更時通知。
同樣地,網頁開發人員應期待程式庫可協助簡化通知和各種計算屬性方法 (並減少樣板程式碼)。
讓我們設定下一個範例,也就是圓形類別。這裡的概念是,我們有這個圓形和半徑屬性。在本例中,半徑是存取子,當其值變更時,它會通知自己值已變更。這項資訊會隨附此物件或任何其他物件的所有其他變更一併傳送。基本上,如果您要實作物件,則需要有合成或計算屬性,或者您必須選擇策略,以便瞭解這項作業的運作方式。完成後,這項設定就會套用至整個系統。
略過程式碼,看看這項功能在開發人員工具中如何運作。
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()
) 的形式提供協助,這兩種公用程式已在前文中介紹過。

我們將透過範例說明如何描述大規模變更,其中我們會使用一些數學公用程式 (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」記錄。未執行此操作的觀測器不會收到「執行函式」所執行工作所導致的基礎變更。
觀察陣列
我們討論了一段時間的物件變更觀察,但陣列呢?好問題。當有人對我說「Great question」時,我從未聽到他們的回答,因為我忙著祝賀自己問了這麼棒的問題,不過我離題了。我們也推出了用於處理陣列的新方法!
Array.observe()
是一種方法,可將自身的大量變更 (例如 splice、unshift 或任何會隱含變更長度的操作) 視為「splice」變更記錄。在內部,這個 API 會使用 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() 對運算效能造成的影響,請將其視為讀取快取。一般來說,在下列情況下,快取是個不錯的選擇 (依重要性排序):
- 讀取頻率遠高於寫入頻率。
- 您可以建立快取,在寫入作業中提供固定的工作量,以便在讀取時以演算法提供更佳的效能。
- 寫入作業的常數時間減慢可接受。
O.o() 是專為 1) 這類用途而設計。
髒值檢查需要保留您觀察到的所有資料副本。這表示您必須為髒值檢查付出結構性記憶體成本,而 O.o() 則不會產生這類成本。髒值檢查雖然是相當不錯的權宜做法,但從根本上來說,它也是一種會洩漏的抽象概念,可能會為應用程式帶來不必要的複雜度。
這是因為髒值檢查必須在資料「可能」變更時執行。這項操作沒有非常可靠的方法,而且任何方法都會帶來重大缺點 (例如檢查輪詢間隔可能會導致視覺異常,以及程式碼問題之間的競爭狀態)。髒值檢查也需要觀察員的全球註冊,這會造成記憶體外洩危險和拆解成本,而 O.o() 可避免這類問題。
讓我們來看看一些數據。
下列基準測試 (可在 GitHub 上取得) 可讓我們比較髒值檢查與 O.o() 的差異。這些測試以圖表呈現觀察到的物件集大小與突變次數。一般來說,髒值檢查效能在演算法上與觀察到的物件數量成正比,而 O.o() 效能則與所做的突變數量成正比。
髒值檢查

已開啟 Object.observe() 的 Chrome

用 Object.observe() 進行多重填充
很好,O.o() 可在 Chrome 36 中使用,但在其他瀏覽器中使用呢?請放心,我們會提供協助。Polymer 的 Observe-JS 是 O.o() 的 polyfill,如果有原生實作項目,就會使用該實作項目,否則會進行 polyfill,並在上面加入一些實用的 sugaring。這項功能可提供全球匯總檢視畫面,讓您一覽變更內容,並產生變更報告。它提供兩項非常強大的功能:
- 您可以觀察路徑。也就是說,您可以說:「我想觀察特定物件中的『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.
});
- 它會告訴您如何進行陣列拼接。陣列拼接基本上是指您必須對陣列執行的最少拼接作業集,才能將舊版陣列轉換為新版陣列。這是一種轉換類型,或陣列的不同檢視畫面。這是從舊狀態移至新狀態時,所需執行的最低工作量。
以下範例說明如何以最少的拼接集合回報陣列變更:
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 已確認,在 Ember 近期的路線圖中,會新增對 O.o() 的支援。Angular 的 Misko Hervy 撰寫了一份設計文件,說明 Angular 2.0 改良後的變更偵測機制。他們的長期做法是,在 Chrome 穩定版推出 Object.observe() 時充分利用這項功能,並選擇使用 Watchtower.js,也就是他們自己的變更偵測方法。太棒了!
結論
O.o() 是網路平台的強大功能,您可以立即開始使用。
我們希望這項功能日後能支援更多瀏覽器,讓 JavaScript 架構能透過存取原生物件觀察功能提升效能。以 Chrome 為目標的應用程式應該可以在 Chrome 36 以上版本中使用 O.o(),且這項功能也應該會在日後的 Opera 版本中推出。
因此,請與 JavaScript 架構作者討論 Object.observe()
,以及他們如何規劃使用 Object.observe()
來改善應用程式中資料繫結的效能。未來一定會有更多精彩內容!
資源
- Harmony wiki 上的 Object.observe()>
- Rick Waldron 的「使用 Object.observe() 進行資料繫結」
- Everything you wanted to know about Object.observe() - JSConf
- 為何 Object.observe() 是 ES7 最佳功能
感謝 Rafael Weinstein、Jake Archibald、Eric Bidelman、Paul Kinlan 和 Vivian Cromwell 提供意見和評論。