SkillAgentSearch skills...

Espo

Observables via proxies. Particularly suited for UI programming. Supports automatic, implicit sub/resub and resource deinit.

Install / Use

/learn @mitranim/Espo
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

Overview

Observables via proxies. Particularly suited for UI programming. Features:

  • Implicit sub/resub, just by accessing properties.
  • Implicit trigger, by property modification.
  • Any object can be made observable; see obs. Subclassing is available but not required.
  • Arbitrary properties can be observed. No need for special annotations. (Opt-out via non-enumerables.)
  • Implicit resource cleanup: automatically calls .deinit() on removed/replaced objects.
  • Your objects are squeaky clean, with no added library junk other than mandatory .deinit().
  • Nice-to-use in plain JS. Doesn't rely on decorators, TS features, etc.
  • Easy to wire into any UI system. Comes with optional adapters for React (react.mjs) and custom DOM elements (dom.mjs).

Tiny (a few KiB un-minified) and dependency-free. Native JS module.

Known limitations:

  • No special support for collections.
  • When targeting IE, requires transpilation and ES2015 polyfills.

TOC

Usage

Install

npm i -E espo

Espo uses native JS modules, which work both in Node and browsers. It's even usable in browsers without a bundler; use either an importmap (polyfillable), an exact file path, or a full URL.

import * as es from 'espo'

import * as es from './node_modules/espo/espo.mjs'

import * as es from 'https://cdn.jsdelivr.net/npm/espo@0.8.2/espo.mjs'

Trichotomy of proxy/handler/target

While Espo is fairly magical (🧚 fairy magical?), the user must be aware of proxies. In Espo, your objects aren't "observables" in a classic sense, full of special properties and methods. Instead, they remain clean, but wrapped into a Proxy, together with a proxy handler object, which is the actual state of the observable, with subscribers, explicit sub/unsub methods, and so on.

Mostly, you wrap via obs or by subclassing Obs, and just use the resulting proxy as if it was the original object. Implicit sub/resub relies on proxy features. For explicit sub/unsub, access the proxy handler via ph, and call the handler's methods.

import * as es from 'espo'

const target = {someProp: 'someVal'}

// Same as `es.obs(target)`, but without support for `.onInit`/`.onDeinit`.
const obs = new Proxy(target, new es.ObsPh())

obs.someProp // 'someVal'

// The `es.ObsPh` object we instantiated before.
const ph = es.ph(obs)

ph.sub(function onTrigger() {})

Implicit sub/resub

Espo provides features, such as comp or Loop, where you provide a function, and within that function, access to any observables' properties automatically establishes subscriptions. Triggering those observables causes a recomputation or a rerun. The timing of these events can be fine-tuned for your needs; lazyComp doesn't immediately recompute, and Moebius doesn't immediately rerun.

See Loop for a simple example.

This is extremely handy for UI programming. Espo comes with optional adapters for React (react.mjs) and custom DOM elements (dom.mjs).

Example with React:

import * as es from 'espo'
import * as esr from 'espo/react.mjs'

// Base class for your views. Has implicit reactivity in `render`.
class View extends Component {
  constructor() {
    super(...arguments)
    esr.viewInit(this)
  }
}

const one = es.obs({val: 10})
const two = es.obs({val: 20})

// Auto-updates on observable mutations.
class Page extends View {
  render() {
    return <div>current total: {one.val + two.val}</div>
  }
}

Example with custom DOM elements:

import * as es from 'espo'
import * as ed from 'espo/dom.mjs'

const one = es.obs({val: 10})
const two = es.obs({val: 20})

class TotalElem extends ed.RecElem {
  // Runs on initialization and when triggered by observables.
  run() {
    this.textContent = `current total: ${one.val + two.val}`
  }
}
customElements.define(`a-total`, TotalElem)

class TotalText extends ed.RecText {
  constructor() {super(), this.upd()}

  // Runs on initialization and when triggered by observables.
  run() {
    this.textContent = `current total: ${one.val + two.val}`
  }
}

Auto-deinit

In addition to observables, Espo implements automatic resource cleanup, relying on the following interface:

interface isDe {
  deinit(): void
}

On all Espo proxies, whenever an enumerable property is replaced or removed, the previous value is automatically deinited, if it implements this method.

All Espo proxies provide a .deinit method, which will:

  • Call .deinit on the proxy handler, dropping all subscriptions.
  • Call .deinit on all enumerable properties of the target (if implemented).
  • Call .deinit on the proxy target (if implemented).

This allows your object hierarchies to have simple, convenient, correct lifecycle management.

Enumerable vs non-enumerable

Non-enumerable properties are exempt from all Espo trickery. Their modifications don't trigger notifications, and they're never auto-deinited.

In the following example, .owned is auto-deinited, but .owner is not:

class State extends es.Deinit {
  constructor(owner, owned) {
    super()
    this.owned = owned
    Object.defineProperty(this, 'owner', {value: owner})
  }
}

new State(owner, owned).deinit()
// Implicitly calls: `owned.deinit()`

For convenience, use the shortcuts priv and privs:

es.privs(this, {owner})

new Proxy vs function vs subclass

The core of Espo's functionality is the proxy handler classes. Ultimately, functions like obs and classes like Obs are shortcuts for the following, with some additional wiring:

new Proxy(target, new es.ObsPh())

Customization is done by subclassing one of the proxy handler classes, such as ObsPh, and providing the custom handler to your proxies, usually via static get ph() in your class. Everything else is just a shortcut.

The advantage of subclassing Deinit or Obs is that after the super() call, this is already a proxy. This matters for bound methods, passing this to other code, and so on. When implementing your own observable classes, the recommendation is to subclass Deinit or Obs when possible, to minimize gotchas.

API

Also see changelog: changelog.md.

interface isDe(val)

Short for "is deinitable". Implemented by every Espo object. All Espo proxies support automatic deinitialization of arbitrary properties that implement this interface, when such properties are replaced or removed.

interface isDe {
  deinit(): void
}

interface isObs(val)

Short for "is observable". Implemented by Espo proxy handlers such as ObsPh.

interface isObs {
  trig   ()           : void
  sub    (sub: isSub) : void
  unsub  (sub: isSub) : void
  deinit ()           : void
}

es.isObs(es.obs({}))        // false
es.isObs(es.ph(es.obs({}))) // true

interface isTrig(val)

Short for "is triggerable". Part of some other interfaces.

interface isTrig {
  trig(): void
}

interface isSub(val)

Short for "is subscriber / subscription". Interface for "triggerables" that get notified by observables. May be either a function, or an object implementing isTrig.

isSub is used for explicit subscriptions, such as ObsPh.prototype.sub, and must also be provided to Loop.

Support for objects with a .trig method, in addition to functions, allows to avoid "bound methods", which is a common technique that leads to noisy code and inefficiencies.

type isSub = isTrig | () => void

interface isSubber(val)

Internal interface. Used for implementing implicit reactivity by Loop and Moebius.

interface isSubber {
  subTo(obs: isObs): void
}

interface isRunTrig(val)

Must be implemented by objects provided to Moebius. Allows a two-stage trigger: .run is invoked immediately; .trig is invoked on observable notifications, and may choose to loop back into .run.

interface isRunTrig {
  run(...any): void
  trig(): void
}

function ph(ref)

Takes an Espo proxy and returns i

Related Skills

View on GitHub
GitHub Stars11
CategoryCustomer
Updated2y ago
Forks0

Languages

JavaScript

Security Score

60/100

Audited on May 2, 2023

No findings