Skip to content

Instantly share code, notes, and snippets.

@wangziling
Last active May 25, 2023 10:02
Show Gist options
  • Save wangziling/c6b6103a590a9e236b2cf3f79dc1f62d to your computer and use it in GitHub Desktop.
Save wangziling/c6b6103a590a9e236b2cf3f79dc1f62d to your computer and use it in GitHub Desktop.
Vue manual render manager.
import Vue, { ComponentOptions, CreateElement } from 'vue';
import { VNodeData } from 'vue/types/vnode';
import { AsyncComponentFactory, AsyncComponentPromise, FunctionalComponentOptions } from 'vue/types/options';
import _ from 'lodash';
import $ from 'jquery';
export type TArrayOrPrimitive<T> = T extends Array<any> | ReadonlyArray<any>
? T | TArrayMember<T>
: Array<T> | T;
export type TArrayMember<T> = T extends Array<infer P> | Readonly<Array<infer P>>
? P
: never;
type TNormalVueCompRenderingEventView = Parameters<CreateElement>[0];
interface INormalVueCompRenderingEventParamsExtra {
entryData: Parameters<CreateElement>[1],
entryChildren: Parameters<CreateElement>[2];
componentOptions?: ComponentOptions<Vue>;
}
interface INormalVueCompRenderingEventParams extends Partial<INormalVueCompRenderingEventParamsExtra> {
target?: string | HTMLElement | Element | ((ins?: Vue) => string | HTMLElement | Element);
useDeferMountSetTimeout?: boolean;
useDeferMountNextTick?: boolean;
useManualMount?: boolean;
instanceCreatedCallback?: (ins?: Vue) => any;
}
interface INormalVueCompRenderingOnceEventParams extends INormalVueCompRenderingEventParams {
existedInstanceCondition?: INormalVueCompRenderingEventInstanceIterator;
instanceResolvedPhase?: `hook:${ 'beforeCreate' | 'created' | 'beforeMount' | 'mounted' }`;
renderPhasePreTask?: Function;
}
interface INormalVueCompRenderingEventInstance {
target: Exclude<INormalVueCompRenderingEventParams['target'], Function>;
instance: Vue;
options: INormalVueCompRenderingEventParams;
}
interface INormalVueCompRenderingEventGenerateSlotParams {
target: string | HTMLElement | Element;
loadingText: string;
idLength: number;
id: string;
}
interface INormalVueCompRenderingEventRemoveSlotParams {
target: string | HTMLElement | Element;
filterSelector?: string;
}
interface INormalVueCompRenderingEventInstanceIterator<Result = boolean> {
(
ins: TArrayMember<NormalVueCompRenderingEvent['instances']>,
idx: number,
arr: NormalVueCompRenderingEvent['instances']
): Result;
}
interface IVueMergeListenersOptions {
validator: (eventName: string, func: Function) => boolean; // Validate function. False means bypass.
useFnWrappedAsOneMode: boolean; // True means we wrap duplicate same eventName functions as one. (Aggregate them to one). False mean Array like [].
useNewlyFnsTakePriorityMode: boolean; // Should we let the subsequent function be triggered first.
}
export interface IVueAsyncLoadingComponentWrapperConfig {
useHiddenPlaceholder: boolean;
loadingCompEntryData: Partial<VNodeData>;
loadingCompOptions: Partial<ComponentOptions<Vue>>;
errorCompEntryData: Partial<VNodeData>;
errorCompOptions: Partial<ComponentOptions<Vue>>;
asyncCompEntryData: Partial<VNodeData>;
asyncHandlerReplaceConfig: Partial<ReturnType<AsyncComponentFactory>>;
}
/**
* Random string.
* @param len {string}
* @return {string}
*/
export function randomString(len?: number): string {
len = len || 32;
const $chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
const maxPos = $chars.length;
let pwd = '';
for (let i = 0; i < len; i++) {
pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
}
return pwd;
}
/**
* Load an async component.
* @param component {AsyncComponentPromise}
* @param [config] {Partial<IVueAsyncLoadingComponentWrapperConfig>}
* @return {Promise<FunctionalComponentOptions>}
*/
export function vueAsyncLoadingComponentWrapper (
component: AsyncComponentPromise | any, /* 'any' for `import()` */
config?: Partial<IVueAsyncLoadingComponentWrapperConfig>
): Promise<FunctionalComponentOptions> {
const useHiddenPlaceholder = _.get(config, 'useHiddenPlaceholder');
const commonTemplateSpanDataSet = {};
if (useHiddenPlaceholder) {
_.merge(
commonTemplateSpanDataSet,
{ style: 'display: none;' }
);
}
function calcTemplateSpanStringBindings (templateSpanDataSet: object) {
return Object.entries(templateSpanDataSet)
.map(function (v) {
return [v[0], `"${ v[1] }"`].join('=');
})
.join(' ');
}
const AsyncHandler: AsyncComponentFactory = () => _.merge(
{
component,
loading: {
functional: true,
// @ts-ignore
render (h, { data, children } = {}) {
const templateSpanDataSet = _.merge(
commonTemplateSpanDataSet,
{
'data-name': 'asyncComponentLoadingPlaceholder'
}
);
return h(
_.merge(
{
template: `<span ${ calcTemplateSpanStringBindings(templateSpanDataSet) }>Loading...</span>`
},
_.get(config, 'loadingCompOptions')
),
_.merge(
// No need 'on', especially the lifeCycle hooks, it will trigger here unexpectedly.
_.has(data, 'on') ? _.omit(data, 'on') : data,
_.get(config, 'loadingCompEntryData')
),
children
);
}
},
error: {
functional: true,
// @ts-ignore
render (h, { data, children } = {}) {
const templateSpanDataSet = _.merge(
commonTemplateSpanDataSet,
{
'data-name': 'asyncComponentErrorPlaceholder'
}
);
return h(
_.merge(
{
template: `<span ${ calcTemplateSpanStringBindings(templateSpanDataSet) }>Error occurred. Please try again later.</span>`,
},
_.get(config, 'errorCompOptions')
),
_.merge(
// No need 'on', especially the lifeCycle hooks, it will trigger here unexpectedly.
_.has(data, 'on') ? _.omit(data, 'on') : data,
_.get(config, 'errorCompEntryData')
),
children
);
}
}
} as ReturnType<AsyncComponentFactory>,
_.omit(config, 'asyncHandlerReplaceConfig')
);
return Promise.resolve({
functional: true,
render (h, { data, children }) {
return h(
AsyncHandler,
_.merge(data, _.get(config, 'asyncCompEntryData')),
children
);
}
});
}
/**
* The event base manager of rendering the RS.
* @Constructor
*/
export class NormalVueCompRenderingEventBase {
instances: INormalVueCompRenderingEventInstance[] = [];
store: Record<string, any> = {}; // Store something.
findInstance (options: Partial<Pick<INormalVueCompRenderingEventInstance, 'target' | 'instance'> & { find: INormalVueCompRenderingEventInstanceIterator }>) {
const findFunc = _.get(options, 'find');
if (typeof findFunc === 'function') {
return this.instances.find(findFunc as any);
}
return this.instances.find(v => _.isMatch(v, options));
}
filterInstances (options: Partial<Pick<INormalVueCompRenderingEventInstance, 'target' | 'instance'> & { filter: INormalVueCompRenderingEventInstanceIterator }>) {
const filterFunc = _.get(options, 'filter');
if (typeof filterFunc === 'function') {
return this.instances.filter(filterFunc as any);
}
return this.instances.filter(v => _.isMatch(v, options));
}
eachInstance (iterator: INormalVueCompRenderingEventInstanceIterator<boolean | void>) {
// Return true will stop loop early.
return this.instances.some(iterator);
}
getViewInstance (instance: INormalVueCompRenderingEventInstance['instance']) {
return _.get(instance, '$children.0') as Vue | undefined;
}
registerInstance (instanceParams: INormalVueCompRenderingEventInstance) {
if (!instanceParams) {
return this;
}
this.instances.push(instanceParams);
return this;
}
unregisterInstance (options: Partial<Pick<INormalVueCompRenderingEventInstance, 'target' | 'instance'>>) {
if (!options) {
return;
}
const targetIndex = this.instances.findIndex(v => {
return v.target === options.target || v.instance === options.instance;
});
if (targetIndex !== -1) {
return this.instances.splice(targetIndex, 1)[0];
}
}
generateSlot (params?: Partial<INormalVueCompRenderingEventGenerateSlotParams>) {
const id = _.get(params, 'id') || randomString(_.get(params, 'idLength'));
const $slot = $(`<div id="${ id }" data-name="slotLoadingPlaceholder">${ _.get(params, 'loadingText') || 'Loading...' }</div>`);
let target = _.get(params, 'target');
if (typeof target === 'string') {
target = $(target)[0];
}
if (!target) {
target = document.body;
}
$(target).append($slot);
return {
id,
$slot,
slot: $slot[0]
};
}
removeSlot (params: INormalVueCompRenderingEventRemoveSlotParams) {
$(_.get(params, 'target') as any).remove(_.get(params, 'filterSelector'));
}
mergeListeners (...args: Parameters<typeof mergeVueListeners>): ReturnType<typeof mergeVueListeners> {
return mergeVueListeners.apply(this, args);
}
aggregateDuplicateListeners (...args: Parameters<typeof aggregateVueDuplicateListeners>): ReturnType<typeof aggregateVueDuplicateListeners> {
return aggregateVueDuplicateListeners.apply(this, args);
}
destroy (options: Partial<Pick<INormalVueCompRenderingEventInstance, 'target' | 'instance'> & { removeDom: boolean }>) {
const targetIndex = this.instances.findIndex(v => {
return v.target === options.target || v.instance === options.instance;
});
if (targetIndex !== -1) {
const targetInstance = this.instances.splice(targetIndex, 1)[0];
// Need 'try...catch'. It may throw out errors.
try {
targetInstance.instance.$destroy();
} catch (e) {}
if (options.removeDom) {
if (targetInstance.instance.$el) {
$(targetInstance.instance.$el).remove();
return this;
}
// Maybe currently the $el hadn't been generated.
// OK add the mounted hook.
targetInstance.instance.$on('hook:mounted', () => {
$(targetInstance.instance.$el).remove();
});
}
}
return this;
}
destroyAll (options?: Partial<Pick<INormalVueCompRenderingEventInstance, 'target' | 'instance'> & { removeDom: boolean }>) {
const presetCondition = _.pick(options, ['target', 'instance']);
const isNeedToRemoveDom = _.get(options, 'removeDom');
const removeFunc = (v: TArrayMember<NormalVueCompRenderingEvent['instances']>) => {
this.destroy({
..._.pick(v, ['instance', 'target']),
removeDom: isNeedToRemoveDom
});
};
_.forEach(
this.instances,
v => {
if (!_.isEmpty(presetCondition) && _.isMatch(v, presetCondition)) {
return removeFunc(v);
}
removeFunc(v);
}
);
return this;
}
patchStore (...args: Array<any>) {
return _.merge(this.store, ...args);
}
getStoreProp (path: Parameters<typeof _['get']>[1]) {
return _.get(this.store, path);
}
}
/**
* The event manager of rendering the RS.
* @Constructor
*/
export class NormalVueCompRenderingEvent extends NormalVueCompRenderingEventBase {
View: TNormalVueCompRenderingEventView;
constructor (View: TNormalVueCompRenderingEventView) {
super();
this.View = View;
}
render (options: INormalVueCompRenderingEventParams = {} as any) {
const {
target: _target,
entryData,
entryChildren,
useDeferMountSetTimeout,
useDeferMountNextTick,
useManualMount,
componentOptions,
instanceCreatedCallback
} = options;
const self = this;
const mergedCustomHooksKey = '__NormalVueCompRenderingEventMergedCustomHooks__';
// Stored once, as a referer.
const clonedEntryDataOn = _.cloneDeep(_.get(entryData, 'on'));
const instance = new Vue(
_.merge(
{
render: (h: CreateElement) => {
const isInstanceAlreadyMergedCustomHooks = _.get(instance, mergedCustomHooksKey);
if (entryData) {
// If rendered, the 'entryData.on' will be undefined.
// Then if 'entryData.props' own dynamic value and changed.
// Here will re-render. With no 'entryData.on' listeners.
_.set(entryData, 'on', clonedEntryDataOn);
let entryDataOn = _.get(entryData, 'on');
if (!isInstanceAlreadyMergedCustomHooks) {
_.set(instance, mergedCustomHooksKey, true);
entryDataOn = mergeVueListeners([
{
'hook:destroyed' () {
self.destroy({ instance });
}
},
entryDataOn!
]);
}
return h(
this.View,
// Use assign. Do Not merge.
_.assign(entryData, { on: entryDataOn }),
entryChildren
);
}
_.set(instance, mergedCustomHooksKey, true);
return h(
this.View,
{
on: {
'hook:destroyed' () {
self.destroy({ instance });
}
}
},
entryChildren
);
}
},
componentOptions
)
);
// If we need the instance immediately.
if (typeof instanceCreatedCallback === 'function') {
instanceCreatedCallback(instance);
}
// Target can be Functionality.
const target = typeof _target === 'function'
? _target(instance)
: _target;
this.instances.push({
target,
instance,
options
});
// Next tick.
if (useDeferMountNextTick) {
// No matter the target exists or not, Vue will help us.
Vue.nextTick(() => instance.$mount(target));
return instance;
}
// Set timeout.
if (useDeferMountSetTimeout) {
// No matter the target exists or not, Vue will help us.
setTimeout(() => instance.$mount(target));
return instance;
}
// Manual mount.
if (!useManualMount) {
// No matter the target exists or not, Vue will help us.
instance.$mount(target);
}
return instance;
}
renderOnceDeferred (options: INormalVueCompRenderingOnceEventParams = {} as any): ReturnType<typeof $.Deferred<ReturnType<NormalVueCompRenderingEvent['render']>>> {
const deferInstanceKey = '__DeferInstance__';
const existedDefer = this.getStoreProp(deferInstanceKey) as ReturnType<typeof $.Deferred<any>>;
if (existedDefer) {
if (existedDefer.state() === 'pending') {
return existedDefer;
}
}
const defer = $.Deferred();
this.patchStore({ [deferInstanceKey]: defer });
const existedInstanceCondition = _.get(options, 'existedInstanceCondition');
const existedVueInstance = typeof existedInstanceCondition === 'function'
? this.findInstance({
find: function (...args) {
// Should find the instance which is under 'deferred' mode.
return _.get(args, '0.instance.__deferred__') && existedInstanceCondition(...args);
}
})
: this.findInstance({
find (v) {
// First deferred.
return _.get(v, 'instance.__deferred__');
}
});
// OK. already own one.
if (existedVueInstance) {
return defer.resolve(_.get(existedVueInstance, 'instance'));
}
// Here. This time is a totally new rendering event.
const renderPhasePreTask = _.get(options, 'renderPhasePreTask');
if (typeof renderPhasePreTask === 'function') {
renderPhasePreTask();
}
let instance: any;
const instanceResolver = function instanceResolver () {
// Should use this.
// If we want to mount instance immediately.
// It will cause an issue that if 'instanceResolver' is triggered on 'hook:mounted'.
// At that moment, the 'instance' here isn't valued but 'instanceResolver' is triggered.
if (instanceResolvedPhase === defaultInstanceResolvedPhaseMounted) {
Vue.nextTick(function instanceResolver () {
return defer.resolve(instance);
});
}
return defer.resolve(instance);
};
// Default Mounted.
const defaultInstanceResolvedPhaseMounted = 'hook:mounted';
const instanceResolvedPhase = typeof _.get(options, 'instanceResolvedPhase') === 'string'
? _.get(options, 'instanceResolvedPhase') || defaultInstanceResolvedPhaseMounted
: defaultInstanceResolvedPhaseMounted;
// Use entryData.
let entryData = _.get(options, 'entryData');
if (!entryData) {
_.set(options, 'entryData', entryData = {});
}
// Merge listeners. Use Assign.
_.assign(entryData, {
on: this.mergeListeners([
{ [instanceResolvedPhase!]: instanceResolver },
_.get(entryData, 'on')!
])
});
// Load instance.
instance = this.render.call(this, _.omit(options, 'existedInstanceCondition') as any);
// Record this instance is rendered with 'deferred' mode.
// @ts-ignore
instance.__deferred__ = true;
// If manually.
if (_.get(options, 'useManualMount')) {
return instanceResolver();
}
return defer;
}
resetView () {
// Finally. I found the solution by reading Vue2 source code.
if (typeof this.View === 'function') {
// Reset the 'async component' related records.
// So that we can reload the async component.
if (_.has(this.View, 'resolved')) {
_.set(this.View, 'resolved', undefined);
_.set(this.View, 'owners', undefined);
}
}
return this;
}
resetDeferred () {
const deferInstanceKey = '__DeferInstance__';
// Remove the stored 'defer'.
this.patchStore({ [deferInstanceKey]: null });
return this;
}
reset (options?: Partial<{ removeDom: boolean }>) {
this.resetView();
this.resetDeferred();
this.destroyAll(options);
}
}
/**
* Merge multiple listeners as a single listener.
* @param listenersArr {*}
* @param [options] {{[validator]: (func: Function) => boolean, [useFnWrappedAsOneMode]: boolean, [useNewlyFnsTakePriorityMode]: boolean}}
* @return {*}
*/
export function mergeVueListeners (
listenersArr: Array<Record<string, TArrayOrPrimitive<Function>>>,
options?: Partial<IVueMergeListenersOptions>
): Record<string, TArrayOrPrimitive<Function>> {
const finalListeners: TArrayMember<typeof listenersArr> = {};
const validator = _.get(options, 'validator');
// Whether to return as 'Function[]' or 'Function'.
const useFnArrayFormatMode = !_.get(options, 'useFnWrappedAsOneMode');
// Should the previous listeners take the priority to be invoked.
const usePreviousFnsTakePriorityMode = !_.get(options, 'useNewlyFnsTakePriorityMode');
listenersArr.forEach(function (ls) {
if (!(ls && typeof ls === 'object')) {
return;
}
Object.keys(ls).forEach(function (eventName) {
// @ts-ignore
let targetListener = ls[eventName];
if (!targetListener) {
return;
}
// Validator.
if (typeof validator === 'function') {
if (Array.isArray(targetListener)) {
targetListener = targetListener.filter(function (func) {
return validator(eventName, func);
});
} else {
// If failed to validate. OK. Remove this listener.
if (!validator(eventName, targetListener)) {
return;
}
}
}
// @ts-ignore
const prevListener = finalListeners[eventName];
if (!prevListener) {
// @ts-ignore
const wrappedTargetLister = Array.isArray(targetListener)
? useFnArrayFormatMode
? targetListener
: aggregateVueDuplicateListeners(targetListener)
: targetListener;
finalListeners[eventName] = useFnArrayFormatMode
? ([] as Function[]).concat(wrappedTargetLister!)
: wrappedTargetLister!;
return;
}
// @ts-ignore
const wrappedTargetLister = useFnArrayFormatMode
? usePreviousFnsTakePriorityMode
? ([] as Function[]).concat(
prevListener, // Invoking with higher priority.
targetListener
)
: ([] as Function[]).concat(
targetListener, // Invoking with higher priority.
prevListener
)
: aggregateVueDuplicateListeners(
([] as Function[]).concat(
usePreviousFnsTakePriorityMode
? ([] as Function[]).concat(
prevListener, // Invoking with higher priority.
Array.isArray(targetListener)
? useFnArrayFormatMode
? targetListener
: aggregateVueDuplicateListeners(targetListener)!
: targetListener
)
: ([] as Function[]).concat(
Array.isArray(targetListener) // Invoking with higher priority.
? useFnArrayFormatMode
? targetListener
: aggregateVueDuplicateListeners(targetListener)!
: targetListener,
prevListener
)
)
);
finalListeners[eventName] = useFnArrayFormatMode
? ([] as Function[]).concat(wrappedTargetLister!)
: wrappedTargetLister!;
});
});
return finalListeners;
}
/**
* Calc duplicate listeners
* @param callbacks {Function[]}
* @return {undefined|Function[]}
*/
export function aggregateVueDuplicateListeners (callbacks: Function[]): Function | undefined {
if (!(Array.isArray(callbacks) && callbacks.length)) {
return;
}
const cbs = callbacks.slice();
// Need to invoke them in parallel.
return function modifiedListener () {
const lastCb = cbs.slice(-1)[0];
// @ts-ignore
// eslint-disable-next-line
cbs.slice(0, -1).forEach(cb => cb.apply(this, arguments));
if (!lastCb) {
return;
}
// We may need the return value.
// @ts-ignore
// eslint-disable-next-line
return lastCb.apply(this, arguments);
};
}
export const vueAsyncLoadingComponentNormalConfig: Parameters<typeof vueAsyncLoadingComponentWrapper>[1] = {
loadingCompOptions: {
created () {
// Global loading enabled.
},
destroyed () {
// Global loading disabled.
}
},
errorCompOptions: {
mounted (this: Vue) {
// Destroy error component instance.
this.$destroy();
// Remove it's dom.
$(this.$el).remove();
}
}
};
/**
* Calc vue async loading component normal config.
* @param normalVueCompRenderingEvent {NormalVueCompRenderingEvent}
* @return {*}
*/
export const calcVueAsyncLoadingComponentNormalConfig = function (
normalVueCompRenderingEvent?: NormalVueCompRenderingEvent
): Parameters<typeof vueAsyncLoadingComponentWrapper>[1] {
if (normalVueCompRenderingEvent instanceof NormalVueCompRenderingEvent) {
return _.merge(
vueAsyncLoadingComponentNormalConfig,
{
errorCompOptions: {
created () {
// We need to 'reset' all the related things.
// So that we can reload the 'normalVueCompRenderingEvent.View'.
// E.g.
// After setting 'offline', we try to render an 'async component.
// It will render this error component. And It will 'remember' the 'offline' result.
// No matter we set 'online' again or not, it will always render the error component after we re-execute the render events.
// So we need to reset the related things to let Vue re-fetch the async component when it failed.
// So that we can re-render what we want not just the error component.
normalVueCompRenderingEvent.reset({ removeDom: true });
}
}
}
);
}
return vueAsyncLoadingComponentNormalConfig;
};
@wangziling
Copy link
Author

