简介: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.js,b.js 会先执行,而 a.js 里的function a还没有定义。原项目标题里说的“a.js 和 b.js 互相调用”,本质上是这个时序问题,而不是安全限制。
| 加载方式 | 执行时机 | 顺序保证 | 适用场景 |
|---|---|---|---|
普通<script> | 下载后立即执行,阻塞后续解析 | 按文档顺序 | 依赖明确的小脚本 |
defer | HTML 解析完成后执行 | 多个 defer 按顺序执行 | 需要保持依赖关系的页面脚本 |
async | 下载完成后马上执行 | 不保证顺序 | 统计、埋点、独立组件 |
defer看起来能解决 b.js 依赖 a.js 的问题:把两个文件都用defer引入,它们会按顺序执行。但defer的边界在于,它要求两个文件提前出现在 HTML 里,并且都要等到 HTML 解析完。如果业务场景是想按需拉取、不写在 HTML 里,动态加载是更贴合的方式。
2.3</body>之后引入脚本的真相
原资源把<script src="b.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.js,a.js 由 b.js 负责按需加载。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>动态加载</title> </head> <body> <button id="runBtn" type="button">执行 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属性在现代浏览器中可以省略,language="JAVASCRIPT"属于历史遗留属性。真正决定行为的只有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 模块:标准化的函数分文件方案
如果项目可以用构建工具,或者浏览器支持原生type="module",ES6 模块是默认选择。
// a.js export function a() { return 'a'; } // b.js import { a } from './a.js'; export function b() { return a(); }HTML 侧引入:
<script type="module" src="./b.js"></script>原生模块会有 CORS 要求,直接双击file://页面会失败,需要http-server这类本地服务。模块之间是静态依赖,顺序由 import 声明决定,不再需要手工loadScript。现代打包工具(Webpack、Rollup、Vite)都会把这种写法打包成浏览器可执行的脚本,并在需要时按路由切割代码。
| 方案 | 维护性 | 适用规模 | 兼容要求 |
|---|---|---|---|
| 动态 script 注入 | 中 | 页面级脚本、老系统 | 最低 |
| window 全局变量 | 低 | 临时联调、小工具 | 最低 |
| 回调参数注入 | 中高 | 组件封装、插件机制 | 低 |
| ES6 模块 | 高 | 中大型项目 | 需要构建或 type=module |
结合前面的动态加载来说,如果你的目标只是让 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 加crossorigin="anonymous" |
| 脚本加载成功,但函数还是 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属性不会解决依赖问题,正确做法是在页面生命周期内尽早触发加载,避免用户已经点击离开后才注入脚本。
本文还有配套的精品资源,点击获取