JavaScript脚本动态加载:跨文件调用与执行顺序全解析

发布时间:2026/9/19 13:45:52
JavaScript脚本动态加载:跨文件调用与执行顺序全解析
简介JS 开发中跨页面调用变量和函数是多脚本协作场景的常见痛点这份 PDF 面向需要让 a.js 与 b.js 相互调用的前端开发者。内容以按钮点击事件为入口演示了在 b.js 中通过 document.createElement(script) 动态创建 script 标签、设置 src 指向 a.js并追加到 body 末尾的延迟加载方案同时说明 HTML 中脚本放在 之前的执行顺序影响。文中强调动态加载能保持代码模块化但也指出每个文件单独请求会带来性能开销因此顺带对比了 window 全局对象与 ES6 import/export 两种替代思路全局方式简单却易引发命名冲突ES6 模块更安全但需要编译环境支持。此外还提到 Webpack/Rollup 等打包工具可将多文件合并为 bundle优化加载速度。资源为单份 PDF 文档大小 42KB内容紧凑可直接查阅目前已有 3222 人学习适合希望理清模块加载顺序、掌握原生 JS 动态脚本方案的中初级开发者快速上手。1. 两个 script 不能互调先搞清楚 JavaScript 在什么时候“准备好”页面里有一个查询按钮点击后要执行 b.js 里的b()而b()内部又必须调用 a.js 的a()。直接上两个script标签也能跑但顺序必须 a 在前 b 在后反过来就会在控制台看到ReferenceError: a is not defined。问题核心不在“跨页面”这个说法而在 JavaScript 代码是按顺序解释执行的函数要等所属脚本执行完才真正挂到全局环境。这里能用的一种最小方案是用document.createElement(script)动态把 a.js 注入到body末尾加载完成后再在b()里调用a()。适合被多个业务脚本互相调用顺序坑过的前端也适合想确认动态加载边界和替代方案的老手。2. 脚本执行顺序与全局命名空间为什么a()会暂时不存在先把结论摆出来不是“两个 js 不能互相调用”而是调用发生时另一个文件里的函数是否已经在全局环境里注册成功。理解这点后面的动态加载代码才有依据。2.1 函数声明、变量提升和window对象JavaScript 脚本在运行时所有var声明的变量和顶层function声明都会成为当前全局对象window的属性。写一个a.js// a.js var globalName from-a; function a() { console.log(a executed, globalName , globalName); }当浏览器加载并执行这段脚本后window.globalName和window.a都会指向对应的值与函数。细心的开发者会想到“变量提升”函数声明和var声明在脚本内部会被提到最前面执行所以同一个脚本里先调用后声明也能通过。但这个提升只发生在当前脚本内部不跨越文件。b.js在加载时不会预知a.js里有什么它只看自己当前执行到的地方以及全局对象里已经存在的成员。这里有一个容易踩的点如果在 a.js 里把变量声明成let sharedValue x即便脚本执行完window.sharedValue也是undefined因为let和const不会挂到window上它们进入的是全局词法环境跨脚本只能靠模块导出或显式赋值给window才能访问。很多“跨页面调用变量”的问题最终都出在这个差异上。2.2 普通script、defer与async的顺序保证HTML 解析到script src...时会停止解析文档下载并执行该脚本然后继续解析。这意味着页面里引入顺序决定了执行顺序。如果在 a.js 之前引入了 b.jsb.js 会先执行而 a.js 里的function a还没有定义。原项目标题里说的“a.js 和 b.js 互相调用”本质上是这个时序问题而不是安全限制。加载方式执行时机顺序保证适用场景普通script下载后立即执行阻塞后续解析按文档顺序依赖明确的小脚本deferHTML 解析完成后执行多个 defer 按顺序执行需要保持依赖关系的页面脚本async下载完成后马上执行不保证顺序统计、埋点、独立组件defer看起来能解决 b.js 依赖 a.js 的问题把两个文件都用defer引入它们会按顺序执行。但defer的边界在于它要求两个文件提前出现在 HTML 里并且都要等到 HTML 解析完。如果业务场景是想按需拉取、不写在 HTML 里动态加载是更贴合的方式。2.3/body之后引入脚本的真相原资源把script srcb.js写在了/body后面这个写法在浏览器的容错解析里会被当作body内的节点处理。它的本意是确保按钮节点已经存在脚本执行时不会因为找不到button而失败。这解决的是“DOM 是否准备好”和“另一个 js 文件是否准备好”是两回事。现代项目会把脚本放在/body之前或者用DOMContentLoaded监听跨文件调用则更推荐下一章的做法。3.document.createElement(script)动态注入的最小可复现写法理解了时序问题后动态加载的思路就非常自然让 b.js 在一个合适的时机把 a.js 以脚本标签的形式插入页面浏览器会自行下载并执行 a.js执行完成后 a 成为全局函数b.js 再调用它。3.1 三份文件的结构与入口绑定先搭好最小结构。HTML 里只显式引入 b.jsa.js 由 b.js 负责按需加载。!DOCTYPE html html langzh-CN head meta charsetutf-8 title动态加载/title /head body button idrunBtn typebutton执行 b()/button script src./b.js/script /body /html按钮不用 inlineonClick也完全可以后面会在 b.js 里绑定事件。这样做的目的是把入口收敛到一个文件里避免模板里分散的全局函数调用。3.2 b.js 里的动态加载代码与关键参数b.js 的核心代码如下// b.js —— 按需加载 a.js并确保 a() 就绪后再暴露入口 function loadScript(src, callback) { var script document.createElement(script); script.type text/javascript; script.src src; script.onload function () { callback callback(); }; script.onerror function () { console.error(脚本加载失败 src); }; document.body.appendChild(script); } loadScript(./a.js, function () { window.b function () { a(); // 此时 a.js 已执行完成a 一定存在 }; document.getElementById(runBtn).addEventListener(click, window.b); });这里有一个原资源没有处理的细节原代码把document.body.appendChild(new_element)写在了 b.js 的顶部随后才定义function b()。如果用户加载页面后立刻点击按钮a.js 大概率已经加载完成所以能跑通但如果 a.js 体积大、加载慢点击发生在加载完成之前a is not defined就会再次出现。把调用放在onload回调里才是真正可控的时机。代码片段实际作用值得注意的点document.createElement(script)创建 script 节点仅创建节点不触发网络请求script.src src设置脚本地址相对路径基于当前 HTML 页面不是 b.js 所在目录document.body.appendChild(script)插入 DOM 并开始下载执行多次调用会重复请求script.onload脚本执行完成后触发在这里调用 a() 最安全从参数角度看type属性在现代浏览器中可以省略languageJAVASCRIPT属于历史遗留属性。真正决定行为的只有src和事件回调。如果从head插入需要等待 DOM 节点存在放在 body 末尾则天然满足这个条件这也是原资源强调“放在 body 下面”的原因。3.3 从 b.js 中导出入口函数刚才window.b function() { a(); }采用的是挂全局的方式。常见做法是在loadScript成功之后把需要外部触发的函数挂到window上否则按钮无法找到它。如果你的项目里不用全局变量也可以像第 4 章那样用回调或模块系统显式传递依赖。对于脚本文件互相调用这种老式写法window.b是最直观的收口。注意b.js 里的loadScript是普通函数声明它内部使用callback callback()来避免回调缺失时报错调用loadScript时传入的匿名函数在 a.js 执行完成之后才运行因此window.b的赋值时机是安全的。4. 跨文件共享还有三种更稳的姿势动态script注入适合老项目和小型页面但工程规模上去之后还有几个常用方案值得对比。它们不是互相排斥而是不同约束下的选择变量、函数分文件的方式直接影响后续维护成本。4.1 显式挂到window简单但要控制数量显示挂在全局对象上是动态加载后最简单的互通方式。// a.js window.a function () { return result from a; }; // b.js window.b function () { const result window.a(); console.log(result); };这里用window.a ...替代function a(){}好处是一眼就能看出这是有意暴露给其他文件的 API坏处是全局命名空间会被污染。当项目里有十个以上文件时命名冲突会让人头疼。偶尔也有人用window.__cache window.__cache || {}做一个统一命名空间这个做法可以接受但要注意别把内部实现细节全部暴露出去。4.2 回调函数把依赖传进去而不是靠全局动态脚本注入讲的是“先加载再调用”而回调函数方案可以在不依赖全局对象的情况下完成函数分文件后的调用。// a.js function a() { console.log(a called); } // b.js function b(deps) { deps.a(); }使用时由调用方把 a.js 中暴露的函数作为参数传入 b.js。这里的“依赖注入”思想在模块化开发中非常普遍回调函数也是现代异步代码的基础。热词里的“箭头函数写法”在这里同样适用window.b () a();与function b() { return a(); }对调用方面没有差异真正的差异在于this绑定跨文件调用场景下并不需要使用this所以箭头函数更简洁。4.3 ES6 模块标准化的函数分文件方案如果项目可以用构建工具或者浏览器支持原生typemoduleES6 模块是默认选择。// a.js export function a() { return a; } // b.js import { a } from ./a.js; export function b() { return a(); }HTML 侧引入script typemodule src./b.js/script原生模块会有 CORS 要求直接双击file://页面会失败需要http-server这类本地服务。模块之间是静态依赖顺序由 import 声明决定不再需要手工loadScript。现代打包工具Webpack、Rollup、Vite都会把这种写法打包成浏览器可执行的脚本并在需要时按路由切割代码。方案维护性适用规模兼容要求动态 script 注入中页面级脚本、老系统最低window 全局变量低临时联调、小工具最低回调参数注入中高组件封装、插件机制低ES6 模块高中大型项目需要构建或 typemodule结合前面的动态加载来说如果你的目标只是让 a.js 和 b.js 互相调用动态注入可以立即生效如果这是新项目的第一行代码直接走 ES6 模块更长远。5. 动态加载的常见坑依赖链、重复注入和报错定位动态加载代码本身只有几行真在生产环境跑起来压在你面前的往往是三个问题加载顺序不够可靠、重复请求浪费流量、报错时不知道去哪看。5.1 依赖链中的回调嵌套当 a.js 依赖 c.js、b.js 依赖 a.js 时单纯的 onload 会形成回调金字塔loadScript(./c.js, function () { loadScript(./a.js, function () { loadScript(./b.js, function () { window.init function () { /* run */ }; }); }); });这种写法性能上没问题但可读性差。第 6 章会改成 Promise 链。依赖链越长失败定位越难c.js 404 会导致后面所有脚本不执行控制台常常只报b is not defined而不直接报 c.js 失败。5.2 重复注入与“只加载一次”的缓存控制每次调用loadScript(./a.js)都会生成新的 script 节点浏览器会再次发起请求。大部分业务脚本不需要重复执行所以要在模块环境里维护一个已加载列表。var loaded window.__loaded || (window.__loaded {}); function loadScriptOnce(src, callback) { if (loaded[src]) { callback callback(); return; } var s document.createElement(script); s.src src; s.onload function () { loaded[src] true; callback callback(); }; s.onerror function () { console.error(加载失败 src); }; document.body.appendChild(s); }window.__loaded是一个全局缓存对象键是脚本路径。注意路径一致性如果第一次加载用的是./a.js第二次用a.js缓存会失配页面里会出现两个 a.js 实例函数被定义两次后加载的覆盖先加载的容易隐藏变量被重置的 bug。提示动态注入的 script 不受defer顺序管理它一旦插入 DOM 就按普通外部脚本处理。如果页面里同时存在多个动态脚本明确用上面的缓存机制保证每个路径只注入一次。5.3 404、跨域和 CORS 错误现象常见原因排查入口Network 面板里脚本显示红色状态 404路径写错或文件不在预期目录看请求 URL 和实际目录结构控制台报Script error.跨域脚本被加载但禁止查看错误详情给 script 加crossoriginanonymous脚本加载成功但函数还是 undefined函数可能没挂到 window或写法用了export在 Console 里输入Object.keys(window)过滤通过document.querySelector(script[src./a.js])可以检查节点是否已存在。如果脚本是后加的DOM 里有节点但 Network 里没有新请求说明命中缓存如果节点存在但函数仍然不可用多半是 a.js 内部语法错误导致执行中断打开 Sources 面板里的a.js打断点看实际执行路径。6. 封装成 Promise 版本按依赖顺序加载并验证动态加载的进阶用法是把回调改成 Promise配合async/await管理依赖链。下面是一个可以直接放进项目的版本。// loader.js function loadScript(src) { return new Promise((resolve, reject) { const cache window.__scriptCache || (window.__scriptCache {}); if (cache[src]) { resolve(cache[src]); return; } const s document.createElement(script); s.src src; s.onload () { cache[src] true; resolve(s); }; s.onerror () reject(new Error(加载失败: ${src})); document.body.appendChild(s); }); }调用侧的顺序由await控制比嵌套回调清晰得多。async function init() { await loadScript(./a.js); await loadScript(./b.js); if (typeof b function) { b(); } } init();验证依赖关系时先清空缓存刷新页面打开 DevTools 的 Network 面板把请求列表按名称排序应该依次出现a.js和b.js并且a.js的加载时间结束在b.js开始之前。再打开 Console 执行typeof a结果是function如果是undefined回到 loader 里检查s.src是否写对了相对路径。如果想进一步控制并发可以用Promise.all加载互相之间没有依赖的脚本存在依赖时保持await顺序。把onload换成addEventListener(load, ...)也不会改变时机但onload属性在重写场景下更直观。页面卸载时如果动态注入还在进行会出现加载请求被取消的情况此时给脚本加async属性不会解决依赖问题正确做法是在页面生命周期内尽早触发加载避免用户已经点击离开后才注入脚本。本文还有配套的精品资源点击获取