Subscript
Subscript is a reactivity runtime for JavaScript. It takes any valid JavaScript code, reads its dependency graph, and offers a mechanism to run it both in whole and in reactive selections, called dependency threads.
What's A Dependency Thread?
That's simply the dependency chain involving two or more JavaScript expressions. 👇
let count = 10, doubleCount = count * 2, quadCount = doubleCount * 2;
We just expressed that doubleCount
should be two times the value of count
, and that quadCount
should be two times the value of doubleCount
; each subsequent expression being a dependent of the previous.
console.log( count, doubleCount, quadCount );
< 10, 20, 40
😉 Can you spot that same dependency chain in the following hypothetical UI render function…?
let count = 10;
let render = function() {
let countElement = document.querySelector( '#count' );
countElement.innerHTML = count;
let doubleCount = count * 2;
let doubleCountElement = document.querySelector( '#double-count' );
doubleCountElement.innerHTML = doubleCount;
let quadCount = doubleCount * 2;
let quadCountElement = document.querySelector( '#quad-count' );
quadCountElement.innerHTML = quadCount;
}
You'll also notice one additional dependent at each level of the chain. That brings the dependency thread for count
to the following sequence: statement 2
-> statement 3
-> statement 5
-> statement 6
-> statement 8
; excluding statements 1
, 4
, 7
.
🤝 Good analysis! But what's the deal?
Programs are generally expected to run in whole, not in dependency threads! It would take some magic to have the latter. But... well, that's what's for dinner with Subscript! 😁
Problem is: the mathematical relationship above only holds for as long as nothing changes. Should the value of count
change, then its dependents are sure out of sync.
count ++;
console.log( count, doubleCount, quadCount );
< 11, 20, 40
This is that reminder that expressions in JavaScript aren't automatically bound to their dependencies. (Something we'd expect of any programming language.) The render()
function must be called again each time the value of count
changes.
An important worry is that we end up running overheads on sebsequent calls to render()
, as those document.querySelector()
calls traverse the DOM again, just to return the same elements as in previous runs. (In real life, there could be even more expensive operations up there.)
Enter dependency threads; suddenly, we can get statements to run in isolation in response to a change! Here comes a new way to think about reactivity and performance in JavaScript! 👇
> Obtain SubscriptFunction
and use as a drop-in replacement for Function
! 👇
let render = new SubscriptFunction(`
let countElement = document.querySelector( '#count' );
countElement.innerHTML = count;
let doubleCount = count * 2;
let doubleCountElement = document.querySelector( '#double-count' );
doubleCountElement.innerHTML = doubleCount;
let quadCount = doubleCount * 2;
let quadCountElement = document.querySelector( '#quad-count' );
quadCountElement.innerHTML = quadCount;`
);
More about the syntatic rhyme between SubscriptFunction
and Function
ahead.
> Use render
as a normal function…
render();
The above executes the function body in whole as we'd expect. Elements are selected and assigned content. And we can see the counters in the console.
console.log( count, doubleCount, quadCount );
< 10, 20, 40
> Run just the count
dependency thread…
count ++;
render.thread( [ 'count' ] );
This time, only statements 2
-> 3
-> 5
-> 6
-> 8
are run - the "count" dependency thread; and the previously selected UI elements in those local variables are only now updated.
console.log( count, doubleCount, quadCount );
< 11, 22, 44
> Use SubscriptFunction
as a building block.
A Custom Element Example ahead
What Is Subscript?
A general-purpose reactivity runtime for JavaScript, with an overarching philosophy of reactivity that is based on the dependency graph of your own code, and nothing of its own syntax!
It takes any piece of code and compiles it into an ordinary JavaScript function that can also run expressions in dependency threads!
Being function-based let's us have all of Subscript as a building block… to fit anywhere!
Concepts
Thread Events
Subscript is not concerned with how changes happen or are detected on the outer scope of the function. It simply gives us a way to announce that something has changed. That announcement is called a thread event.
A Subscript function has a thread()
method that lets us trigger a thread for the list of outside variables or properties that have changed.
let a = 'Apple', b = 'Banana', c = { prop: 'Fruits' };
let fn = new SubscriptFunction(`
console.log( \`The value of 'a' is: \${ a }\` );
console.log( \`The value of 'b' is: \${ b }\` );
console.log( \`The value of 'c.prop' is: \${ c.prop }\` );
`);
fn();
b = 'Breadfruit';
fn.thread( [ 'b' ] );
The array syntax allows us to represent properties as paths.
fn.thread( [ 'c', 'prop' ] );
And we can run one thread for multiple changes.
fn.thread( [ 'a' ], [ 'b' ] );
Variable declarations within the function belong in their own scope and do not respond to outside events. But when they do reference variables from the outside scope, they are included in the dependency thread of those outside variables.
let fn = new SubscriptFunction(`
let a = 'Apple', b = 'Banana' + ' ' + c.prop;
console.log( \`The value of 'a' is: \${ a }\` );
console.log( \`The value of 'b' is: \${ b }\` );
console.log( \`The value of 'c.prop' is: \${ c.prop }\` );
`);
fn();
fn.thread( [ 'a' ], [ 'b' ] );
fn.thread( [ 'c', 'prop' ] );
References And Bindings
Expressions and statements in Subscript maintain a binding to their references. And that's the basis for reactivity in Subscript.
For example, variable declarations, with let
and var
, and assignment expressions, are bound to any references that may be in their argument. (const
declarations are an exception as they're always const in nature.)
var tense = score > 50 ? 'passed' : 'failed';
Above, the assignment expression is bound to the reference score
; and thus responds to a thread event for score
.
The thread continues with any susequent bindings to the tense
variable itself...
let message = `Hi ${ candidate.firstName }, you ${ tense } this test!`;
Above, the assignment expression is bound to the references candidate
, candidate.firstName
, and tense
; and thus responds to a thread event for each.
And the thread continues with any susequent bindings to the message
variable itself... and any bindings of those bindings...
let fullMessage = [ message, ' ', 'Thank you!' ].join( '' );
let broadcast = { [ candidate.username ]: fullMessage };
console.log( broadcast );
let broadcastInstance = new BroadcastMessage( broadcast );
Conditionals And Logic
When the test expression of an "If/Else" statement, "Switch" statement, or other logical expressions contains references, the statement or logical expression is bound to those references. This lets us have reactive conditionals and logic.
"If/Else" Statements
An "If/Else" statement is bound to references in its "test" expression.
if ( score > 80 && passesSomeOtherTest() ) {
addBadge( candidate );
candidate.remark = 'You\'ve got a badge';
} else {
}
Above, the "If/Else" construct is bound to the references score
and passesSomeOtherTest
- yes, should that also change. A thread event for any of these gets the construct re-evaluated; first, the "test" expression (score > 80 && passesSomeOtherTest()
), then, the body of the appropriate branch of the construct.
Statements in the body of the "consequent" and "alternate" branches form a binding to references of their own, independent of their containing "If" construct. But they only respond to thread events for as long as the "state" of all conditions in context allows.
Above, the addBadge()
expression is bound to the reference candidate
, and joins alone in the dependency thread, independent of the "If" construct, but for as long as the condition in context (score > 80 && passesSomeOtherTest()
) holds true.
The "state" of all conditions in context are determined via memoization, and no re-evaluation ever takes place.
An "Else/If" block is taken for just an "If" statement in the "Else" block of a parent "If" statement...
if ( score > 80 && passesSomeOtherTest() ) {
addBadge( candidate );
candidate.remark = 'You\'ve got a badge';
} else if ( someOtherCondition ) {
} else {
}
...and is bound to references in its own "test" expression, independent of its parent. But it only responds to thread events for as long as the "state" of all conditions in context allows.
Above, the nested "If" statement is bound to the reference someOtherCondition
, and joins alone in the dependency thread, independent of the parent "If" construct, but for as long as the parent condition (score > 80 && passesSomeOtherTest()
) holds false.
"Switch" Statements
A "Switch" statement is bound to references in its "test" expressions - the "switch/case" expressions.
switch( score ) {
case 0:
candidate.remark = 'You got nothing at all';
break;
case maxScore:
candidate.remark = 'You got the most';
break;
default:
candidate.remark = defaultRemark;
}
Above, the "Switch" construct is bound to the references score
and maxScore
. A thread event for any of these gets the construct re-evaluated; first, the "switch/case" expressions (score === 0
| score === maxScore
| score === null
), then, the body of the appropriate branch of the construct.
Statements in the body of the branches form a binding to references of their own, independent of the "Switch" construct. But they only respond to thread events for as long as the "state" of all conditions in context allows.
Above, the assignment to candidate.remark
(in the "default" case) is bound to the reference defaultRemark
, and joins alone in the dependency thread, independent of the "Switch" construct, but for as long as the conditions in context (score === null
) hold true.
The "state" of all conditions in context are determined via memoization, and no re-evaluation ever takes place.
Logical And Ternary Expressions
Subscript observes the state of logical (a && b || c
) and ternary (a ? b : c
) expressions when running dependency threads.
let a = () => 1;
let b = 2;
let c = 3;
let d, e;
A logical expression...
e = a() && b || c;
A ternary expression...
d = a() ? b : c;
Above, each of the two expressions is bound to the references a
, b
and c
. A thread event for any of a
and b
- or a
and c
, as determined by the "logical state" of the expressions - gets the expressions re-evaluated; first, the "test" expression (a()
), then, the expression on the appropriate side of the construct.*
*Since expressions in the "consequent" and "alternate" sides of a conditional or logical expression are mutually exclusive (b
and c
above), as determined by the "test" expression (a()
above), only the thread events for the references in the currently active side (b
above) are honoured by the expression at any given point in time.
Loops
When the parameters of a loop ("For" loops, "While" and "Do … While" loops) contain references, the loop is bound to those references. This lets us have reactive loops.
A for
Loop, while
And do … while
Loop
A "For" loop is bound to references in its 3-part definition.
let start = 0;
let items = [ 'one', 'two', 'three', 'four', 'five' ];
let targetItems = [];
for ( let index = start; index < items.length; index ++ ) {
targetItems[ index ] = items[ index ];
}
The loop above is bound to the references start
, items
, and items.length
. A thread event for any of these gets the loop to run again.
start = 2;
fn.thread( [ 'start' ] );
items.unshift( 'zero' );
fn.thread( [ 'items', 'length' ] );
As with a "For" loop, a "While" and "Do ... While" loop are bound to references in their "test" expression.
let index = 0;
let items = [ 'one', 'two', 'three', 'four', 'five' ];
let targetItems = [];
while ( index < items.length ) {
targetItems[ index ] = items[ index ];
index ++;
}
The loop above is bound to the references items
and items.length
. A thread event for any of these gets the loop to run again.
items.unshift( 'zero' );
fn.thread( [ 'items', 'length' ] );
A for … of
Loop
A "For ... Of" loop is bound to references in its iteratee.
let entries = [ 'one', 'two', 'three', 'four', 'five' ];
let targetEntries = [];
for ( let entry of entries ) {
let index = entries.indexOf( entry );
console.log( `Current iteration index is: ${ index }, and entry is: '${ entry }'` );
targetEntries[ index ] = entries[ index ];
}
The loop above is bound to the reference entries
. A thread event for entries
gets the loop to run again.
entries = [ 'six', 'seven', 'eight', 'nine', 'ten' ];
fn.thread( [ 'entries' ] );
As an added advantage of this form of loop, updating a specific entry in entries
moves the loop's pointer to the specific iteration involving that entry, and the body of that iteration is run again.
entries[ 7 ] = 'This is new eight';
fn.thread( [ 'entries', 7 ] );
Now, the console reports…
Current iteration index is: 7, and entry is: 'This is new eight'
…and index 7
of targetEntries
is updated.
A for … in
Loop
A "For ... In" loop is bound to references in its iteratee.
let entries = { one: 'one', two: 'two', three: 'three', four: 'four', five: 'five' };
let targetEntries = {};
for ( let propertyName in entries ) {
console.log( `Current property name is: ${ propertyName }, and associated value is: '${ entries[ propertyName ] }'` );
targetEntries[ propertyName ] = entries[ propertyName ];
}
The loop above is bound to the reference entries
. A thread event for entries
gets the loop to run again.
entries = { six: 'six', seven: 'seven', eight: 'eight', nine: 'nine', ten: 'ten' };
fn.thread( [ 'entries' ] );
As an added advantage of this form of loop, updating a specific property in entries
moves the loop's pointer to the specific iteration involving that property, and the body of that iteration is run again.
entries[ 'eight' ] = 'This is new eight';
fn.thread( [ 'entries', 'eight' ] );
Now, the console reports…
Current property name is: eight, and property value is: 'This is new eight'
…and the property eight
of targetEntries
is updated.
Iteration States
Conceptually, each round of iteration in a loop is an instance that Subscript can access directly when running a thread. A round of iteration is thus updatable in isolation, in response to a directed event. This is what happens when the iteratee of a "For ... Of" and "For ... In" loop has any of its properties updated, as seen above.
Below is a similar case.
let entries = { one: { name: 'one' }, two: { name: 'two' } };
let targetEntries = {};
for ( let propertyName in entries ) {
console.log( `Current property name is: ${ propertyName }, and its alias name is: '${ entries[ propertyName ].name }'` );
targetEntries[ propertyName ] = entries[ propertyName ];
}
On updating the first entry, only the first round of iteration is executed again.
entries[ 'one' ] = { name: 'New one' };
fn.thread( [ 'entries', 'one' ] );
For even more granularity, individual expressions inside a round of iteration are also responsive to thread events of their own. So, if we updated just entries.one.name
…
entries.one.name = 'New one';
fn.thread( [ 'entries', 'one', 'name' ] );
…we would have skipped the iteration instance itself, to target just the first statement within it.
This granular reactivity makes it often pointless to trigger a full rerun of a loop, offering multiple opportunities to deliver unmatched performance.
Breakouts
Subscript observes break
and continue
statements even when running a thread. And any of these statements may employ labels.
let entries = { one: { name: 'one' }, two: { name: 'two' } };
parentLoop: for ( let propertyName in entries ) {
childLoop: for ( let subPropertyName in entries[ propertyName ] ) {
If ( propertyName === 'one' ) {
break parentLoop;
}
console.log( propertyName, subPropertyName );
}
}
Functions
Functions are static definitions...
function sum( a, b ) {
}
let sum = function( a, b ) {
}
let sum = ( a, b ) => {
}
...and nothing about their parameters is reactive!
They are really only significant at call-time; and call-time arguments are rightly reactive!
let result = sum( score, 100 );
The expression above is bound to the reference score
. A thread event for score
gets the sum()
function called again with its current value.
Side Effects
When a function modifies anything outside of its scope, it is said to have side effects.
let callCount = 0;
function sum( a, b ) {
callCount ++;
return a + b;
}
When it does not, it is said to be a pure function.
function sum( a, b ) {
return a + b;
}
Regardless, Subscript's dependency threads are fully able to pick up changes made via a side effect.
function sum( a, b ) {
callCount ++;
return a + b;
}
let callCount = 0;
let result = sum( score, 100 );
console.log( 'Number of times we\'ve summed:', callCount );
Above, each time the thread event for score
gets the sum()
expression to run again, callCount
is incremented as a side effect; and the dependent console.log()
expression joins in the thread to pick that up!
Since statements in a dependency thread are executed in normal program execution order, side effects only trigger dependent expressions that appear after the point of call, not before.
function sum( a, b ) {
callCount ++;
return a + b;
}
let callCount = 0;
console.log( 'BEFORE POINT OF CALL: Number of times we\'ve summed:', callCount );
let result = sum( score, 100 );
console.log( 'AFTER POINT OF CALL: Number of times we\'ve summed:', callCount );
Above, on the thread event for score
, the first console.log()
expression doesn't run because at that point sum()
hasn't been called to make the side effect!
Also, since Subscript does not change runtime expection in any way, side effects made by function calls outside of a running thread do not get to start a thread in a bid to engage its dependent expressions!
function sum( a, b ) {
callCount ++;
return a + b;
}
let callCount = 0;
document.body.addEventListener( 'click', () => {
let result = sum( score, 100 );
} );
console.log( 'Number of times we\'ve summed:', callCount );
This time, sum()
is triggerred from a click event handler, not via a dependency thread, and we do not expect the console.log()
expression to run!
Subscript Function Syntax (New)
Subscript explores the possibility of defining functions outright as reactive functions using regular Function Declaration and Function Expression syntaxes!
function** sum( a, b ) {
}
let sum = function**( a, b ) {
}
Notice the double star **
symbol above; it's just one star extra to the standard syntax for Generator Functions (function* gen() {}
) - one more thing in the same classification of a special-purpose function in JavaScript! 😎
Functions defined this way are compiled as SubscriptFunction
, exposing a .thread()
method for running dependency threads, and offering everything else as in when we use the SubscriptFunction
constructor.
The following syntaxes are interchangeable...
function** sum( a, b ) {
return a + b;
}
let sum = function**( a, b ) {
return a + b;
}
let sum = new SubscriptFunction( `a`, `b`, `return a + b;` );
...but the first two (proposed syntaxes) are only currently supported within a Subscript Function itself!
let score = 10;
let program = new SubscriptFunction(`
function** sum( a, b ) {
callCount ++;
return a + b;
}
let callCount = 0;
// The following call results in a side effect
let result = sum( score, 100 );
// and "callCount" is logged as "1" to the console
console.log( 'Number of times we\'ve summed:', callCount );
// The following call runs a dependency thread that excludes the side effect
// while return the sum of the previous values of "a" and "b"
let result = sum.thread( [ 'a' ] );
// and "callCount" is still logged as "1", not "2", to the console
console.log( 'Number of times we\'ve summed:', callCount );
`);
program();
API
SubscriptFunction
is a one-to-one equivalent of the JavaScript Function constructor. They work interchangeably 😎.
Syntax
let subscrFunction = SubscriptFunction( functionBody );
let subscrFunction = SubscriptFunction( arg1, functionBody );
let subscrFunction = SubscriptFunction( arg1, ... argN, functionBody );
let subscrFunction = new SubscriptFunction( functionBody );
let subscrFunction = new SubscriptFunction( arg1, functionBody );
let subscrFunction = new SubscriptFunction( arg1, ... argN, functionBody );
Parameters
arg1, ... argN
Names to be used by the function as formal argument names. Each must be a string that corresponds to a valid JavaScript parameter (any of plain identifier, rest parameter, or destructured parameter, optionally with a default), or a list of such strings separated with commas.
functionBody
A string that represents the function body.
Return Value
A regular Function
object, or an async function
object where the await
keyword is used within functionBody
.
let sum = SubscriptFunction( 'a', 'b', 'return a + b;' );
console.log( sum( 10, 2 ) );
< 12
let sum = SubscriptFunction( 'a', 'b', 'return a + await b;' );
sum( 10, 2 ).then( result => {
console.log( result );
} );
< 12
The this
Binding
Functions returned by SubscriptFunction
are standard functions that can have their own this
binding at call time.
let colorSwitch = SubscriptFunction( 'color', 'this.style.color = color;' );
let element = document.body;
colorSwitch.call( element, 'red' );
But, where the this
binding is undefined
at call time, the this
binding of the SubscriptFunction
itself is used. This lets us have a default this
binding at creation time.
let element = document.body;
let colorSwitch = SubscriptFunction.call( element, 'color', 'this.style.color = color;' );
colorSwitch( 'red' );
colorSwitch.call( undefined, 'red' );
let h1Element = document.getElementById( 'h1' );
colorSwitch.call( h1Element, 'red' );
The subscrFunction.thread()
Method
The .thread()
method is the reactivity API in Subscript functions that lets us send thread events into the reactivity runtime. It takes a list of the outside variables or properties that have changed; each as an array path.
Syntax
let returnValue = subscrFunction.thread( path1, ... pathN );
Parameters
path1, ... pathN
An array path representing each variable, or object property, that has changed. See Thread Events for concepts and usage.
Return Value
The return value of this method depends on the return value of the dependency thread it initiates within the function body.
a = 10;
b = 2;
let sum = SubscriptFunction(`
if ( a > 10 ) {
return a + await b;
}
return a + b;
`);
console.log( sum() );
< 12
a = 20;
console.log( sum.thread( [ 'a' ] ) );
< Promise { 22 }
Installation
> Install via npm
npm i @webqit/subscript
import SubscriptFunction from '@webqit/subscript';
> Include from a CDN
<script src="https://unpkg.com/@webqit/subscript/dist/main.js"></script>
const SubscriptFunction = WebQit.Subscript;
A Custom Element Example
As trivial as our hypothetical render()
function above is, we can see it applicable in real life places! Consider a neat reactive Custom Element example based on SubscriptElement
from OOHTML.
let count = 10;
customElements.define( 'click-counter', class extends SubscriptElement( HTMLElement ) {
static get subscriptMethods() {
return [ 'render' ];
}
connectedCallback() {
this.render();
this.addEventListener( 'click', () => {
count ++;
this.render.thread( [ 'count' ] );
} );
}
render() {
let countElement = document.querySelector( '#count' );
countElement.innerHTML = count;
let doubleCount = count * 2;
let doubleCountElement = document.querySelector( '#double-count' );
doubleCountElement.innerHTML = doubleCount;
let quadCount = doubleCount * 2;
let quadCountElement = document.querySelector( '#quad-count' );
quadCountElement.innerHTML = quadCount;
}
} );
Continue to SubscriptElement for the full story.
Motivation
The Best Syntax Is No Syntax At All!
Frontend has a syntax problem! Every framework has come contributing something JavaScript-like, HTML-like, or even JavaScript/HTML-like2 to the plague! And for many of us, that bit is a non-starter! 😡
So, we're rethinking reactivity, again! This, time, to lay its very principles on nothing at all but plain JavaScript!
Performant JavaScript
With an insane focus on pure JavaScript syntax, Subscript is able to keep its footprint, and your footprint, ridiculously small. This less clutter, is more performance!
Subscript follows a compiler-aided approach that translates to a tiny, highly-optimized piece at runtime - no diffing; no callback wizardry!
Developer "Joooy" 😎
Much work goes into learning and using today's slew of reactivity primitives - those on____
and use____
hooks! But to explicitly construct reactive relationships is to slave over something that is implicit in a program's dependency graph!
Subscript lets you write your code, not the hooks! Graph-based reactivity just kicks in! 🤩
Offering the full range of modern JavaScript, with zero additional clutter, none of a complex toolchain and no required build step is a new dimension to developer productivity!
Compasable Reactivity
Subscript comes as reactivity in a function - the smallest possible unit of composition, and this is new! But that is to say: composition is king!
Thinking of reactive JavaScript classes? Make one... with Subscript Function as a method! Building the next reactive system? Put Subscript Functions under the hood!
Progressive Development
What's the possibility of turning reactivity on and off on an existing code base, in an afterthought? Oh that's a nobrainer with Subscript Functions!
-
Using the Function Constructor approach? Just toggle between the function type, while everything else stays intact:
let sum = new Function( `a`, `b`, `return a + b` );
let sum = new SubscriptFunction( `a`, `b`, `return a + b` );
-
Using the Function Synctax approach? Just toggle the double star, while everything else stays intact:
function sum( a, b, ) {
return a + b;
}
function** sum( a, b, ) {
return a + b;
}
This togglability is new!
Issues
To report bugs or request features, please submit an issue.
License
MIT.