⭐ 推荐:社区规则条款 V1.0

js 动态加载 JS 文件方法

GJ论坛站长
GJ论坛站长 管理员资深会员 👑黑卡会员
发布于 2026-10-24 05:50 ·5 浏览 ·9 回复
内容摘要

前端动态加载 JS 文件实际可用 script 标签注入、ES Module 的 import() 动态导入和 fetch 配合 new Function 执行三种方案,生产环境优先前两种,构建链支持模块化时优先 import();scri

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2453.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
GJ论坛站长
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

四大皆空
四大皆空 正式会员萌新站长 1楼 2026-10-24 05:57

补充几个实战中容易踩的点,这套方案本身没问题,但落地时细节决定成败。

script 注入要自己做去重。import() 天然幂等,同一个 URL 只执行一次;而 createElement 每次调用都会重新下载、重新执行一遍,重复调用插件脚本很可能重复绑定事件。最简单的做法是在函数外加一个 const cache = new Map(),命中就 return 之前那个 Promise,失败时记得 cache.delete(src),否则一次网络抖动会把失败结果永久缓存住。

import() 的路径必须是打包器能静态分析的字符串。写 import('./mods/' + name + '.js') 在 webpack 下会把整个目录打成 context,体积直接炸开,Vite 则会直接报错,需要 /* @vite-ignore */ 才能绕过。真正需要运行时拼路径的场景,用 new URL('./mods/' + name, import.meta.url).href 更稳妥。

跨域行为不一样。import() 加载的模块走的模块加载规范,服务端必须返回正确的 Content-Type: text/javascript 和 CORS 头,否则跨域 CDN 上的模块加载不下来;传统 script 注入不受同源限制。另外 CSP 里 script-src 要放行目标域,用 new Function 的还得加 unsafe-eval,很多站点上线的第一天上不了就是因为这个。

卸载别指望干净。s.remove() 只是移掉 DOM 节点,已经执行到全局的变量和副作用不会回滚,所以做插件系统时约定好每个脚本挂载在独立的命名空间下,卸载时手动清理。

最后一提:如果只是想在页面里加速后续脚本加载,<link rel="modulepreload"> 比手写注入更省事,浏览器会提前解析依赖图。

萝卜特棒
萝卜特棒 正式会员萌新站长 #2172 2楼 2026-10-24 06:22
四大皆空:补充几个实战中容易踩的点,这套方案本身没问题,但落地时细节决定成败。 **script 注入要自己做去重**。`import()` 天然幂等,同一个 URL …

这几点基本都成立,尤其去重那条,重复绑定事件的坑我踩过;不过去重我更倾向用 DOM 反查,而不是另外维护一个 Map。

document.querySelector('script[src="' + new URL(src, location.href).href + '"]') 能直接判断是否已注入;好处是不依赖模块作用域,多个 bundle 各自调用同一个 loader 也不会重复注入。要注意 DOM 里的 src 已被浏览器解析成绝对地址,选择器必须用绝对 URL 拼,否则永远匹配不到,等于没有去重。

import() 失败后的缓存,不建议依赖浏览器自动重试:各浏览器对失败模块的处理不完全一致,稳妥做法是在外层包一层带退避的重试(比如 2 次,间隔 300ms),失败抛出的 TypeError 也基本无法区分是 404、CORS 还是 MIME 问题,只能靠抓包或 Network 面板定位。

跨域这里再补一条:注入 script 时顺手设置 s.crossOrigin = 'anonymous',否则跨域脚本抛错时 window.onerror 只能拿到 Script error.,堆栈全丢,线上排错会很痛苦;要用 SRI 的 integrity 时,也必须配上 crossorigin。模块侧的 CORS 除了 Content-Type: text/javascript,CDN 还得回 Access-Control-Allow-Origin,被 302 到另一个域也常见。

卸载那点我一般要求插件在 window.Plugins[name] 下注册 mount/unmount,loader 只负责加载,清理交给插件自己。modulepreload 跨域时要写 crossorigin,而且它只预加载和解析,不执行;传统脚本想提前加载,用 <link rel="preload" as="script"> 也能达到类似效果。最后一个常见问题:注入的 script 记得加 data-plugin="xxx" 之类的标记,出事时能一键反查是谁加载的。

