结论:在前端动态加载 JS 文件,实际可用的只有三条方案:document.createElement('script') 注入、ES Module 的 import() 动态导入,以及 fetch 取回源码后用 new Function 执行。生产环境优先选前两种;只要构建链支持模块化,就优先用 import()。
js 动态加载 JS 文件有哪几种方法
结论:四种做法里只有前两种值得在项目里使用,后两种属于历史遗留方案。
- script 标签注入:用
document.createElement('script') 创建节点、设置 src 后插入 <head>。兼容性最好,从 IE9 到最新 Chrome 都能用,脚本在全局作用域执行。
- 动态
import():ES2020 引入的语法,返回 Promise,加载完成后得到模块的命名空间对象。模块作用域隔离,同一个 URL 只执行一次,配合打包器还能做代码分割。
fetch + eval 或 new Function:能拿到源码字符串再执行,但会被 CSP 的 unsafe-eval 限制卡住,没有 sourcemap,报错行号全乱,只在做沙箱或插件系统时才考虑。
document.write('<script src=...>'):只在文档解析阶段有效,页面加载完成后调用会直接覆盖整个文档。已废弃,不要用。
用 createElement 动态加载 JS 怎么写
结论:一行注入三行收尾,关键是必须监听 onload 和 onerror,否则加载失败你永远不知道。
function loadScript(src, { timeout = 10000 } = {}) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.async = true;
const timer = setTimeout(() => reject(new Error('timeout: ' + src)), timeout);
s.onload = () => { clearTimeout(timer); resolve(s); };
s.onerror = () => { clearTimeout(timer); s.remove(); reject(new Error('load fail: ' + src)); };
document.head.appendChild(s);
});
}
三个参数细节:s.async = true 让脚本下载完立即执行,不阻塞解析;timeout 建议设 10000 毫秒,CDN 抖动时能快速失败;失败后把节点 remove() 掉,避免残留空标签。如果目标脚本依赖已有的全局变量(比如 jQuery 插件),串行执行用 await loadScript(a); await loadScript(b);,或者写成 Promise.all 并行后再按需初始化。
动态 import() 和 script 注入有什么区别
结论:import() 是模块化方案,script 注入是全局方案;前者更干净,后者兼容性更广。
- 作用域:
import() 加载的代码在模块作用域中执行,顶层 this 是 undefined,变量不外泄;script 注入的代码在全局执行,会往 window 上挂变量。
- 执行次数:同一 URL 的模块只会求值一次,重复
import() 直接返回缓存的 Promise;script 注入每次插入都会新建一个节点,浏览器虽然会走 HTTP 缓存,但脚本会重复执行一遍。
- 协议要求:
import() 要求服务器返回 Content-Type: text/javascript,并且跨域时必须带 CORS 响应头,否则报 “Failed to fetch dynamically imported module”。
- 用法:
const mod = await import('/js/chart.js'); mod.render(el);
动态加载如何避免重复加载和并发问题
结论:用一个 Map 缓存 Promise,是解决重复请求和并发竞争最省事的做法。
const cache = new Map();
function loadOnce(src) {
if (!cache.has(src)) cache.set(src, loadScript(src));
return cache.get(src);
}
这样 10 个组件同时调用 loadOnce('/js/map.js'),只会产生 1 个 <script> 节点和 1 次网络请求,其余调用拿到的是同一个 Promise。注意缓存 key 要带版本号,比如 /js/map.js?v=20240612,否则发布新版本后浏览器仍拿旧缓存。用 import() 时可以靠打包器生成的 chunk hash 天然避坑。
加载失败怎么重试
结论:单次失败就放弃的用户体验很差,给 2 次重试加备用域名,能覆盖绝大多数 CDN 抖动。
async function loadWithRetry(src, retries = 2, backup) {
for (let i = 0; i <= retries; i++) {
try {
return await loadScript(i === 0 ? src : (backup || src) + '?r=' + i);
} catch (e) {
if (i === retries) throw e;
}
}
}
重试时在 URL 后加 ?r=1 这类查询参数可以绕过失败的缓存记录。生产上建议主域名 + 备用 CDN 双路,主路失败自动切备用。
动态加载的性能优化怎么做
结论:用 <link rel="preload"> 和 rel="modulepreload" 提前拉取,用 async 和 defer 控制执行时机,比加载完了再优化有效得多。
<link rel="preload" as="script" href="/js/editor.js">:让浏览器在空闲时提前下载,等真正 loadScript 时直接命中缓存,省掉 100~300 毫秒。
<link rel="modulepreload" href="/js/mod.js">:针对 import() 的专用预加载,会提前解析依赖树。
<link rel="prefetch">:用于下一个页面才会用到的资源,优先级最低。
async(下载完立刻执行、顺序不保证)和 defer(DOM 解析完后按顺序执行)二选一,需要保序就选 defer。
动态加载 JS 有哪些常见坑
结论:90% 的“加载了但没生效”都出在 CSP、跨域、执行时机这三处。
- CSP 拦截:站点开了
script-src 白名单,动态插入的 <script> 必须设置 s.nonce = '随机值',或者把域名加进 script-src。
- 跨域报错:加载 CDN 脚本要在标签上加
s.crossOrigin = 'anonymous',并且 CDN 返回 Access-Control-Allow-Origin,否则错误堆栈拿不到。
- 执行时机:动态脚本里如果再调用
document.write,因为文档已加载完成,会直接清空页面。
- 依赖顺序:并行
Promise.all 只保证都加载完,不保证执行顺序,有依赖关系就老老实实串行。
一句话收尾:日常业务优先用 import(),需要兼容老浏览器或加载第三方全局脚本再用 createElement 注入,并且都配上 Promise 缓存、失败重试和 version 参数;document.write 和 eval 这两条路,能不走就别走。