import { isNil, isString, isPlainObject, isArray, isFunction } from '../libs/mobius-utils' export interface JavaScriptCollection { internal: Array<{ element: HTMLScriptElement }> external: Array<{ element: HTMLScriptElement, src: string }> [key: string]: any } /** * 采集页面中已经存在的 JavaScript 脚本,包括内部脚本和外部脚本两种 * * - 内部脚本:(InternalScript)指 JavaScript 代码直接写在 `` 标签内部 * * - 外部脚本:(ExternalScript)指通过设置 `` 标签的 `src` 属性进行引入的 JavaScript 脚本文件 * * @refer https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/script#attr-type */ export const collectJavaScript = (): JavaScriptCollection => { const scripts = Array.from(document.scripts) const collection = scripts.reduce((acc, item) => { if (item.src !== null) { acc.external.push({ src: item.src, element: item }) } else { acc.internal.push({ element: item }) } return acc }, { internal: [], external: [] }) return collection } export interface SingleJavaScriptLoadOptions { src: string name?: string group?: string collect?: (element: HTMLScriptElement) => unknown isolate?: () => void deisolate?: () => void beforeLoad?: (contexts: { src: string, name?: string }) => void onLoad?: (contexts: { src: string, name?: string, element: HTMLScriptElement, options: SingleJavaScriptLoadOptions }) => void afterLoad?: (contexts: { src: string, name?: string, element: HTMLScriptElement }) => void onError?: ( contexts: { src: string, name?: string, options: SingleJavaScriptLoadOptions, error: string | Event, reject: (reason?: any) => void } ) => void } export interface SingleJavaScriptLoadResult extends SingleJavaScriptLoadOptions { element: HTMLScriptElement pack?: unknown } const JAVASCRIPT_LOAD_STACK: Array<[Promise, Promise]> = [] /** * 将指定的 script 脚本加载到页面中 * * 加载之前进行检测: * - 如果指定地址(src)的脚本已经存在于页面中,直接返回该脚本作为加载结果 * - 如果指定地址(src)的脚本不存在于页面中,则通过插入 `script` 标签的方式将脚本加载到页面中,然后返回加载结果 * * 以 `script` 标签加载到页面中的脚本全部拥有 JavaScript 环境的完全访问权限,当脚本加载涉及到全局变量的变更时, * 并行加载会出现难以预料的错误,所以这里统一采用串行加载。 * - 加载器会维护一个加载队列,只有先申请的脚本加载完成之后才会加载后申请的脚本 * - 脚本加载的选项中可以定义辅助隔离全局变量的函数(isolate、deisolate), * 在加载某个脚本之前,加载器会遍历执行其它已加载的脚本的 isolate 函数,加载完成之后,再遍历执行已加载脚本的 deisolate 函数。 * * @refer https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLScriptElement */ export const loadSingleJavascript = async (options: SingleJavaScriptLoadOptions): Promise => { const { src, name, collect, beforeLoad, onLoad, afterLoad, onError } = options if (isNil(src)) { throw (new TypeError('"src" is required.')) } if (!isString(src)) { throw (new TypeError('"src" is expected to be type of "String".')) } if (!isNil(onLoad) && !isFunction(onLoad)) { throw (new TypeError('"onLoad" is expected to be type of "Function".')) } if (!isNil(onError) && !isFunction(onError)) { throw (new TypeError('"onError" is expected to be type of "Function".')) } let _resolveResult: (value: SingleJavaScriptLoadResult | PromiseLike) => void let _rejectResult: (reason?: any) => void const resultPromise = new Promise((resolve, reject) => { _resolveResult = resolve _rejectResult = reject }) let _resolveLoad: (value: void | PromiseLike) => void let _rejectLoad: (reason?: any) => void const loadPromise = new Promise((resolve, reject) => { _resolveLoad = resolve _rejectLoad = reject }) const load = async (): Promise => { return await new Promise((resolve) => { const existElement: HTMLScriptElement | null = document.querySelector(`script[src="${src}"]`) interface LoadResult { src: string name?: string pack?: any } const loadResult: LoadResult = { src, name } if (existElement !== null) { if (isFunction(collect)) { loadResult.pack = collect(existElement) } _resolveResult({ ...options, ...loadResult, element: existElement }) } else { const head = document.head ?? document.getElementsByTagName('head')[0] const script = document.createElement('script') script.type = 'text/javascript' script.onerror = (error) => { if (isFunction(onError)) { try { onError({ name, src, options, error, reject: _rejectResult }) } catch (error) { _rejectResult(error) } } _rejectResult(error) } script.onload = () => { if (isFunction(onLoad)) { try { onLoad({ name, src, element: script, options }) } catch (error) { _rejectResult(error) } } if (isFunction(afterLoad)) { try { afterLoad({ name, src, element: script }) } catch (error) { } } if (isFunction(collect)) { loadResult.pack = collect(script) } _resolveResult({ ...options, ...loadResult, element: script }) } if (isFunction(beforeLoad)) { try { beforeLoad({ name, src }) } catch (error) { } } script.src = src head.appendChild(script) } }) } if (JAVASCRIPT_LOAD_STACK.length === 0) { JAVASCRIPT_LOAD_STACK.push([loadPromise, resultPromise]) void resultPromise.then(() => { _resolveLoad() }) void load() } else { const [previousLoad, previousResult] = JAVASCRIPT_LOAD_STACK[JAVASCRIPT_LOAD_STACK.length - 1] const currentLoadStack = [...JAVASCRIPT_LOAD_STACK] JAVASCRIPT_LOAD_STACK.push([loadPromise, resultPromise]) void previousLoad.then(() => { const isolateStep = new Promise((resolve) => { const isolateStates = Array.from({ length: currentLoadStack.length }) currentLoadStack.forEach(([_, result], index) => { void result.then(value => { if (isFunction(value.isolate)) { value.isolate() } isolateStates[index] = true if (isolateStates.every(i => i)) { resolve() } }) }) }) const loadStep = isolateStep.then(async () => { void load() return await resultPromise }) const deisolateStep = loadStep.then(async () => { return await new Promise((resolve) => { const deisolateStates = Array.from({ length: currentLoadStack.length }) currentLoadStack.forEach(([_, result], index) => { void result.then(value => { if (isFunction(value.deisolate)) { value.deisolate() } deisolateStates[index] = true if (deisolateStates.every(i => i)) { resolve() } }) }) }) }) void deisolateStep.then(() => { _resolveLoad() }) }) } return await resultPromise } export interface MultipleJavaScriptLoadOptions { options: Array onLoad?: SingleJavaScriptLoadOptions['onLoad'] onError?: SingleJavaScriptLoadOptions['onError'] } export interface MultipleJavaScriptLoadResult { success: SingleJavaScriptLoadResult[] fail: Array<{ src: string, reason: any }> } export const loadMultipleJavaScript = async ( options: MultipleJavaScriptLoadOptions ): Promise => { const { options: loadOptions, onLoad, onError } = options if (isNil(loadOptions)) { throw (new TypeError('"options" is required.')) } if (!isArray(loadOptions)) { throw (new TypeError('"options" is expected to be type of "Array".')) } if (!isNil(onLoad) && !isFunction(onLoad)) { throw (new TypeError(`"onLoad" is expected to be type of Function, but received "${typeof onLoad}".`)) } if (!isNil(onError) && !isFunction(onError)) { throw (new TypeError(`"onError" is expected to be type of Function, but received "${typeof onError}".`)) } if ((loadOptions.length > 0) && !isFunction(Promise.allSettled)) { throw (new TypeError('Promise.allSettled is not supported, pls upgrade your browser.')) } const preparedOptions = loadOptions.map(item => { if (isString(item)) { return { src: item } } else if (isPlainObject(item)) { return item } else { throw (new TypeError('item of src is expected to be type of "PlainObject" | "String".')) } }) return await Promise.allSettled(preparedOptions.map(async item => await loadSingleJavascript(item))) .then(res => res.reduce((acc, item, index) => { if (item.status === 'fulfilled') { acc.success.push(item.value) } if (item.status === 'rejected') { acc.fail.push({ src: preparedOptions[index].src, reason: item.reason }) } return acc }, { success: [], fail: [] })) }