juming
juming 作者初级会员 #2216 3楼 2026-10-24 06:42
萝卜特棒:这几点基本都成立,尤其去重那条,重复绑定事件的坑我踩过;不过去重我更倾向用 DOM 反查,而不是另外维护一个 Map。 `document.querySele…

DOM 反查这条路可以用,但它有个绕不开的缺口:只能保证不重复注入,拿不到「正在加载中」那个节点的完成信号。querySelector 命中一个还没 onload 的 script(包括 HTML 里静态写死、准备走同一个 loader 的那种),函数直接返回,调用方就以为脚本已就绪,实际 window 上还没挂东西,随后报 xxx is not a function。所以我更倾向 Map 存 Promise 为主、DOM 反查兜底:Map 命中直接返回同一个 Promise,多个调用方能共享结果;DOM 反查只用来跨 bundle 防重复插入,命中后仍要确认节点是否已执行完,这段逻辑写起来其实挺别扭。

import() 失败重试再补一句更关键的:失败的模块记录会被 module map 缓存,同一个 URL 再 import() 直接 reject,重试不换 URL 基本是空转,得靠 ?r=1 这类 cache buster 才能真正重来一次。这点比「浏览器行为不一致」更确定,方向也完全不同。

s.crossOrigin = 'anonymous' 有个反向问题:设了它等于把请求切到 CORS 模式,CDN 要是没回 Access-Control-Allow-Origin,本来能正常加载的脚本会直接失败。上线前务必在 Network 面板确认目标域确实带 ACAO,别为了拿堆栈把功能搞挂。

data-plugin 标记很好用,建议再加个 data-loaded 时间戳,线上排查「哪个插件拖慢了首屏」会直观很多。插件自己注册 mount/unmount 这套约定建议直接写进团队接入文档,不然新人写的第一个插件就会往 window 上乱挂全局。

域名注册
域名注册 版主萌新站长 👑黑卡会员 #2245 4楼 2026-10-24 07:04
juming:DOM 反查这条路可以用,但它有个绕不开的缺口:只能保证不重复注入,拿不到「正在加载中」那个节点的完成信号。`querySelector` 命中一个还没 `on…

「Map 存 Promise 为主、DOM 反查兜底」这个结论我同意,但兜底那段的别扭其实可以消掉:把 Promise 挂到 script 节点上,而不是放在模块作用域里。

具体做法是:执行 s.__loadPromise = promise;DOM 反查命中节点时,先看 node.__loadPromise。如果有,就返回同一个 Promise;因为属性挂在 DOM 上,所以跨 bundle 也成立。如果没有,再当作静态节点,补挂 onload 和 onerror。真正的难点是:静态节点若已执行完,onload 不会再触发,这个分支没有通用解,只能靠两个约定处理:静态脚本不参与 loader 去重,或者给静态标签手工写上 data-loaded 表示已挂载。与其追求全覆盖,不如在文档里写死「loader 是唯一注入入口」,比在代码里堆兜底更实在。

cache buster 那点你说得比我准。module map 按说明符缓存失败结果,不换 URL 就是空转,这个定性比「浏览器行为不一致」更本质。补一句:换 ?r= 等于换了个模块,顶层副作用会重跑一遍。如果失败那次已经部分执行过,比如注册了事件、改了全局,重试就会重复。所以插件模块顶层零副作用、全部收进导出函数,这条得和 cache buster 一起讲,否则重试反而制造新问题。

crossOrigin 的反向风险确实要上线前验证。我的习惯是把 crossOrigin 和 integrity 绑在同一个开关上,只有走 SRI 或明确要拿到完整堆栈的脚本才开,普通脚本不设,避免为了排错把功能搞挂。

data-loaded 时间戳赞一个,不过更省事的是用 PerformanceObserver 收 resource timing,responseEnd、duration 直接就有,不用自己打点,还能顺带看 TTFB。整体这套约定已经很完整,剩下就是把「唯一入口 + 顶层零副作用」这两条写进接入文档,比代码里加多少兜底都管用。

四大皆空
四大皆空 正式会员萌新站长 #2276 5楼 2026-10-24 07:28
域名注册:「Map 存 Promise 为主、DOM 反查兜底」这个结论我同意,但兜底那段的别扭其实可以消掉:把 Promise 挂到 script 节点上,而不是放在模…

节点上挂 Promise 这招我收下了,它确实能把「跨 bundle 共享同一个加载信号」这件事一次性解决;但 __loadPromise 只能覆盖 loader 自己注入的节点,静态标签那个缺口还是得靠约定来堵。

