Created
November 25, 2019 06:19
-
-
Save piyushchauhan2011/d30bf4bce524e97f688c948887fbdb9d to your computer and use it in GitHub Desktop.
Vanilla Hooks with Web Components
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <meta http-equiv="X-UA-Compatible" content="ie=edge" /> | |
| <title>Web Component Hooks</title> | |
| </head> | |
| <body> | |
| <my-counter></my-counter> | |
| <script type="module"> | |
| import { | |
| html, | |
| render, | |
| directive | |
| } from "https://unpkg.com/lit-html/lit-html.js"; | |
| const phaseSymbol = Symbol.for("haunted.phase"); | |
| const hookSymbol = Symbol.for("haunted.hook"); | |
| const updateSymbol = Symbol.for("haunted.update"); | |
| const commitSymbol = Symbol.for("haunted.commit"); | |
| const effectsSymbol = Symbol.for("haunted.effects"); | |
| let current; | |
| let currentId = 0; | |
| function setCurrent(element) { | |
| current = element; | |
| } | |
| function clear() { | |
| current = null; | |
| currentId = 0; | |
| } | |
| function notify() { | |
| let id = currentId; | |
| currentId++; | |
| return id; | |
| } | |
| function scheduler() { | |
| let tasks = []; | |
| let id; | |
| function runTasks() { | |
| id = null; | |
| let t = tasks; | |
| tasks = []; | |
| for (var i = 0, len = t.length; i < len; i++) { | |
| t[i](); | |
| } | |
| } | |
| return function(task) { | |
| tasks.push(task); | |
| if (id == null) { | |
| id = requestAnimationFrame(runTasks); | |
| } | |
| }; | |
| } | |
| const read = scheduler(); | |
| const write = scheduler(); | |
| class Container { | |
| constructor(renderer, frag, host) { | |
| this.renderer = renderer; | |
| this.frag = frag; | |
| this.host = host || frag; | |
| this[hookSymbol] = new Map(); | |
| this[phaseSymbol] = null; | |
| this._updateQueued = false; | |
| } | |
| update() { | |
| if (this._updateQueued) return; | |
| read(() => { | |
| let result = this.handlePhase(updateSymbol); | |
| write(() => { | |
| this.handlePhase(commitSymbol, result); | |
| if (this[effectsSymbol]) { | |
| write(() => { | |
| this.handlePhase(effectsSymbol); | |
| }); | |
| } | |
| }); | |
| this._updateQueued = false; | |
| }); | |
| this._updateQueued = true; | |
| } | |
| handlePhase(phase, arg) { | |
| this[phaseSymbol] = phase; | |
| switch (phase) { | |
| case commitSymbol: | |
| return this.commit(arg); | |
| case updateSymbol: | |
| return this.render(); | |
| case effectsSymbol: | |
| return this.runEffects(effectsSymbol); | |
| } | |
| this[phaseSymbol] = null; | |
| } | |
| commit(result) { | |
| render(result, this.frag); | |
| this.runEffects(commitSymbol); | |
| } | |
| render() { | |
| setCurrent(this); | |
| let result = this.args | |
| ? this.renderer.apply(this.host, this.args) | |
| : this.renderer.call(this.host, this.host); | |
| clear(); | |
| return result; | |
| } | |
| runEffects(symbol) { | |
| let effects = this[symbol]; | |
| if (effects) { | |
| setCurrent(this); | |
| for (let effect of effects) { | |
| effect.call(this); | |
| } | |
| clear(); | |
| } | |
| } | |
| teardown() { | |
| let effects = this[effectsSymbol]; | |
| if (effects) { | |
| for (let effect of effects) { | |
| effect.teardown(); | |
| } | |
| } | |
| } | |
| } | |
| function component(renderer, BaseElement = HTMLElement) { | |
| class Element extends BaseElement { | |
| static get observedAttributes() { | |
| return renderer.observedAttributes || []; | |
| } | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this._container = new Container(renderer, this.shadowRoot, this); | |
| } | |
| connectedCallback() { | |
| this._container.update(); | |
| } | |
| disconnectedCallback() { | |
| this._container.teardown(); | |
| } | |
| attributeChangedCallback(name, _, newValue) { | |
| let val = newValue === "" ? true : newValue; | |
| Reflect.set(this, name, val); | |
| } | |
| } | |
| function reflectiveProp(initialValue) { | |
| let value = initialValue; | |
| return Object.freeze({ | |
| enumerable: true, | |
| configurable: true, | |
| get() { | |
| return value; | |
| }, | |
| set(newValue) { | |
| value = newValue; | |
| this._container.update(); | |
| } | |
| }); | |
| } | |
| const proto = new Proxy(BaseElement.prototype, { | |
| set(target, key, value, receiver) { | |
| if (key in target) { | |
| Reflect.set(target, key, value); | |
| } | |
| let desc; | |
| if (typeof key === "symbol" || key[0] === "_") { | |
| desc = { | |
| enumerable: true, | |
| configurable: true, | |
| writable: true, | |
| value | |
| }; | |
| } else { | |
| desc = reflectiveProp(value); | |
| } | |
| Object.defineProperty(receiver, key, desc); | |
| if (desc.set) { | |
| desc.set.call(receiver, value); | |
| } | |
| return true; | |
| } | |
| }); | |
| Object.setPrototypeOf(Element.prototype, proto); | |
| return Element; | |
| } | |
| class Hook { | |
| constructor(id, el) { | |
| this.id = id; | |
| this.el = el; | |
| } | |
| } | |
| function use(Hook, ...args) { | |
| let id = notify(); | |
| let hooks = current[hookSymbol]; | |
| let hook = hooks.get(id); | |
| if (!hook) { | |
| hook = new Hook(id, current, ...args); | |
| hooks.set(id, hook); | |
| } | |
| return hook.update(...args); | |
| } | |
| function hook(Hook) { | |
| return use.bind(null, Hook); | |
| } | |
| function setEffects(el, cb) { | |
| if (!(effectsSymbol in el)) { | |
| el[effectsSymbol] = []; | |
| } | |
| el[effectsSymbol].push(cb); | |
| } | |
| const useEffect = hook( | |
| class extends Hook { | |
| constructor(id, el) { | |
| super(id, el); | |
| this.values = false; | |
| setEffects(el, this); | |
| } | |
| update(callback, values) { | |
| this.callback = callback; | |
| this.lastValues = this.values; | |
| this.values = values; | |
| } | |
| call() { | |
| if (this.values) { | |
| if (this.hasChanged()) { | |
| this.run(); | |
| } | |
| } else { | |
| this.run(); | |
| } | |
| } | |
| run() { | |
| this.teardown(); | |
| this._teardown = this.callback.call(this.el); | |
| } | |
| teardown() { | |
| if (this._teardown) { | |
| this._teardown(); | |
| } | |
| } | |
| hasChanged() { | |
| return ( | |
| this.lastValues === false || | |
| this.values.some((value, i) => this.lastValues[i] !== value) | |
| ); | |
| } | |
| } | |
| ); | |
| const useState = hook( | |
| class extends Hook { | |
| constructor(id, el, initialValue) { | |
| super(id, el); | |
| this.updater = this.updater.bind(this); | |
| this.makeArgs(initialValue); | |
| } | |
| update() { | |
| return this.args; | |
| } | |
| updater(value) { | |
| this.makeArgs(value); | |
| this.el.update(); | |
| } | |
| makeArgs(value) { | |
| this.args = Object.freeze([value, this.updater]); | |
| } | |
| } | |
| ); | |
| const useReducer = hook( | |
| class extends Hook { | |
| constructor(id, el, _, initialState) { | |
| super(id, el); | |
| this.dispatch = this.dispatch.bind(this); | |
| this.state = initialState; | |
| } | |
| update(reducer) { | |
| this.reducer = reducer; | |
| return [this.state, this.dispatch]; | |
| } | |
| dispatch(action) { | |
| this.state = this.reducer(this.state, action); | |
| this.el.update(); | |
| } | |
| } | |
| ); | |
| const useMemo = hook( | |
| class extends Hook { | |
| constructor(id, el, fn, values) { | |
| super(id, el); | |
| this.value = fn(); | |
| this.values = values; | |
| } | |
| update(fn, values) { | |
| if (this.hasChanged(values)) { | |
| this.values = values; | |
| this.value = fn(); | |
| } | |
| return this.value; | |
| } | |
| hasChanged(values) { | |
| return values.some((value, i) => this.values[i] !== value); | |
| } | |
| } | |
| ); | |
| class DirectiveContainer extends Container { | |
| commit(result) { | |
| this.host.setValue(result); | |
| this.host.commit(); | |
| } | |
| } | |
| const map = new WeakMap(); | |
| function withHooks(renderer) { | |
| function factory(...args) { | |
| return part => { | |
| let cont = map.get(part); | |
| if (!cont) { | |
| cont = new DirectiveContainer(renderer, part); | |
| map.set(part, cont); | |
| } | |
| cont.args = args; | |
| cont.update(); | |
| }; | |
| } | |
| return directive(factory); | |
| } | |
| // End of haunted library | |
| const initialState = { count: 0 }; | |
| function reducer(state, action) { | |
| switch (action.type) { | |
| case "reset": | |
| return initialState; | |
| case "increment": | |
| return { count: state.count + 1 }; | |
| case "decrement": | |
| return { count: state.count - 1 }; | |
| } | |
| } | |
| function Counter() { | |
| const [state, dispatch] = useReducer(reducer, initialState); | |
| return html` | |
| Count: ${state.count} | |
| <button @click=${() => dispatch({ type: "reset" })}>Reset</button> | |
| <button @click=${() => dispatch({ type: "increment" })}>+</button> | |
| <button @click=${() => dispatch({ type: "decrement" })}>-</button> | |
| `; | |
| } | |
| customElements.define("my-counter", component(Counter)); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment