@selfage/observable
v1.0.2
Published
Runtime lib for generated observables.
Downloads
24
Readme
@selfage/observable
Install
npm install @selfage/observable
Overview
Written in TypeScript and compiled to ES6 with inline source map & source. See @selfage/tsconfig for full compiler options. Provides a runtime lib to be used together with ObservableDescriptor
generated by @selfage/generator_cli
, which can parse, copy and merge observable objects.
An observable object exposes events/callbacks to observe every state change.
Example generated code
See @selfage/generator_cli#observable for how to generate ObservableDescriptor
. Suppose the following has been generated and committed as basic.ts
. We will continue using the example below.
import { ObservableArray } from '@selfage/observable_array';
import { EventEmitter } from 'events';
import { ObservableDescriptor, ArrayType } from '@selfage/observable/descriptor';
import { PrimitiveType } from '@selfage/message/descriptor';
export interface BasicData {
on(event: 'numberField', listener: (newValue: number, oldValue: number) => void): this;
on(event: 'stringArrayField', listener: (newValue: Array<string>, oldValue: Array<string>) => void): this;
on(event: 'observableArrayField', listener: (newValue: ObservableArray<boolean>, oldValue: ObservableArray<boolean>) => void): this;
on(event: 'init', listener: () => void): this;
}
export class BasicData extends EventEmitter {
private numberField_?: number;
get numberField(): number {
return this.numberField_;
}
set numberField(value: number) {
let oldValue = this.numberField_;
if (value === oldValue) {
return;
}
this.numberField_ = value;
this.emit('numberField', this.numberField_, oldValue);
}
private stringArrayField_?: Array<string>;
get stringArrayField(): Array<string> {
return this.stringArrayField_;
}
set stringArrayField(value: Array<string>) {
let oldValue = this.stringArrayField_;
if (value === oldValue) {
return;
}
this.stringArrayField_ = value;
this.emit('stringArrayField', this.stringArrayField_, oldValue);
}
private observableArrayField_?: ObservableArray<boolean>;
get observableArrayField(): ObservableArray<boolean> {
return this.observableArrayField_;
}
set observableArrayField(value: ObservableArray<boolean>) {
let oldValue = this.observableArrayField_;
if (value === oldValue) {
return;
}
this.observableArrayField_ = value;
this.emit('observableArrayField', this.observableArrayField_, oldValue);
}
public triggerInitialEvents(): void {
if (this.numberField_ !== undefined) {
this.emit('numberField', this.numberField_, undefined);
}
if (this.stringArrayField_ !== undefined) {
this.emit('stringArrayField', this.stringArrayField_, undefined);
}
if (this.observableArrayField_ !== undefined) {
this.emit('observableArrayField', this.observableArrayField_, undefined);
}
this.emit('init');
}
public toJSON(): Object {
return {
numberField: this.numberField,
stringArrayField: this.stringArrayField,
observableArrayField: this.observableArrayField,
};
}
}
export let BASIC_DATA: ObservableDescriptor<BasicData> = {
name: 'BasicData',
constructor: BasicData,
fields: [
{
name: 'numberField',
primitiveType: PrimitiveType.NUMBER,
},
{
name: 'stringArrayField',
primitiveType: PrimitiveType.STRING,
asArray: ArrayType.NORMAL,
},
{
name: 'observableArrayField',
primitiveType: PrimitiveType.BOOLEAN,
asArray: ArrayType.OBSERVABLE,
},
]
};
Listen on observable object
Changes are detected through TypeScript setter. Events are emitted via NodeJs's EventEmitter
.
import { BasicData } from './basic'; // Generated by @selfage/generator_cli.
import { ObservableArray } from '@selfage/observable_array';
let basicData = new BasicData();
basicData.on('numberField', (newValue, oldValue) => {
console.log(`newValue: ${newValue}; oldValue: ${oldValue};`);
});
basicData.numberField = 10;
// Print: newValue: 10; oldValue: undefined;
basicData.numberField = 100;
// Print: newValue: 100; oldValue: 10;
delete basicData.numberField;
// Actually does nothing. basicData.numberField is still 100.
basicData.numberField = undefined;
// Print: newValue: undefined; oldValue: 100;
basicData.on('stringArrayField', (newValue, oldValue) => {
console.log(`newValue: ${JSON.stringify(newValue)}; oldValue: ${JSON.stringify(oldValue)};`);
});
basicData.stringArrayField = ['str1', 'str2'];
// Print: newValue: ['str1','str2']; oldValue: undefined;
basicData.stringArrayField = ['str1', 'str2'];
// Print: newValue: ['str1','str2']; oldValue: ['str1','str2'];
// This is because the new and old ObservableArray's are not the instance. I.e., they are not equal by `===`.
basicData.stringArrayField.push('str3');
// Nothing to print as changes are not bubbled up.
basicData.on('observableArrayField', (newValue, oldValue) => {
console.log(`newValue: ${JSON.stringify(newValue)}; oldValue: ${JSON.stringify(oldValue)};`);
});
basicData.observableArrayField = ObservableArray.of(true, false);
// Print: newValue: [true,false]; oldValue: undefined;
basicData.observableArrayField.push(false);
// Nothing to print as changes are not bubbled up.
Note that changes on arrays or objects are not bubbled up.
In order to observe arrays, you need to add a listener on basicData.observableArrayField
directly. Refer to package @selfage/observable_array
for how to observe an ObservableArray
.
Similarly, if you nest BasicData
inside another observable object, you need to add listeners on nested observable objects directly.
Trigger initial events
If you have created an observable object before you could add listeners to it, you can trigger initial events, such that listeners called as if each field is just assigned with the new value.
import { BASIC_DATA, BasicData } from './basic'; // Generated by @selfage/generator_cli.
let data = new BasicData();
data.numberField = 111;
data.triggerInitialEvents();
// Emit `numberField` event with newValue as 111, and oldValue as undefined.
// A special 'init' event will also be triggered which passes nothing to the listener. It can be used to flip undefined fields.
Parse observables
You might not create an observable object directly, but parse a JSON-parsed object as the following.
import { parseObservable } from '@selfage/observable/parser';
import { BASIC_DATA, BasicData } from './basic'; // Generated by @selfage/generator_cli.
let raw = JSON.parse(`{ "numberField": 111, "otherField": "random", "stringArrayField": ["str1", "str2"] }`);
let basicData = parseObservable(raw, BASIC_DATA); // Of type `BasicData`.
You can also supply an in-place output object.
let output = new BasicData();
parseObservable(raw, BASIC_DATA, output);
Note that it will overwrite everything in output
.
Copy observables
You can copy observables.
import { copyObservable } from '@selfage/observable/copier';
import { BASIC_DATA, BasicData } from './basic'; // Generated by @selfage/generator_cli.
let basicData = new BasicData();
basic.numberField = 100;
let dest = copyObservable(basicData, BASIC_DATA);
// Or in-place copy.
let dest2 = new BasicData();
copyObservable(data, BASIC_DATA, dest2);
Merge observables
If provided with a destination/existing observable object, both parseObservable
and copyObservable
will replace every field with the new one. mergeObservable
, however, will only overwrite a field if the corresponding new field actually has a value.
import { mergeObservable } from '@selfage/observable/merger';
import { BASIC_DATA, BasicData } from './basic'; // Generated by @selfage/generator_cli.
let source = new BasicData();
source.stringArrayField = ["123"];
let existing = new BasicData();
existing.numberField = 111;
mergeMessage(source, BASIC_DATA, existing);
// Now `existing` becomes: { numberField: 111, stringArrayField: ["123"] }
Test matcher
By importing @selfage/observable/test_matcher
, you can use it together with @selfage/test_matcher
to match messages.
import { BasicData, BASIC_DATA } from './basic'; // Generated by @selfage/generator_cli.
import { eqObservable } from '@selfage/observable/test_matcher';
import { assertThat } from '@selfage/test_matcher'; // Install @selfage/test_matcher
let basicData = new BasicData();
basic.numberField = 111;
let expectedData = new BasicData();
assertThat(basicData, eqObservable(expectedData, BASIC_DATA), `basicData`);
Design considerations for observable object
We have also provided @selfage/observable_js
in pure JavaScript to convert any objects into observable objects via ES6 proxy. The main reason we didn't do the same thing in TypeScript is that we failed to find a way to make the converted observable objects type-safe. I.e., what would be the return type for function toObservable<T>(obj: T): ?
requring on(event: '<field name>', listener:...)
to be added to T
and can be type checked by TypeScript?
As for why we didn't allow bubbling up changes, it's because:
- Our main use case is to observe changes on states to trigger UI changes, where each component can own its own observable object. Nested objects should be observed by nested components. It could be messy to ignore nested objects.
- If you want to push new states into browser history, you probably don't want to push upon every single change, because an operation might trigger multiple changes which should be grouped into one history entry.