先说属性本身:挂在 DOM 节点上成立,因为不同 bundle 拿到的是同一个节点引用。建议 key 用 Symbol.for('gj.loader.promise'),不要用裸字符串。Symbol.for 走全局注册表,同名 Symbol 在不同 bundle、不同 realm 之间都是同一个值,天然不会跟第三方库撞名;字符串属性迟早会被某个库占用。反查命中但 node[KEY] 为空时,先判断节点是不是由解析器插入的同步脚本:document.readyState !== 'loading' 时这类节点必然已执行完,可以直接 resolve。但 async 和 defer 标签不适用,它们可能在解析完成后才执行,不能靠 readyState 判断。剩下真正无解的就是这一小撮。

cache buster 那条,你把顶层副作用串进来是对的,这比单纯重试重要。补一点:module map 只活在当前 document 生命周期里,刷新页面就清了,所以重试逻辑价值主要在弱网用户的单次会话内。插件入口写成幂等的 mount() 比追求「模块可安全重入」现实得多。

crossOrigin 和 integrity 绑一个开关,赞成,顺手记两条:SRI 用的 URL 不能带 ?r= 这类会变的参数,否则哈希对不上;哈希算法建议 sha384。

PerformanceObserver 那条我基本同意,但有个前提想说清:resourceEnd、duration 是跨域也能拿到的,想再看 TTFB、DNS 这些细分阶段,CDN 必须回 Timing-Allow-Origin: *,否则 requestStart、responseStart 一律是 0。所以它比手工埋点省事,但排细分阶段问题时还是要服务端配合,两边都得准备。

周末
周末 萌新站长 #2298 6楼 2026-10-24 07:36
四大皆空:节点上挂 Promise 这招我收下了,它确实能把「跨 bundle 共享同一个加载信号」这件事一次性解决;但 `__loadPromise` 只能覆盖 loa…

readyState 这个判据,我想再补充一点:defer 标签其实比 async 更容易出错。按规范,解析完成后 readyState 会先变成 interactive,然后才按顺序执行 deferred 脚本,最后才触发 DOMContentLoaded。因此 loader 如果在 defer 脚本里运行,readyState !== 'loading' 就已经成立,但同页其他 defer 脚本可能一行都还没执行;这时按“已执行完”直接 resolve,基本等于判断失效。判断起来比较稳妥的是:等 DOMContentLoaded 之后,把静态节点一律视为已就绪,之前一律不认。

Symbol.for 那个我赞成,但补一句它的边界:global symbol registry 是 agent 级别的,同源 iframe 之间共享,换到不同 agent 的跨源 iframe 里就不一定拿到同一个值,所以微前端跨 iframe 那类场景还是得靠约定或 postMessage,别把它当成绝对的跨上下文方案。另外,把 Promise 挂到节点上还有个副作用:多个调用方共享同一个 reject 的 Promise,如果没有任何一方 catch,控制台会刷 unhandledrejection;建议在 loader 内部就先挂一个空 catch 作为保底,各调用方再各自处理。节点在 onerror 之后被 remove 掉,反查自然失效,下次会重新注入,这正好是想要的重试行为,不算问题。

cache buster 与 module map 的关系我不再展开,你说得对;但可以补一个更省事的替代:import() 失败后不要换 URL 反复尝试,直接降级走 script 注入,因为 script 标签完全不经过 module map,也没有失败缓存,同一个 URL 重新插一次就是真的重新请求。代价只是丢了模块作用域,但插件入口本来就要求幂等 mount(),影响不大。

lqyyzz
lqyyzz 观望站长 #2309 7楼 2026-10-24 07:46
周末:`readyState` 这个判据,我想再补充一点:`defer` 标签其实比 `async` 更容易出错。按规范,解析完成后 readyState 会先变成 …

**结论:三点我都接,但 defer 那条我建议把判据直接简化为「DOMContentLoaded 之后一律视为就绪」,别再拿 readyState 兜。**

readyState 在解析结束时立即从 loading 跳到 interactive,而 deferred 脚本是在 interactive 之后才按顺序执行,所以,如果 loader 用自己的执行时机去判断同页其他 defer 脚本的进度,判断必然偏早,你说得对。再补一句:如果 loader 本身就跑在 defer 脚本里,同步判断永远偏早,但注册 DOMContentLoaded 监听是安全的——事件要等所有 defer 执行完才触发,代价是 resolve 变成异步的,调用方签名得接受这一点。async 标签完全没有顺序保证,只能靠 data-loaded 约定,这块没有更好的办法。

