简介:一份聚焦JavaScript跨文件通信的技术笔记,面向需要处理a.js与b.js互相调用场景的前端初中级开发者。资源从HTML中script标签按顺序加载的基本规则切入,说明为何两个独立JS文件默认无法直接通信;随后给出在b.js内使用document.createElement动态创建script节点加载a.js的完整写法,并解释该节点需追加到body末尾的底层原因,同时演示在b()函数中如何安全调用a(),避免因加载顺序导致函数未定义。除了动态加载方案,还对比了window全局变量、ES6 import/export等常见做法,分析各自在命名冲突、模块化程度、浏览器兼容性及构建配置上的优缺点,并给出针对小型工具页与大型项目的选择建议。资源共1个文件,为PDF格式,压缩包大小约42KB,便于快速查阅与收藏。已有3223人学习下载,适合希望理清跨页面调用逻辑、了解轻量级脚本加载方案的前端学习者。
1. 跨页面调用变量和函数:两个 script 标签为什么互相“不认账”
在维护一个没有构建工具的老项目时,经常会遇到这种尴尬:a.js里写了一个getData(),b.js里想直接复用,结果在 HTML 里先后用两个<script>标签引进来,b.js里调用a()时控制台直接报a is not defined。这不是 JS 的老 bug,而是因为每个 script 文件默认是独立作用域,跨文件没有显式暴露的变量和函数,谁也看不到谁。这份资源讲的就是怎么用动态加载的方式让a.js和b.js互相调起来,原理不复杂,但加载时机、路径写法、重复引入这些细节踩过坑才记得住。适合无构建工具的纯前端页面、需要快速联调的开发场景,新手照着敲能跑通,熟手也能从避坑章节里找出自己忽略的边界。
2. 动态加载 script 标签:手写实现跨文件调用的原理与参数边界
2.1 createElement 动态加载:b.js 里现场“拉”a.js
原帖的核心做法,是在b.js里用document.createElement动态生成一个<script>标签,把a.js的路径塞进src,然后挂到body末尾。这一步的本质,是绕过 HTML 里静态<script>标签的独立性限制,让浏览器在运行时主动去请求并执行另一个 JS 文件。先说结论,再看代码。
// b.js // 1. 创建 script 标签,此时只是一个内存中的 DOM 节点 var new_element = document.createElement("script"); // 2. 设置 type,必须写 text/javascript,否则部分浏览器不执行 new_element.setAttribute("type", "text/javascript"); // 3. 指定要加载的脚本路径,a.js 与 b.js 放在同一目录 new_element.setAttribute("src", "a.js"); // 4. 把标签真实挂到 body 末尾,浏览器才会发请求并执行 a.js document.body.appendChild(new_element); function b() { // 这里调用的 a 来自 a.js,前提是 a.js 已经加载完成 a(); }这段代码的关键点在第 2 步和第 4 步。setAttribute("type", "text/javascript")是兼容性最稳的写法,虽然现代浏览器对type的默认值有兜底,但老项目里漏写确实会遇到脚本不执行的案例。appendChild这一步决定了脚本何时真正加载——挂到body末尾而不是head,是为了确保 DOM 结构已经就绪,同时也避免在 head 里动态加载脚本时阻塞后续渲染。
对应 HTML 侧的写法,原帖强调要把b.js放在</body>之后(实际是</body>之前最末尾),这也是新手最容易忽略的一步。
<!DOCTYPE html> <html> <body> <input type="button" value="ok" onclick="b()"> <!-- script 必须放在 body 最末尾,按钮渲染完再加载 b.js --> <script src="b.js"></script> </body> </html>放在末尾的原因很简单:onclick="b()"是内联事件绑定,如果b.js加载在前而按钮渲染在后,用户点击时函数已经存在,没问题;但如果b.js在head里加载,而脚本内部又用了document.getElementById之类的 DOM 操作,就会拿到null。这个顺序问题在动态加载方案里同样存在,后面避坑章节会展开。
2.2 src 路径、加载顺序与回调时机的参数边界
动态加载脚本不是appendChild完就立刻能用,这里涉及三个参数边界:路径写法、加载时序、重复加载。
路径写法有三种常见情况。src="a.js"是相对当前页面的路径,适合a.js和 HTML 在同一目录;src="./js/a.js"是相对当前目录的子目录路径;src="/js/a.js"是根相对路径,适合站点根目录固定的场景。如果a.js在 CDN 上,可以直接写完整 URL,但要注意跨域问题——CDN 必须返回正确的Access-Control-Allow-Origin头,否则脚本会加载失败。我一般建议在无构建工具的项目里统一用根相对路径,避免页面在不同层级目录下打开时相对路径失效。
加载时序是这个方案里最微妙的地方。动态创建的<script>标签默认是异步加载,也就是说appendChild之后,代码会继续往下执行,不会等a.js加载完。如果b()函数里第一行就调用a(),而a.js还没下载完,就会报a is not defined。解决思路是手动监听加载完成事件:
// 通用脚本加载函数:带加载完成回调 function loadScript(src, callback) { // 先查一下是否已经存在相同 src 的 script 标签,避免重复加载 var existing = document.querySelector('script[src="' + src + '"]'); if (existing) { callback && callback(); return; } var script = document.createElement('script'); script.type = 'text/javascript'; script.src = src; // onload 只会在脚本执行完成后触发 script.onload = function () { callback && callback(); }; document.body.appendChild(script); }这个loadScript函数把前面 2.1 里的四行代码封装成了可复用工具,核心新增了两个参数:src指定脚本路径,callback指定加载完成后要执行的回调函数。querySelector那行是去重逻辑——如果页面里已经存在相同src的 script 标签,说明a.js已经加载过了,直接执行回调,不需要再发一次请求。
使用方式也直观了:
function b() { loadScript('a.js', function () { // 回调里确保 a.js 已加载完成,这里调用 a() 才安全 a(); }); }注意这里回调用了匿名函数,如果你更习惯箭头函数写法,写成loadScript('a.js', () => { a(); })完全等价,但老项目如果还在用 IE 或低版本浏览器,箭头函数的兼容性问题会让你翻车,建议保守用function关键字。
3. 跨页面共享变量:全局对象、命名空间与模块化方案的取舍
3.1 window 挂载全局变量:最直接但也有隐患的做法
动态加载解决了“函数能不能调到”的问题,但实际开发里跨文件调用不只是函数,更多时候是变量。比如a.js里定义了一个用户信息对象userInfo,b.js里想读取这个对象里的字段,怎么办?
最直接的做法是把变量挂到window上:
// a.js // 显式把对象挂到 window 上,b.js 里才能通过 window.userInfo 访问 window.userInfo = { name: 'zhang', role: 'admin' }; // 直接函数声明也会挂到 window 上 function getData() { return 'from a.js'; }// b.js function b() { // 访问 a.js 暴露的全局对象 console.log(window.userInfo.name); // 调用 a.js 暴露的函数 getData(); }这里有一个新手容易踩的盲区:顶层声明的function getData(){}会自动成为window.getData,但顶层声明的var userInfo = {...}不会自动变成window.userInfo。前者是 JavaScript 语言规范的一部分,函数声明会被挂到全局对象上;后者在传统浏览器里其实也能挂上,但在严格模式和模块环境下行为不同,所以显式写window.userInfo才是稳定做法,没有玄学空间。
全局对象方案的优点是用起来零成本,a.js里声明,b.js里直接用,不需要任何依赖管理。缺点是全局命名空间被塞得越满,越容易冲突。比如a.js定义了var name = 'zhang',另一个第三方库也定义了window.name,后加载的会覆盖先加载的,而且这种覆盖往往是静默发生的,排查起来极其痛苦。我的经验是:全局变量只适合放真正需要跨文件共享的常量或工具函数,比如站点配置、枚举字典、统一请求入口。业务变量不要挂全局,否则后期维护就是给自己挖坑。
3.2 命名空间封装:适合中小项目的折中方案
既然直接挂window容易冲突,折中方案是搞一个全局命名空间对象,所有跨文件调用的变量和函数都挂在这个对象下面。相当于把杂乱的全局变量归拢到一个“抽屉”里,降低冲突概率,也让调用关系在代码里看得更清楚。
// a.js 顶部 // 全局命名空间,所有公共方法都挂在这里 window.App = window.App || {}; // 挂一个函数 window.App.getData = function () { return 'from a.js'; }; // 挂一个变量 window.App.config = { apiBase: '/api', timeout: 3000 };// b.js function b() { // 通过命名空间调用 a.js 暴露的东西,一眼就能看出依赖关系 var data = window.App.getData(); var timeout = window.App.config.timeout; console.log(data, timeout); }注意第一行window.App = window.App || {}的作用。b.js里可能也会挂自己的方法到App上,这个写法保证了无论a.js和b.js谁先加载,App对象都只有一个实例,后加载的文件是在已有对象上继续挂属性,而不是把前面的覆盖掉。这是命名空间方案里最值得记住的一行代码。
和直接挂window相比,命名空间方案的优点有三个:一是全局只增加了一个App变量,冲突面骤然缩小;二是代码里出现window.App.xxx时,读者立刻知道这是跨文件调用的公共接口,方便排查;三是后续如果要迁移到 ES6 模块,只需要把window.App.xxx的赋值改成export const xxx,改动面很小。
缺点是它仍然占用了全局对象,而且要求团队成员自律——如果有人偷懒直接var something = ...而不是挂到App上,这个方案就形同虚设。对中小项目来说,这个方案是性价比最高的选择,不需要引入任何构建工具,一行代码就能让跨文件调用从“碰运气”变成“有迹可循”。
4. ES6 模块与打包器:跨文件调用的现代解法
4.1 从 import/export 看模块化的依赖加载逻辑
动态创建 script 标签的方案在传统页面里好用,但它有一个天然短板:依赖关系完全靠手写维护。a.js依赖b.js,b.js又依赖c.js,这种链式加载用动态标签写起来会变成回调地狱。ES6 模块从语言层面解决了这个问题,核心就是export和import。
// a.js // 导出函数和变量,b.js 才能 import 到 export function getData() { return 'from a.js'; } export const config = { apiBase: '/api', timeout: 3000 };// b.js // 从 a.js 显式导入需要用到的符号 import { getData, config } from './a.js'; export function b() { // import 进来的函数可以直接调用 console.log(getData()); console.log(config.apiBase); }这段代码和动态加载方案的关键区别在于加载时机。ES6 模块是编译期静态分析——import语句在代码执行之前就已经被解析了,浏览器会在执行b()之前确保a.js已经被加载并初始化完毕。这意味着你不需要写任何回调,不需要关心加载顺序,import进来的函数可以放心大胆地直接用。
还有一个容易忽略的细节:ES6 模块天然是严格模式,import进来的变量是只读绑定,在b.js里给config重新赋值会直接报错。这对团队协作是好事——跨文件的变量只能通过export的接口修改,不会出现全局变量那种“谁都能改、改完不知道是谁改的”的失控局面。但如果你要把老项目的动态加载方案迁移到 ES6 模块,得先处理掉那些在b.js里修改a.js变量值的代码,这是个需要专门留时间的重构步骤。
ES6 模块的引入方式也有讲究:在 HTML 里用type="module"而不是普通的text/javascript。
<!DOCTYPE html> <html> <body> <input type="button" value="ok" onclick="b()"> <!-- 模块脚本 defer 是默认行为,且 b.js 内部 import 会自动拉起 a.js --> <script type="module" src="b.js"></script> </body> </html>type="module"有两个关键行为:模块脚本默认启用defer模式,会等 DOM 解析完再执行;同时b.js里的import语句会自动触发a.js的加载,HTML 里不需要再手动写第二个<script>标签。这个特性对比第 2 章里appendChild手动拉脚本的做法,确实是两个时代的东西。
4.2 构建工具与框架场景:Webpack/Rollup 下的跨文件调用
ES6 模块虽然优雅,但在浏览器里直接使用有一个现实约束:必须通过 HTTP 协议加载,file://协议下会报 CORS 错误。所以现代项目通常会把源码里的 ES6 模块通过 Webpack 或 Rollup 打包成普通脚本文件,这个环节顺便解决了文件合并、压缩、公共代码抽取等问题。
构建工具下跨文件调用,代码写法和 4.1 完全一致,区别在于编译产物。比如一个简单项目有a.js和b.js,打包后可能合并成单个bundle.js,浏览器里只需要引入这一个文件。这种方案的收益是 HTTP 请求数下降,部署路径简单,代价是每次修改都要跑一次构建,而且调试时看到的是压缩后的代码,需要配合 sourcemap 才能映射回源码。
如果项目里用了 Vue 或 React 这类框架,“跨 JS 文件调用”其实已经被框架自己的模块体系消化了。比如在 Vue 组件里,你更常用的是:
// 在任意组件里引入一个公共工具模块 import { getData } from '@/utils/a.js';这里@是构建工具配置的别名,通常指向src目录。框架场景下,跨文件调用的重点不再是“怎么让两个 script 互相看到”,而是“怎么把公共逻辑从组件里抽出来放进独立模块”。这份资源的动态加载方案在框架项目里的价值反而在别处——比如路由懒加载、按需加载第三方脚本,原理和 2.2 的loadScript一模一样。
| 方案 | 加载方式 | 适用场景 | 主要缺点 |
|---|---|---|---|
| 动态 script 标签 | 运行时异步加载 | 无构建工具的页面、按需加载 | 依赖管理靠手写 |
| window 全局对象 | 运行时直接访问 | 小型页面、共享常量 | 命名冲突 |
| 命名空间封装 | 运行时直接访问 | 中小型项目 | 仍占用全局对象 |
| ES6 模块 | 编译期静态分析 | 新项目、模块化开发 | 浏览器直接跑有 CORS 限制 |
| 构建工具打包 | 构建时合并 | 大型项目、框架项目 | 需要 Node 环境与配置成本 |
5. 避坑:跨文件调用的常见问题与排查记录
5.1 报错“b is not defined”,点击按钮毫无反应
现象:页面打开正常,点击按钮控制台报Uncaught ReferenceError: b is not defined。
原因:b.js的<script>标签放在了<head>里,而 HTML 解析到按钮的onclick属性时,b.js可能还没执行完毕,函数b尚未挂到全局。虽然script默认同步阻塞加载,但如果b.js体积大或网络慢,用户点击时函数确实可能不存在。更隐蔽的情况是b.js里本身有语法错误,整个文件不执行,函数自然不挂。
解决:把<script src="b.js"></script>移到</body>之前,保证 DOM 先解析完;同时临时在b.js顶部加一行console.log('b.js loaded')确认文件真的执行了。我见过太多“函数没定义”最后定位到是脚本内部某个写法错误导致整段不执行的情况,先确认基础加载再排查内部逻辑,能省掉大量无效调试时间。
5.2 动态加载后调用 a() 依然报 undefined
现象:b()函数里第一行a(),控制台报Uncaught ReferenceError: a is not defined,但 Network 面板里a.js明明已经请求成功。
原因:这几乎是动态加载方案里最常见的坑。document.body.appendChild(new_element)只负责发起加载,浏览器异步去请求并执行a.js,代码不会停下来等。a()被调用时,a.js可能还在传输中,函数声明还没执行完。用原帖那种裸写appendChild的方式调用,本质上是在赌网络速度,十次里可能有两三次翻车。
解决:改用第 2.2 节的loadScript带回调版本,把a()的调用放进回调里,确保a.js的onload触发后再执行。如果业务里确实无法避免“动态加载完立刻调用”的场景,还有一个兜底手段——在a.js被加载前,先用一个临时占位对象挡住调用,等真正加载完成后替换掉占位实现。
// b.js 顶部 // 先放一个占位用的 a 函数,防止某个角落提前调用 var a = function () { console.warn('a.js 还没加载完成,稍后重试'); }; function b() { // 如果此时 a.js 还没到,会走占位逻辑而不是直接报错 a(); }这个写法不能根治问题,但能把“白屏报错”变成“控制台警告”,在排查阶段很有用。
5.3 同一个脚本被重复请求,多次 appendChild 多次加载
现象:Network 面板里a.js出现了三四次请求,且每次状态都是 200。
原因:loadScript如果没做去重,每次调用都会新建一个 script 标签,浏览器对相同的 URL 在普通模式不会自动合并请求,于是重复加载、重复执行。a.js如果是纯函数声明还好,如果里面有window.userInfo = ...这种副作用代码,重复执行会导致变量被多次重置,状态丢失。
解决:在loadScript里用querySelector检查是否已有相同src的标签,有了就直接复用并执行回调;或者在window上挂一个已加载标记数组,比如window.__loadedScripts = window.__loadedScripts || [],加载前先查数组,加载完成后 push 进去。这本质上是一个“已加载清单”,是手写模块加载器最简形态。
5.4 变量明明定义了,跨文件访问却是 undefined
现象:a.js里写了var version = '1.0';,b.js里console.log(window.version)输出undefined,函数访问却正常。
原因:var声明的变量在全局作用域下虽然会成为全局对象的属性,但存在一个容易被忽视的差异——在传统浏览器里,顶层var确实会挂到window,但在严格模式或模块环境里不会,而且不同浏览器对“顶层 var 是否挂 window”的行为有过历史分歧。函数声明则是规范强制挂载。所以依赖“声明即全局”是不可靠的。
解决:统一用显式挂载。在a.js里写成window.version = '1.0';,或者用window.App.version = '1.0';挂在命名空间下,这样跨文件访问的结果是确定性的,不受严格模式影响。这条坑对于长期维护的老项目很重要——那些用var声明、靠“碰巧能用”跑起来的代码,一旦项目切到模块化或引入构建工具,立刻全线崩。
6. 进阶:把 loadScript 封装成带依赖管理的简易加载器
动态加载的核心loadScript在第 2.2 节已经有了雏形,但要支撑实际业务,还需要补齐两个能力:并发加载和串行依赖。比如b()需要调用a.js里的a(),同时还需要c.js里的c(),而c()内部又依赖a()的返回值,这就是一个典型的依赖链场景。
我先把完整版加载器贴出来,再逐行说明设计意图。
/** * 简易脚本加载器:支持单脚本加载、并发加载、依赖回调 * 用法: * loadScript('a.js', function() { a(); }); * loadScript(['a.js', 'c.js'], function() { a(); c(); }); */ (function (global) { // 已加载记录,key 是 src,value 是加载状态:'loading' 或 'done' var loaded = {}; function loadSingle(src, callback) { // 已经是 done 状态,直接执行回调 if (loaded[src] === 'done') { callback && callback(); return; } // 正在加载中,把回调存起来等 onload 统一触发 if (loaded[src] === 'loading') { // 简化处理:把回调放到 onload 期间执行,这里直接追加到队列 } loaded[src] = 'loading'; var script = document.createElement('script'); script.type = 'text/javascript'; script.src = src; script.onload = function () { loaded[src] = 'done'; callback && callback(); }; script.onerror = function () { // 加载失败要留痕迹,否则排查时完全无头绪 console.error('脚本加载失败: ' + src); }; document.body.appendChild(script); } function loadScript(src, callback) { if (Array.isArray(src)) { // 并发加载多个脚本,全部完成后再执行回调 var remaining = src.length; var hasError = false; src.forEach(function (item) { loadSingle(item, function () { remaining--; if (remaining === 0 && !hasError) { callback && callback(); } }); }); } else { loadSingle(src, callback); } } global.loadScript = loadScript; })(window);这段代码由三个部分组成:loaded对象记录每个脚本的加载状态存的是'loading'或'done'两个枚举值,用于去重;loadSingle处理单脚本的加载但不能自动处理依赖链上的串行关系;loadScript负责解析入口参数,支持传入字符串或字符串数组。数组并发加载的场景里,remaining计数器的含义是“还有几个脚本没加载完”,每次回调remaining--,归零时说明所有脚本都已就绪,这时才触发最终回调。
这个加载器真正解决的是第 5.2 节那个“动态加载后立刻调用”的痛点。写业务代码时,你不再需要关心a.js具体什么时候加载完,只需要声明“我依赖 a.js,加载完了请叫我”,剩下的事情交给加载器的回调机制处理。比如:
// 按钮点击逻辑:依赖 a.js 和 c.js,全部加载完成后执行核心流程 document.getElementById('btn').addEventListener('click', function () { loadScript(['a.js', 'c.js'], function () { var data = a(); // 来自 a.js var result = c(data); // 来自 c.js,依赖 a 的返回结果 console.log(result); }); });数组['a.js', 'c.js']里两个脚本是并发加载的,谁先到不一定,但回调保证两个都完成后才执行。这里有一个隐含假设:c.js内部依赖a.js提供的函数,但这个依赖是在c()函数被调用时才发生的,而不是在c.js加载时就发生,所以并发加载是安全的。如果c.js在文件顶层就调用了a()(比如初始化代码里直接用),那必须改成串行加载:先加载a.js,完成后再加载c.js。判断标准是“脚本顶层是否依赖另一个脚本的符号”,而不是“函数内部是否依赖”。
用这个加载器做验证时,最有效的手段是在每个脚本里输出日志:
// a.js console.log('a.js 执行了,时间戳: ' + Date.now()); window.getData = function () { return 'from a.js'; }; // b.js 里 loadScript(['a.js', 'c.js'], function () { console.log('全部加载完成'); });通过控制台日志的执行顺序,能直观看到并发加载的乱序效果以及回调触发时机。这个验证习惯我到现在还在用——把日志写在脚本顶层,可以区分“文件被加载了”和“文件里的函数被调用了”这两个完全不同的事件,很多诡异 bug 的根源恰恰是混淆了这两者。
说到这里想起一次真实排障:客户环境里a.js偶发加载失败,页面功能时好时坏。当时用的就是类似上述的加载器,我在script.onerror里加了日志,排查后发现问题不是代码逻辑,而是客户内网 CDN 节点缓存了旧的失效响应。从那以后我每次接这类跨文件调用需求,都会强制让加载器先把失败日志打全,再做功能开发。希望帮到你。
本文还有配套的精品资源,点击获取