4

GitHub - loreanvictor/quel: Imperative Reactive Programming for JavaScript

 1 year ago
source link: https://github.com/loreanvictor/quel
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

loreanvictor/quel

main

Go to file

Code

light.svg#gh-light-mode-only

Imperative Reactive Programming for JavaScript

npm i quel

warningEXPERIMENTAL, not for use on production

Most applications written in JavaScript require some degree of reactive programming. This is either achieved via domain-specific frameworks (such as React) or general-purpose libraries like RxJS, which are centered around a functional reactive programming paradigm.

quel is a general-purpose library for reactive programming with an imperative style, resulting in code more in line with most other JavaScript code, and easier to read, write, understand and maintain.

import { from, observe } from 'quel'


const div$ = document.querySelector('div')

// 👇 this is an event source
const input = from(document.querySelector('textarea'))

// 👇 these are computed values based on that source
const chars = $ => $(input)?.length ?? 0
const words = $ => $(input)?.split(' ').length ?? 0

// 👇 this is a side effect executed when the computed values change
observe($ => div$.textContent = `${$(chars)} chars, ${$(words)} words`)

A more involved example:

//
// this code creates a timer whose rate changes
// based on values from an input.
//

import { from, observe, Timer } from 'quel'


const div$ = document.querySelector('div')
const input = from(document.querySelector('input'))

const timer = async $ => {
  const rate = parseInt($(input) ?? 100)
  await sleep(200)

  // 👇 a timer is a source itself, we have a higher-level event source here!
  return new Timer(rate)
}

observe($ => {
  const elapsed = $($(timer)) ?? '-'
  div$.textContent = `elapsed: ${elapsed}`
})

Installation

On node:

npm i quel

On browser (or deno):

import { from, observe } from 'https://esm.sh/quel'

Usage

Sources

Create a subject (whose value you can manually set at any time):

import { Subject } from 'quel'

const a = new Subject()
a.set(2)

Create a timer:

import { Timer } from 'quel'

const timer = new Timer(1000)

Create an event source:

import { from } from 'quel'

const click = from(document.querySelector('button'))
const hover = from(document.querySelector('button'), 'hover')
const input = from(document.querySelector('input'))

Create a custom source:

import { Source } from 'quel'

const src = new Source(async emit => {
  await sleep(1000)
  emit('Hellow World!')
})

Read latest value of a source:

src.get()

Stop a source:

src.stop()

Wait for a source to be stopped:

await src.stops()

Expressions

Combine two sources:

const sum = $ => $(a) + $(b)

Filter values:

import { SKIP } from 'quel'

const odd = $ => $(a) % 2 === 0 ? SKIP : $(a)

Do async operations:

const response = async $ => {
  const query = $(input)
  await sleep(200)
  
  const res = await fetch(`https://my.api/q?=${query}`)
  const json = await res.json()
  
  return res
}

Flatten higher-order sources:

const variableTimer = $ => new Timer($(input))
const message = $ => 'elapsed: ' + $($(timer))

Stop the expression:

import { STOP } from 'quel'

let count = 0
const take5 = $ => {
  if (count++ > 5) return STOP

  return $(src)
}

information_sourceIMPORTANT

Only pass stable references to the track function $. Expressions are re-run whenever the tracked sources emit a new value, so if sources they are tracking aren't stable with regards to execution of the expression itself, they will be tracking new sources each time, resulting in behavior that is most probably not intended.

// 👇 this is WRONG ❌
const computed = $ => $(new Timer(1000)) * 2
// 👇 this is CORRECT ✅
const timer = new Timer(1000)
const computed = $ => $(timer) * 2

The track function $ itself returns a stable reference, so you can safely chain it for flattening higher-order sources:

const timer = $ => new Timer($(rate))

// 👇 this is OK, as $(timer) is a stable reference
const msg = 'elapsed: ' + $($(timer))

Observation

Run side effects:

import { observe } from 'quel'

observe($ => console.log($(message)))

Observations are sources themselves:

const y = observe($ => $(x) * 2)
console.log(y.get())

Expression functions might get aborted mid-execution. You can handle those events by passing a second argument to observe():

let ctrl = new AbortController()

const data = observe(async $ => {
  const q = $(input)
  await sleep(200)
  
  // 👇 pass abort controller signal to fetch to cancel mid-flight requests
  const res = await fetch('https://my.api/?q=' + q, {
    signal: ctrl.signal
  })

  return await res.json()
}, () => {
  ctrl.abort()
  ctrl = new AbortController()
})

Iteration

