Declarative programming techniques provide leverage and power to developers. The web platform has pioneered many -- not the least of which is HTML. As the practice of application development evolves, it is important that primitives exist in the platform which allow developers to create and evolve robust declarative mechanisms independent of those provided directly by the platform.
A class of declarative mechanisms depends on discovering that ECMAScript values have changed. For example, UI frameworks often want to provide an ability to databind objects in a datamodel to UI elements. Likewise, domain objects, application logic and persistence strategies can often best be described in terms of constraints and relationships on data.
Today, ECMAScript code wishing to observe changes to objects typically either creates objects wrapping the real data or employs dirty-checking strategies for discovering changes. The first approach requires objects being observed to buy into the strategy, making the user model more complex and eroding composability of concerns. The second approach has poor algorithmic behavior, requiring work proportional to the number of objects observed to discover if any change has taken place. Both require increased working sets.
The desired characteristics of a solution are:
Object.observe allows for the direct observation of changes to ECMAScript objects. It allows an observer to receive a time-ordered sequence of change records which describe the set of changes which took place to the set of observed objects.
Changes to objects are directly observable and are described in terms of
A flexible system is provided via a "notifier"
object associated with every observable object, which allows for:
Lastly, Array.observe allows for the efficient description of certain changes which may affect many index-valued properties as single "splice"
change record.
Object.observe is a relatively significant and cross-cutting feature, as it
var records;
function observer(recs) {
records = recs;
}
var obj = { id: 1 };
Object.observe(obj, observer);
obj.a = 'b';
obj.id++;
Object.defineProperty(obj, 'a', { enumerable: false });
delete obj.a;
Object.preventExtensions(obj);
Object.deliverChangeRecords(observer);
assertChangesAre(records, [
{ object: obj, type: 'add', name: 'a' },
{ object: obj, type: 'update', name: 'id', oldValue: 1 },
{ object: obj, type: 'reconfigure', name: 'a' },
{ object: obj, type: 'delete', name: 'a', oldValue: 'b' },
{ object: obj, type: 'preventExtensions', }
]);
var basicChanges;
function basicObserver(recs) {
basicChanges = recs;
}
var arrayChanges;
function arrayObserver(recs) {
arrayChanges = recs;
}
var array = [1, 2, 3];
Object.observe(array, basicObserver);
Array.observe(array, arrayObserver);
array.push(4);
array.splice(2, 2);
array[5] = 'a';
array.length = 0;
Object.deliverChangeRecords(basicObserver);
assertChangesAre(basicChanges, [
// push
{ object: array, type: 'add', name: '3' },
{ object: array, type: 'update', name: 'length', oldValue: 3 },
// splice
{ object: array, type: 'delete', name: '3', oldValue: 4 },
{ object: array, type: 'delete', name: '2', oldValue: 3 },
{ object: array, type: 'update', name: 'length', oldValue: 4 },
// index assignment
{ object: array, type: 'add', name: '5' },
{ object: array, type: 'update', name: 'length', oldValue: 2 },
// length assignment
{ object: array, type: 'delete', name: '5', oldValue: 'a' },
{ object: array, type: 'delete', name: '1', oldValue: 2 },
{ object: array, type: 'delete', name: '0', oldValue: 1 },
{ object: array, type: 'update', name: 'length', oldValue: 6 },
]);
Object.deliverChangeRecords(arrayObserver);
assertChangesAre(arrayChanges, [
// push
{ object: array, type: 'splice', index: 3, removed: [], addedCount: 1 },
// splice
{ object: array, type: 'splice', index: 2, removed: [3, 4], addedCount: 0 },
// index assignment
{ object: array, type: 'splice', index: 2, removed: [], addedCount: 4 },
// length assignment
{ object: array, type: 'splice', index: 0, removed: [1, 2,,,,'a'], addedCount: 0 },
]);
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.power(radius * Math.PI, 2);
},
set: function(a) {
r = Math.sqrt(a)/Math.PI;
notifyAreaAndRadius(radius);
radius = r;
}
});
}
var changes;
function observer(recs) {
changes = recs;
}
var circle = new Circle(5);
Object.observe(circle, observer);
circle.radius = 10;
circle.area = 100;
Object.deliverChangeRecords(observer);
assertChangesAre(changes, [
// 'radius' assignment
{ object: circle, type: 'update', name: 'radius', oldValue: 5 },
{ object: circle, type: 'update', name: 'area', oldValue: 246.74011002723395 },
// 'area' assignment
{ object: circle, type: 'update', name: 'radius', oldValue: 10 },
{ object: circle, type: 'update', name: 'area', oldValue: 986.9604401089358 },
]);
function Square(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
Square.prototype = {
scale: function(ratio) {
Object.getNotifier(this).performChange('scale', () => {
this.width *= ratio;
this.height *= ratio;
return {
ratio: ratio
};
});
},
translate: function(dx, dy) {
Object.getNotifier(this).performChange('translate', () => {
this.x += dx;
this.y += dy;
return {
dx: dx,
dy: dy
}
});
}
}
Square.observe = function(square, callback) {
return Object.observe(square, callback, ['update', 'translate', 'scale']);
}
var basicChanges;
function basicObserver(recs) {
basicChanges = recs;
}
var squareChanges;
function squareObserver(recs) {
squareChanges = recs;
}
var square = new Square(0, 0, 10, 10);
Object.observe(square, basicObserver);
Square.observe(square, squareObserver);
square.translate(5, 5);
square.x = -5;
square.scale(2);
Object.deliverChangeRecords(basicObserver);
assertChangesAre(basicChanges, [
// translate
{ object: square, type: 'update', name: 'x', oldValue: 0 },
{ object: square, type: 'update', name: 'y', oldValue: 0 },
// assignment to 'x'
{ object: square, type: 'update', name: 'x', oldValue: 5 },
// scale
{ object: square, type: 'update', name: 'width', oldValue: 10 },
{ object: square, type: 'update', name: 'height', oldValue: 10 },
]);
Object.deliverChangeRecords(squareObserver);
assertChangesAre(squareChanges, [
// translate
{ object: square, type: 'translate', dx: 5, dy: 5 },
// assignment to 'x'
{ object: square, type: 'update', name: 'x', oldValue: 5 },
// scale
{ object: square, type: 'scale', ratio: 2 },
]);
Conceptually, at the heart of the EMCAScript observation mechanism are two structures:
Object.observe enters a function into an object's associated set of ChangeObservers (while Object.unobserve removes it).
When a change takes place to object, a change record may be appended to the PendingChangeRecords of the functions which are registered in its associated ChangeObservers.
An observer will receive the changes, either asynchronously, at the end of the current turn, or synchronously, if it calls Object.deliverChangeRecords() with itself as an argument. In either case, IFF the function has pending changes, it will be invoked with the contents of its PendingChangeRecords as the only argument.
The vocabulary of observable changes to objects hews close to the object model and reflective APIs of ECMAScript.
"add"
, "reconfigure"
respectively.
"update"
change records.
"delete"
.
"setPrototype"
, or "preventExtensions"
change records.
"splice"
change records.
"synthetic"
) change records.
Change records are enqueued to observers via the internal EnqueueChangeRecord algorithm.
Every object has an associated Notifier object which can be retrieved via Object.getNotifier() as long as the object is not frozen. The Notifier has internal properties which reference the object's associated set of observers (ChangeObservers) as well as the set of changes which are currently being performed on the object (ActiveChanges).
The Notifier has public API (notify() and performChange()) which can be used to deliver "synthetic"
change records to observers and describe higher-level changes.
An observer must only receive change records whose type matches one provided in the accept list, which is passed as the (optional) third argument to Object.observe (if the argument is not provided, the list defaults to the "intrinsic"
set of change types).
An observation is conceptually a tuple:
The performChange() method of an object's associated Notifier provides a mechanism to describe a set of changes as a single (more compact) higher-level change type.
The set of change types being performed on an object is represented in the set of properties of the Notifier's BeginChange before, and EndChange after invoking the provided changeFn function.
When a change is to be enqueued to an object's associated observers, the internal EnqueueChangeRecord algorithm tests whether each observer should receive the change by invoking ShouldDeliverToObserver.
ShouldDeliverToObserver returns true IFF an observer accepts the candidate change record's type, but accepts none of the change types currently being performed on the object (as represented in the ActiveChanges).
At the end of the current processing turn (or "Microtask"
), DeliverAllChangeRecords is invoked, which continuously delivers pending change records to observers until there are none remaining.
The order in which observers are delivered to is maintained in the ObserverCallbacks list. The first time a function is used as an observer by providing it as an argument to Object.observe, it is appended to the end of ObserverCallbacks.
Delivery takes place by repeatedly iterating front-to-back through ObserverCallbacks, delivering to any observer who has pending change records, until no observer have pending change records.
There is now an ordered list, [[ObserverCallbacks]] which is shared per event queue. It is initially empty.
Every function now has a [[PendingChangeRecords]] internal slot which is an ordered list of ChangeRecords. It is initially empty.
Every object O now has a [[Notifier]] internal slot which is initially
A Notifier Object is an object returned from Object.getNotifier
. There is not a named constructor for Notifier Objects.
All Notifier Objects inherit properties from the %NotifierPrototype% intrinsic object. The %NotifierPrototype% intrinsic object is an ordinary object and its [[Prototype]] internal slot is the %ObjectPrototype% intrinsic object (19.1.3). In addition, %NotifierPrototype% has the following properties:
"type"
)."object"
and desc as arguments."object"
, then
"object"
and desc as arguments."type"
and desc as arguments."object"
and N is not "type"
, then
When the abstract operation GetNotifier is called with Object O the following steps are taken:
When the abstract operation BeginChange is called with Object O and string changeType the following steps are taken:
When the abstract operation EndChange is called with Object O and string changeType the following steps are taken:
When the abstract operation ShouldDeliverToObserver is called with Object activeChanges, List acceptList and string changeType the following steps are taken:
When the abstract operation EnqueueChangeRecord is called with Object O and change record changeRecord the following steps are taken:
"type"
).When the abstract operation DeliverChangeRecords is called with callback C, the following steps are taken:
When the abstract operation DeliverAllChangeRecords is called, the following steps are taken:
When the abstract operation CreateChangeRecord is called with string type, Object object, name, Object oldDesc and Object newDesc the following steps are taken:
"type"
and desc as arguments."object"
and desc as arguments."name"
and desc as arguments."oldValue"
and desc as arguments.There is now an abstract operation [[CreateSpliceChangeRecord]]:
?.??.?? [[CreateSpliceChangeRecord]] (object, index, removed, addedCount)
When the abstract operation CreateSpliceChangeRecord is called with the arguments: object, index, removed, and addedCount, the following steps are taken:
"splice"
, [[Writable]]: When the observe
method is called with arguments Object O, function callback and options the following steps are taken:
"frozen"
) is "acceptTypes"
)."add"
, "update"
, "delete"
, "reconfigure"
, "setPrototype"
and "preventExtensions"
."length"
)"skipRecords"
).When the unobserve
method is called with arguments Object O and function callback the following steps are taken:
A new function Array.observe is added, which is equivalent to. TODO(arv) Use a real spec algorithm here. Refactor Object.observe into a new spec algorithm that is shared between these two.
function(O, callback,
{accept = ["add", "update", "delete", "splice"], skip = false} = {}) {
return Object.observe(O, callback, {
acceptTypes: accept,
skipRecords: skip
});
}
A new function Array.unobserve(o, callback) is added, which is equivalent to
function(O, callback) {
return Object.unobserve(O, callback);
}
When the deliverChangeRecords
method is called with argument callback, the following steps are taken:
When the getNotifier
method is called with argument O, the following steps are taken:
Modifications to existing algorithms are indicated like this.
When the abstract operation ValidateAndApplyPropertyDescriptor is called with Object O, property key P, Boolean value extensible, and property descriptors Desc, and current the following steps are taken:
This algorithm contains steps that test various fields of the Property Descriptor Desc for specific values. The fields that are tested in this manner need not actually exist in Desc. If a field is absent then its value is considered to be false.
"reconfigure"
.
"add"
, O, P, current, Desc).
"update"
.
When the [[Delete]] internal method of O is called with property key P the following steps are taken:
"delete"
, O, P, desc).
When the [[PreventExtensions]] internal method of O is called the following steps are taken:
"preventExtensions"
, O).
[[SetPrototypeOf]] (V)
When the [[SetPrototypeOf]] internal method of O is called with argument V the following steps are taken:
"setPrototype"
, [[Writable]]: "type"
and desc as arguments.
"object"
and desc as arguments.
"oldValue"
and desc as arguments.
22.1.3.16 Array.prototype.pop ()
The last element of the array is removed from the array and returned.
"length"
)."length"
, 0, true)."splice"
.
"length"
, newLen, true).
"splice"
.
22.1.3.17 Array.prototype.push ( [ item1 [ , item2 [ , … ] ] ] )
The arguments are appended to the end of the array, in the order in which they appear. The new length of the array is returned as the result of the call.
When the push method is called with zero or more arguments item1, item2, etc., the following steps are taken:
"length"
)."splice"
.
"length"
, n, true).
"splice"
.
22.1.3.21 Array.prototype.shift ( )
The first element of the array is removed from the array and returned.
"length"
)."length"
, 0, true)."splice"
.
"0"
)."length"
, len–1, true).
"splice"
.
22.1.3.25 Array.prototype.splice (start, deleteCount [ , item1 [ , item2 [ , … ] ] ] )
When the splice method is called with two or more arguments start, deleteCount and (optionally) item1, item2, etc., the deleteCount elements of the array starting at array index start are replaced by the arguments item1, item2, etc. An Array object containing the deleted elements (if any) is returned. The following steps are taken:
"length"
)"constructor"
)."splice"
.
"length"
, actualDeleteCount, true).
"length"
, len – actualDeleteCount + itemCount, true)."splice"
.
22.1.3.28 Array.prototype.unshift ( [ item1 [ , item2 [ , … ] ] ] )
The arguments are prepended to the start of the array, such that their order within the array is the same as the order in which they appear in the argument list.
When the unshift method is called with zero or more arguments item1, item2, etc., the following steps are taken:
"length"
)"splice"
.
"length"
, len+argCount, true)."splice"
.
9.4.2.1 [[DefineOwnProperty]] (P, Desc)
When the [[DefineOwnProperty]] internal method of an exotic Array object A is called with property P, and Property Descriptor Desc the following steps are taken:
"length"
, then
"length"
as the argument. The result will never be undefined or an accessor descriptor because Array objects are created with a length data property that cannot be deleted or reconfigured."length"
, and oldLenDesc as arguments."splice"
.
"splice"
.
9.4.2.2 ArraySetLength Abstract Operation
When the abstract operation ArraySetLength is called with an exotic Array object A, and Property Descriptor Desc the following steps are taken:
"length"
, and Desc as arguments."length"
as the argument. The result will never be undefined or an accessor descriptor because Array objects are created with a length data property that cannot be deleted or reconfigured."length"
, and newLenDesc as arguments."length"
, and newLenDesc as arguments."splice"
.
"length"
, and newLenDesc as arguments.
"length"
, and Property Descriptor{[[Writable]]: false} as arguments. This call will always return true."splice"
.
11/14/2013: - Re-organized structure. - Added "Key Algorithms and Semantics" section. - Added "Cross-cutting Concerns and Potential Implementation Challenges" 11/13/2013: - Fixed a bunch of editorial issues discovered by Joshua Bell. - Rebase spec changes against Rev21 ES6 Draft 10/29/2013: - notifier.performChange now emits a change record if the return value of the changeFn is an object (feedback from Sept TC-39) - "intrinsic" change record types renamed for consistency. "new" -> "add", "updated" -> "update", "deleted" -> "delete", "reconfigured" -> "reconfigure", "prototype" -> "setPrototype" (feedback from Sept TC-39) - "preventExtensions" changeRecord type is now emitted when the first time an object's `[[Extensible]]` internal property is set to false. 9/12/2013: - Object.observe accept arg can be zero-length (now specifies that accept.length must be > 0). 7/20/2013: - Notifier.performChange, Array.observe & some refactoring of algorithms. 1/30/2013: - Suppress oldValue when the changeRecord is of type 'reconfigured' and the oldValue and present value are the same. Note that CreateChangeRecord now takes both oldDesc and newDesc as arguments. 12/21/2012: - Object.deliverChangeRecords now calls `[[DeliverChangeRecords]]` repeatedly until there no pending records to deliver. 11/19/2012: - Object.observer/unobserve now return the object (for consistency with Object.freeze, etc...). 11/13/2012: - In Object.unobserve, passing a non-function as the callback now throws (for symmetry with Object.observe). 10/28/2012: - In CreateChangeRecord, renamed fourth argument to "oldDesc" (from "desc") for clarity. - In CreateChangeRecord, shallow freeze created changeRecord (consistent with Notifier.prototype.notify). 10/23/2012: - In notify, moved the check for type not being a string above the check for empty observers so the error will throw regardless of if there are observers. - Change the spec language of notify to make it clear to return before creating the changeRecord if there are no observers. 9/11/2012: - Added a section that we need to schedule a `{type: "prototype", object: ..., oldValue: ...}` change record when the `[[Prototype]]` internal property is changed. 7/18/2012: - Only fire **`"updated"`** changes when value changes (using SameValue) and no other configuration change happens. 7/17/2012: Based on feedback from Mark Miller, Tom van Cutsem and Andreas Rossberg: - _anyWorkDone_ should be or'ed with the previous value. - Refactored into `[[GetNotifier]]` to ensure it is always initialized. - **`"descriptor"`** is now always **`"reconfigured"`** - Enforce that _changeRecord_ is frozen in `[[NotifierPrototype]]`.notify. - Never pass a descriptor in the change record. Only include oldValue if it was a data property before the change. - Remove redundant IsCallable check in unobserve. - Ensure that _O_ is an object in Object.getNotifier. 7/4/2012: Per security feedback from Mark Miller - Object.getNotifier() of frozen object, returns null. - Object `[[Notifier]]` is spec'd to be lazily created to avoid infinite regress. - All changeRecords are shallowly frozen, and "reconfigured" changeRecords have their oldValue (property descriptor) frozen. - Validate **`"object"`** field of _changeRecord_ for _notifyFunction_ so as to ensure that notifier of an object can only broadcast changes of its `[[Target]]`. - Validate **`"type"`** and **`"name"`** fields of _changeRecord_ for _notifyFunction_ and perform a shallow freeze to prevent unintentional delivery of shared mutable state. - Object.retrieveChangeRecords -> Object.deliverChangeRecords (invoke the function rather than return records). - Object callbacks invokations silently ignore all thrown exceptions and return values. - Frozen callback function objects cannot be registered as change observers (Object.observe) throws TypeError. 7/2/2012: - Remove changeRecord validation. Since there is no way to validate "oldValue", arbitrary data can always be passed. - Added Object.getNotifier(obj).notify(changeRecord) separation, so as to allow freezing of an object to prevent side-channel, but retaining the ability to notify if getNotifier() was called prior to freezing. 5/17/2012: - Call the callbacks with a ChangeRecord object describing the change. - Add sanity checks in `[[ToChangeRecord]]` which is used in `Object.notifyObservers`. - Add `Object.retrieveChangeRecords` which allows synchronous access to the pending changes. 3/16/2012: Two requests from feedback from Rafael Weinstein and Erik Arvidsson: - Pass old property values as well as new to `[[FireChangeListeners]]` - Schedule change events to be delivered asynchronously "at the end of the turn"