mirror of
https://github.com/marko-js/marko.git
synced 2026-01-18 14:55:13 +00:00
671 lines
19 KiB
JavaScript
671 lines
19 KiB
JavaScript
'use strict';
|
|
/* jshint newcap:false */
|
|
|
|
var domInsert = require('../runtime/dom-insert');
|
|
var defaultCreateOut = require('../runtime/createOut');
|
|
var getComponentsContext = require('./ComponentsContext').___getComponentsContext;
|
|
var componentsUtil = require('./util');
|
|
var componentLookup = componentsUtil.___componentLookup;
|
|
var emitLifecycleEvent = componentsUtil.___emitLifecycleEvent;
|
|
var destroyComponentForEl = componentsUtil.___destroyComponentForEl;
|
|
var destroyElRecursive = componentsUtil.___destroyElRecursive;
|
|
var getElementById = componentsUtil.___getElementById;
|
|
var EventEmitter = require('events-light');
|
|
var RenderResult = require('../runtime/RenderResult');
|
|
var SubscriptionTracker = require('listener-tracker');
|
|
var inherit = require('raptor-util/inherit');
|
|
var updateManager = require('./update-manager');
|
|
var morphdom = require('../morphdom');
|
|
var eventDelegation = require('./event-delegation');
|
|
|
|
var slice = Array.prototype.slice;
|
|
|
|
var MORPHDOM_SKIP = true;
|
|
|
|
var COMPONENT_SUBSCRIBE_TO_OPTIONS;
|
|
var NON_COMPONENT_SUBSCRIBE_TO_OPTIONS = {
|
|
addDestroyListener: false
|
|
};
|
|
|
|
function outNoop() { /* jshint -W040 */ return this; }
|
|
|
|
var emit = EventEmitter.prototype.emit;
|
|
|
|
function removeListener(removeEventListenerHandle) {
|
|
removeEventListenerHandle();
|
|
}
|
|
|
|
function checkCompatibleComponent(globalComponentsContext, el) {
|
|
var component = el._w;
|
|
while(component) {
|
|
var id = component.id;
|
|
var newComponentDef = globalComponentsContext.___componentsById[id];
|
|
if (newComponentDef && component.___type == newComponentDef.___component.___type) {
|
|
break;
|
|
}
|
|
|
|
var rootFor = component.___rootFor;
|
|
if (rootFor) {
|
|
component = rootFor;
|
|
} else {
|
|
component.___destroyShallow();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleCustomEventWithMethodListener(component, targetMethodName, args, extraArgs) {
|
|
// Remove the "eventType" argument
|
|
args.push(component);
|
|
|
|
if (extraArgs) {
|
|
args = extraArgs.concat(args);
|
|
}
|
|
|
|
|
|
var targetComponent = componentLookup[component.___scope];
|
|
var targetMethod = targetComponent[targetMethodName];
|
|
if (!targetMethod) {
|
|
throw Error('Method not found: ' + targetMethodName);
|
|
}
|
|
|
|
targetMethod.apply(targetComponent, args);
|
|
}
|
|
|
|
function getElIdHelper(component, componentElId, index) {
|
|
var id = component.id;
|
|
|
|
var elId = componentElId != null ? id + '-' + componentElId : id;
|
|
|
|
if (index != null) {
|
|
elId += '[' + index + ']';
|
|
}
|
|
|
|
return elId;
|
|
}
|
|
|
|
/**
|
|
* This method is used to process "update_<stateName>" handler functions.
|
|
* If all of the modified state properties have a user provided update handler
|
|
* then a rerender will be bypassed and, instead, the DOM will be updated
|
|
* looping over and invoking the custom update handlers.
|
|
* @return {boolean} Returns true if if the DOM was updated. False, otherwise.
|
|
*/
|
|
function processUpdateHandlers(component, stateChanges, oldState) {
|
|
var handlerMethod;
|
|
var handlers;
|
|
|
|
for (var propName in stateChanges) {
|
|
if (stateChanges.hasOwnProperty(propName)) {
|
|
var handlerMethodName = 'update_' + propName;
|
|
|
|
handlerMethod = component[handlerMethodName];
|
|
if (handlerMethod) {
|
|
(handlers || (handlers=[])).push([propName, handlerMethod]);
|
|
} else {
|
|
// This state change does not have a state handler so return false
|
|
// to force a rerender
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we got here then all of the changed state properties have
|
|
// an update handler or there are no state properties that actually
|
|
// changed.
|
|
if (handlers) {
|
|
// Otherwise, there are handlers for all of the changed properties
|
|
// so apply the updates using those handlers
|
|
|
|
handlers.forEach(function(handler, i) {
|
|
var propertyName = handler[0];
|
|
handlerMethod = handler[1];
|
|
|
|
var newValue = stateChanges[propertyName];
|
|
var oldValue = oldState[propertyName];
|
|
handlerMethod.call(component, newValue, oldValue);
|
|
});
|
|
|
|
emitLifecycleEvent(component, 'update');
|
|
|
|
component.___reset();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function checkInputChanged(existingComponent, oldInput, newInput) {
|
|
if (oldInput != newInput) {
|
|
if (oldInput == null || newInput == null) {
|
|
return true;
|
|
}
|
|
|
|
var oldKeys = Object.keys(oldInput);
|
|
var newKeys = Object.keys(newInput);
|
|
var len = oldKeys.length;
|
|
if (len !== newKeys.length) {
|
|
return true;
|
|
}
|
|
|
|
for (var i=0; i<len; i++) {
|
|
var key = oldKeys[i];
|
|
if (oldInput[key] !== newInput[key]) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function onNodeDiscarded(node) {
|
|
if (node.nodeType === 1) {
|
|
destroyComponentForEl(node);
|
|
}
|
|
}
|
|
|
|
function onBeforeNodeDiscarded(node) {
|
|
return eventDelegation.___handleNodeDetach(node);
|
|
}
|
|
|
|
function onBeforeElUpdated(fromEl, key, globalComponentsContext) {
|
|
if (key) {
|
|
var preserved = globalComponentsContext.___preserved[key];
|
|
|
|
if (preserved === true) {
|
|
// Don't morph elements that are associated with components that are being
|
|
// reused or elements that are being preserved. For components being reused,
|
|
// the morphing will take place when the reused component updates.
|
|
return MORPHDOM_SKIP;
|
|
} else {
|
|
// We may need to destroy a Component associated with the current element
|
|
// if a new UI component was rendered to the same element and the types
|
|
// do not match
|
|
checkCompatibleComponent(globalComponentsContext, fromEl);
|
|
}
|
|
}
|
|
}
|
|
|
|
function onBeforeElChildrenUpdated(el, key, globalComponentsContext) {
|
|
if (key) {
|
|
var preserved = globalComponentsContext.___preservedBodies[key];
|
|
if (preserved === true) {
|
|
// Don't morph the children since they are preserved
|
|
return MORPHDOM_SKIP;
|
|
}
|
|
}
|
|
}
|
|
|
|
function onNodeAdded(node, globalComponentsContext) {
|
|
eventDelegation.___handleNodeAttach(node, globalComponentsContext.___out);
|
|
}
|
|
|
|
var componentProto;
|
|
|
|
/**
|
|
* Base component type.
|
|
*
|
|
* NOTE: Any methods that are prefixed with an underscore should be considered private!
|
|
*/
|
|
function Component(id) {
|
|
EventEmitter.call(this);
|
|
this.id = id;
|
|
this.el = null;
|
|
this.___state = null;
|
|
this.___roots = null;
|
|
this.___subscriptions = null;
|
|
this.___domEventListenerHandles = null;
|
|
this.___bubblingDomEvents = null; // Used to keep track of bubbling DOM events for components rendered on the server
|
|
this.___customEvents = null;
|
|
this.___scope = null;
|
|
this.___renderInput = null;
|
|
this.___input = undefined;
|
|
this.___mounted = false;
|
|
this.___global = undefined;
|
|
|
|
this.___destroyed = false;
|
|
this.___updateQueued = false;
|
|
this.___dirty = false;
|
|
this.___settingInput = false;
|
|
|
|
this.___document = undefined;
|
|
}
|
|
|
|
Component.prototype = componentProto = {
|
|
___isComponent: true,
|
|
|
|
subscribeTo: function(target) {
|
|
if (!target) {
|
|
throw TypeError();
|
|
}
|
|
|
|
var subscriptions = this.___subscriptions || (this.___subscriptions = new SubscriptionTracker());
|
|
|
|
var subscribeToOptions = target.___isComponent ?
|
|
COMPONENT_SUBSCRIBE_TO_OPTIONS :
|
|
NON_COMPONENT_SUBSCRIBE_TO_OPTIONS;
|
|
|
|
return subscriptions.subscribeTo(target, subscribeToOptions);
|
|
},
|
|
|
|
emit: function(eventType) {
|
|
var customEvents = this.___customEvents;
|
|
var target;
|
|
|
|
if (customEvents && (target = customEvents[eventType])) {
|
|
var targetMethodName = target[0];
|
|
var extraArgs = target[1];
|
|
var args = slice.call(arguments, 1);
|
|
|
|
handleCustomEventWithMethodListener(this, targetMethodName, args, extraArgs);
|
|
}
|
|
|
|
if (this.listenerCount(eventType)) {
|
|
return emit.apply(this, arguments);
|
|
}
|
|
},
|
|
getElId: function (componentElId, index) {
|
|
return getElIdHelper(this, componentElId, index);
|
|
},
|
|
getEl: function (componentElId, index) {
|
|
var doc = this.___document;
|
|
|
|
if (componentElId != null) {
|
|
return getElementById(doc, getElIdHelper(this, componentElId, index));
|
|
} else {
|
|
return this.el || getElementById(doc, getElIdHelper(this));
|
|
}
|
|
},
|
|
getEls: function(id) {
|
|
var els = [];
|
|
var i = 0;
|
|
var el;
|
|
while((el = this.getEl(id, i))) {
|
|
els.push(el);
|
|
i++;
|
|
}
|
|
return els;
|
|
},
|
|
getComponent: function(id, index) {
|
|
return componentLookup[getElIdHelper(this, id, index)];
|
|
},
|
|
getComponents: function(id) {
|
|
var components = [];
|
|
var i = 0;
|
|
var component;
|
|
while((component = componentLookup[getElIdHelper(this, id, i)])) {
|
|
components.push(component);
|
|
i++;
|
|
}
|
|
return components;
|
|
},
|
|
destroy: function() {
|
|
if (this.___destroyed) {
|
|
return;
|
|
}
|
|
|
|
var els = this.els;
|
|
|
|
this.___destroyShallow();
|
|
|
|
var rootComponents = this.___rootComponents;
|
|
if (rootComponents) {
|
|
rootComponents.forEach(function(rootComponent) {
|
|
rootComponent.___destroy();
|
|
});
|
|
}
|
|
|
|
els.forEach(function(el) {
|
|
destroyElRecursive(el);
|
|
|
|
var parentNode = el.parentNode;
|
|
if (parentNode) {
|
|
parentNode.removeChild(el);
|
|
}
|
|
});
|
|
},
|
|
|
|
___destroyShallow: function() {
|
|
if (this.___destroyed) {
|
|
return;
|
|
}
|
|
|
|
emitLifecycleEvent(this, 'destroy');
|
|
this.___destroyed = true;
|
|
|
|
this.el = null;
|
|
|
|
// Unsubscribe from all DOM events
|
|
this.___removeDOMEventListeners();
|
|
|
|
var subscriptions = this.___subscriptions;
|
|
if (subscriptions) {
|
|
subscriptions.removeAllListeners();
|
|
this.___subscriptions = null;
|
|
}
|
|
|
|
delete componentLookup[this.id];
|
|
},
|
|
|
|
isDestroyed: function() {
|
|
return this.___destroyed;
|
|
},
|
|
get state() {
|
|
return this.___state;
|
|
},
|
|
set state(newState) {
|
|
var state = this.___state;
|
|
if (!state && !newState) {
|
|
return;
|
|
}
|
|
|
|
if (!state) {
|
|
state = this.___state = new this.___State(this);
|
|
}
|
|
|
|
state.___replace(newState || {});
|
|
|
|
if (state.___dirty) {
|
|
this.___queueUpdate();
|
|
}
|
|
|
|
if (!newState) {
|
|
this.___state = null;
|
|
}
|
|
},
|
|
setState: function(name, value) {
|
|
var state = this.___state;
|
|
|
|
if (typeof name == 'object') {
|
|
// Merge in the new state with the old state
|
|
var newState = name;
|
|
for (var k in newState) {
|
|
if (newState.hasOwnProperty(k)) {
|
|
state.___set(k, newState[k], true /* ensure:true */);
|
|
}
|
|
}
|
|
} else {
|
|
state.___set(name, value, true /* ensure:true */);
|
|
}
|
|
},
|
|
|
|
setStateDirty: function(name, value) {
|
|
var state = this.___state;
|
|
|
|
if (arguments.length == 1) {
|
|
value = state[name];
|
|
}
|
|
|
|
state.___set(name, value, true /* ensure:true */, true /* forceDirty:true */);
|
|
},
|
|
|
|
replaceState: function(newState) {
|
|
this.___state.___replace(newState);
|
|
},
|
|
|
|
get input() {
|
|
return this.___input;
|
|
},
|
|
set input(newInput) {
|
|
if (this.___settingInput) {
|
|
this.___input = newInput;
|
|
} else {
|
|
this.___setInput(newInput);
|
|
}
|
|
},
|
|
|
|
___setInput: function(newInput, onInput, out) {
|
|
onInput = onInput || this.onInput;
|
|
var updatedInput;
|
|
|
|
var oldInput = this.___input;
|
|
this.___input = undefined;
|
|
|
|
if (onInput) {
|
|
// We need to set a flag to preview `this.input = foo` inside
|
|
// onInput causing infinite recursion
|
|
this.___settingInput = true;
|
|
updatedInput = onInput.call(this, newInput || {}, out);
|
|
this.___settingInput = false;
|
|
}
|
|
|
|
newInput = this.___renderInput = updatedInput || newInput;
|
|
|
|
if ((this.___dirty = checkInputChanged(this, oldInput, newInput))) {
|
|
this.___queueUpdate();
|
|
}
|
|
|
|
if (this.___input === undefined) {
|
|
this.___input = newInput;
|
|
if (newInput.$global) {
|
|
this.___global = newInput.$global;
|
|
}
|
|
}
|
|
|
|
return newInput;
|
|
},
|
|
|
|
forceUpdate: function() {
|
|
this.___dirty = true;
|
|
this.___queueUpdate();
|
|
},
|
|
|
|
___queueUpdate: function() {
|
|
if (!this.___updateQueued) {
|
|
updateManager.___queueComponentUpdate(this);
|
|
}
|
|
},
|
|
|
|
update: function() {
|
|
if (this.___destroyed === true || this.___isDirty === false) {
|
|
return;
|
|
}
|
|
|
|
var input = this.___input;
|
|
var state = this.___state;
|
|
|
|
if (this.___dirty === false && state !== null && state.___dirty === true) {
|
|
if (processUpdateHandlers(this, state.___changes, state.___old, state)) {
|
|
state.___dirty = false;
|
|
}
|
|
}
|
|
|
|
if (this.___isDirty === true) {
|
|
// The UI component is still dirty after process state handlers
|
|
// then we should rerender
|
|
|
|
if (this.shouldUpdate(input, state) !== false) {
|
|
this.___rerender(false);
|
|
}
|
|
}
|
|
|
|
this.___reset();
|
|
},
|
|
|
|
|
|
get ___isDirty() {
|
|
return this.___dirty === true || (this.___state !== null && this.___state.___dirty === true);
|
|
},
|
|
|
|
___reset: function() {
|
|
this.___dirty = false;
|
|
this.___updateQueued = false;
|
|
this.___renderInput = null;
|
|
var state = this.___state;
|
|
if (state) {
|
|
state.___reset();
|
|
}
|
|
},
|
|
|
|
shouldUpdate: function(newState, newProps) {
|
|
return true;
|
|
},
|
|
|
|
___emitLifecycleEvent: function(eventType, eventArg1, eventArg2) {
|
|
emitLifecycleEvent(this, eventType, eventArg1, eventArg2);
|
|
},
|
|
|
|
___rerender: function(isRerenderInBrowser) {
|
|
var self = this;
|
|
var renderer = self.___renderer;
|
|
|
|
if (!renderer) {
|
|
throw TypeError();
|
|
}
|
|
var fromEls = self.___getRootEls({});
|
|
var doc = self.___document;
|
|
var input = this.___renderInput || this.___input;
|
|
var globalData = this.___global;
|
|
|
|
updateManager.___batchUpdate(function() {
|
|
var createOut = renderer.createOut || defaultCreateOut;
|
|
var out = createOut(globalData);
|
|
out.sync();
|
|
out.___document = self.___document;
|
|
|
|
if (isRerenderInBrowser === true) {
|
|
out.e =
|
|
out.be =
|
|
out.ee =
|
|
out.t =
|
|
out.h =
|
|
out.w =
|
|
out.write =
|
|
out.html =
|
|
outNoop;
|
|
}
|
|
|
|
var componentsContext = getComponentsContext(out);
|
|
var globalComponentsContext = componentsContext.___globalContext;
|
|
globalComponentsContext.___rerenderComponent = self;
|
|
globalComponentsContext.___isRerenderInBrowser = isRerenderInBrowser;
|
|
|
|
renderer(input, out);
|
|
|
|
var result = new RenderResult(out);
|
|
|
|
if (isRerenderInBrowser !== true) {
|
|
var targetNode = out.___getOutput();
|
|
|
|
var fromEl;
|
|
|
|
var targetEl = targetNode.___firstChild;
|
|
while (targetEl) {
|
|
var nodeName = targetEl.___nodeName;
|
|
|
|
if (nodeName === 'HTML') {
|
|
fromEl = document.documentElement;
|
|
} else if (nodeName === 'BODY') {
|
|
fromEl = document.body;
|
|
} else if (nodeName === 'HEAD') {
|
|
fromEl = document.head;
|
|
} else {
|
|
fromEl = fromEls[targetEl.id];
|
|
}
|
|
|
|
if (fromEl) {
|
|
morphdom(
|
|
fromEl,
|
|
targetEl,
|
|
globalComponentsContext,
|
|
onNodeAdded,
|
|
onBeforeElUpdated,
|
|
onBeforeNodeDiscarded,
|
|
onNodeDiscarded,
|
|
onBeforeElChildrenUpdated);
|
|
}
|
|
|
|
targetEl = targetEl.___nextSibling;
|
|
}
|
|
}
|
|
|
|
result.afterInsert(doc);
|
|
|
|
out.emit('___componentsInitialized');
|
|
});
|
|
|
|
this.___reset();
|
|
},
|
|
|
|
___getRootEls: function(rootEls) {
|
|
var i, len;
|
|
|
|
var componentEls = this.els;
|
|
|
|
for (i=0, len=componentEls.length; i<len; i++) {
|
|
var componentEl = componentEls[i];
|
|
rootEls[componentEl.id] = componentEl;
|
|
}
|
|
|
|
var rootComponents = this.___rootComponents;
|
|
if (rootComponents) {
|
|
for (i=0, len=rootComponents.length; i<len; i++) {
|
|
var rootComponent = rootComponents[i];
|
|
rootComponent.___getRootEls(rootEls);
|
|
}
|
|
}
|
|
|
|
return rootEls;
|
|
},
|
|
|
|
___removeDOMEventListeners: function() {
|
|
var eventListenerHandles = this.___domEventListenerHandles;
|
|
if (eventListenerHandles) {
|
|
eventListenerHandles.forEach(removeListener);
|
|
this.___domEventListenerHandles = null;
|
|
}
|
|
},
|
|
|
|
get ___rawState() {
|
|
var state = this.___state;
|
|
return state && state.___raw;
|
|
},
|
|
|
|
___setCustomEvents: function(customEvents, scope) {
|
|
var finalCustomEvents = this.___customEvents = {};
|
|
this.___scope = scope;
|
|
|
|
customEvents.forEach(function(customEvent) {
|
|
var eventType = customEvent[0];
|
|
var targetMethodName = customEvent[1];
|
|
var extraArgs = customEvent[2];
|
|
|
|
finalCustomEvents[eventType] = [targetMethodName, extraArgs];
|
|
});
|
|
}
|
|
};
|
|
|
|
componentProto.elId = componentProto.getElId;
|
|
componentProto.___update = componentProto.update;
|
|
componentProto.___destroy = componentProto.destroy;
|
|
|
|
// Add all of the following DOM methods to Component.prototype:
|
|
// - appendTo(referenceEl)
|
|
// - replace(referenceEl)
|
|
// - replaceChildrenOf(referenceEl)
|
|
// - insertBefore(referenceEl)
|
|
// - insertAfter(referenceEl)
|
|
// - prependTo(referenceEl)
|
|
domInsert(
|
|
componentProto,
|
|
function getEl(component) {
|
|
var els = this.els;
|
|
var elCount = els.length;
|
|
if (elCount > 1) {
|
|
var fragment = component.___document.createDocumentFragment();
|
|
els.forEach(function(el) {
|
|
fragment.appendChild(el);
|
|
});
|
|
return fragment;
|
|
} else {
|
|
return els[0];
|
|
}
|
|
},
|
|
function afterInsert(component) {
|
|
return component;
|
|
});
|
|
|
|
inherit(Component, EventEmitter);
|
|
|
|
module.exports = Component;
|