@muban/muban
Version:
Writing components for server-rendered HTML
360 lines (359 loc) • 15.8 kB
JavaScript
/* eslint-disable @typescript-eslint/no-explicit-any,max-lines */
import { reactive, toRaw, watchEffect, readonly } from '@vue/runtime-core';
import { createAppContext } from './api/apiCreateApp';
import { applyBindings } from './bindings/applyBindings';
import { devtoolsComponentAdded, devtoolsComponentRemoved, devtoolsComponentUpdated, } from './utils/devtools';
import { getDirectChildComponents } from './utils/domUtils';
import { getComponentForElement, getGlobalMubanInstance, initGlobalComponents, registerComponentForElement, setComponentElementLoadingState, } from './utils/global';
import { isLazyComponent } from './api/apiLazy';
import { LifecycleHooks } from './api/apiLifecycle';
import { getComponentProps } from './props/getComponentProps';
import { createComponentRefs } from './refs/createComponentRefs';
import { recursiveUnref } from './utils/utils';
let currentInstance = null;
export function getCurrentComponentInstance() {
return currentInstance;
}
export function setCurrentComponentInstance(instance) {
currentInstance = instance;
}
let componentId = 0;
const emptyAppContext = createAppContext();
export function createComponentInstance(createOptions, element, options) {
var _a, _b, _c, _d;
const { parent } = createOptions;
// eslint-disable-next-line no-underscore-dangle
const appContext = (_c = (_b = (_a = createOptions.app) === null || _a === void 0 ? void 0 : _a._context) !== null && _b !== void 0 ? _b : parent === null || parent === void 0 ? void 0 : parent.appContext) !== null && _c !== void 0 ? _c : emptyAppContext;
const instance = {
uid: componentId++,
type: 'component',
name: options.name,
parent: parent !== null && parent !== void 0 ? parent : null,
appContext,
element,
api: null,
subTree: [],
props: {},
reactiveProps: reactive({}),
refs: {},
// Always create an inherited clone to allow setting context values
// It differs from Vue because of the component and setup creation order
provides: Object.create((_d = parent === null || parent === void 0 ? void 0 : parent.provides) !== null && _d !== void 0 ? _d : Object.create(appContext.provides)),
options,
bindings: [],
children: [],
refChildren: [],
removeBindingsList: [],
disposers: [],
mount() {
var _a;
// console.log('[mount]', options.name);
(_a = this[LifecycleHooks.Mounted]) === null || _a === void 0 ? void 0 : _a.forEach((hook) => hook());
// this.ee.emit('mount');
this.isMounted = true;
devtoolsComponentAdded(this);
},
unmount() {
var _a, _b, _c, _d, _e, _f, _g;
// console.log('[unmount]', options.name);
(_a = this.removeBindingsList) === null || _a === void 0 ? void 0 : _a.forEach((binding) => binding === null || binding === void 0 ? void 0 : binding());
(_b = this.disposers) === null || _b === void 0 ? void 0 : _b.forEach((dispose) => dispose());
(_c = this[LifecycleHooks.Unmounted]) === null || _c === void 0 ? void 0 : _c.forEach((hook) => hook());
// unregister itself from its parent
(_d = this.parent) === null || _d === void 0 ? void 0 : _d.children.splice((_e = this.parent) === null || _e === void 0 ? void 0 : _e.children.findIndex((c) => c.element === this.element), 1);
(_f = this.parent) === null || _f === void 0 ? void 0 : _f.subTree.splice((_g = this.parent) === null || _g === void 0 ? void 0 : _g.subTree.findIndex((c) => c.element === this.element), 1);
this.isUnmounted = true;
devtoolsComponentRemoved(this);
},
// lifecycle hooks
// not using enums here because it results in computed properties
isSetup: false,
isMounted: false,
isUnmounted: false,
// isDeactivated: false,
m: null,
um: null,
};
if (parent) {
parent.subTree.push(instance);
}
return instance;
}
export const defineComponent = (options) => {
// TODO: this function doesn't expose the component name, which is something we might want
return Object.assign(((element, createOptions = {}) => {
if (!element) {
throw new Error(`No element found for component "${options.name}"`);
}
const instance = createComponentInstance(createOptions, element, options);
// TODO: Devtools only
if (typeof element.__mubanInstance === 'undefined') {
// TODO: an element can be part of multiple parent refs
Object.defineProperty(element, '__mubanInstance', {
value: instance,
enumerable: false,
});
}
else {
// console.error(
// `This element is already initialized for another component:`,
// (element as any).__mubanInstance.name,
// element,
// (element as any).__mubanInstance,
// );
}
// console.log(`[Create ${options.name}]`);
// retrieve and create refs, will instantiate child components
instance.refs = createComponentRefs(options === null || options === void 0 ? void 0 : options.refs, instance);
// process props
const sources = getGlobalMubanInstance().propertySources;
instance.props = getComponentProps(options.props, element, sources, instance.refs);
instance.reactiveProps = reactive(instance.props);
// this will instantiate all registered "components" that are non-refs
processNonRefChildComponents(instance);
// listen for DOM removal and child component DOM updates
instance.disposers.push(createObservers(instance));
// only initialize itself when it's a "root" component
// otherwise it's initialized by its parent
if (!createOptions.parent) {
setupComponent(instance);
}
const componentApiInstance = {
get name() {
return options.name;
},
setProps(props) {
// console.log('new props', props);
Object.entries(props).forEach(([name, value]) => {
var _a, _b;
// todo check existence and validation
if (!(name in ((_a = instance.options.props) !== null && _a !== void 0 ? _a : {}))) {
// eslint-disable-next-line no-console
console.warn(`Prop "${name}" does not exist on component "${instance.name}", only supported props are [${Object.keys((_b = instance.options.props) !== null && _b !== void 0 ? _b : {})}]`);
}
else {
instance.reactiveProps[name] = value;
}
});
},
get props() {
return toRaw(instance.reactiveProps);
},
get element() {
return element;
},
setup() {
setupComponent(instance);
},
dispose() {
// console.log('dispose');
instance.unmount();
},
// eslint-disable-next-line @typescript-eslint/naming-convention
__instance: instance,
};
instance.api = componentApiInstance;
registerComponentForElement(instance.element, componentApiInstance);
return componentApiInstance;
}), { displayName: options.name });
};
let documentObserver;
const callbacks = new Set();
function onNodeRemoval(callback) {
// observer is only added once on the document, and will trigger the callback for
// each mounted component, which will do its own checks
if (!documentObserver) {
documentObserver = new MutationObserver(() => {
for (const callbackItem of callbacks) {
callbackItem();
}
});
documentObserver.observe(document, { attributes: false, childList: true, subtree: true });
}
callbacks.add(callback);
return () => {
callbacks.delete(callback);
};
}
/**
* Create MutationObservers for:
* - the document to detect the deletion of this component
* - this element to detect new "child component elements" that need to be created
* @param instance
*/
function createObservers(instance) {
// Keep watching for DOM updates, and update elements whenever they become available or are removed
// This should happen before applyBindings is called, since that can update the DOM
const elementObserver = new MutationObserver(() => {
// check refs
Object.values(instance.refs).forEach((refFunction) => refFunction.refreshRefs());
// check non-ref components
processNonRefChildComponents(instance);
});
elementObserver.observe(instance.element, { attributes: false, childList: true, subtree: true });
const disposeNodeRemovalCallback = onNodeRemoval(() => {
if (!instance.element.isConnected) {
instance.unmount();
}
});
return () => {
elementObserver.disconnect();
disposeNodeRemovalCallback();
};
}
/**
* Retrieve all child [data-component] elements, and filter them to exclude "nested" components.
* @param instance
*/
function processNonRefChildComponents(instance) {
// get all direct child data-component elements to see what we need to load and/or instantiate
getDirectChildComponents(instance.element).forEach((childElement) => instantiateChildComponent(instance, childElement));
queueMicrotask(() => {
// init globally registered components for anything that's not picked up in this component
initGlobalComponents(instance.element);
});
}
/**
* Instantiate a child component if a list of conditions are met:
* - it doesn't already have an instance on that element
* - it isn't already created as part of a explicit refComponent
* @param instance
* @param element
*/
export function instantiateChildComponent(instance, element) {
var _a;
// ignore if already part of current registered children
// with this in place, we can also call this function after DOM updates
if (!!getComponentForElement(element)) {
return;
}
const initComponent = (component, componentElement) => {
if (isLazyComponent(component)) {
setComponentElementLoadingState(componentElement, true);
component().then((factory) => {
if (!getComponentForElement(componentElement)) {
const childInstance = factory(componentElement, { parent: instance });
// since this is async, we'll set this up as soon as it's loaded
// which is later than anything else anyway
childInstance.setup();
instance.children.push(childInstance);
setComponentElementLoadingState(componentElement, false);
}
});
}
else if (!getComponentForElement(componentElement)) {
const childInstance = component(componentElement, { parent: instance });
// this will be "setup" later with the other ref-components
instance.children.push(childInstance);
}
};
// check locally or globally registered components
const componentName = element.dataset.component;
const factory = (_a = instance.options.components) === null || _a === void 0 ? void 0 : _a.find((component) => component.displayName === componentName);
if (factory) {
initComponent(factory, element);
}
}
/**
* The "setup" phase of a component is split up to allow correct timing between
* "creation" and "setup" when dealing with parent/child components and refs
*
* This will:
* - call the setup function of this component
* - call the setup function of all the child components (except the lazy loaded ones)
* - which applies bindings of child components
* - apply all bindings of this component
* - call the mount hook (after everything for itself and its children are finished)
*
* @param instance
*/
function setupComponent(instance) {
var _a, _b;
// has been setup before
if (instance.isSetup) {
return;
}
currentInstance = instance;
// console.log('[setup]', instance.options.name);
const bindings = (_b = (_a = instance.options).setup) === null || _b === void 0 ? void 0 : _b.call(_a, {
props: readonly(instance.reactiveProps),
refs: instance.refs,
element: instance.element,
});
/* eslint-disable no-param-reassign */
instance.bindings = bindings || [];
// TODO: Devtools only
instance.refChildren =
(bindings === null || bindings === void 0 ? void 0 : bindings.map((binding) => createRefComponentInstance(instance, binding))) || [];
instance.isSetup = true;
currentInstance = null;
instance.children.forEach((component) => component.setup());
instance.removeBindingsList = applyBindings(bindings, instance);
instance.mount();
/* eslint-enable no-param-reassign */
// console.log('[/Create]');
}
// filter out
// - empty refs/collections, as they don't have any current effect
// - bindings that also apply on "self" or any "child components"
// - so this will only show pure refs
function shouldRefInstanceBeIncludedInSubtree(instance, component) {
var _a;
const elements = ((_a = component.binding) === null || _a === void 0 ? void 0 : _a.getElements()) || [];
return (elements.length > 0 &&
[instance, ...instance.children].every((child) => !elements.includes(child.element)));
}
/**
* Create a "component instance" for all ref-bindings that exist in this component
* These are showed in devtools as "grey nodes" with their bindings and the "owner" component
* @param instance
* @param binding
*/
function createRefComponentInstance(instance, binding) {
// TODO: if devtools
const refInstance = {
uid: componentId++,
type: 'ref',
get name() {
var _a, _b;
return (_b = (_a = binding.getElements()[0]) === null || _a === void 0 ? void 0 : _a.dataset.ref) !== null && _b !== void 0 ? _b : '[unknown]';
},
parent: instance,
appContext: instance.appContext,
get element() {
return binding.getElements()[0];
},
binding,
};
// update devtools if the DOM changes and bindings become (in)active
watchEffect(() => {
const elements = binding.getElements();
if (elements.length === 0) {
const index = instance.subTree.indexOf(refInstance);
if (index !== -1) {
instance.subTree.splice(index, 1);
devtoolsComponentRemoved(refInstance);
}
// remove from subTree
}
else {
// add to subTree
// eslint-disable-next-line no-lonely-if
if (shouldRefInstanceBeIncludedInSubtree(instance, refInstance) &&
!instance.subTree.includes(refInstance)) {
instance.subTree.push(refInstance);
devtoolsComponentAdded(refInstance);
}
}
});
// update devtools if any of the reactive binding values update
watchEffect(() => {
// "trigger" all observables for this binding by unwrapping it
recursiveUnref(binding.props);
// only trigger if this ref is relevant at this point
if (shouldRefInstanceBeIncludedInSubtree(instance, refInstance)) {
devtoolsComponentUpdated(refInstance);
}
});
return refInstance;
}