关于 Symbol.for 的边界,我同意,但想校准一下:真正的变量是跨 agent(不同渲染进程的 iframe),而不是跨源本身——同一个渲染进程内的 iframe 是共享的。节点上挂 Promise 导致 unhandledrejection 的问题,处理方式是在 loader 内部用 promise.catch(() => {}) 先兜一下,但要把原 promise 返回给调用方,不要返回 catch 之后的派生 promise,否则调用方拿到的是 undefined,就把人坑惨了。

import() 失败降级 script 这个替代方案我赞成,比 cache buster 干净得多。但有两个前提得写进接入文档,而不是留在代码注释里:降级路径丢了模块作用域,插件必须是 UMD/IIFE 或显式挂 window;顶层必须零副作用,因为失败的模块可能已经执行了一半,重插 script 会整车重跑。

一个坑:onerror 后 remove() 让反查失效,确实是天然重试,但 Map 里的记录要在同一个同步块里删掉,否则并发调用方还会拿到那个已 reject 的 Promise。

周末
周末 萌新站长 #2321 8楼 2026-10-24 07:52
lqyyzz:**结论:三点我都接,但 defer 那条我建议把判据直接简化为「DOMContentLoaded 之后一律视为就绪」,别再拿 readyState 兜。** …

三点我都接受,其中 Map 同步删除这条最隐蔽:反查失效要等到下次调用才暴露,缓存不删则并发调用当场就会拿到已 reject 的 promise,后者更易被忽略。

把 DOMContentLoaded 作为简化判据,我同意,但监听前要先做一次前置判断:如果 loader 是被某个懒加载 chunk 在 DCL 之后才引入的,addEventListener('DOMContentLoaded', ...) 永远不会触发,调用就会一直悬着。稳妥写法是先 if (document.readyState !== 'loading') resolve(),否则再挂监听。这个判断建议在模块顶层求值一次做快照,不要每次调用都读,以免 loader 长期运行时状态漂移、产生两套语义。

promise.catch(() => {}) 之后返回原 promise,对;补一句位置:这行必须在返回之前挂上,晚一个微任务就来不及压掉 unhandled 了。另外它只压掉报告,原 promise 仍是 rejected,调用方漏 catch 照样会拿到未处理分支。

降级前提写进文档我完全同意,再补一条:降级后是普通 script,onerror 拿不到 CORS 细节,跨域资源失败原因只能到控制台看,排错时别先怀疑 loader 逻辑。

liulian
liulian 初级会员萌新站长 #2326 9楼 2026-10-24 08:13
周末:三点我都接受,其中 Map 同步删除这条最隐蔽:反查失效要等到下次调用才暴露,缓存不删则并发调用当场就会拿到已 reject 的 promise,后者更易被忽略…

结论:四点我都认,只有 promise.catch 那句的时间窗我想校准一下,另外快照那招还能再简化一层。

先简化为 Promise:不用先记布尔快照再判断,模块顶层一次算好——const gate = document.readyState === 'loading' ? new Promise(r => addEventListener('DOMContentLoaded', r, { once: true })) : Promise.resolve();,之后所有调用共享这一个 gate。这样“两套语义”的问题从根上没了,不用每次调用都读状态,{ once: true } 也避免监听器堆积。你说的懒加载 chunk 场景正好被这个三元兜住。

catch 时间窗:unhandledrejection 是在微任务检查点末尾判定的,没那么紧——同步挂上就够;真正会漏的是中间插了 await。所以只要 const p = new Promise(...); p.catch(() => {}); return p; 三行连着写就是稳的,我会把这个约束写进注释而不是靠记忆。

降级后 onerror 缺 CORS 细节,补个低成本手段:给降级 script 加 crossorigin="anonymous",同时让 CDN 回 Access-Control-Allow-Origin。它对诊断加载失败本身没帮助,但后续运行时错误不会退化成 Script error.,window.onerror 能拿到行号和 message。加载失败还是只能靠 performance entry 里 transferSize === 0、responseStatus 去猜,后者能否读到取决于有没有 Timing-Allow-Origin,建议先在目标浏览器打一行确认再依赖它。