objerve
v1.0.6
Published
define callbacks listening for value changes inside given object paths
Downloads
2
Readme
objerve
Define callbacks that get fired when given object properties change.
example
const objerve = require('./main.js')
const obj = objerve()
objerve.addListener(obj, ['a', 'b'], (newValue, oldValue, action, path, obj) => {
console.log(`${action} ${path.join('.')}: `+
`${JSON.stringify(oldValue)} -> ${JSON.stringify(newValue)}`)
})
obj.a = { b: 'hi' }
obj.a.b = 'hello'
obj.a = null
create a.b: undefined -> "hi" change a.b: "hi" -> "hello" delete a.b: "hello" -> undefined
features
- Behaves exactly like an ordinary Object
- Can listen to fixed paths or to all paths with a prefix, and use
objerve.each
inside paths to match all array indexes. - Putting objerve instances (or parts of them) inside each others' properties fully works, and property changes are propagated between instances as you'd expect if they are properties of each other (even with circular references)
- Can tell apart
undefined
property value and property deletion, thanks to theaction
argument passed to callbacks (showscreated
/changed
/deleted
). - Calls your callbacks in a nesting-respecting order, so your callbacks can setup and teardown state in the correct order (bottom-up construction, top-down destruction)
- Stores listeners in a prefix tree by target path, to speed up queries with large objects and many listeners.
api
objerve([obj])
Wrap the given object (default {}
) so it can be subscribed to.
The resulting object behaves like the object did before, but changes to its
paths can be listened to with objerve.addListener
or
objerve.addPrefixListener
.
objerve.addListener(obj, path, callback)
The path can contain objerve.each
, which will match any Array index at that
position.
Works inside listener callbacks. If inside a listener you add a new listener that matches the same path, the new listener will also be called with this same change.
objerve.removeListener(obj, path, callback)
Remove the listener from the given path, so the callback is no longer called. The path is useful to disambiguate in case the same callback function is being used as the listener for multiple paths.
Does nothing if it cannot find such a listener.
Works inside listener callbacks. If you remove a listener for the same path inside a callback for that path, the removed listener won't be called for that change either (unless it was already called before this one).
objerve.addPrefixListener(obj, path, callback)
Same as addListener
, but will be called for any property at all that has the
given path
as a prefix. Pass []
for the path to be called for every change
to any property.
objerve.removePrefixListener(obj, path, callback)
Same as removeListener
, but for prefix listeners.
objerve.each
A special Symbol value that can be passed as part of a path to listen to. It
matches any valid array index, i.e. 0
, 1
, 999999999
etc, so your listener
is called for every element of an array being created, changed, or deleted.
how callbacks are called
Your callback function is called immediately before the described change is actually applied to the object, with these arguments:
newValue
oldValue
action
: One of the following strings:'create'
if the property did not exist, and is being created'change'
if the property exists, and its value is changing'delete'
if the property exists, but it is being deleted
const objerve = require('./main.js') const obj = objerve() objerve.addListener(obj, ['x'], (newValue, oldValue, action) => { console.log(`${action} ${oldValue} -> ${newValue}`) }) obj.x = true obj.x = undefined delete obj.x
create undefined -> true change true -> undefined delete undefined -> undefined
Note how although both the
obj.x = undefined
anddelete obj.x
lines triggered a callback withnewValue
undefined
, theiraction
s differed:'change'
and'delete'
.path
: An Array representing the property path through the object at which this update happened. Useful if you have a single callback function listening to multiple paths.obj
: A reference to the object as it currently exists (just before the described update is actually applied).updateId
: A number uniquely identifying the currently happening change. All listeners that get called due to the same change (or caused by a callback reacting to the same change) see the same identifier.const objerve = require('./main.js') const obj = objerve() // Listen to changes to 'obj.a'. Reduce it by 1 unless it's 0. objerve.addListener(obj, ['a'], (val, previousVal, action, path, objRef, updateId) => { console.log(`[${action}] ${previousVal} -> ${val} (updateId ${updateId})`) if (val > 0) { obj.a = val - 1 } }) // Also create a listener listening to all properties on 'obj'. objerve.addPrefixListener(obj, [], (val, previousVal, action, path, objRef, updateId) => { console.log(`prefix listener called (updateId ${updateId})`) }) obj.a = 3 console.log(obj.a) obj.a = 2 console.log(obj.a)
Each time something is assigned to
obj.a
, the first listener gets called, and assigns it 1 lower, until it's 0:prefix listener called (updateId 0) [create] undefined -> 3 (updateId 0) prefix listener called (updateId 0) [create] undefined -> 2 (updateId 0) prefix listener called (updateId 0) [create] undefined -> 1 (updateId 0) prefix listener called (updateId 0) [create] undefined -> 0 (updateId 0) 0 prefix listener called (updateId 1) [change] 0 -> 2 (updateId 1) prefix listener called (updateId 1) [change] 0 -> 1 (updateId 1) prefix listener called (updateId 1) [change] 0 -> 0 (updateId 1) 0
Note that for each individual change (
obj.a = 3
andobj.a = 2
), both listeners were called multiple times, but during each change both were called with the sameupdateId
.
If your callback wants to cancel the described change from happening, simply assign a value to the property being changed and it will take priority.
Note that callbacks are always called for every matching change, even if
changes essentially invalidate previous ones by overwriting their values. Some
use-cases (such as updating a UI in response to property changes) may only care
about the final results at the end of this event loop tick, so you may wish to
accumulate the changes and defer your rendering with an API appropriate for
your use-case (such as setImmediate
,
process.nextTick
, queueMicrotask
,
requestAnimationFrame
, etc).
const objerve = require('./main.js')
const ArrayKeyedMap = require('array-keyed-map')
const obj = objerve()
const accumulatedChanges = new ArrayKeyedMap()
const render = () => {
// Put your expensive UI rendering code here
console.log(Array.from(accumulatedChanges.entries()))
accumulatedChanges.clear()
}
objerve.addListener(obj, ['a'],
(newVal, oldVal, action, path) => {
if (accumulatedChanges.size === 0) process.nextTick(render)
if (!accumulatedChanges.has(path)) {
accumulatedChanges.set(path, {newVal, oldVal})
} else {
accumulatedChanges.get(path).newVal = newVal
}
})
// Make a bunch of changes
obj.a = 1
obj.a = 2
obj.a = 3
The render
function only gets called on next event loop tick tick, with the
total accumulated change from undefined
to 3
, and none of the intermediate
states between:
[ [ [ 'a' ], { newVal: 3, oldVal: undefined } ] ]
call order
When one change triggers multiple callbacks, the order they are called depends on whether the change is constructive or destructive: If the property is being created or changed, callbacks are called in root→leaf order. If the property is being deleted, callbacks are called in leaf→root order.
Because of this feature, your listeners can setup or teardown state (e.g. managing DOM elements) in response to creation or deletion, and sub-properties can use that state (e.g. appending their own DOM elements to the parent's ones) while still being able to clean up the sub-properties' state gracefully and in the right order even when a whole chain of properties is deleted all at once.
const objerve = require('./main.js')
const obj = objerve()
const callback = (name) => {
return (val, previousVal, action) => {
console.log(`${action} ${name}`)
}
}
objerve.addListener(obj, ['a'], callback('a'))
objerve.addListener(obj, ['a', 'b'], callback('a.b'))
objerve.addListener(obj, ['a', 'b', 'c'], callback('a.b.c'))
obj.a = { b: { c: 'value' } }
delete obj.a
create a create a.b create a.b.c delete a.b.c delete a.b delete a
Prefix listeners and objerve.each
-matching listeners are also considered
"parents" of concrete property paths, so their listeners are called before the
concrete path's listeners on creation/change (prefix→each→concrete), and after
them on deletion (concrete→each→prefix).
const objerve = require('./main.js')
const obj = objerve([])
const callback = (name) => {
return (val, previousVal, action) => console.log(`${action} ${name}`)
}
// Listen for property '0'
objerve.addListener(obj, [0], callback('concrete'))
// Listen for any array index
objerve.addListener(obj, [objerve.each], callback('each'))
// Listen for all properties
objerve.addPrefixListener(obj, [], callback('prefix'))
obj[0] = true
delete obj[0]
create prefix create each create concrete delete concrete delete each delete prefix
If there are multiple listeners for a property that changes, the listeners are called in insertion order.
Other than the above rules, the relative order in which any two paths' callbacks are called may be arbitrary, so you shouldn't rely on it.
use-cases
- Binding data to UI.
- Testing. Transparently adding logging to property changes is handy.
- Reactive programming.
license
ISC; summary: use for anything, credit me, no warranty