UNPKG

substance

Version:

Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing systems.

890 lines (787 loc) 26.4 kB
import { isFunction, forEach, uuid, substanceGlobals } from '../util' import { DefaultDOMElement } from '../dom' import VirtualElement from './VirtualElement' import Component from './Component' /* ## Rendering Algorithm TODO: document the algorithm ## Findings What makes our rendering algorithm so difficult? - Dependency Injection requires a (direct) parent to be allow constructor injection, i.e. that injected dependencies are available in the constructor already. As a consequence a component tree must to be constructed from top to down. - The earliest time to evaluate `$$(MyComponent)`, is when it has been attached to an existing component. I.e., to run `MyComponent.render()` an instance of `MyComponent` is needed, which can only be created with an existing parent component. - In general, it is *not* possible to have a naturally descending rendering algorithm, i.e. a simple recursion calling `render()` and creating or updating Components on the way, preserving a simple stack-trace. Instead, it requires calling `render()` on one level, then doing comparisons with the existing tree to be able to reuse components, and then descend into the sub-tree. - If components are passed down via props, things get even more difficult. For example, consider a situation where components are passed via props: ``` render($$) { return $$('div').append( $$(Wrapper, { foo: $$(MyComponent) }) ) } ``` At the time when this component gets rendered, `MyComponent` can not be instantiated, as it is not known what `Wrapper` actually does with it. While the general approach is working from top-to-down, in this case it has a bottom-to-up nature, i.e., the child needs to be rendered to know what to do with the passed component. Particularly, this is problematic when the passed component has a reference: ``` render($$) { return $$('div').append( $$(Wrapper, { foo: $$(MyComponent).ref('foo') }) ) } ``` As nothing is known at the time of descending about the content of `Wrapper` the rendering algorithm can not tell that it ought to be preserved. For now, the correct way to deal with this situation is to use a reference for the wrapper as well: ``` render($$) { return $$('div').append( $$(Wrapper, { foo: $$(MyComponent).ref('foo') }).ref('wrapper') ) } ``` ## TODO There is a lot of hacks in the current implementation, which should be cleaned up when we have time. - remove means to change props of VirtualComponent dynamically: currently, it is possible to do something like ``` $$(MyComponent).attr({ "data-id": "foo" }).append('foo') ``` ATM, `attr()` and `append()` represent a means to change the `props` of the component. This is pretty inconsistent and creates confusion about the responsibility for rendering the element. Only the Component's `render()` should be responsible for that. If necessary, children or anything like that must be passed down via props. - remove outlets: outlets are just another way to change props. - try to fuse `virtualComponent._content` into virtualComponent: ATM, `VirtualComponent` uses a `VirtualHTMLElement` instance to store the result of `render()`. This makes understanding the virtual tree after rendering difficult, as there is another layer via `virtualComponent._content`. - Rethink strategy for 'reusing' components: ATM we consider refs as the key indicator whether to preserve a component or not. This makes sense essentially, but could be extended to an opportunistic strategy where components are reused implicitly when they are at the right place. */ export default class RenderingEngine { constructor(options = {}) { this.elementFactory = options.elementFactory || DefaultDOMElement.createDocument('html') } _render(comp, oldProps, oldState) { // var t0 = Date.now() var vel = _createWrappingVirtualComponent(comp) var state = new RenderingEngine.State(this.elementFactory) if (oldProps) { state.setOldProps(vel, oldProps) } if (oldState) { state.setOldState(vel, oldState) } try { // capture: this calls the render() method of components, creating a virtual DOM // console.log('### capturing...') // let t0 = Date.now() _capture(state, vel, 'forceCapture') // console.log('### ... finished in %s ms', Date.now()-t0) // console.log('### rendering...') // t0 = Date.now() _render(state, vel) // console.log('### ... finished in %s ms', Date.now()-t0) _triggerUpdate(state, vel) } finally { state.dispose() } // console.log("RenderingEngine: finished rendering in %s ms", Date.now()-t0) } // this is used together with the incremental Component API _renderChild(comp, vel) { // HACK: to make this work with the rest of the implementation // we ingest a fake parent var state = new RenderingEngine.State(this.elementFactory) vel.parent = { _comp: comp } try { _capture(state, vel) _render(state, vel) return vel._comp } finally { state.dispose() } } } function _create(state, vel) { var comp = vel._comp console.assert(!comp, "Component instance should not exist when this method is used.") var parent = vel.parent._comp // making sure the parent components have been instantiated if (!parent) { parent = _create(state, vel.parent) } if (vel._isVirtualComponent) { console.assert(parent, "A Component should have a parent.") comp = new vel.ComponentClass(parent, vel.props) // HACK: making sure that we have the right props vel.props = comp.props comp.__htmlConfig__ = vel._copyHTMLConfig() } else if (vel._isVirtualHTMLElement) { comp = new Component.Element(parent, vel) } else if (vel._isVirtualTextNode) { comp = new Component.TextNode(parent, vel) } if (vel._ref) { comp._ref = vel._ref } if (vel._owner) { comp._owner = vel._owner._comp } vel._comp = comp return comp } function _capture(state, vel, forceCapture) { if (state.isCaptured(vel)) { return vel } // a captured VirtualElement has a component instance attached var comp = vel._comp if (!comp) { comp = _create(state, vel) state.setNew(vel) } if (vel._isVirtualComponent) { var needRerender // NOTE: forceCapture is used for the first entrance // from this.render(comp) where we want to fource capturing // as it has already been cleared that a rerender is necessary if (forceCapture) { needRerender = true } else { // NOTE: don't ask shouldRerender if no element is there yet needRerender = !comp.el || comp.shouldRerender(vel.props, comp.state) comp.__htmlConfig__ = vel._copyHTMLConfig() state.setOldProps(vel, comp.props) state.setOldState(vel, comp.state) // updates prop triggering willReceiveProps comp._setProps(vel.props) if (!state.isNew(vel)) { state.setUpdated(vel) } } if (needRerender) { var context = new CaptureContext(vel) var content = comp.render(context.$$) if (!content || !content._isVirtualHTMLElement) { throw new Error("Component.render must return VirtualHTMLElement") } if (comp.__htmlConfig__) { content._mergeHTMLConfig(comp.__htmlConfig__) } content._comp = comp vel._content = content if (!state.isNew(vel) && comp.isMounted()) { state.setUpdated(vel) } // Mapping: map virtual elements to existing components based on refs _prepareVirtualComponent(state, comp, content) // Descending // TODO: only do this in DEBUG mode if (substanceGlobals.DEBUG_RENDERING) { // in this case we use the render() function as iterating function, where // $$ is a function which creates components and renders them recursively. // first we can create all element components that can be reached // without recursion var stack = content.children.slice(0) while (stack.length) { var child = stack.shift() if (state.isCaptured(child)) continue // virtual components are addressed via recursion, not captured here if (child._isVirtualComponent) continue if (!child._comp) { _create(state, child) } if (child._isVirtualHTMLElement && child.children.length > 0) { stack = stack.concat(child.children) } state.setCaptured(child) } state.setCaptured(content) // then we run comp.render($$) with a special $$ that captures VirtualComponent's // recursively var descendingContext = new DescendingContext(state, context) while (descendingContext.hasPendingCaptures()) { descendingContext.reset() comp.render(descendingContext.$$) } } else { // a VirtualComponent has its content as a VirtualHTMLElement // which needs to be captured recursively _capture(state, vel._content) } } else { state.setSkipped(vel) } } else if (vel._isVirtualHTMLElement) { for (var i = 0; i < vel.children.length; i++) { _capture(state, vel.children[i]) } } state.setCaptured(vel) return vel } function _render(state, vel) { if (state.isSkipped(vel)) return // console.log('... rendering', vel._ref) // before changes can be applied, a VirtualElement must have been captured // FIXME: with DEBUG_RENDERING we are having troubles with this assumption. // It happens when the rerendered component is having children injected from its parent. // Then the parent is no rerendered this, these injected components are not recaptured, and this assertion does not hold. // However, it seems not to be critical, as these components don't need to be rerendered // Still we should find a consistent way let comp = vel._comp console.assert(comp && comp._isComponent, "A captured VirtualElement must have a component instance attached.") // VirtualComponents apply changes to its content element if (vel._isVirtualComponent) { _render(state, vel._content) // store refs and foreignRefs const context = vel._content._context let refs = {} let foreignRefs = {} forEach(context.refs, (vel, ref) => { refs[ref] = vel._comp }) forEach(context.foreignRefs, (vel, ref) => { foreignRefs[ref] = vel._comp }) comp.refs = refs comp.__foreignRefs__ = foreignRefs return } // render the element if (!comp.el) { comp.el = _createElement(state, vel) comp.el._comp = comp } _updateElement(comp, vel) // structural updates are necessary only for HTML elements (without innerHTML set) if (vel._isVirtualHTMLElement && !vel.hasInnerHTML()) { var newChildren = vel.children var oldComp, virtualComp, newComp var pos1 = 0; var pos2 = 0 // HACK: removing all childNodes that are not owned by a component // this happened in Edge every 1s. Don't know why. // With this implementation all external DOM mutations will be eliminated var oldChildren = [] comp.el.getChildNodes().forEach(function(node) { var childComp = node._comp // TODO: to allow mounting a prerendered DOM element // we would need to allow to 'take ownership' instead of removing // the element. This being a special situation, we should only // do that when mounting // remove orphaned nodes and relocated components if (!childComp || state.isRelocated(childComp)) { comp.el.removeChild(node) } else { oldChildren.push(childComp) } }) while(pos1 < oldChildren.length || pos2 < newChildren.length) { // skip detached components // Note: components get detached when preserved nodes // are found in a swapped order. Then the only way is // to detach one of them from the DOM, and reinsert it later at the new position do { oldComp = oldChildren[pos1++] } while (oldComp && (state.isDetached(oldComp))) virtualComp = newChildren[pos2++] // remove remaining old ones if no new one is left if (oldComp && !virtualComp) { while (oldComp) { _removeChild(state, comp, oldComp) oldComp = oldChildren[pos1++] } break } // Try to reuse TextNodes to avoid unnecesary DOM manipulations if (oldComp && oldComp.el.isTextNode() && virtualComp && virtualComp._isVirtualTextNode && oldComp.el.textContent === virtualComp.text ) { continue } // deep-render the virtual component if not yet done if (!state.isRendered(virtualComp)) { _render(state, virtualComp) } newComp = virtualComp._comp // update the parent for relocated components // ATTENTION: relocating a component does not update its context if (state.isRelocated(newComp)) { newComp._setParent(comp) } console.assert(newComp, 'Component instance should now be available.') // append remaining new ones if no old one is left if (virtualComp && !oldComp) { _appendChild(state, comp, newComp) continue } // Differential update else if (state.isMapped(virtualComp)) { // identity if (newComp === oldComp) { // no structural change } else if (state.isMapped(oldComp)) { // the order of elements with ref has changed state.setDetached(oldComp) _removeChild(state, comp, oldComp) pos2-- } // the old one could not be mapped, thus can be removed else { _removeChild(state, comp, oldComp) pos2-- } } else if (state.isMapped(oldComp)) { _insertChildBefore(state, comp, newComp, oldComp) pos1-- } else { // both elements are not mapped // TODO: we could try to reuse components if they are of same type // However, this needs a better mapping strategy, not only // based on refs. _replaceChild(state, comp, oldComp, newComp) } } } state.setRendered(vel) } function _triggerUpdate(state, vel) { if (vel._isVirtualComponent) { if (!state.isSkipped(vel)) { vel._content.children.forEach(_triggerUpdate.bind(null, state)) } if (state.isUpdated(vel)) { vel._comp.didUpdate(state.getOldProps(vel), state.getOldState(vel)) } } else if (vel._isVirtualHTMLElement) { vel.children.forEach(_triggerUpdate.bind(null, state)) } } function _appendChild(state, parent, child) { parent.el.appendChild(child.el) _triggerDidMount(state, parent, child) } function _replaceChild(state, parent, oldChild, newChild) { parent.el.replaceChild(oldChild.el, newChild.el) if (!state.isDetached(oldChild)) { oldChild.triggerDispose() } _triggerDidMount(state, parent, newChild) } function _insertChildBefore(state, parent, child, before) { parent.el.insertBefore(child.el, before.el) _triggerDidMount(state, parent, child) } function _removeChild(state, parent, child) { parent.el.removeChild(child.el) if (!state.isDetached(child)) { child.triggerDispose() } } function _triggerDidMount(state, parent, child) { if (!state.isDetached(child) && parent.isMounted() && !child.isMounted()) { child.triggerDidMount(true) } } /* Prepares a new virtual component by comparing it with the old version. It sets the _comp references in the new version where its ancestors can be mapped to corresponding virtual components in the old version. */ function _prepareVirtualComponent(state, comp, vc) { var newRefs = {} var foreignRefs = {} // TODO: iron this out. refs are stored on the context // though, it would be cleaner if they were on the VirtualComponent // Where vc._owner would need to be a VirtualComponent and not a // component. if (vc._context) { newRefs = vc._context.refs foreignRefs = vc._context.foreignRefs } var oldRefs = comp.refs var oldForeignRefs = comp.__foreignRefs__ // map virtual components to existing ones forEach(newRefs, function(vc, ref) { var comp = oldRefs[ref] if (comp) _mapComponents(state, comp, vc) }) forEach(foreignRefs, function(vc, ref) { var comp = oldForeignRefs[ref] if (comp) _mapComponents(state, comp, vc) }) } /* This tries to map the virtual component to existing component instances by looking at the old and new refs, making sure that the element type is compatible. This is then applied to the ancestors leading to an implicit mapping of parent elements, which makes */ function _mapComponents(state, comp, vc) { if (!comp && !vc) return true if (!comp || !vc) return false // Stop if one them has been mapped already // or the virtual element has its own component already // or if virtual element and component do not match semantically // Note: the owner component is mapped at very first, so this // recursion will stop at the owner at the latest. if (state.isMapped(vc) || state.isMapped(comp)) { return vc._comp === comp } if (vc._comp) { if (vc._comp === comp) { state.setMapped(vc) state.setMapped(comp) return true } else { return false } } if (!_isOfSameType(comp, vc)) { return false } vc._comp = comp state.setMapped(vc) state.setMapped(comp) var canMapParent var parent = comp.getParent() if (vc.parent) { canMapParent = _mapComponents(state, parent, vc.parent) } // to be able to support implicit retaining of elements // we need to propagate mapping through the 'preliminary' parent chain // i.e. not taking the real parents as rendered, but the Components into which // we have passed children (via vel.append() or vel.outlet().append()) else if (vc._preliminaryParent) { while (parent && parent._isElementComponent) { parent = parent.getParent() } canMapParent = _mapComponents(state, parent, vc._preliminaryParent) } if (!canMapParent) { state.setRelocated(vc) state.setRelocated(comp) } return canMapParent } function _isOfSameType(comp, vc) { return ( (comp._isElementComponent && vc._isVirtualHTMLElement) || (comp._isComponent && vc._isVirtualComponent && comp.constructor === vc.ComponentClass) || (comp._isTextNodeComponent && vc._isVirtualTextNode) ) } function _createElement(state, vel) { var el if (vel._isVirtualTextNode) { el = state.elementFactory.createTextNode(vel.text) } else { el = state.elementFactory.createElement(vel.tagName) } return el } function _updateElement(comp, vel) { if (comp._isTextNodeComponent) { comp.setTextContent(vel.text) return } var el = comp.el console.assert(el, "Component's element should exist at this point.") var tagName = el.getTagName() if (vel.tagName.toLowerCase() !== tagName) { el.setTagName(vel.tagName) } _updateHash({ oldHash: el.getAttributes(), newHash: vel.getAttributes(), update: function(key, val) { el.setAttribute(key, val) }, remove: function(key) { el.removeAttribute(key) } }) _updateHash({ oldHash: el.htmlProps, newHash: vel.htmlProps, update: function(key, val) { el.setProperty(key, val) }, remove: function(key) { el.removeProperty(key) } }) _updateListeners({ el: el, oldListeners: el.getEventListeners(), newListeners: vel.getEventListeners() }) // special treatment of HTML elements having custom innerHTML if (vel.hasInnerHTML()) { if (!el._hasInnerHTML) { el.empty() el.setInnerHTML(vel.getInnerHTML()) } else { var oldInnerHTML = el.getInnerHTML() var newInnerHTML = vel.getInnerHTML() if (oldInnerHTML !== newInnerHTML) { el.setInnerHTML(newInnerHTML) } } el._hasInnerHTML = true } } function _updateHash(args) { const newHash = args.newHash const oldHash = args.oldHash || {} const update = args.update const remove = args.remove let updatedKeys = {} for (let key in newHash) { if (newHash.hasOwnProperty(key)) { var oldVal = oldHash[key] var newVal = newHash[key] updatedKeys[key] = true if (oldVal !== newVal) { update(key, newVal) } } } // HACK: we have a horrible mixture of Objects and // Maps here if (isFunction(oldHash.keys) && oldHash.size > 0) { let keys = Array.from(oldHash.keys()) keys.forEach((key) => { if (!updatedKeys[key]) { remove(key) } }) } else { for (let key in oldHash) { if (oldHash.hasOwnProperty(key) && !updatedKeys[key]) { remove(key) } } } } function _updateListeners(args) { var el = args.el // NOTE: considering the low number of listeners // it is quicker to just remove all // and add again instead of computing the minimal update var newListeners = args.newListeners || [] el.removeAllEventListeners() for (var i=0; i<newListeners.length;i++) { el.addEventListener(newListeners[i]) } } /* Descending Context Used by RenderingEngine */ class DescendingContext { constructor(state, captureContext) { this.state = state this.owner = captureContext.owner this.refs = {} this.foreignRefs = {} this.elements = captureContext.elements this.pos = 0 this.updates = captureContext.components.length this.remaining = this.updates this.$$ = this._createComponent.bind(this) } _createComponent() { var state = this.state var vel = this.elements[this.pos++] // only capture VirtualComponent's with a captured parent // all others have been captured at this point already // or will either be captured by a different owner if (!state.isCaptured(vel) && vel._isVirtualComponent && vel.parent && state.isCaptured(vel.parent)) { _capture(state, vel) this.updates++ this.remaining-- } // Note: we return a new VirtualElement so that the render method does work // as expected. // TODO: instead of creating a new VirtualElement each time, we could return // an immutable wrapper for the already recorded element. vel = VirtualElement.createElement.apply(this, arguments) // these variables need to be set make the 'ref()' API work vel._context = this vel._owner = this.owner // Note: important to deactivate these methods as otherwise the captured // element will be damaged when calling el.append() vel._attach = function() {} vel._detach = function() {} return vel } hasPendingCaptures() { return this.updates > 0 && this.remaining > 0 } reset() { this.pos = 0 this.updates = 0 this.refs = {} } _ancestorsReady(vel) { while (vel) { if (this.state.isCaptured(vel) || // TODO: iron this out vel === this.owner || vel === this.owner._content) { return true } vel = vel.parent } return false } } RenderingEngine._internal = { _capture: _capture, _wrap: _createWrappingVirtualComponent, } class CaptureContext { constructor(owner) { this.owner = owner this.refs = {} this.foreignRefs = {} this.elements = [] this.components = [] this.$$ = this._createComponent.bind(this) this.$$.capturing = true } _createComponent() { var vel = VirtualElement.createElement.apply(this, arguments) vel._context = this vel._owner = this.owner if (vel._isVirtualComponent) { // virtual components need to be captured recursively this.components.push(vel) } this.elements.push(vel) return vel } } function _createWrappingVirtualComponent(comp) { var vel = new VirtualElement.Component(comp.constructor) vel._comp = comp if (comp.__htmlConfig__) { vel._mergeHTMLConfig(comp.__htmlConfig__) } return vel } RenderingEngine.createContext = function(comp) { var vel = _createWrappingVirtualComponent(comp) return new CaptureContext(vel) } class RenderingState { constructor(elementFactory) { this.elementFactory = elementFactory this.poluted = [] this.id = "__"+uuid() } dispose() { var id = this.id this.poluted.forEach(function(obj) { delete obj[id] }) } set(obj, key, val) { var info = obj[this.id] if (!info) { info = {} obj[this.id] = info this.poluted.push(obj) } info[key] = val } get(obj, key) { var info = obj[this.id] if (info) { return info[key] } } setMapped(c) { this.set(c, 'mapped', true) } isMapped(c) { return Boolean(this.get(c, 'mapped')) } // 'relocated' means a node with ref // has been attached to a new parent node setRelocated(c) { this.set(c, 'relocated', true) } isRelocated(c) { return Boolean(this.get(c, 'relocated')) } setDetached(c) { this.set(c, 'detached', true) } isDetached(c) { return Boolean(this.get(c, 'detached')) } setCaptured(vc) { this.set(vc, 'captured', true) } isCaptured(vc) { return Boolean(this.get(vc, 'captured')) } setNew(vc) { this.set(vc, 'created', true) } isNew(vc) { return Boolean(this.get(vc, 'created')) } setUpdated(vc) { this.set(vc, 'updated', true) } isUpdated(vc) { return Boolean(this.get(vc, 'updated')) } setSkipped(vc) { this.set(vc, 'skipped', true) } isSkipped(vc) { return Boolean(this.get(vc, 'skipped')) } setRendered(vc) { this.set(vc, 'rendered', true) } isRendered(vc) { return Boolean(this.get(vc, 'rendered')) } setOldProps(vc, oldProps) { this.set(vc, 'oldProps', oldProps) } getOldProps(vc) { return this.get(vc, 'oldProps') } setOldState(vc, oldState) { this.set(vc, 'oldState', oldState) } getOldState(vc) { return this.get(vc, 'oldState') } } RenderingEngine.State = RenderingState