preact
Version:
Tiny & fast Component-based virtual DOM framework.
1,058 lines (871 loc) • 25.9 kB
JavaScript
const ATTR_PREFIX = '__preactattr_';
const HAS_DOM = typeof document!=='undefined';
const EMPTY = {};
const NO_RENDER = { render: false };
const SYNC_RENDER = { renderSync: true };
const DOM_RENDER = { build: true };
const EMPTY_BASE = '';
const TEXT_CONTENT = !HAS_DOM || 'textContent' in document ? 'textContent' : 'nodeValue';
const NON_DIMENSION_PROPS = {
boxFlex:1,boxFlexGroup:1,columnCount:1,fillOpacity:1,flex:1,flexGrow:1,
flexPositive:1,flexShrink:1,flexNegative:1,fontWeight:1,lineClamp:1,lineHeight:1,
opacity:1,order:1,orphans:1,strokeOpacity:1,widows:1,zIndex:1,zoom:1
};
let toArray = obj => {
let arr = [];
for (let i=obj.length; i--; ) arr[i] = obj[i];
return arr;
};
let hop = Object.prototype.hasOwnProperty;
/** Create a caching wrapper for the given function.
* @private
*/
let memoize = (fn, mem={}) => k => hop.call(mem, k) ? mem[k] : (mem[k] = fn(k));
/** Get a deep property value from the given object, expressed in dot-notation.
* @private
*/
let delve = (obj, key) => (key.split('.').map( p => (obj = obj && obj[p]) ), obj);
/** Global options
* @public
* @namespace {Object}
*/
let options = {
/** If `true`, `prop` changes trigger synchronous component updates.
* @boolean
*/
syncComponentUpdates: true
};
/** Global hook methods
* @public
* @namespace {Object}
*/
let hooks = {
/** Processes all created VNodes.
* @param {VNode} vnode A newly-created VNode to normalize/process
* @protected
*/
vnode({ attributes }) {
if (!attributes) return;
let s = attributes.style;
if (s && !s.substring) {
attributes.style = styleObjToCss(s);
}
let c = attributes['class'];
if (hop.call(attributes, 'className')) {
c = attributes['class'] = attributes.className;
delete attributes.className;
}
if (c && !c.substring) {
attributes['class'] = hashToClassName(c);
}
}
};
/** Base Component class, for he ES6 Class method of creating Components
* @public
*
* @example
* class MyFoo extends Component {
* render(props, state) {
* return <div />;
* }
* }
*/
export class Component {
constructor() {
/** @private */
this._dirty = this._disableRendering = false;
/** @private */
this._linkedStates = {};
/** @public */
this.nextProps = this.base = null;
/** @type {object} */
this.props = hook(this, 'getDefaultProps') || {};
/** @type {object} */
this.state = hook(this, 'getInitialState') || {};
// @TODO remove me?
hook(this, 'initialize');
}
/** Returns a `boolean` value indicating if the component should re-render when receiving the given `props` and `state`.
* @param {object} props
* @param {object} state
*/
// shouldComponentUpdate() {
// return true;
// }
/** Returns a function that sets a state property when called.
* Calling linkState() repeatedly with the same arguments returns a cached link function.
*
* Provides some built-in special cases:
* - Checkboxes and radio buttons link their boolean `checked` value
* - Inputs automatically link their `value` property
* - Event paths fall back to any associated Component if not found on an element
* - If linked value is a function, will invoke it and use the result
*
* @param {string} key The path to set - can be a dot-notated deep key
* @param {string} [eventPath] If set, attempts to find the new state value at a given dot-notated path within the object passed to the linkedState setter.
* @returns {function} linkStateSetter(e)
*
* @example Update a "text" state value when an input changes:
* <input onChange={ this.linkState('text') } />
*
* @example Set a deep state value on click
* <button onClick={ this.linkState('touch.coords', 'touches.0') }>Tap</button
*/
linkState(key, eventPath) {
let c = this._linkedStates,
cacheKey = key + '|' + (eventPath || '');
return c[cacheKey] || (c[cacheKey] = createLinkedState(this, key, eventPath));
}
/** Update component state by copying properties from `state` to `this.state`.
* @param {object} state A hash of state properties to update with new values
*/
setState(state) {
extend(this.state, state);
triggerComponentRender(this);
}
/** @private */
setProps(props, opts) {
return setComponentProps(this, props, opts);
}
/** Accepts `props` and `state`, and returns a new Virtual DOM tree to build.
* Virtual DOM is generally constructed via [JSX](http://jasonformat.com/wtf-is-jsx).
* @param {object} props Props (eg: JSX attributes) received from parent element/component
* @param {object} state The component's current state
* @returns VNode
*/
render(props) {
return h('div', null, props.children);
}
}
/** Virtual DOM Node */
export class VNode {
constructor(nodeName, attributes, children) {
/** @type {string|function} */
this.nodeName = nodeName;
/** @type {object<string>|undefined} */
this.attributes = attributes;
/** @type {array<VNode>|undefined} */
this.children = children;
}
}
VNode.prototype.__isVNode = true;
/** Render JSX into a `parent` Element.
* @param {VNode} vnode A (JSX) VNode to render
* @param {Element} parent DOM element to render into
* @param {Element} [merge] Attempt to re-use an existing DOM tree rooted at `merge`
* @public
*
* @example
* // render a div into <body>:
* render(<div id="hello">hello!</div>, document.body);
*
* @example
* // render a "Thing" component into #foo:
* const Thing = ({ name }) => <span>{ name }</span>;
* render(<Thing name="one" />, document.querySelector('#foo'));
*/
export function render(vnode, parent, merge) {
let existing = merge && merge._component && merge._componentConstructor===vnode.nodeName,
built = build(merge, vnode),
c = !existing && built._component;
if (c) deepHook(c, 'componentWillMount');
if (build.parentNode!==parent) {
parent.appendChild(built);
}
if (c) deepHook(c, 'componentDidMount');
return built;
}
/** @public JSX/hyperscript reviver
* @see http://jasonformat.com/wtf-is-jsx
* @example
* /** @jsx h *\/
* import { render, h } from 'preact';
* render(<span>foo</span>, document.body);
*/
export function h(nodeName, attributes, ...args) {
let children,
sharedArr = [],
len = args.length,
arr, lastSimple;
if (len) {
children = [];
for (let i=0; i<len; i++) {
let p = args[i];
if (empty(p)) continue;
if (p.join) {
arr = p;
}
else {
arr = sharedArr;
arr[0] = p;
}
for (let j=0; j<arr.length; j++) {
let child = arr[j],
simple = !empty(child) && !isVNode(child);
if (simple) child = String(child);
if (simple && lastSimple) {
children[children.length-1] += child;
}
else if (!empty(child)) {
children.push(child);
}
lastSimple = simple;
}
}
}
if (attributes && attributes.children) {
delete attributes.children;
}
let p = new VNode(nodeName, attributes || undefined, children || undefined);
hook(hooks, 'vnode', p);
return p;
}
/** Invoke a "hook" method with arguments if it exists.
* @private
*/
function hook(obj, name, ...args) {
let fn = obj[name];
if (fn && typeof fn==='function') return fn.apply(obj, args);
}
/** Invoke hook() on a component and child components (recursively)
* @private
*/
function deepHook(obj, ...args) {
do {
hook(obj, ...args);
} while ((obj=obj._component));
}
/** Fast check if an object is a VNode.
* @private
*/
function isVNode(obj) {
return obj && obj.__isVNode===true;
}
/** Check if a value is `null` or `undefined`.
* @private
*/
function empty(x) {
return x===null || x===undefined;
}
/** Check if two nodes are equivalent.
* @param {Element} node
* @param {VNode} vnode
* @private
*/
function isSameNodeType(node, vnode) {
if (node.nodeType===3) {
return typeof vnode==='string';
}
if (isFunctionalComponent(vnode)) return true;
let nodeName = vnode.nodeName;
if (typeof nodeName==='function') return node._componentConstructor===nodeName;
return node.nodeName.toLowerCase()===nodeName;
}
/** Check if a VNode is a reference to a stateless functional component.
* A function component is represented as a VNode whose `nodeName` property is a reference to a function.
* If that function is not a Component (ie, has no `.render()` method on a prototype), it is considered a stateless functional component.
* @param {VNode} vnode A VNode
* @private
*/
function isFunctionalComponent({ nodeName }) {
return typeof nodeName==='function' && !nodeName.prototype.render;
}
/** Construct a resultant VNode from a VNode referencing a stateless functional component.
* @param {VNode} vnode A VNode with a `nodeName` property that is a reference to a function.
* @private
*/
function buildFunctionalComponent(vnode) {
return vnode.nodeName(getNodeProps(vnode)) || EMPTY_BASE;
}
/** Mark component as dirty and queue up a render.
* @param {Component} component
* @private
*/
function triggerComponentRender(component) {
if (!component._dirty) {
component._dirty = true;
renderQueue.add(component);
}
}
/** Set a component's `props` (generally derived from JSX attributes).
* @param {Object} props
* @param {Object} [opts]
* @param {boolean} [opts.renderSync=false] If `true` and {@link options.syncComponentUpdates} is `true`, triggers synchronous rendering.
* @param {boolean} [opts.render=true] If `false`, no render will be triggered.
*/
function setComponentProps(component, props, opts=EMPTY) {
let d = component._disableRendering;
component._disableRendering = true;
hook(component, 'componentWillReceiveProps', props, component.props);
component.nextProps = props;
component._disableRendering = d;
if (opts.render!==false) {
if (opts.renderSync || options.syncComponentUpdates) {
renderComponent(component);
}
else {
triggerComponentRender(component);
}
}
}
/** Render a Component, triggering necessary lifecycle events and taking High-Order Components into account.
* @param {Component} component
* @param {Object} [opts]
* @param {boolean} [opts.build=false] If `true`, component will build and store a DOM node if not already associated with one.
* @private
*/
function renderComponent(component, opts) {
if (component._disableRendering) return;
component._dirty = false;
let p = component.nextProps,
s = component.state;
if (component.base) {
if (hook(component, 'shouldComponentUpdate', p, s)===false) {
component.props = p;
return;
}
hook(component, 'componentWillUpdate', p, s);
}
component.props = p;
let rendered = hook(component, 'render', p, s),
childComponent = rendered && rendered.nodeName,
base;
if (typeof childComponent==='function' && childComponent.prototype.render) {
// set up high order component link
let inst = component._component;
if (inst && inst.constructor!==childComponent) {
unmountComponent(inst.base, inst, false);
inst = null;
}
let childProps = getNodeProps(rendered);
if (inst) {
setComponentProps(inst, childProps, SYNC_RENDER);
}
else {
inst = componentRecycler.create(childComponent, childProps);
inst._parentComponent = component;
component._component = inst;
if (component.base) deepHook(inst, 'componentWillMount');
setComponentProps(inst, childProps, NO_RENDER);
renderComponent(inst, DOM_RENDER);
if (component.base) deepHook(inst, 'componentDidMount');
}
base = inst.base;
}
else {
// destroy high order component link
if (component._component) {
unmountComponent(component.base, component._component);
}
component._component = null;
if (component.base || (opts && opts.build)) {
base = build(component.base, rendered || EMPTY_BASE, component);
}
}
if (component.base && base!==component.base) {
let p = component.base.parentNode;
if (p) p.replaceChild(base, component.base);
}
component.base = base;
if (base) {
base._component = component;
base._componentConstructor = component.constructor;
}
hook(component, 'componentDidUpdate', p, s);
return rendered;
}
/** Apply the Component referenced by a VNode to the DOM.
* @param {Element} dom The DOM node to mutate
* @param {VNode} vnode A Component-referencing VNode
* @returns {Element} dom The created/mutated element
* @private
*/
function buildComponentFromVNode(dom, vnode) {
let c = dom && dom._component;
if (isFunctionalComponent(vnode)) {
let p = build(dom, buildFunctionalComponent(vnode));
p._componentConstructor = vnode.nodeName;
return p;
}
let isOwner = c && dom._componentConstructor===vnode.nodeName;
while (c && !isOwner && (c=c._parentComponent)) {
isOwner = c.constructor===vnode.nodeName;
}
if (isOwner) {
setComponentProps(c, getNodeProps(vnode), SYNC_RENDER);
}
else {
if (c) {
unmountComponent(dom, c);
dom = null;
}
dom = createComponentFromVNode(vnode, dom);
}
return dom;
}
/** Instantiate and render a Component, given a VNode whose nodeName is a constructor.
* @param {VNode} vnode
* @private
*/
function createComponentFromVNode(vnode, dom) {
let props = getNodeProps(vnode);
let component = componentRecycler.create(vnode.nodeName, props);
if (dom) component.base = dom;
setComponentProps(component, props, NO_RENDER);
renderComponent(component, DOM_RENDER);
// let node = component.base;
//if (!node._component) {
// node._component = component;
// node._componentConstructor = vnode.nodeName;
//}
return component.base;
}
/** Remove a component from the DOM and recycle it.
* @param {Element} dom A DOM node from which to unmount the given Component
* @param {Component} component The Component instance to unmount
* @private
*/
function unmountComponent(dom, component, remove) {
// console.warn('unmounting mismatched component', component);
hook(component, 'componentWillUnmount');
if (remove!==false) {
if (dom._component===component) {
delete dom._component;
delete dom._componentConstructor;
}
let base = component.base;
if (base && base.parentNode) {
base.parentNode.removeChild(base);
}
}
component._parentComponent = null;
hook(component, 'componentDidUnmount');
componentRecycler.collect(component);
}
/** Apply differences in a given vnode (and it's deep children) to a real DOM Node.
* @param {Element} [dom=null] A DOM node to mutate into the shape of the `vnode`
* @param {VNode} vnode A VNode (with descendants forming a tree) representing the desired DOM structure
* @returns {Element} dom The created/mutated element
* @private
*/
function build(dom, vnode) {
let out = dom,
nodeName = vnode.nodeName;
if (typeof nodeName==='function' && !nodeName.prototype.render) {
vnode = buildFunctionalComponent(vnode);
nodeName = vnode.nodeName;
}
if (typeof nodeName==='function') {
return buildComponentFromVNode(dom, vnode);
}
if (typeof vnode==='string') {
if (dom) {
if (dom.nodeType===3) {
dom[TEXT_CONTENT] = vnode;
return dom;
}
else if (dom.nodeType===1) {
recycler.collect(dom);
}
}
return document.createTextNode(vnode);
}
if (nodeName===null || nodeName===undefined) {
nodeName = 'x-undefined-element';
}
if (!dom) {
out = recycler.create(nodeName);
}
else if (dom.nodeName.toLowerCase()!==nodeName) {
out = recycler.create(nodeName);
appendChildren(out, toArray(dom.childNodes));
// reclaim element nodes
if (dom.nodeType===1) recycler.collect(dom);
}
// apply attributes
let old = getNodeAttributes(out) || EMPTY,
attrs = vnode.attributes || EMPTY;
// removed attributes
if (old!==EMPTY) {
for (let name in old) {
if (hop.call(old, name)) {
let o = attrs[name];
if (o===undefined || o===null) {
setAccessor(out, name, null, old[name]);
}
}
}
}
// new & updated attributes
if (attrs!==EMPTY) {
for (let name in attrs) {
if (hop.call(attrs, name)) {
let value = attrs[name];
if (value!==undefined && value!==null) {
let prev = getAccessor(out, name, old[name]);
if (value!=prev) {
setAccessor(out, name, value, prev);
}
}
}
}
}
let children = toArray(out.childNodes);
let keyed = {};
for (let i=children.length; i--; ) {
let t = children[i].nodeType;
let key;
if (t===3) {
key = t.key;
}
else if (t===1) {
key = children[i].getAttribute('key');
}
else {
continue;
}
if (key) keyed[key] = children.splice(i, 1)[0];
}
let newChildren = [];
if (vnode.children) {
for (let i=0, vlen=vnode.children.length; i<vlen; i++) {
let vchild = vnode.children[i];
// if (isFunctionalComponent(vchild)) {
// vchild = buildFunctionalComponent(vchild);
// }
let attrs = vchild.attributes,
key, child;
if (attrs) {
key = attrs.key;
child = key && keyed[key];
}
// attempt to pluck a node of the same type from the existing children
if (!child) {
let len = children.length;
if (children.length) {
for (let j=0; j<len; j++) {
if (isSameNodeType(children[j], vchild)) {
child = children.splice(j, 1)[0];
break;
}
}
}
}
// morph the matched/found/created DOM child to match vchild (deep)
newChildren.push(build(child, vchild));
}
}
// apply the constructed/enhanced ordered list to the parent
for (let i=0, len=newChildren.length; i<len; i++) {
// we're intentionally re-referencing out.childNodes here as it is a live NodeList
if (out.childNodes[i]!==newChildren[i]) {
let child = newChildren[i],
c = child._component,
next = out.childNodes[i+1];
if (c) deepHook(c, 'componentWillMount');
if (next) {
out.insertBefore(child, next);
}
else {
out.appendChild(child);
}
if (c) deepHook(c, 'componentDidMount');
}
}
// remove orphaned children
for (let i=0, len=children.length; i<len; i++) {
let child = children[i],
c = child._component;
if (c) hook(c, 'componentWillUnmount');
child.parentNode.removeChild(child);
if (c) {
hook(c, 'componentDidUnmount');
componentRecycler.collect(c);
}
else if (child.nodeType===1) {
recycler.collect(child);
}
}
return out;
}
/** Create an Event handler function that sets a given state property.
* @param {Component} component The component whose state should be updated
* @param {string} key A dot-notated key path to update in the component's state
* @param {string} eventPath A dot-notated key path to the value that should be retrieved from the Event or component
* @returns {function} linkedStateHandler
* @private
*/
function createLinkedState(component, key, eventPath) {
let path = key.split('.'),
p0 = path[0];
return function(e) {
let t = this,
obj = component.state,
v, i;
if (typeof eventPath==='string') {
v = delve(e, eventPath);
if (empty(v) && (t=t._component)) {
v = delve(t, eventPath);
}
}
else {
v = (t.nodeName+t.type).match(/^input(checkbox|radio)$/i) ? t.checked : t.value;
}
if (typeof v==='function') v = v.call(t);
for (i=0; i<path.length-1; i++) {
obj = obj[path[i]] || {};
}
obj[path[i]] = v;
component.setState({ [p0]: component.state[p0] });
};
}
/** Managed queue of dirty components to be re-rendered.
* @private
*/
let renderQueue = {
// items/itemsOffline swap on each process() call (just a simple pool technique)
items: [],
itemsOffline: [],
add(component) {
if (renderQueue.items.push(component)!==1) return;
let d = hooks.debounceRendering;
if (d) d(renderQueue.process);
else setTimeout(renderQueue.process, 0);
},
process() {
let items = renderQueue.items,
len = items.length;
if (!len) return;
renderQueue.items = renderQueue.itemsOffline;
renderQueue.items.length = 0;
renderQueue.itemsOffline = items;
while (len--) {
if (items[len]._dirty) {
renderComponent(items[len]);
}
}
}
};
/** Trigger all queued component renders.
* @function
*/
let rerender = renderQueue.process;
/** DOM node pool, keyed on nodeName.
* @private
*/
let recycler = {
nodes: {},
normalizeName: memoize(name => name.toUpperCase()),
collect(node) {
recycler.clean(node);
let name = recycler.normalizeName(node.nodeName),
list = recycler.nodes[name];
if (list) list.push(node);
else recycler.nodes[name] = [node];
},
create(nodeName) {
let name = recycler.normalizeName(nodeName),
list = recycler.nodes[name];
return list && list.pop() || document.createElement(nodeName);
},
clean(node) {
if (node.parentNode) node.parentNode.removeChild(node);
if (node.nodeType===3) return;
delete node._component;
delete node._componentConstructor;
let len = ATTR_PREFIX.length;
for (let i in node) {
if (i.indexOf(ATTR_PREFIX)===0) {
setAccessor(node, i.substring(len), null, node[i]);
}
}
// if (node.childNodes.length>0) {
// console.warn(`Warning: Recycler collecting <${node.nodeName}> with ${node.childNodes.length} children.`);
// toArray(node.childNodes).forEach(recycler.collect);
// }
}
};
/** Retains a pool of Components for re-use, keyed on component name.
* @private
*/
let componentRecycler = {
components: {},
collect(component) {
let name = component.constructor.name,
list = componentRecycler.components[name];
if (list) list.push(component);
else componentRecycler.components[name] = [component];
},
create(ctor, props) {
let list = componentRecycler.components[ctor.name];
if (list && list.length) {
for (let i=list.length; i--; ) {
if (list[i].constructor===ctor) {
return list.splice(i, 1)[0];
}
}
}
return new ctor(props);
}
};
/** Append multiple children to a Node.
* Uses a Document Fragment to batch when appending 2 or more children
* @private
*/
function appendChildren(parent, children) {
let len = children.length;
if (len<=2) {
parent.appendChild(children[0]);
if (len===2) parent.appendChild(children[1]);
return;
}
let frag = document.createDocumentFragment();
for (let i=0; i<len; i++) frag.appendChild(children[i]);
parent.appendChild(frag);
}
/** Retrieve the value of a rendered attribute
* @private
*/
function getAccessor(node, name, value) {
let key = `${ATTR_PREFIX}${name}`;
if (name!=='type' && name in node) return node[name];
if (name==='class') return node.className;
if (name==='style') return node.style.cssText;
if (hop.call(node, key)) return node[key];
return value;
}
/** Set a named attribute on the given Node, with special behavior for some names and event handlers.
* If `value` is `null`, the attribute/handler will be removed.
* @private
* @param {Element} node Element to mutate
* @param {String} name prop to set
* @param {Any} value new value to apply
* @param {Any} old old value (not currently used)
*/
function setAccessor(node, name, value) {
if (name==='class') {
node.className = value;
}
else if (name==='style') {
node.style.cssText = value;
}
else if (name in node && name!=='type') {
node[name] = value;
}
else {
setComplexAccessor(node, name, value);
}
node[`${ATTR_PREFIX}${name}`] = getAccessor(node, name, value);
}
/** For props without explicit behavior, apply to a Node as event handlers or attributes.
* @private
*/
function setComplexAccessor(node, name, value) {
if (name.substring(0,2)==='on') {
let type = normalizeEventName(name),
l = node._listeners || (node._listeners = {});
if (!l[type]) node.addEventListener(type, eventProxy);
l[type] = value;
// @TODO automatically remove proxy event listener when no handlers are left
return;
}
let type = typeof value;
if (value===null) {
node.removeAttribute(name);
}
else if (type!=='function' && type!=='object') {
node.setAttribute(name, value);
}
}
/** Proxy an event to hooked event handlers
* @private
*/
function eventProxy(e) {
let fn = this._listeners[normalizeEventName(e.type)];
if (fn) return fn.call(this, hook(hooks, 'event', e) || e);
}
/** Convert an Event name/type to lowercase and strip any "on*" prefix.
* @function
* @private
*/
let normalizeEventName = memoize(t => t.replace(/^on/i,'').toLowerCase());
/** Get a node's attributes as a hashmap, regardless of type.
* @private
*/
function getNodeAttributes(node) {
let list = node.attributes;
if (!list || !list.getNamedItem) return list;
if (list.length) return getAttributesAsObject(list);
}
/** Convert a DOM `.attributes` NamedNodeMap to a hashmap.
* @private
*/
function getAttributesAsObject(list) {
let attrs = {};
for (let i=list.length; i--; ) {
let item = list[i];
attrs[item.name] = item.value;
}
return attrs;
}
/** Reconstruct Component-style `props` from a VNode
* @private
*/
function getNodeProps(vnode) {
let props = extend({}, vnode.attributes);
if (vnode.children) {
props.children = vnode.children;
}
return props;
}
/** Convert a hashmap of styles to CSSText
* @private
*/
function styleObjToCss(s) {
let str = '',
sep = ': ',
term = '; ';
for (let prop in s) {
if (hop.call(s, prop)) {
let val = s[prop];
str += jsToCss(prop);
str += sep;
str += val;
if (typeof val==='number' && !hop.call(NON_DIMENSION_PROPS, prop)) {
str += 'px';
}
str += term;
}
}
return str;
}
/** Convert a hashmap of CSS classes to a space-delimited className string
* @private
*/
function hashToClassName(c) {
let str = '';
for (let prop in c) {
if (c[prop]) {
if (str) str += ' ';
str += prop;
}
}
return str;
}
/** Convert a JavaScript camel-case CSS property name to a CSS property name
* @private
* @function
*/
let jsToCss = memoize( s => s.replace(/([A-Z])/,'-$1').toLowerCase() );
/** Copy own-properties from `props` onto `obj`.
* @returns obj
* @private
*/
function extend(obj, props) {
for (let i in props) if (hop.call(props, i)) {
obj[i] = props[i];
}
return obj;
}
export { options, hooks, rerender };
export default { options, hooks, render, rerender, h, Component };