wangziling commented Feb 6, 2023

Usage(demo):

const instanceManager = new NormalVueCompRenderingEvent(
  () => vueAsyncLoadingComponentWrapper(
    import('./index.vue'),
    calcVueAsyncLoadingComponentNormalConfig(instanceManager )
  )
);
instanceManager
  .renderOnceDeferred({ // Only render Once.
    target: () => instanceManager.generateSlot().slot
  })
  .then(ins => { // Rendered or re-used.
    const viewInstance = instanceManager.getViewInstance(ins);
  });
const xVueManager = new NormalVueCompRenderingEvent(
  () => vueAsyncLoadingComponentWrapper(
    import('./index.vue'),
    calcVueAsyncLoadingComponentNormalConfig(xVueManager)
  )
);

const xVueManagerInstance = xVueManager
  .destroyAll({ removeDom: true })
  .render({
    useDeferMountNextTick: true,
    entryData: {
      props: {
        static: 1,
        get dymanic () {
          // Dynamic logics.
          return 2;
        }
      },
      attrs: { id: 'domId' },
      on: {
        'hook:mounted' () {
          const signBannerInstance = xVueManager.getViewInstance(
            xVueManagerInstance
          );
        }
      }
    },
    target: () => xVueManager.generateSlot().slot
  });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment