在网页性能优化中,给script标签加上async属性可以让浏览器并行下载脚本而不阻塞解析。但多个异步脚本之间存在依赖关系时,执行顺序无法保证,这就产生了竞态条件。比如工具库先加载、业务脚本后加载,若业务脚本先执行就会因找不到全局函数而崩溃。

一、异步脚本竞态条件的底层原理
浏览器在解析HTML时遇到带async的script标签,会立即发起网络请求,同时继续向下解析DOM。脚本下载完成后,浏览器暂停解析并执行该脚本,执行完毕再恢复解析。由于网络环境不稳定,先写的async脚本可能比后写的下载更慢,导致后写脚本先执行。
这种机制与传统同步脚本(不带async和defer)完全不同。同步脚本严格按照文档顺序执行,而async脚本的执行时机只取决于下载完成的时间点。当脚本A定义了window.Tool,脚本B使用window.Tool,若B先下载完并执行,就会抛出TypeError。这类错误具有随机性,在本地快网络难以复现,到了用户弱网环境频繁出现。
1.1 典型的出错代码布局
下面是一段容易引发竞态的页面代码。utils.js中挂载了全局对象,app.js依赖它,但两者都使用async。
<!DOCTYPE html> <html> <head> <script async src="utils.js"></script> <script async src="app.js"></script> </head> <body> <div id="root"></div> </body> </html>
在utils.js中我们可能这样写:
window.Tool = {
format: function (str) {
return str.trim();
}
};
而app.js直接调用:
var text = window.Tool.format(' hello ');
document.getElementById('root').innerText = text;
当app.js先执行时,window.Tool还是undefined,代码在第1行就报错,后续逻辑全部失效。由于async不保证顺序,这个bug只在特定网络下出现,给排查带来很大困难。
二、常见错误应对及其局限
不少团队首先尝试通过调整标签前后位置来规避,认为把依赖库放在前面就更安全。但async属性让位置失去意义,浏览器依旧并行拉取,先写完不代表先执行。
另一种做法是去掉async改用defer。defer能保持执行顺序且并行下载,看似解决问题。但在动态插入脚本、微前端子应用、第三方埋点等场景,defer无法用于document.write或动态创建的标签,局限性明显。而且多个独立团队各自的defer脚本若隐式共享全局状态,仍可能在复杂组合下出错。
2.1 为什么单纯去掉async不够
如果页面由多个系统拼接,A系统注入async脚本,B系统也注入,你无法强迫对方改标签。此时需要一种不依赖对方配合的通用协调机制。下表对比了三种加载方式:
| 加载方式 | 下载并行 | 执行顺序 | 适用场景 |
|---|---|---|---|
| 同步(无属性) | 否 | 严格文档序 | 少量强依赖脚本 |
| async | 是 | 不保证 | 完全独立无依赖脚本 |
| defer | 是 | 文档序 | 静态已知依赖链 |
可以看到,当依赖关系动态且跨团队时,上述原生属性都难以单独胜任,必须引入加载控制器。
三、基于动态加载器的串行解决方案
我们可以编写一个loadScript函数,它返回Promise,在脚本onload后才resolve,从而用async/await控制顺序。这样无论网络多乱,依赖库一定先于业务脚本执行。
function loadScript(src) {
return new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = src;
// 不使用async,动态插入默认同步顺序可控
s.onload = function () {
resolve();
};
s.onerror = function () {
reject(new Error('加载失败: ' + src));
};
document.head.appendChild(s);
});
}
// 按顺序加载,彻底规避竞态
async function boot() {
try {
await loadScript('utils.js');
await loadScript('app.js');
console.log('所有脚本安全执行完毕');
} catch (e) {
console.error(e);
}
}
boot();
这段代码中,loadScript创建了一个script元素并监听加载事件。由于我们没有给它加async,动态添加的脚本在插入DOM后按插入顺序执行,且await保证了utils.js的onload触发后才会插入app.js。即使utils.js网络很慢,app.js也会乖乖等着。
这种方案的优点是侵入性低,不用改原有脚本内容,只需统一走boot函数。缺点是失去了async带来的并行下载收益,但因为是有依赖的脚本,本来就不能并行执行,所以性能损失可以忽略。对于无依赖的独立脚本,仍可单独用原生async加载。
3.1 支持依赖图的增强版加载器
当脚本多且依赖复杂,可定义依赖配置,用递归加载。下面示例展示如何按deps字段串行加载前置项:
var manifest = {
utils: { src: 'utils.js', deps: [] },
api: { src: 'api.js', deps: ['utils'] },
app: { src: 'app.js', deps: ['api', 'utils'] }
};
var cache = {};
function loadByKey(key) {
if (cache[key]) return cache[key];
var item = manifest[key];
var p = Promise.resolve();
item.deps.forEach(function (d) {
p = p.then(function () { return loadByKey(d); });
});
p = p.then(function () { return loadScript(item.src); });
cache[key] = p;
return p;
}
loadByKey('app').then(function () {
console.log('依赖树全部就绪');
});
利用cache避免重复加载,利用Promise链确保依赖顺序。这样在大型项目中,任何脚本只要声明deps,就能被安全调度,从根源消灭竞态条件。
四、使用ES Module从根本上规避
现代浏览器支持type="module"的脚本,其导入语法import会在执行前静态分析依赖,并且模块本身默认延迟执行且有序。把utils.js改为导出,app.js用import引入,浏览器自动处理顺序。
// utils.js
export function format(str) {
return str.trim();
}
// app.js
import { format } from './utils.js';
var text = format(' hello ');
document.getElementById('root').innerText = text;
在HTML中只需一行:
<script type="module" src="app.js"></script>
模块脚本天然解决竞态,因为依赖在图解析阶段完成,且模块只执行一次。缺点是旧浏览器需打包器降级,以及文件路径必须是URL形式。但新项目应优先采用此方案,既清晰又免维护加载器。
五、总结与实践建议
异步脚本竞态不是偶发玄学,而是async语义与依赖缺失顺序约束的必然冲突。在简单页面可用defer保序;跨团队动态注入请用Promise加载器串行控制;新项目直接上ES Module。无论哪种,核心原则都是让依赖项的执行先于使用者,而不是寄希望于网络巧合。
建议在代码评审中把async标签的使用列为检查项:凡是有全局变量共享或相互调用的脚本,禁止随意标async。通过架构层面的加载规范,可以让这类竞态条件在编码阶段就被消除,而不是等到线上报错才去抓包排查。
async_scriptrace_conditionscript_loading修改时间:2026-07-31 16:39:37