
简介针对Web前端开发中多个脚本文件无法直接通信的常见问题这份PDF集中讲解了在a.js与b.js之间实现跨页面变量和函数调用的具体方案。内容以动态创建script标签并追加至body末尾为核心完整演示了b.js延迟加载a.js后调用其函数的步骤同时给出HTML页面中的按钮触发示例代码简洁、易于复现也解释了加载顺序和元素插入方式等关键细节。文章还总结了全局对象window与ES6模块import/export两种替代方案的适用场景与局限提醒开发者注意命名冲突和现代模块化工具的取舍对保持代码结构清晰、避免盲目合并文件有实际参考意义。资源共1个PDF文件包体仅42KB轻量精炼适合中高级前端开发者快速查阅已有3222人学习使用。1. 跨页面调用到底在解决什么问题不少人在第一家公司写前端时都会遇到这样的场景a.js 里定义了一个userInfo对象b.js 里封装了一个formatTime()函数结果页面里把两个文件都引进来之后b.js 里却拿不到 a.js 的变量或者 a.js 里调 b.js 的函数时控制台直接报xxx is not defined。其实这类问题的根源不在「变量是否定义」而在「JS 的执行上下文、加载顺序和全局对象挂载方式」这三点上没对齐。本文要讲的「JS 中跨页面调用变量和函数的方法」实际上覆盖了两个层面同一页面里多个 js 文件之间的互相调用以及真正意义上不同页面比如父页面与 iframe、window.open 的子窗口之间的变量与函数互访。这两类需求在后台管理系统、报表大屏、老项目分批改造里都非常常见。适合已经能熟练写业务组件、但对 JS 模块化边界和跨窗口通信机制还不太清晰的前端开发者阅读看完可以马上把方案落到自己的项目里。2. 同一页面中 a.js 和 b.js 的互相调用全局对象与加载顺序很多人一上来就把 a.js 和 b.js 的互相调用理解成「跨文件」但实际上在同一个 HTML 页面里浏览器并不会为每个 js 文件创建独立的作用域。所有通过script标签引入的非模块化脚本最终都共享同一个全局作用域也就是window对象这个执行上下文Global Execution Context。2.1 为什么 b.js 里读不到 a.js 的变量先从最常见的报错说起。假设页面里有这样的结构!DOCTYPE html html langzh-CN head meta charsetUTF-8 title跨文件调用示例/title /head body script src./a.js/script script src./b.js/script /body /htmla.js 内容// a.js let userName zhang san; const siteName my-site; function sayHello() { return hello, ${userName}; }b.js 内容// b.js console.log(userName); // 报错userName is not defined console.log(siteName); // 报错siteName is not defined sayHello(); // 报错sayHello is not defined这段代码里最直接的原因在于let和const声明的变量不会挂到window对象上。它们会存在于「脚本级作用域」里但不会成为全局对象window的属性。也就是说a.js 里的userName在 b.js 执行时依然存在于内存中但 b.js 无法通过标识符userName直接访问到它。而对于用var声明的变量以及直接使用function关键字声明的函数它们会挂载到window对象上b.js 中直接用就能访问到。这是最容易踩的坑之一。很多人把let换回var之后发现能用了就以为问题解决了其实只是因为var声明会挂到window上。但如果项目里开启了严格模式use strict情况又会变复杂。改回var并不是一种可靠的方案更通用的做法是显式地把需要共享的数据挂到window上。// a.js —— 主动挂到全局 window.userName zhang san; window.siteName my-site; window.sayHello function() { return hello, ${window.userName}; };// b.js —— 通过 window 读取 console.log(window.userName); // zhang san console.log(window.siteName); // my-site window.sayHello(); // hello, zhang san这里有一个执行上下文的问题需要说清楚b.js 运行时a.js 的脚本必须已经「执行完成」。因为script标签默认是同步加载、同步执行的浏览器按文档顺序阻塞解析所以只要 a.js 在 b.js 前面引入且 a.js 没有语法错误那么 b.js 里就能拿到 a.js 挂到window上的内容。反过来如果 b.js 先引入那 b.js 执行时 a.js 还没加载window.userName一定是undefined调用window.sayHello()会直接抛window.sayHello is not a function。提示解决这类问题的最简单验证方式是在 b.js 里先console.log(window)展开看第二步确认要调用的属性是否已经存在于全局对象中。2.2 使用命名空间和 IIFE 避免变量冲突把变量和函数直接挂到window上是最快的方案但项目里的文件一多、参与的人一多全局命名空间就会被各种window.userName、window.formatTime占满。这也是老项目里最常见的「全局污染」问题。更可靠的做法是让 a.js 只暴露一个全局命名空间对象把内部实现藏进立即执行函数表达式IIFE里。// a.js —— IIFE 命名空间 (function(global) { // 这里的变量是私有变量不会污染全局 let userName zhang san; const siteName my-site; function sayHello() { return hello, ${userName}; } function setName(name) { userName name; } // 只暴露一个全局对象 global.AModule { getUserName: () userName, sayHello: sayHello, setName: setName, siteName: siteName }; })(window);// b.js —— 通过命名空间调用 console.log(AModule.getUserName()); // zhang san console.log(AModule.sayHello()); // hello, zhang san AModule.setName(li si); console.log(AModule.sayHello()); // hello, li si这种写法的本质是把「需要跨文件共享的 API」收敛到AModule这一个全局变量上内部细节全部私有化。即便之后 a.js 内部重构了userName的实现只要AModule暴露的接口签名不变b.js 就完全不需要改动。这里有一个值得注意的细节IIFE 里的匿名函数在初始化时会执行一次里层的function sayHello在函数声明阶段就已经完成变量提升hoisting。所以即使把sayHello这个函数声明写在 IIFE 的末尾在 IIFE 开头的代码去调用它也不会报错。这跟var声明变量提升是同一个底层机制。理解这一点遇到「为什么函数可以提前调用」这类问题时就不会一头雾水了。2.3 使用 ES Moduleimport / export 才是现代推荐方案如果项目可以用打包工具webpack、vite 等或者浏览器环境支持原生 ES Module那就不太建议再手写 IIFE 和命名空间了。原生 ESM 的import/export语法天然解决了依赖关系和加载顺序的问题它比「挂全局 注意 script 顺序」多了一层静态分析的能力很多错误能在编译阶段就暴露出来。// a.js —— 具名导出 export let userName zhang san; export const siteName my-site; export function sayHello() { return hello, ${userName}; }// b.js —— 导入后直接使用 import { userName, siteName, sayHello } from ./a.js; console.log(userName); // zhang san sayHello(); // hello, zhang san需要特别注意的是ES Module 的import具有「提升」效果。即使在 b.js 里import语句写在文件最底部浏览器在解析模块时也会先完成依赖加载再执行 b.js 的代码。这跟普通script标签的同步阻塞加载不是一个模型。所以如果你在项目里用了原生 ESM基本不需要考虑 a.js 和 b.js 的物理加载顺序只需要保证import路径正确、a.js 本身没有运行时错误。还有一个实用场景是「循环依赖」如果 a.js 导出了foo且导入了 b.js 的barb.js 又反过来导入了 a.js 的fooESM 的静态解析机制能处理这种情况。但实际开发里循环依赖依然是逻辑设计上的坏味道能用依赖注入或公共模块拆分开就别依赖循环依赖的正常工作。这个边界值得五年以上的开发者留意。3. 父页面与 iframe / 子窗口之间的互调window 引用获取当「跨页面调用」指的不再是同一个文档里的多个文件而是真正意义上的不同文档——比如父页面嵌了一个 iframe或者用window.open()打开了另一个页面——那 a.js 和 b.js 的关系就变成了两个独立 JavaScript 运行环境之间的关系。此时最简单的调用方法是拿到目标页面的window引用再访问上面的变量和函数。3.1 拿到 iframe 内容窗口的引用假设父页面是index.html里面嵌了一个 iframeiframe 的 src 是child.htmlchild.html 引入了 c.js。父页面的 a.js 想调用 c.js 里定义的函数// a.js —— 父页面访问 iframe 内部 const iframeEl document.getElementById(myIframe); // 方式一通过 contentWindow 获取 iframe 内部的 window 对象 const childWin iframeEl.contentWindow; // 方式二通过 iframe 的 contentDocument 获取内部 document 再拿 defaultView const childWin2 iframeEl.contentDocument.defaultView; // 如果 iframe 还未加载完成childWin 里的函数可能还不存在 // 所以需要等 iframe 的 load 事件触发后再调用 iframeEl.addEventListener(load, () { const childWin iframeEl.contentWindow; console.log(childWin); // 可展开看 iframe 的全局对象 console.log(childWin.cModuleData); // 访问 c.js 挂到 window 上的变量 childWin.cModule.init(); // 调用 c.js 里定义的函数 });需要注意的是这里使用let或const声明变量时如果 c.js 里定义的是let userName child那么这个userName不会挂到childWin上父页面通过childWin.userName拿到的是undefined。这种「拿到contentWindow却读不到变量」的情况本质上跟本页面里let与var的区别是完全一致的。所以被 iframe 嵌入的 c.js 在暴露共享变量和函数时必须显式挂到window上或者用一个命名空间对象来暴露。还有一个必须说清楚的边界问题same-origin policy同源策略。如果 iframe 加载的是不同源不同协议、域名或端口的页面父页面通过contentWindow去读取内部变量时在 Chrome 的 DevTools 里会看到类似SecurityError: Blocked a frame with origin ... from accessing a cross-origin frame的报错。这时候就必须用后续章节会讲的postMessage那是跨域场景下唯一可靠的消息通道。3.2 通过 window.open从父页面调用子窗口子窗口回调父页面window.open()打开的子窗口同样遵循同源策略。同源前提下window.open()的返回值就是子窗口的window引用父页面可以直接使用它。// a.js —— 父页面打开子窗口并调用子窗口函数 const childWin window.open(child.html, childWindow); // 某些浏览器会延迟加载需要轮询或等 load 后调用 // 常用的做法是把调用放在 setTimeout 里配合 ready 标记 setTimeout(() { if (childWin childWin.childApi) { childWin.childApi.refreshList({ page: 1 }); } }, 500);反过来子窗口的 b.js 想调用父页面的函数可以通过window.opener拿到打开它的父页面窗口对象。// b.js —— 子窗口内调用父页面函数 if (window.opener) { // 调用父页面上 a.js 暴露的函数 if (window.opener.parentApi) { window.opener.parentApi.notify(child closed); } // 也可以直接访问父页面的全局变量 console.log(window.opener.globalConfig); }这种「contentWindow/window.open返回值 /window.opener」的三件套方案核心限制就是同源。此外还要注意一个比较隐蔽的坑window.open()返回的引用在页面刚打开时往往还没有执行完子页面的脚本所以如果立刻去调用子页面的函数大概率拿到的是undefined而不是报错。比较稳妥的做法是在子页面里主动维护一个ready标志比如子页面在自身脚本执行完毕时挂一个window.childReady true父页面用轮询去检查这个标志。但这套代码需要处理超时和性能开销的问题所以只建议在需要早期调用的场景里用。3.3 封装一个轻量级的跨窗口调用的桥接工具把前面的逻辑抽出来可以封装成一个复用度很高的「同源窗口桥」让同源场景下的「调用子窗口函数、子窗口回调父页面函数」变成一行代码的调用// windowBridge.js —— 同源窗口桥 (function(global) { // 存储当前窗口允许被外部调用的接口 const exposedApi {}; function expose(name, fn) { exposedApi[name] fn; // 将接口挂到 window 上供外层窗口通过 contentWindow / opener 访问 global.window.__bridgeApi exposedApi; } function call(win, name, ...args) { if (!win || !win.__bridgeApi) { console.warn([windowBridge] 目标窗口尚未暴露接口: ${name}); return undefined; } const fn win.__bridgeApi[name]; if (typeof fn ! function) { throw new Error([windowBridge] 接口不存在: ${name}); } return fn.apply(null, args); } global.WindowBridge { expose: expose, call: call }; })(window);子页面的 c.js 中暴露接口// c.js —— 子页面暴露自己的函数 WindowBridge.expose(getUserInfo, () { return { id: 1001, name: zhang san }; });父页面的 a.js 中调用子页面接口// a.js —— 父页面调用子页面接口 iframeEl.addEventListener(load, () { const info WindowBridge.call(iframeEl.contentWindow, getUserInfo); console.log(info); // { id: 1001, name: zhang san } });这套方案虽然简单但有一个明显边界它假设你「已经拥有目标 window 引用」且目标页面是同一个源。一旦目标页面是跨域的、或者你只能拿到一个消息通道而拿不到 window 引用比如同源页面通过标签页的window.open获取引用算是拿到了引用而通过 storage 事件就完全拿不到引用这套桥就不适用了。跨域和「无法直接持引用」的情况需要postMessage方案这是下一章的主题。4. 真正的「跨页面」postMessage 实现不同窗口间的函数调用postMessage是 HTML5 引入的跨文档消息传递 API也是目前呼声最高、最被长期维护的方案。它不要求两个页面同源也不要求调用方能持有对方window的完整引用——只要持有对方window引用就能发送消息只要监听message事件就能接收消息。真正的 a.js 和 b.js 互调在生产环境最可靠落地方案就是它。4.1 父页面向 iframe 发送消息并接收回调父页面 a.js// a.js —— 父页面发送消息给 iframe 并监听 iframe 的回复 const iframeEl document.getElementById(myIframe); const childWin iframeEl.contentWindow; // 发送消息targetOrigin 最好写精确的源不要用 * childWin.postMessage({ type: FROM_PARENT, payload: { id: 1 } }, http://localhost:3000); // 监听 iframe 发回的消息 window.addEventListener(message, (event) { // 安全校验检查来源源 if (event.origin ! http://localhost:3000) return; // 校验消息类型 if (event.data event.data.type FROM_CHILD) { console.log(父页面收到子页面消息:, event.data.payload); } });iframe 子页面 c.js// c.js —— iframe 内部监听消息并回复父页面 window.addEventListener(message, (event) { // 校验 event.source只有来自父页面的消息才处理 if (!event.source || event.origin ! http://localhost:3000) return; console.log(子页面收到父页面消息:, event.data.payload); // 处理逻辑 const result { status: ok, data: { total: 100 } }; // 回复父页面event.source 就是父页面的 window 引用 event.source.postMessage({ type: FROM_CHILD, payload: result }, event.origin); });这里要重点说清楚message事件对象上的三个关键属性event.data发送方携带的数据可以是任意结构化克隆算法支持的数据类型对象、数组、字符串等但不能包含函数。所以如果你想通过postMessage传递一个函数引用是行不通的。正因为这个限制postMessage更适合传「数据指令」让接收方根据指令执行自己内部已经定义好的函数而不是直接把函数传过去。event.origin发送方的源协议域名端口。这是安全校验中最重要的字段必须显式校验不能只看event.source存在就放行。event.source发送方的window引用。接收方回复消息时可以直接用它比如event.source.postMessage(...)这比记录一个全局的window.opener或contentWindow引用更适合动态场景。4.2 模块化封装 request / response 模式postMessage原生 API 是「发送即忘」的单向消息模型这意味着跨窗口调用函数时没法天然地把「调用」和「结果返回」对应起来。老练的开发者会在这个基础上封装一层装饰器把单向消息包装成带messageId的 request/response 模式。这样 a.js 调 c.js 里的函数时可以用 Promise 等待结果。// bridge.js —— 挂载到当前窗口提供 request 方法 (function(global) { // 保存待响应的回调messageId - resolve/reject const pendingMap new Map(); let msgId 0; // 监听所有 message 事件匹配 messageId window.addEventListener(message, (event) { const data event.data || {}; if (!data.__isResponse) return; if (!pendingMap.has(data.__messageId)) return; const { resolve, reject } pendingMap.get(data.__messageId); pendingMap.delete(data.__messageId); if (data.__isError) { reject(new Error(data.__message)); } else { resolve(data.payload); } }); // 向指定窗口发送请求 function request(targetWin, targetOrigin, type, payload) { return new Promise((resolve, reject) { const curMsgId msgId; pendingMap.set(curMsgId, { resolve, reject }); targetWin.postMessage({ __isRequest: true, __messageId: curMsgId, type: type, payload: payload }, targetOrigin); // 超时兜底避免 pendingMap 里的回调永远不会被消费 setTimeout(() { if (pendingMap.has(curMsgId)) { pendingMap.delete(curMsgId); reject(new Error([bridge] 请求超时: ${type})); } }, 5000); }); } // 让接收方注册处理函数type - handler const handlers {}; function on(type, handler) { handlers[type] handler; } // 接收方需要调用的消息分发入口 window.addEventListener(message, async (event) { const data event.data || {}; if (!data.__isRequest) return; // 拿到注册的 handler const handler handlers[data.type]; if (!handler) return; try { const result await handler(data.payload); event.source.postMessage({ __isResponse: true, __messageId: data.__messageId, payload: result }, event.origin); } catch (error) { event.source.postMessage({ __isResponse: true, __messageId: data.__messageId, __isError: true, __message: error.message || String(error) }, event.origin); } }); global.MsgBridge { request: request, on: on }; })(window);接收方 c.js 注册自己的处理函数// c.js —— 接收方注册处理逻辑 MsgBridge.on(GET_USER_INFO, (payload) { // 这里可以调用 c.js 内部定义的函数 return getUserInfoFromDB(payload.id); });调用方 a.js 发起调用// a.js —— 发起调用并等待结果 const childWin document.getElementById(myIframe).contentWindow; MsgBridge.request(childWin, http://localhost:3000, GET_USER_INFO, { id: 1 }) .then((info) { console.log(c.js 返回的用户信息:, info); }) .catch((error) { console.error(调用失败:, error); });这套封装的价值在于a.js 和 b.js或 c.js之间不再需要手工管理消息的type字符串、messageId的对应关系、超时逻辑这些细节全部收拢在MsgBridge内部。实现上有三个点要特别注意。第一pendingMap里保存的是 Promise 的 resolve 和 reject属于闭包引用不会泄漏给外部。第二超时时间定在 5000ms实际项目中要根据自己的接口耗时调整避免因为一次耗时的数据处理导致所有后续调用都被误判超时。第三两端的事件监听都必须校验来源和类型否则任何页面都可以往你窗口注入消息。4.3 跨域下的安全校验细节跨域场景下postMessage几乎是唯一稳妥的通道。但「能用」和「安全地能用」之间差别很大。这里列出几项必做的校验也是面试里常被追问的点发送方发送消息时第二个参数targetOrigin不要传*。*的含义是「任何窗口都能收到这条消息」如果对方不是预期的页面可能造成数据泄漏。开发环境图方便用*但上线前必须改成具体的源比如https://app.example.com。接收方在message事件里第一件事就是校验event.origin让它和期望的源做字符串匹配或数组 includes 匹配。这个校验不能省也不能只看协议或域名前缀。校验event.source是否存在。有些浏览器环境下event.source可能是null直接调用event.source.postMessage会报错。数据格式上不要直接信任event.data。先用一个独立字段做协议判别如event.data.__isRequest再做具体业务数据的解析。任何「消息里带什么就执行什么」的写法都是安全漏洞的温床。5. 同源页面触发事件storage 事件与 BroadcastChannel 的联动postMessage需要先拿到对方的window引用。但在某些实际业务里调用方和目标页面之间拿不到window引用——比如你只打开了两个标签页它们不是通过window.open互开的此时 JS 语言本身没有直接提供「获取另一个标签页 window」的 API。同源场景下storage事件和BroadcastChannel是标准解法。5.1 storage 事件监听 localStorage 的变化跨标签页触发函数调用storage事件的触发条件很明确同一源下一个标签页修改localStorage的数据时浏览器向其他所有同源的标签页广播storage事件。注意事件不会在触发修改的那个页面自身触发这是它的一个隐藏特性。a.js 所在页面发起调用请求// a.js —— 通过 localStorage 写入指令 localStorage.setItem(__page_command__, JSON.stringify({ type: REFRESH_LIST, payload: { page: 1 }, timestamp: Date.now() }));b.js 所在页面监听并执行函数// b.js —— 监听其他页面的指令 window.addEventListener(storage, (event) { // 过滤只看自己关心的 key if (event.key ! __page_command__) return; // event.newValue 是修改之后的值event.oldValue 是修改之前的值 if (!event.newValue) return; try { const cmd JSON.parse(event.newValue); if (cmd.type REFRESH_LIST) { // 这里是 b.js 中定义的函数 refreshList(cmd.payload.page); } } catch (error) { console.error(解析 storage 指令失败:, error); } });这里有一个「重复执行」的经典坑每次写入时需要保证key的值和上一次不同否则不会触发storage事件。我见过有人用localStorage.setItem(cmd, do something)连续点击两次按钮第二次点击时newValue和oldValue一样事件不触发页面看起来像「没反应」。上面代码里加入timestamp: Date.now()就是用来保证每次写入时值都不同。测试的时候为了稳定验证事件是否触达可以在 b.js 的storage事件回调里console.log(event.key, event.newValue, event.oldValue)。如果只看到一条日志说明可能第一次写入时值不同被触发了第二次写入值相同被略过了。5.2 BroadcastChannel更直观的同源标签页消息通道BroadcastChannel是个很契合「a.js 和 b.js 互相调用」场景的 API它允许同源上下文之间包括标签页、iframe、Web Worker广播消息且不需要持有订阅方的引用。a.js 所在页面// a.js —— 创建频道并发送消息 const channel new BroadcastChannel(order_channel); // 发送消息 channel.postMessage({ type: ORDER_STATUS_CHANGED, orderId: 1001, status: paid }); // 也可以监听别的页面发来的消息 channel.addEventListener(message, (event) { console.log(a.js 收到消息:, event.data); });b.js 所在页面// b.js —— 监听同源同频道的消息 const channel new BroadcastChannel(order_channel); channel.addEventListener(message, (event) { const data event.data || {}; if (data.type ORDER_STATUS_CHANGED) { // 调用 b.js 内部的函数 updateOrderStatus(data.orderId, data.status); } });对比storage事件BroadcastChannel有三个明显优势一是消息体不经过 localStorage没有 5MB 的大小限制当然实际消息也不建议太大二是触发方自己的页面也能通过同一个channel收到消息storage事件无法在触发页触发三是接收方不需要判断event.key和oldValue的区别语义更清晰。劣势则是浏览器兼容性相对较老版本差一点但现代浏览器包括移动端 WebView基本都已支持。最后给出一个实用的调试技巧。在 DevTools 的 Console 面板里可以手动执行这段代码验证两页面的通信链路是否通畅。打开两个同源标签页在其中一个执行const ch new BroadcastChannel(test_ch); ch.onmessage (e) console.log(收到:, e.data);在另一个标签页执行new BroadcastChannel(test_ch).postMessage(hello from page 2);如果第一个标签页控制台里打印出收到: hello from page 2说明两个页面之间同源消息通道完全正常之后再排查业务代码。如果没打印先确认两个页面是否同源协议、域名、端口完全一致再检查是否使用了同一个BroadcastChannel频道名。这个验证方法能帮助你快速区分「浏览器通道问题」和「业务代码问题」是排查这类通信毛病的最高效手段建议直接收藏这组代码作为手边工具。本文还有配套的精品资源点击获取