The Observer API

Motivation • Overview • Polyfill • Design Discussion • Getting Involved • License
Observe and intercept operations on arbitrary JavaScript objects and arrays using a utility-first, general-purpose reactivity API! This API re-explores the unique design of the Object.observe()
API and takes a stab at what could be a unifying API over related but disparate things like Object.observe()
, Reflect APIs, and the "traps" API (proxy traps)!
Observer API is an upcoming proposal!
Motivation
Tracking mutations on JavaScript objects has historically relied on "object wrapping" techniques with ES6 Proxies, and on "property mangling" techniques with getters and setters. Besides the object identity problem of the first and the object compromissory nature of the second, there is also the "scalability" issue inherent to the techniques and much "inflexibility" in the programming model they enable!
This is discussed extensively in the introductory blog postdraft
We find a design precedent to object observability in the Object.observe()
API, which at one time checked all the boxes and touched the very pain points we have today! The idea with the new Observer API is to re-explore that unique design with a more wholistic approach that considers the broader subject of Reactive Programming in JavaScript!
An Overview
The Observer API is a set of utility functions.
Note
This is documentation for Observer@2.x
. (Looking for Observer@1.x
?)
Method: Observer.observe()
Observe mutations on any object or array!
Observer.observe( obj, callback[, options = {} ]);
Observer.observe( obj, [ prop, prop, ... ], callback[, options = {} ]);
Observer.observe( obj, prop, callback[, options = {} ]);
Usage
Observe arbitrary objects and arrays:
const obj = {};
const abortController = Observer.observe( obj, handleChanges );
const arr = [];
const abortController = Observer.observe( arr, handleChanges );
└ Changes are delivered synchronously - as they happen.
function handleChanges( mutations ) {
mutations.forEach( mutation => {
console.log( mutation.type, mutation.key, mutation.value, mutation.oldValue );
} );
}
--> Stop observing at any time by calling abort()
on the returned abortController:
abortController.abort();
└ And you can provide your own Abort Signal instance:
const abortController = new AbortController;
Observer.observe( obj, handleChanges, { signal: abortController.signal } );
abortController.abort();
--> Where listeners initiate nested observers (child observers), leverage "AbortSignal-cascading" to tie child observers to parent observer's lifecycle:
const abortController = Observer.observe( obj, ( mutations, flags ) => {
Observer.observe( obj, handleChanges, { signal: flags.signal } );
} );
└ "Child" gets automatically aborted at parent's "next turn", and at parent's own abortion!
Concept: Mutation APIs
Programmatically mutate properties of an object using the Reflect-like set of operators; each operation will be reported by observers:
Observer.set( obj, 'prop0', 'value0' );
Observer.defineProperty( obj, 'prop1', { get: () => 'value1' } );
Observer.deleteProperty( obj, 'prop2' );
Observer.set( arr, 0, 'item0' );
Observer.deleteProperty( arr, 0 );
Polyfill limitations
Beware non-reactive operations:
delete obj.prop0;
obj.prop3 = 'value3';
arr.push( 'item3' );
arr.pop();
--> Enable reactivity on specific properties with literal object accessors - using the Observer.accessorize()
method:
Observer.accessorize( obj );
Observer.accessorize( obj, [ 'prop0', 'prop1', 'prop2' ] );
obj.prop0 = 'value0';
obj.prop1 = 'value1';
obj.prop2 = 'value2';
Observer.accessorize( arr );
Observer.accessorize( arr, [ 0, 1, 2 ] );
arr[ 0 ] = 'item0';
arr[ 1 ] = 'item1';
arr[ 2 ] = 'item2';
arr.unshift( 'new-item0' );
arr.shift();
Polyfill limitations
Beware non-reactive operations:
delete obj.prop0;
obj.prop3 = 'value3';
arr.push( 'item0' );
arr.pop();
--> Enable reactivity on arbitray properties with Proxies - using the Observer.proxy()
method:
const $obj = Observer.proxy( obj );
$obj.prop1 = 'value1';
$obj.prop4 = 'value4';
$obj.prop8 = 'value8';
delete $obj.prop0;
const $arr = Observer.proxy( arr );
$arr[ 0 ] = 'item0';
$arr[ 1 ] = 'item1';
$arr[ 2 ] = 'item2';
$arr.push( 'item3' );
└ And no problem if you end up nesting the approaches.
Observer.accessorize( obj, [ 'prop0', 'prop1', 'prop2', ] );
obj.prop1 = 'value1';
let $obj = Observer.proxy( obj );
$obj.prop1 = 'value1';
Observer.set( $obj, 'prop1', 'value1' );
--> "Restore" accessorized properties to their normal state by calling the unaccessorize()
method:
Observer.unaccessorize( obj, [ 'prop1', 'prop6', 'prop10' ] );
--> "Reproduce" original objects from Proxies obtained via Observer.proxy()
by calling the unproxy()
method:
obj = Observer.unproxy( $obj );
Concept: Paths
Observe "the value" at a path in a given tree:
const obj = {
level1: {
level2: 'level2-value',
},
};
const path = Observer.path( 'level1', 'level2' );
Observer.observe( obj, path, m => {
console.log( m.type, m.path, m.value, m.isUpdate );
} );
Observer.set( obj.level1, 'level2', 'level2-new-value' );
Console
type | path | value | isUpdate |
---|
set | [ level1 , level2 , ] | level2-new-value | true |
└ And the initial tree structure can be whatever:
const obj = {};
const path = Observer.path( 'level1', 'level2', 'level3', 'level4' );
Observer.observe( obj, path, m => {
console.log( m.type, m.path, m.value, m.isUpdate );
} );
└ Now, any operation that changes what "the value" at the path resolves to - either by tree extension or tree truncation - will fire our listener:
Observer.set( obj, 'level1', { level2: {}, } );
Console
type | path | value | isUpdate |
---|
set | [ level1 , level2 , level3 , level4 , ] | undefined | false |
└ Meanwhile, this next one completes the tree, and the listener reports a value at its observed path:
Observer.set( obj.level1, 'level2', { level3: { level4: 'level4-value', }, } );
Console
type | path | value | isUpdate |
---|
set | [ level1 , level2 , level3 , level4 , ] | level4-value | false |
--> Use the event's context
property to inspect the parent event if you were to find the exact point at which mutation happened in the path in an audit trail:
let context = m.context;
console.log(context);
└ And up again one level until the root event:
let parentContext = context.context;
console.log(parentContext);
--> Observe trees that are built asynchronously! Where a promise is encountered along the path, further access is paused until promise resolves:
Observer.set( obj.level1, 'level2', Promise.resolve( { level3: { level4: 'level4-value', }, } ) );
Concept: Batch Mutations
Make multiple mutations at a go, and they'll be correctly delivered as a batch to observers!
Observer.set( obj, {
prop0: 'value0',
prop1: 'value1',
prop2: 'value2',
} );
Observer.defineProperties( obj, {
prop0: { value: 'value0' },
prop1: { value: 'value1' },
prop2: { get: () => 'value2' },
} );
Observer.deleteProperties( obj, [ 'prop0', 'prop1', 'prop2' ] );
Observer.set( arr, {
'0': 'item0',
'1': 'item1',
'2': 'item2',
} );
Object.proxy( arr ).push( 'item3', 'item4', 'item5', );
Object.proxy( arr ).unshift( 'new-item0' );
Object.proxy( arr ).splice( 0 );
--> Use the Observer.batch()
to batch multiple arbitrary mutations - whether related or not:
Observer.batch( arr, async () => {
Observer.set( arr, 0, 'item0' );
await somePromise();
Observer.set( arr, 2, 'item2' );
} );
Method calls on a proxied instance - e.g. Object.proxy( arr ).splice( 0 )
- also follow this strategy.
Concept: Custom Details
Pass some custom detail - an arbitrary value - to observers via a params.detail
property.
Observer.set( obj, {
prop2: 'value2',
prop3: 'value3',
}, { detail: 'Certain detail' } );
└ Observers recieve this value on their mutation.detail
property.
Observer.observe( obj, 'prop1', mutation => {
console.log( 'A mutation has been made with detail:' + mutation.detail );
} );
Concept: Diffing
Receive notifications only for mutations that actually change property state, and ignore those that don't.
Observer.observe( obj, handleChanges, { diff: true } );
Observer.set( obj, 'prop0', 'value' );
Observer.set( obj, 'prop0', 'value' );
Method: Observer.intercept()
Intercept operations on any object or array before they happen!
Observer.intercept( obj, type, handler[, options = {} ]);
Observer.intercept( obj, traps[, options = {} ]);
Usage
Extend standard operations on an object - Observer.set()
, Observer.deleteProperty()
, etc - with custom traps using the Observer.intercept()
method!
└ Below, we intercept all "set" operations for an HTTP URL then transform it to an HTTPS URL.
const setTrap = ( operation, previous, next ) => {
if ( operation.key === 'url' && operation.value.startsWith( 'http:' ) ) {
operation.value = operation.value.replace( 'http:', 'https:' );
}
return next();
};
Observer.intercept( obj, 'set', setTrap );
└ Now, only the first of the following will fly as-is.
Observer.set( obj, 'url', 'https://webqit.io' );
Observer.set( obj, 'url', 'http://webqit.io' );
└ And below, we intercept all "get" operations for a certain value to trigger a network fetch behind the scenes.
const getTrap = ( operation, previous, next ) => {
if ( operation.key === 'token' ) {
return next( fetch( tokenUrl ) );
}
return next();
};
Observer.intercept( obj, 'get', getTrap );
└ And all of that can go into one "traps" object:
Observer.intercept( obj, {
get: getTrap,
set: setTrap,
deleteProperty: deletePropertyTrap,
defineProperty: definePropertyTrap,
ownKeys: ownKeysTrap,
has: hasTrap,
} );
The Polyfill
Use as an npm package:
npm i @webqit/observer
import Observer from '@webqit/observer';;
Use as a script:
<script src="https://unpkg.com/@webqit/observer/dist/main.js"></script>
4.4 kB min + gz | 13.9 KB min ↗
const Observer = window.webqit.Observer;
API Reference
Observer API | Reflect API | Trap |
---|
apply() ↗ | ✓ | apply() {} |
batch() ↗ | × | - |
construct() ↗ | ✓ | construct() {} |
defineProperties() ↗ | × | defineProperty() {} |
defineProperty() ↗ | ✓ | defineProperty() {} |
deleteProperties() ↗ | × | deleteProperty() {} |
deleteProperty() ↗ | ✓ | deleteProperty() {} |
get() ↗ | ✓ | get() {} |
getOwnPropertyDescriptor() ↗ | ✓ | getOwnPropertyDescriptor() {} |
getPrototypeOf() ↗ | ✓ | getPrototypeOf() {} |
has() ↗ | ✓ | has() {} |
intercept() ↗ | × | - |
isExtensible() ↗ | ✓ | isExtensible() {} |
observe() ↗ | × | - |
ownKeys() ↗ | ✓ | ownKeys() {} |
path() ↗ | × | - |
preventExtensions() ↗ | ✓ | preventExtensions() {} |
set() ↗ | ✓ | set() {} |
setPrototypeOf() ↗ | ✓ | setPrototypeOf() {} |
. | . | . |
accessorize() ↗ | × | - |
proxy() ↗ | × | - |
Design Discussion
See more in the introductory blog postdraft
Getting Involved
All forms of contributions are welcome at this time. For example, implementation details are all up for discussion. And here are specific links:
License
MIT.