Add bundle to cut out unnecessary subscriber calls.

This commit is contained in:
Timothy Farrell 2017-02-09 23:01:01 -06:00
parent 49259828af
commit 3230a3ef5a
5 changed files with 208 additions and 3 deletions

View File

@ -108,3 +108,50 @@ inViewport(false); // showDialog result recomputed and `false` is written to the
shouldShow(false); // showDialog result recomputed, console.log is not called.
showDialog(); // showDialog does not recompute, console.log is not called. `false` is returned.
```
# [bundle](./src/bundle.js)
`bundle` is a wrapper around a group of `observables` for the purpose of applying changes to all of
them at once without having to trigger a subscription that may depend on more than observable in the
group.
Another way to think of a `bundle` is an `observable` that takes an object and exposes the
properties as individual observables.
## Behavior
A `bundle` wraps observables to intercept dependency hooks in such a way that updating all
observables can happen at once before any downstream `computeds` are evaluated. A bundle returns a
function that can be called with an object to set values for the mapped member observables.
## Usage
### Creation
```js
const layoutEventBundle = bundle({
width: observable(1),
height: observable(2)
});
const ratio = computed((a, b) => a / b, [layoutEventBundle.width, layoutEventBundle.height]);
ratio.subscribe(render);
```
### Change Member Observables atomically
```js
layoutEventBundle({ width: 640, height: 480 });
```
`ratio` would normally be evaluated twice and `render` would be called after each intermediate
change. But bundle allows both values to change and `ratio` will only be evaluated once and `render`
called once.
### Change Member Observables individually
```js
layoutEventBundle.width(640);
layoutEventBundle.height(480);
```
The observables exposed by the bundle can also be updated apart from their grouping.

View File

@ -0,0 +1,114 @@
const { observable, computed, bundle } = require('../lib/index.js');
describe('bundle', () => {
const methods = {
add: (a, b) => a + b,
square: a => a * a,
getVal: val => {}
};
beforeEach(() => {
spyOn(methods, 'add').and.callThrough();
spyOn(methods, 'square').and.callThrough();
spyOn(methods, 'getVal').and.callThrough();
});
it('bundles observable changes together', () => {
const a = bundle({
a: observable(0),
b: observable(10)
});
const b = computed(methods.square, [a.a]);
const c = computed(methods.add, [a.a, a.b]);
b.subscribe(methods.getVal);
c.subscribe(methods.getVal);
expect(methods.getVal).toHaveBeenCalledTimes(0);
expect(b()).toEqual(0);
expect(methods.getVal).toHaveBeenCalledTimes(1);
b();
expect(methods.getVal).toHaveBeenCalledTimes(1);
expect(c()).toEqual(10);
c();
expect(methods.getVal).toHaveBeenCalledTimes(2);
expect(methods.add).toHaveBeenCalledTimes(1);
expect(methods.square).toHaveBeenCalledTimes(1);
a({ a: 2, b: 20 });
expect(methods.add).toHaveBeenCalledTimes(2);
expect(methods.square).toHaveBeenCalledTimes(2);
expect(methods.getVal).toHaveBeenCalledTimes(4);
expect(b()).toEqual(4);
expect(c()).toEqual(22);
expect(methods.add).toHaveBeenCalledTimes(2);
expect(methods.square).toHaveBeenCalledTimes(2);
expect(methods.getVal).toHaveBeenCalledTimes(4);
});
it('unbundled changes are less efficient', () => {
const a = observable(0);
const _b = observable(10);
const b = computed(methods.square, [a]);
const c = computed(methods.add, [a, _b]);
b.subscribe(methods.getVal);
c.subscribe(methods.getVal);
expect(methods.getVal).toHaveBeenCalledTimes(0);
expect(b()).toEqual(0);
expect(methods.getVal).toHaveBeenCalledTimes(1);
b();
expect(methods.getVal).toHaveBeenCalledTimes(1);
expect(c()).toEqual(10);
c();
expect(methods.getVal).toHaveBeenCalledTimes(2);
expect(methods.add).toHaveBeenCalledTimes(1);
expect(methods.square).toHaveBeenCalledTimes(1);
a(2);
_b(20);
expect(methods.add).toHaveBeenCalledTimes(3);
expect(methods.square).toHaveBeenCalledTimes(2);
expect(methods.getVal).toHaveBeenCalledTimes(5);
expect(b()).toEqual(4);
expect(c()).toEqual(22);
expect(methods.add).toHaveBeenCalledTimes(3);
expect(methods.square).toHaveBeenCalledTimes(2);
expect(methods.getVal).toHaveBeenCalledTimes(5);
});
it('allows individual members to be updated', () => {
const a = bundle({
a: observable(0),
b: observable(10)
});
const b = computed(methods.square, [a.a]);
const c = computed(methods.add, [a.a, a.b]);
b.subscribe(methods.getVal);
c.subscribe(methods.getVal);
expect(methods.getVal).toHaveBeenCalledTimes(0);
expect(b()).toEqual(0);
expect(methods.getVal).toHaveBeenCalledTimes(1);
b();
expect(methods.getVal).toHaveBeenCalledTimes(1);
expect(c()).toEqual(10);
c();
expect(methods.getVal).toHaveBeenCalledTimes(2);
expect(methods.add).toHaveBeenCalledTimes(1);
expect(methods.square).toHaveBeenCalledTimes(1);
a.a(2);
a.b(20);
expect(methods.add).toHaveBeenCalledTimes(3);
expect(methods.square).toHaveBeenCalledTimes(2);
expect(methods.getVal).toHaveBeenCalledTimes(5);
expect(b()).toEqual(4);
expect(c()).toEqual(22);
expect(methods.add).toHaveBeenCalledTimes(3);
expect(methods.square).toHaveBeenCalledTimes(2);
expect(methods.getVal).toHaveBeenCalledTimes(5);
});
});

View File

@ -0,0 +1,43 @@
export function bundle(observables) {
const activeSubscribers = new Set();
let activeUpdate = false;
const accessor = function _bundle(values) {
const result = {};
activeUpdate = true;
Object.keys(values)
.filter(k => typeof observables[k] === 'function')
.forEach(k => {
result[k] = observables[k](values[k]);
});
const subscribers = Array.from(activeSubscribers);
// Set them dirty but don't propagate.
subscribers.forEach(s => s(result, true));
// Now propagate.
subscribers.forEach(s => s(result));
activeSubscribers.clear();
activeUpdate = false;
return result;
};
const subscriptionFactory = obsFn => fn => {
return obsFn(v => {
if (activeUpdate) {
activeSubscribers.add(fn);
} else {
fn(v);
}
return v;
});
};
Object.keys(observables).forEach(k => {
const obs = observables[k];
accessor[k] = obs;
obs._d = subscriptionFactory(obs._d);
});
return accessor;
}

View File

@ -4,13 +4,13 @@ export function computed(fn, dependencies = []) {
let isDirty = true;
let val;
function _computedDirtyReporter() {
function _computedDirtyReporter(_, skipPropagation) {
if (!isDirty) {
isDirty = true;
dependents.forEach(d => d(_, skipPropagation));
}
dependents.forEach(runParam);
if (subscribers.size) {
if (subscribers.size && !skipPropagation) {
accessor();
}
}

View File

@ -1,2 +1,3 @@
export { observable } from './observable';
export { computed } from './computed';
export { bundle } from './bundle';