The Observer API

A web-native object observability API!
Observe and intercept operations on any type of JavaScript objects and arrays, using a notably lightweight and predictable utility-first reactivity API!
Motivation
Tracking mutations on JavaScript objects has historically relied on "object wrapping" with ES6 Proxies and "property mangling" with getters and setters. Besides the object identity trade-off problem of the first and the property compromisal problem of the second, there is also the "scalability" issue inherent to the techniques and much "inflexibility" in the programming model they enable:
-
Scalability: objects have to be created a certain way, or purpose-built for the specific technique, to participate in the reactive system; objects you don't own have to be altered in some way - where that's even possible - to be onboarded into the reactivity system. Scalability is hamstrung as we must fulfill the implementation criteria for as many objects as will be needed in the design - clamped to the finite number of objects that can be made to work this way!
-
Programming model: proxy traps and object accessors by design only interface with one listenining logic in the entire program. Objects are effectively open to multiple interactions on the outside but closed-off to one observer on the inside, enabling just a "many to one" model. This does not correctly reflect the most common usecases where the idea is to have any number of listeners per event; i.e. a "many to many" model! It takes yet a non-trivial effort to go from the default model to the one desired.
Surprisingly, we at one time had an object observability primitive that checked all the boxes and touched the very pain points we have today: the Object.observe()
API. How about an equivalent API that brings all of the good thinking from Object.observe()
together with the idea of Proxies and accessors in one design, delivered as one utility for all things reactivity? This is the idea with the new Observer API!
Table of Contents
Download Options
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>
const Observer = window.webqit.Observer;
An Overview
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, props, callback[, options = {} ]);
Concept
Observe arbitrary objects and arrays:
const obj = {};
const abortController = Observer.observe( obj, handleChanges );
const arr = [];
const abortController = Observer.observe( arr, handleChanges );
Now changes will be delivered synchronously - as they happen. (The sync design is discussed shortly.)
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();
...or you can provide your own Abort Signal instance:
const abortController = new AbortController;
Observer.observe( obj, mutations => {
}, { signal: abortController.signal } );
abortController.abort();
Concept: Mutations
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 );
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();
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: Batch Mutations
Make multiple mutations at a go, and they'll be correctly delivered in 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, prop, handler[, options = {} ]);
Observer.intercept( obj, traps[, options = {} ]);
Concept
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,
} );
Design Discussion
[TODO]
Issues
To report bugs or request features, please submit an issue.
License
MIT.