Last active
December 19, 2018 06:52
-
-
Save emonkak/0366a2c17c26b7e6bb55fa27d899d86a to your computer and use it in GitHub Desktop.
StatefulComponent.ts
This file contains 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
export interface PropType<T> { | |
transform(propKey: string, propValue: string): T; | |
defaultValue?: T; | |
} | |
export type PropTypes<TProps> = { [P in keyof TProps]: PropType<TProps[P]> }; | |
export const string: PropType<string> = { | |
transform(propKey, propValue) { | |
return propValue; | |
}, | |
}; | |
export const number: PropType<number> = { | |
transform(propKey, propValue) { | |
return parseInt(propValue, 10); | |
}, | |
} | |
export const boolean: PropType<boolean> = { | |
transform(propKey, propValue) { | |
return true; | |
}, | |
defaultValue: false | |
} | |
export const json: PropType<any> = { | |
transform(propKey, propValue) { | |
return JSON.parse(propValue); | |
} | |
} | |
export function oneOf<T>(...expectedValues: T[]): PropType<T> { | |
return { | |
transform(propKey, propValue) { | |
if (expectedValues.indexOf(propValue as any) < 0) { | |
const valuesString = JSON.stringify(expectedValues); | |
throw new Error(`Invalid \`${propKey}\` of value \`${propValue}\`, expected one of \`${valuesString}\`.`); | |
} | |
return propValue as any; | |
} | |
} | |
} |
This file contains 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
import { TemplateResult, render } from 'lit-html'; | |
import { PropTypes } from './propTypes'; | |
interface StatefulComponentClass<TProps, TState> { | |
new(): StatefulComponent<TProps, TState>; | |
defaultProps: Partial<TProps>; | |
getInitialState(props: Readonly<TProps>): TState; | |
observedAttributes: string[]; | |
propTypes: PropTypes<TProps>; | |
} | |
interface Action { | |
type: string; | |
} | |
export type AsyncAction<TAction, TResult> = (commit: (action: TAction) => void) => TResult; | |
export default interface StatefulComponent<TProps, TState> { | |
constructor: StatefulComponentClass<TProps, TState>; | |
} | |
export interface MountPoint { | |
element: HTMLElement; | |
dispose(): void; | |
} | |
type Children = Node[]; | |
const ACTION_EVENT = '@@action'; | |
export default abstract class StatefulComponent<TProps = {}, TState = {}> extends HTMLElement { | |
static get observedAttributes(): string[] { | |
return Object.keys((this as StatefulComponentClass<any, any>).propTypes) | |
.map(toAttributeName); | |
}; | |
static propTypes = {}; | |
static defaultProps = {}; | |
static getInitialState(props: any): any { | |
return {}; | |
} | |
props: Readonly<TProps & { children?: Children }>; | |
state: Readonly<TState>; | |
readonly mountPoint: MountPoint; | |
private _pendingProps: Readonly<TProps & { children?: Children }>; | |
private _pendingState: Readonly<TState>; | |
private _pendingUpdate: number = 0; | |
private _isMounted: boolean = false; | |
private readonly _subscribers: (() => void)[] = []; | |
constructor() { | |
super(); | |
this._prepareAccessors(); | |
this.props = this._pendingProps = {} as any; | |
this.state = this._pendingState = {} as any; | |
this.mountPoint = this.getMountPoint(); | |
} | |
connectedCallback() { | |
Promise.resolve().then(() => { | |
const props = this._prepareProps(); | |
const state = this._prepareState(props); | |
this.props = this._pendingProps = props; | |
this.state = this._pendingState = state; | |
render(this.render(), this.mountPoint.element); | |
this._isMounted = true; | |
this.componentDidMount(); | |
}); | |
} | |
disconnectedCallback() { | |
this.componentWillUnmount(); | |
this.mountPoint.dispose(); | |
for (const subscriber of this._subscribers) { | |
subscriber(); | |
} | |
} | |
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { | |
if (!this._isMounted) { | |
return; | |
} | |
const { propTypes } = this.constructor; | |
const propKey = toPropKey(name) as keyof TProps; | |
const propType = propTypes[propKey]; | |
if (!propType) { | |
return; | |
} | |
const propValue = newValue !== null | |
? propType.transform(propKey as string, newValue) | |
: propType.defaultValue; | |
this._updateProp(propKey, propValue); | |
} | |
componentDidMount(): void { | |
} | |
componentWillReceiveProps(nextProps: Readonly<TProps>): void { | |
} | |
componentDidUpdate(prevProps: Readonly<TProps>, prevState: Readonly<TState>): void { | |
} | |
componentWillUnmount(): void { | |
} | |
shouldComponentUpdate(nextProps: Readonly<TProps>, nextState: Readonly<TState>): boolean { | |
return !isShallowEqual(this.props, nextProps) || | |
!isShallowEqual(this.state, nextState); | |
} | |
getMountPoint(): MountPoint { | |
return { | |
element: this, | |
dispose() {} | |
}; | |
} | |
dispatchAction<TAction extends Action, TResult>(action: AsyncAction<TAction, TResult>): TResult { | |
return action(this.commitAction.bind(this)); | |
} | |
commitAction<TAction extends Action>(action: TAction): void { | |
this.emitEvent(ACTION_EVENT, action); | |
} | |
applyAction<TAction extends Action>(reducer: (state: TState, action: TAction) => TState, target: EventTarget = this): void { | |
const subscriber = subscribeAction<TAction>(target, (action) => { | |
this.setState((state) => reducer(state, action)); | |
}); | |
this._subscribers.push(subscriber); | |
} | |
emitEvent<T>(type: string, detail?: T): void { | |
let event: CustomEvent; | |
try { | |
event = new CustomEvent<T>(type, { | |
bubbles: true, | |
detail | |
}); | |
} catch { | |
event = document.createEvent('CustomEvent'); | |
event.initCustomEvent(type, true, false, detail); | |
} | |
this.dispatchEvent(event); | |
} | |
setState(state: Readonly<Partial<TState>> | ((state: Readonly<TState>) => TState)): TState { | |
const nextState = typeof state === 'function' ? | |
state(this._pendingState) : | |
Object.assign({}, this._pendingState, state); | |
if (this._isMounted && this.shouldComponentUpdate(this._pendingProps, nextState)) { | |
this._scheduleUpdate(); | |
} | |
this._pendingState = nextState; | |
return nextState; | |
} | |
abstract render(): TemplateResult; | |
private _prepareAccessors(): void { | |
const { propTypes } = this.constructor; | |
for (const propKey in propTypes) { | |
Object.defineProperty(this, propKey, { | |
get() { | |
return this.props[propKey]; | |
}, | |
set(newValue) { | |
this._updateProp(propKey, newValue); | |
} | |
}); | |
} | |
} | |
private _prepareProps(): TProps & { children?: Children } { | |
const { defaultProps, propTypes } = this.constructor; | |
const props: Partial<TProps & { children?: Children }> = {}; | |
for (const propKey in propTypes) { | |
const attributeName = toAttributeName(propKey); | |
const propType = propTypes[propKey]; | |
const value = this.getAttribute(attributeName); | |
if (value !== null) { | |
props[propKey] = propType.transform(propKey, value); | |
} else if (this._pendingProps.hasOwnProperty(propKey)) { | |
props[propKey] = this._pendingProps[propKey]; | |
} else if (defaultProps.hasOwnProperty(propKey)) { | |
props[propKey] = defaultProps[propKey]; | |
} else if (propType.hasOwnProperty('defaultValue')) { | |
props[propKey] = propType.defaultValue; | |
} | |
} | |
if (this.childNodes.length > 0) { | |
const children = []; | |
for (let i = 0, l = this.childNodes.length; i < l; i++) { | |
const child = this.childNodes[i]; | |
if (child.nodeName.toUpperCase() === 'TEMPLATE') { | |
const templateChildren = (child as HTMLTemplateElement).content.childNodes || []; | |
for (let j = 0, m = templateChildren.length; j < m; j++) { | |
const templateChild = templateChildren[j]; | |
children.push(templateChild); | |
} | |
} else { | |
children.push(child); | |
} | |
} | |
props.children = children; | |
} | |
return props as TProps & { children?: Children }; | |
} | |
private _prepareState(props: TProps & { children?: Children }): TState { | |
const { getInitialState } = this.constructor; | |
return Object.assign({}, this._pendingState, getInitialState(props)); | |
} | |
private _updateProp<TKey extends keyof TProps>(key: TKey, newValue: TProps[TKey] | undefined): void { | |
const nextProps = Object.assign({}, this._pendingProps, { | |
[key]: newValue | |
}); | |
if (this._isMounted) { | |
this.componentWillReceiveProps(nextProps); | |
if (this.shouldComponentUpdate(nextProps, this._pendingState)) { | |
this._scheduleUpdate(); | |
} | |
} | |
this._pendingProps = nextProps; | |
} | |
private _scheduleUpdate(): void { | |
if (this._pendingUpdate === 0) { | |
this._pendingUpdate = window.requestAnimationFrame(this._update); | |
} | |
} | |
private _update = () => { | |
const prevProps = this.props; | |
const prevState = this.state; | |
this.props = this._pendingProps; | |
this.state = this._pendingState; | |
this._pendingUpdate = 0; | |
render(this.render(), this.mountPoint.element); | |
this.componentDidUpdate(prevProps, prevState); | |
} | |
} | |
function isShallowEqual<T>(x: T, y: T): boolean { | |
if (x !== y) { | |
for (const k in x) { | |
if (!(k in y) || x[k] !== y[k]) { | |
return false | |
} | |
} | |
for (const k in y) { | |
if (!(k in x) || x[k] !== y[k]) { | |
return false | |
} | |
} | |
} | |
return true; | |
} | |
function toPropKey(name: string): string { | |
return name.replace(/-./g, (s) => s.charAt(1).toUpperCase()); | |
} | |
function toAttributeName(key: string): string { | |
return key.replace(/[A-Z]/g, (s) => '-' + s.toLowerCase()); | |
} | |
function subscribeAction<TAction extends Action>(target: EventTarget, subscriber: (action: TAction) => void): () => void { | |
const listener = (event: Event) => { | |
const action = (event as CustomEvent<TAction>).detail; | |
subscriber(action); | |
}; | |
target.addEventListener(ACTION_EVENT, listener); | |
return () => { | |
target.removeEventListener(ACTION_EVENT, listener); | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment