UNPKG

@muban/muban

Version:

Writing components for server-rendered HTML

360 lines (359 loc) 15.8 kB
/* 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; }