/** * @description 将脚本地址转换为基于当前文档的绝对地址,避免相对路径、完整地址和等价地址之间比较不一致。 */ export const normalizeScriptSrc = (src: string): string => { return new URL(src, document.baseURI).href } /** * @description 在当前页面中查找与目标地址匹配的 `` 标签内部。 * - 外部脚本:(ExternalScript)指通过设置 `` 标签的 `src` 属性进行引入的 JavaScript 脚本文件。 * * {@link 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( (value, item) => { if (item.hasAttribute("src")) { const normalizedSrc = normalizeScriptSrc(item.src) value.external.push({ src: normalizedSrc, element: item }) } else { value.internal.push({ element: item }) } return value }, { internal: [], external: [] }, ) return collection } /** * @description 表示单个脚本的加载选项。 */ export interface ScriptLoadOptions { /** * @description 待加载脚本的地址。 */ src: string /** * @description 调用方自定义的脚本标识,仅用于透传到结果与回调中。 */ name?: string | undefined /** * @description 指定脚本类型。 */ type?: "module" | undefined /** * @description 是否为插入的脚本节点设置 `async=true`。 */ async?: boolean | undefined /** * @description 加载超时时间,单位为毫秒。 */ timeout?: number | undefined /** * @description 脚本可用后执行的采集函数,返回值会保存在加载结果中。 */ collect?: ((element: HTMLScriptElement) => unknown) | undefined /** * @description 在后续脚本开始加载前执行,用于隔离当前脚本对全局环境的影响。 */ isolate?: (() => void) | undefined /** * @description 在后续脚本加载完成后执行,用于恢复当前脚本的全局环境。 */ deisolate?: (() => void) | undefined beforeLoad?: ((context: { options: ScriptLoadOptions }) => void) | undefined onLoad?: | ((context: { options: ScriptLoadOptions; element: HTMLScriptElement }) => void) | undefined afterLoad?: | ((context: { options: ScriptLoadOptions; element: HTMLScriptElement }) => void) | undefined onError?: ((context: { options: ScriptLoadOptions; error: unknown }) => void) | undefined } /** * @description 表示单个脚本的加载结果。 */ export interface SingleScriptLoadResult extends ScriptLoadOptions { element: HTMLScriptElement /** * @description Save result of `collect` function. */ collected?: unknown | undefined } /** * @description 表示批量脚本加载的输入选项。 */ export interface MultipleScriptLoadOptions { /** * @description 待加载脚本列表。字符串会被视为仅包含 `src` 的加载项。 */ items: Array /** * @description 为未单独指定 `onLoad` 的加载项提供统一的成功回调。 */ onLoad?: ScriptLoadOptions["onLoad"] | undefined /** * @description 为未单独指定 `onError` 的加载项提供统一的失败回调。 */ onError?: ScriptLoadOptions["onError"] | undefined } /** * @description 表示批量脚本加载的汇总结果。 */ export interface MultipleScriptLoadResult { success: SingleScriptLoadResult[] failure: Array<{ src: string; reason: unknown }> } /** * @description 按串行队列将指定脚本加载到当前页面中。 * * 加载之前进行检测: * - 如果指定地址(src)的脚本已经存在于页面中,直接返回该脚本作为加载结果。 * - 如果指定地址(src)的脚本不存在于页面中,则通过插入 `script` 标签的方式将脚本加载到页面中,然后返回加载结果。 * * 以 `script` 标签加载到页面中的脚本全部拥有 JavaScript 环境的完全访问权限,当脚本加载涉及到全局变量的变更时, * 并行加载会出现难以预料的错误,所以这里统一采用串行加载。 * - 加载器会维护一个加载队列,只有先申请的脚本加载完成之后才会加载后申请的脚本, * - 脚本加载的选项中可以定义辅助隔离全局变量的函数(isolate、deisolate), * 在加载某个脚本之前,加载器会遍历执行其它已加载的脚本的 isolate 函数,加载完成之后,再遍历执行已加载脚本的 deisolate 函数。 */ export class ScriptLoader { private queue: Promise private readonly registry: Map> private readonly loaded: Map constructor() { this.queue = Promise.resolve() this.registry = new Map() this.loaded = new Map() } protected hasLoaded(src: string): boolean { return this.loaded.has(normalizeScriptSrc(src)) } protected getLoaded(src: string): SingleScriptLoadResult | undefined { return this.loaded.get(normalizeScriptSrc(src)) } private setLoaded(src: string, result: SingleScriptLoadResult): void { this.loaded.set(normalizeScriptSrc(src), result) } protected removeLoaded(src: string): void { this.loaded.delete(normalizeScriptSrc(src)) } protected isolateAll(): void { this.loaded.forEach((item) => { if (item.isolate !== undefined) { item.isolate() } }) } protected deisolateAll(): void { this.loaded.forEach((item) => { if (item.deisolate !== undefined) { item.deisolate() } }) } /** * @description 统一组装加载结果,并在需要时执行 `collect`。 */ private createResult( options: ScriptLoadOptions, element: HTMLScriptElement, ): SingleScriptLoadResult { const result: SingleScriptLoadResult = { ...options, element } if (options.collect !== undefined) { result.collected = options.collect(element) } return result } /** * @description 将各种失败原因统一转换为 `Error` 实例,便于上层消费。 */ private toError(error: unknown): Error { if (typeof error === "object" && error !== null && "message" in error) { const { message } = error as { message?: unknown } if (typeof message === "string") { return new Error(message) } } return new Error(typeof error === "string" ? error : "Unknown script load error.") } /** * @description 实际创建并注入 `