Iterate on values of a source using iterate():

import { iterate } from 'quel'

for await (const i of iterate(src)) {
  // do something with it
}

If the source emits values faster than you consume them, you are going to miss out on them:

const timer = new Timer(500)

// 👇 loop body is slower than the source. values will be lost!
for await (const i of iterate(timer)) {
  await sleep(1000)
  console.log(i)
}

Cleanup

Manually cleanup:

const timer = new Timer(1000)
const effect = observe($ => console.log($(timer)))

// 👇 this just stops the side-effect, the timer keeps going ...
effect.stop()

// 👇 this stops the timer. you don't need to stop the effect manually.
timer.stop()

Specify cleanup code in custom sources:

const myTimer = new Source(emit => {
  let i = 0
  const interval = setInterval(() => emit(++i), 1000)
  
  // 👇 clear the interval when the source is stopped
  return () => clearInterval(interval)
})
// 👇 with async producers, use a callback to specify cleanup code
const asyncTimer = new Source(async (emit, finalize) => {
  let i = 0
  let stopped = false
  
  finalize(() => stopped = true)
  
  while (!stopped) {
    emit(++i)
    await sleep(1000)
  }
})

Typing

TypeScript wouldn't be able to infer proper types for expressions. To resolve this issue, use Track type:

import { Track } from 'quel'

const expr = ($: Track) => $(a) * 2

point_rightCheck this for more useful types.

Features

jigsawquel has a minimal API surface (the whole package is ~1.3KB), and relies on composability instead of providng tons of operators / helper methods:

// combine two sources:
$ => $(a) + $(b)
// debounce:
async $ => {
  const val = $(src)
  await sleep(1000)
  
  // ...
}
// flatten (e.g. switchMap):
$ => $($(src))
// filter a source
$ => $(src) % 2 === 0 ? $(src) : SKIP
// take until other source emits a value
$ => !$(notifier) ? $(src) : STOP
// merge sources
new Source(emit => {
  const obs = sources.map(src => observe($ => emit($(src))))
  return () => obs.forEach(ob => ob.stop())
})
// throttle
let timeout = null
  
$ => {
  const value = $(src)
  if (timeout === null) {
    timeout = setTimeout(() => timeout = null, 1000)
    return value
  } else {
    return SKIP
  }
}

passport_controlquel is imperative (unlike most other general-purpose reactive programming libraries such as RxJS, which are functional), resulting in code that is easier to read, write and debug:

import { interval, map, filter } from 'rxjs'

const a = interval(1000)
const b = interval(500)

combineLatest(a, b).pipe(
  map(([x, y]) => x + y),
  filter(x => x % 2 === 0),
).subscribe(console.log)
import { Timer, observe } from 'quel'

const a = new Timer(1000)
const b = new Timer(500)

observe($ => {
  const sum = $(a) + $(b)
  if (sum % 2 === 0) {
    console.log(sum)
  }
})

zapquel is as fast as RxJS. Note that in most cases performance is not the primary concern when programming reactive applications (since you are handling async events). If performance is critical for your use case, I'd recommend using likes of xstream or streamlets, as the imperative style of quel does tax a performance penalty inevitably compared to the fastest possible implementation.

brainquel is more memory-intensive than RxJS. Similar to the unavoidable performance tax, tracking sources of an expression will use more memory compared to explicitly tracking and specifying them.

coffeequel only supports hot listenables. Certain use cases would benefit (for example, in terms of performance) from using cold listenables, or from having hybrid pull-push primitives. However, most common event sources (user events, timers, Web Sockets, etc.) are hot listenables, and quel does indeed use the limited scope for simplification and optimization of its code.

Related Work

Contribution

You need node, NPM to start and git to start.

# clone the code
git clone [email protected]:loreanvictor/quel.git
# install stuff
npm i

Make sure all checks are successful on your PRs. This includes all tests passing, high code coverage, correct typings and abiding all the linting rules. The code is typed with TypeScript, Jest is used for testing and coverage reports, ESLint and TypeScript ESLint are used for linting. Subsequently, IDE integrations for TypeScript and ESLint would make your life much easier (for example, VSCode supports TypeScript out of the box and has this nice ESLint plugin), but you could also use the following commands:

# run tests
npm test
# check code coverage
npm run coverage
# run linter
npm run lint
# run type checker
npm run typecheck

You can also use the following commands to run performance benchmarks:

# run all benchmarks
npm run bench
# run performance benchmarks
npm run bench:perf
# run memory benchmarks
npm run bench:mem




About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK