From 3554f9efed62320490e718c756172705e1134261 Mon Sep 17 00:00:00 2001 From: Michael Di Prisco Date: Sat, 4 Mar 2023 21:59:40 +0100 Subject: [PATCH] feat: first release - yet to complete --- .gitignore | 2 + README.md | 68 ++++++++++++++++++++ examples/attachTo.html | 25 ++++++++ examples/benchmark.html | 37 +++++++++++ examples/btn.html | 26 ++++++++ examples/computed.html | 36 +++++++++++ examples/copyTo.html | 31 +++++++++ examples/subscriber.html | 25 ++++++++ package-lock.json | 84 ++++++++++++++++++++++++ package.json | 36 +++++++++++ src/Computed.ts | 52 +++++++++++++++ src/Signal.ts | 134 +++++++++++++++++++++++++++++++++++++++ src/index.ts | 12 ++++ tsconfig.json | 15 +++++ 14 files changed, 583 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 examples/attachTo.html create mode 100644 examples/benchmark.html create mode 100644 examples/btn.html create mode 100644 examples/computed.html create mode 100644 examples/copyTo.html create mode 100644 examples/subscriber.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/Computed.ts create mode 100644 src/Signal.ts create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea3c971 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# What Is This? +This is a super simple signal library for browser and Node. It's mostly an experiment which provided good benchmarks and a nice, clean and simple API. + +# Installation +```bash +npm install super-simple-signal +``` + +# Usage +```html + + + +
+ +``` + +# API +## Signal +### `new Signal(value)` +Creates a new signal with the given initial value. + +### `new Signal(node)` +Creates a new signal which is attached to the given node. + +### `new Signal(node, value)` +Creates a new signal which is attached to the given node and has the given initial value. + +### `new Signal(node, value, property)` +Creates a new signal which is attached to the given node and has the given initial value. The signal will update the given property of the node. Defaults to `innerHTML`. + +### `signal.value` +The current value of the signal. + +### `signal.subscribe(callback)` +Subscribes the given callback to the signal. The callback will be called whenever the signal's value changes. The callback will be called with the new value as the first argument. + +### `signal.unsubscribe(callback)` +Unsubscribes the given callback from the signal. + +### `signal.attachTo(node, property)` +Attaches the signal to the given node. The signal will update the node's given property whenever the signal's value changes. Defaults to `innerHTML`. + +### `signal.detachFrom(node)` +Detaches the signal from the given node. + +### `signal.copyTo(node, property, keepInSync)` +Copies the signal's value to the given node. This will actually create a new `Signal`. If `keepInSync` is `true`, the new signal will be updated whenever the initial signal changes. Defaults to `false`. + + + +# Considerations +- Currently the library only supports one-way binding. Updating the signal will update the DOM but not the other way around. + +# ToDo +- [ ] Add tests +- [ ] Two-way binding \ No newline at end of file diff --git a/examples/attachTo.html b/examples/attachTo.html new file mode 100644 index 0000000..d9a570d --- /dev/null +++ b/examples/attachTo.html @@ -0,0 +1,25 @@ + + + + + + + Signals Demo + + +
+
+ + + + diff --git a/examples/benchmark.html b/examples/benchmark.html new file mode 100644 index 0000000..6bc3c74 --- /dev/null +++ b/examples/benchmark.html @@ -0,0 +1,37 @@ + + + + + + + Signals Demo + + +
+
+
+ + + + diff --git a/examples/btn.html b/examples/btn.html new file mode 100644 index 0000000..65ed3ea --- /dev/null +++ b/examples/btn.html @@ -0,0 +1,26 @@ + + + + + + + Signals Demo + + + + +
+ + + diff --git a/examples/computed.html b/examples/computed.html new file mode 100644 index 0000000..568beeb --- /dev/null +++ b/examples/computed.html @@ -0,0 +1,36 @@ + + + + + + + Signals Demo + + +
+
+ + + + diff --git a/examples/copyTo.html b/examples/copyTo.html new file mode 100644 index 0000000..ca6cdcf --- /dev/null +++ b/examples/copyTo.html @@ -0,0 +1,31 @@ + + + + + + + Signals Demo + + +
+
+ + + + diff --git a/examples/subscriber.html b/examples/subscriber.html new file mode 100644 index 0000000..0b0b0da --- /dev/null +++ b/examples/subscriber.html @@ -0,0 +1,25 @@ + + + + + + + Signals Demo + + +
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9750de8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,84 @@ +{ + "name": "super-simple-signal", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "super-simple-signal", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "rollup": "^3.18.0", + "typescript": "^4.9.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/rollup": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.18.0.tgz", + "integrity": "sha512-J8C6VfEBjkvYPESMQYxKHxNOh4A5a3FlP+0BETGo34HEcE4eTlgCrO2+eWzlu2a/sHs2QUkZco+wscH7jhhgWg==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + } + }, + "dependencies": { + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "rollup": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.18.0.tgz", + "integrity": "sha512-J8C6VfEBjkvYPESMQYxKHxNOh4A5a3FlP+0BETGo34HEcE4eTlgCrO2+eWzlu2a/sHs2QUkZco+wscH7jhhgWg==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4aaeb02 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "super-simple-signal", + "version": "1.0.0", + "description": "A simple signal implementation in TypeScript for the browser and Node.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "start": "tsc -w", + "build": "rm -rf ./dist/* && tsc && rollup ./dist/tmp/index.js --file ./dist/index.js --format iife --name SuperSimpleSignal --sourcemap && rm -rf ./dist/tmp", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "signal", + "typescript", + "browser", + "node", + "event", + "event emitter", + "event dispatcher", + "event bus", + "event system", + "event manager", + "event handler", + "event listener", + "event binding" + ], + "author": "Michael Di Prisco ", + "license": "ISC", + "devDependencies": { + "rollup": "^3.18.0", + "typescript": "^4.9.5" + } +} diff --git a/src/Computed.ts b/src/Computed.ts new file mode 100644 index 0000000..130838c --- /dev/null +++ b/src/Computed.ts @@ -0,0 +1,52 @@ +import Signal from "./Signal"; + +// Computed is a signal that is derived from other signals and is updated when any of its dependencies change. +declare class Computed extends Signal { + /** @internal */ + _dependencies: Array; + + /** @internal */ + _fn: (...args: any[]) => T; + + constructor(fn: () => T, dependencies: Signal[]); + + /** @internal */ + _set(): void; +} + +function Computed(this: Computed, dependencies: Signal | Signal[], fn: (...args: any[]) => any, node: Node | undefined = undefined, value: unknown = undefined, property: string | undefined = "innerHTML") { + dependencies = Array.isArray(dependencies) ? dependencies : [dependencies]; + if (!(this instanceof Computed)) { + return new Computed(fn, dependencies); + } + + this._dependencies = dependencies; + this._fn = fn; + + for (const dependency of dependencies) { + dependency.subscribe(this._set.bind(this)); + } + + this._set(); +} + +Computed.prototype = Object.create(Signal.prototype); +Computed.prototype.constructor = Computed; + +Computed.prototype._set = function () { + this._oldValue = this._value; + this._value = this._fn(this._dependencies.length > 1 ? this._dependencies.map(dependency => dependency.value) : this._dependencies[0].value, this._dependencies.length > 1 ? this._dependencies.map(dependency => dependency._oldValue) : this._dependencies[0]._oldValue); + this._version++; + this._listeners.forEach(listener => listener(this._value, this._oldValue)); + this._refresh(); +}; + +Object.defineProperty(Computed.prototype, "value", { + set: function (value) { + throw new Error("Computed signals cannot be set"); + }, + get: function () { + return this._value; + }, +}); +export default Computed; diff --git a/src/Signal.ts b/src/Signal.ts new file mode 100644 index 0000000..701f1b3 --- /dev/null +++ b/src/Signal.ts @@ -0,0 +1,134 @@ +declare class Signal { + /** @internal */ + _value: unknown; + + /** @internal */ + _oldValue: unknown; + + /** + * @internal + */ + _version: number; + + /** @internal */ + _node?: Node; + + /** @internal */ + _property: string; + + /** @internal */ + _listeners: Set<(...args: unknown[]) => void>; + + constructor(node?: Node | undefined, value?: T, property?: string); + constructor(value?: T, node?: Node | undefined, property?: string); + + /** @internal */ + _refresh(): boolean; + + subscribe(fn: (value: T) => void): () => void; + + unsubscribe(fn: (value: T) => void): void; + + attachTo(node: Node, property?: string): void; + + copyTo(node: Node, property?: string, keepInSync?: boolean): Signal; + + detachFrom(node: Node): void; + + toString(): string; + + get value(): T; + set value(value: T); +} + +function Signal(this: Signal, node: Node | undefined = undefined, value: unknown = undefined, property: string = "innerHTML") { + if (!(this instanceof Signal)) { + return new Signal(node, value, property); + } + + if (typeof node !== "object") { + // We can assume that the first argument is the value + value = node; + node = undefined; + } + + this._value = value; + this._oldValue = undefined; + this._version = 0; + this._node = node; + this._property = property; + this._listeners = new Set(); + + if (node && value) { + node[property] = value; + } else if (node && !value) { + this._value = node[property]; + } + // else if (!node && !value) { + // throw new Error("Signal must be initialized with a value or a node"); + // } +} + +Signal.prototype = { + _value: undefined, + _oldValue: undefined, + _version: 0, + _node: undefined, + _property: "innerHTML", + _listeners: new Set(), + + _refresh() { + if (this._node) { + this._node[this._property] = this._value; + return true; + } + return false; + }, + + subscribe(fn: (value: unknown) => void) { + this._listeners.add(fn); + return () => this.unsubscribe(fn); + }, + + unsubscribe(fn: (value: unknown) => void) { + this._listeners.delete(fn); + }, + + attachTo(node: Node, property: string) { + property = property || this._property; + this._node = node; + node[property] = this._value; + }, + + copyTo(node: Node, property: string, keepInSync: boolean = false) { + property = property || this._property; + const signal = new Signal(node, this._value, property); + // To prevent circular updates, we only subscribe to the signal if keepInSync is true and don't subscribe back. + if (keepInSync) this.subscribe(value => (signal.value = value)); + return signal; + }, + + detachFrom(node: Node) { + if (this._node === node) { + this._node = undefined; + } + }, + + toString() { + return String(this._value); + }, + + get value() { + return this._value; + }, + + set value(value: unknown) { + this._oldValue = this._value; + this._value = value; + this._version += 1; + this._listeners.forEach(fn => fn(value, this._oldValue)); + this._refresh(); + }, +}; + +export default Signal; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9bbdd2f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +import Signal from "./Signal"; +import Computed from "./Computed"; + +declare global { + interface Window { + Signal: typeof Signal; + Computed: typeof Computed; + } +} + +window.Signal = Signal; +window.Computed = Computed; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0c2d430 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "es6", + "lib": ["es2015", "dom"], + "outDir": "./dist/tmp", + "moduleResolution": "node", + "sourceMap": true, + "declaration": true, + "strict": true, + "noImplicitAny": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}