|
type Unwatcher = () => void |
|
type Watcher<T> = (newValue: T, oldValue: T) => void |
|
type WatchMethod<T> = (callback: Watcher<T>) => Unwatcher |
|
type Value<T> = { watch: WatchMethod<T>; value: T } |
|
type Computed<T> = { watch: WatchMethod<T>; readonly value: T } |
|
type Observable<T> = Value<T> | Computed<T> |
|
|
|
/** |
|
* Simple pub/sub implementation |
|
* Returns a destructurable array of of functions: |
|
* [publish(...payload), subscribe(callback), hasSubscribers()] |
|
*/ |
|
function pubsub<T extends any[] = any>() { |
|
const callbacks = [] as ((...args: T) => void)[] |
|
|
|
return [ |
|
(...args: T) => callbacks.forEach(callback => callback(...args)), |
|
(callback: (...args: T) => void) => { |
|
callbacks.push(callback) |
|
return () => { |
|
if (callbacks.includes(callback)) { |
|
callbacks.splice(callbacks.indexOf(callback), 1) |
|
} |
|
} |
|
}, |
|
() => callbacks.length > 0 |
|
] as const |
|
} |
|
|
|
/** |
|
* Handle tracking |
|
*/ |
|
class TrackingStack<T = any> { |
|
private stack: Set<Observable<T>>[] = [] |
|
|
|
public get isEnabled() { |
|
return this.stack.length > 0 |
|
} |
|
|
|
private get currentDependencies() { |
|
return this.stack[this.stack.length - 1] |
|
} |
|
|
|
public addDependency(value: Observable<T>) { |
|
if (!this.isEnabled) { |
|
throw new Error('Trying to add dependency outside tracking phase') |
|
} |
|
|
|
this.currentDependencies.add(value) |
|
} |
|
|
|
public startLayer() { |
|
this.stack.push(new Set()) |
|
} |
|
|
|
public finishLayer() { |
|
if (!this.isEnabled) { |
|
throw new Error( |
|
'Trying to finish dependency layer outside tracking phase' |
|
) |
|
} |
|
|
|
return this.stack.pop()! |
|
} |
|
} |
|
|
|
// The global tracking stack |
|
const tracking = new TrackingStack() |
|
|
|
/** |
|
* Create a plain reactive value |
|
* |
|
* @param init The initial value |
|
* |
|
* @example |
|
* const number = value(5) |
|
* number.value // Get value, returns 5 |
|
* number.value = 10 // Set value to 10 |
|
* |
|
* @example |
|
* const number = value(5) |
|
* const unwatch = number.watch((newValue, oldValue) => |
|
* console.log('number changed from %o to %o', oldValue, newValue) |
|
* ) |
|
* |
|
* number.value = 10 // Outputs "number changed from 5 to 10" |
|
* |
|
* unwatch() // Removes the watcher |
|
* number.value = 10 // Outputs nothing |
|
*/ |
|
export function value<T>(init: T): Value<T> { |
|
let current = init |
|
|
|
const [publish, subscribe] = pubsub<[T, T]>() |
|
const observable = { |
|
get value() { |
|
if (tracking.isEnabled) { |
|
tracking.addDependency(observable) |
|
} |
|
|
|
return current |
|
}, |
|
set value(newValue) { |
|
const oldValue = current |
|
|
|
if (newValue !== oldValue) { |
|
current = newValue |
|
publish(newValue, oldValue) |
|
} |
|
}, |
|
watch(callback: Watcher<T>) { |
|
const unwatch = subscribe((...args) => { |
|
callback(...args) |
|
}) |
|
return () => { |
|
unwatch() |
|
} |
|
} |
|
} |
|
|
|
return observable |
|
} |
|
|
|
/** |
|
* The cache that handles invalidating computed properties |
|
*/ |
|
class ComputedCache<T, U = any> { |
|
private wasFilled = false |
|
private filled = false |
|
private value?: T |
|
private unwatchers: Unwatcher[] = [] |
|
|
|
constructor(private bustCallback: () => void) {} |
|
|
|
public isHit() { |
|
return this.filled |
|
} |
|
|
|
public wasHit() { |
|
return this.wasFilled |
|
} |
|
|
|
public getValue() { |
|
return this.value |
|
} |
|
|
|
public update(value: T, dependencies: Set<Observable<U>>) { |
|
this.bust() |
|
|
|
dependencies.forEach(dependency => { |
|
const unwatch = dependency.watch((v, o) => { |
|
this.bust() |
|
}) |
|
this.unwatchers.push(() => { |
|
unwatch() |
|
}) |
|
}) |
|
|
|
this.value = value |
|
this.filled = true |
|
this.wasFilled = true |
|
} |
|
|
|
public bust() { |
|
if (!this.isHit()) { |
|
return |
|
} |
|
|
|
this.filled = false |
|
this.unwatchers.forEach(unwatcher => unwatcher()) |
|
this.unwatchers.splice(0) |
|
|
|
this.bustCallback() |
|
} |
|
} |
|
|
|
// Using the global Reflect actually is a hack to prevent TypeScript from |
|
// removing raw property access |
|
const accessProp = Reflect |
|
? Reflect.get |
|
: (object: any, prop: string) => object[prop] |
|
|
|
/** |
|
* Create a computed reactive value |
|
* |
|
* @param generator The function that derives reactive values from other reactive values |
|
* |
|
* @example |
|
* const number = value(5) |
|
* const square = computed(() => number.value ** 2) |
|
* |
|
* square.value // Get computed value, returns 25 |
|
* number.value = 10 // Set value to 10 |
|
* square.value // Get computed value, returns 100 |
|
* |
|
* @example |
|
* See 2nd example of `value()` for watching |
|
* |
|
*/ |
|
export function computed<T>(generator: () => T): Computed<T> { |
|
const cache = new ComputedCache<T>(() => { |
|
if (hasSubscribers()) { |
|
accessProp(observable, 'value') |
|
} |
|
}) |
|
|
|
const [publish, subscribe, hasSubscribers] = pubsub<[T, T]>() |
|
|
|
const observable = { |
|
get value() { |
|
if (cache.isHit()) { |
|
if (tracking.isEnabled) { |
|
tracking.addDependency(observable) |
|
} |
|
|
|
return cache.getValue()! |
|
} |
|
|
|
if (tracking.isEnabled) { |
|
tracking.addDependency(observable) |
|
} |
|
|
|
tracking.startLayer() |
|
const oldValue = cache.getValue()! |
|
const newValue = generator() |
|
const cachedDependencies = tracking.finishLayer() |
|
|
|
cache.update(newValue, cachedDependencies) |
|
|
|
if (oldValue !== newValue) { |
|
publish(newValue, oldValue) |
|
} |
|
|
|
return newValue |
|
}, |
|
watch(callback: Watcher<T>) { |
|
// If not evaluated yet, evaluate now |
|
if (!cache.wasHit()) { |
|
accessProp(observable, 'value') |
|
} |
|
|
|
const unwatch = subscribe(callback) |
|
return () => { |
|
unwatch() |
|
} |
|
} |
|
} |
|
|
|
return observable |
|
} |
|
|
|
/** |
|
* Decorate a class property as a plain reactive value by replacing it with a getter/setter |
|
*/ |
|
function decorateValue<T>(target: any, key: string) { |
|
const observable = value(target[key]) |
|
|
|
Object.defineProperty(target, key, { |
|
configurable: true, |
|
get: () => observable.value, |
|
set(value) { |
|
observable.value = value |
|
} |
|
}) |
|
} |
|
|
|
/** |
|
* Decorate a class getter method as a computed reactive value |
|
*/ |
|
function decorateComputed(target: any, key: string) { |
|
const descriptor = Object.getOwnPropertyDescriptor(target, key) |
|
if (!descriptor || typeof descriptor.get !== 'function') { |
|
throw new Error( |
|
`@computed can only be applied to getters, ${key} is not a getter` |
|
) |
|
} |
|
|
|
if (!descriptor.configurable) { |
|
throw new Error( |
|
`@computed can not be applied to non-configurable getter "${key}"` |
|
) |
|
} |
|
|
|
const store = computed(descriptor.get) |
|
|
|
Object.defineProperty(target, key, { |
|
configurable: true, |
|
get: () => store.value |
|
}) |
|
} |
|
|
|
/** |
|
* Decorate a class member, removing the need to explicitely call .value |
|
* However, this removes their ability to be watched |
|
* |
|
* @example |
|
* class Store { |
|
* @reactive |
|
* number = 5 |
|
* |
|
* @reactive |
|
* get square() { |
|
* return this.reactive ** 2 |
|
* } |
|
* } |
|
* |
|
* const store = new Store() |
|
* store.number // yields 5 |
|
* store.square // yields 25 |
|
* store.number = 10 |
|
* store.square // yields 100 |
|
*/ |
|
export function reactive(target: any, key: string) { |
|
const descriptor = Object.getOwnPropertyDescriptor(target, key) |
|
if (descriptor && typeof descriptor.get === 'function') { |
|
decorateComputed(target, key) |
|
} else { |
|
decorateValue(target, key) |
|
} |
|
} |