下面这篇文章,将尽量用通俗+深入的方式,围绕 “JavaScript 并行处理” 和 “setTimeout(fn, 0)
的真实含义与用法” 这两个主题做一个系统性讲解,并配以示例代码来帮助理解。
一、JavaScript 并行处理:从单线程到多线程
1.1 单线程、事件循环和异步
JavaScript 语言本质上是单线程的,这条主线程上所有的代码都按照**事件循环(Event Loop)**的机制来调度。
- 单线程:JS 只有一个主线程来执行脚本,如果某个操作非常耗时(例如复杂循环),就会阻塞整个页面(浏览器端)或阻塞后续任务(Node.js)。
- 事件循环:主线程会处理一个个“任务”(Task),当我们执行异步操作(如 Ajax 请求、定时器、文件读写)时,实际工作会交给浏览器或 Node.js 的其他线程或系统 API 来处理,一旦有结果,就把对应的回调推入主线程的任务队列,等待“事件循环”调度执行。
这意味着:在 JS 里,“并发”或“并行”大多数时候是并发地处理 I/O,而非真正让单线程脚本自己同时执行多段 CPU 指令。
1.2 “并行”其实可能有两层含义
- 并行发起或并发处理 I/O(网络请求、文件读写等)。
- 这在 JS 中十分常见,也是我们主要关心的“并发”。例如同时请求多个接口、同时读取多个文件,不必一个完了再发下一个请求。
- 从代码角度看,我们在同一时刻发出多个异步操作,然后共同等待它们的结果。
- 真正的多线程并行(CPU 多核并行)。
- JS 单线程要想利用多核 CPU,需要借助 Web Worker(浏览器)或 Worker Threads / Child Process(Node.js)在子线程或子进程中执行计算任务。
- 只有这样,才能让 CPU 同时(物理级别)做多段繁重运算,而不阻塞 JS 主线程的执行或 UI 渲染。
接下来我们会从以上两个角度,分别给出一些案例和具体实现方式。
二、并发 I/O:Promise 并行 & 代码示例
大部分“并行”需求,是指同时发起多个异步请求(I/O 操作)。在现代 JavaScript 中,我们常用 Promise 及其相关方法(Promise.all()
, Promise.race()
等)来处理。
2.1 基础示例:Promise.all()
实现多请求并发
2.1.1 什么是 Promise.all()
?
- 传入一个“Promise 数组”,会并行执行这些 Promise。
- 只有当全部成功时,才会进入
.then()
;只要有任意一个报错,就会进入.catch()
。 Promise.all()
返回的结果数组顺序与传入顺序一一对应,不因谁先完成而打乱。
2.1.2 代码示例
假设我们想同时请求两个数据接口(/api/user
和 /api/posts
),等待它们都返回后再进行处理:
function fetchUser() {
return new Promise((resolve, reject) => {
setTimeout(() => {
// 这里假设成功返回
resolve({ name: 'Alice', age: 20 });
}, 1000); // 模拟接口耗时 1s
});
}
function fetchPosts() {
return new Promise((resolve, reject) => {
setTimeout(() => {
// 这里假设成功返回
resolve([
{ id: 1, title: 'Post A' },
{ id: 2, title: 'Post B' }
]);
}, 1500); // 模拟接口耗时 1.5s
});
}
// 并发请求
Promise.all([fetchUser(), fetchPosts()])
.then(([user, posts]) => {
console.log('用户信息:', user);
console.log('帖子列表:', posts);
})
.catch(err => {
console.error('请求出错:', err);
});
在这段代码里:
fetchUser()
和fetchPosts()
同时发起,在 1s 和 1.5s 后各自拿到结果。Promise.all()
要等都成功后,返回数组[userData, postData]
。- 若任何一个请求失败,就会走
.catch()
分支。
2.1.3 处理错误 & 保证顺序
- 如果只想在它们全部完成后拿到“谁成功、谁失败”的详情,可以用
Promise.allSettled()
。 - 如果想在第一个成功/失败时立刻返回,则可用
Promise.race()
。 - “顺序”并不会乱,因为
Promise.all()
的结果数组与最初传入的顺序对应,但它们发起请求的时机是一并开始的,所以时间上是“并发”的。
2.2 借助 async/await
语法糖
如果你习惯 async/await
:
async function fetchDataInParallel() {
try {
// 并行发起
const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);
console.log('用户:', user, '帖子:', posts);
} catch (error) {
console.error('并行请求出错:', error);
}
}
fetchDataInParallel();
- 只要把多个 Promise 放进
Promise.all()
,就能享受并发带来的效率提升。 - 注意不要写成:
这样就是串行了,后一个要等前一个完全结束才能启动。// 会先请求 user 等完再请求 posts,无法并发 const user = await fetchUser(); const posts = await fetchPosts();
三、真正的多线程并行:Web Workers & Worker Threads
如前所述,“并发 I/O”并不是真的多线程。如果你要同时做CPU 密集型的计算(例如处理图像、加解密大文件等),在主线程里做,就会卡住页面或阻塞事件循环。此时可以通过“开子线程”来获得多核并行。
3.1 浏览器端:Web Worker 示例
-
worker.js(子线程脚本):
self.onmessage = function (event) { const { upperBound } = event.data; let sum = 0; for (let i = 0; i < upperBound; i++) { sum += i; } // 把结果发回主线程 self.postMessage({ sum }); };
-
主线程脚本:
const worker = new Worker('worker.js'); // 监听 Worker 的返回结果 worker.onmessage = function (event) { console.log('Worker 计算结果:', event.data.sum); }; // 发送任务给 Worker worker.postMessage({ upperBound: 1e8 });
这样,大循环就跑在 Worker 线程里,不会阻塞主线程的 UI 响应。
- 对于复杂的算法或耗时操作,多开 Worker 可以利用多核 CPU 并行运算。
- Worker 不能直接访问主线程 DOM,需要通过
postMessage
通信。
3.2 Node.js:Worker Threads
Node.js 也有类似的 Worker 机制(v10.5+),或你也可以使用多进程(child_process.fork()
、cluster
)来做并行计算或分布式部署。
四、setTimeout(fn, 0)
在实际开发中的用途
现在谈谈另一个常被问到的话题:setTimeout(fn, 0)
。在很多项目里,都能看到有人写 setTimeout(() => { ... }, 0)
,或使用非常小的延迟,比如 setTimeout(fn, 1)
或 setTimeout(fn, 4)
。它究竟是干什么用的?是否真的是“立即执行”?
4.1 代码不会“马上执行”,只是“尽快异步”
setTimeout(() => {
console.log('回调执行');
}, 0);
- 虽然设定了 0 毫秒,但浏览器仍有最小时间分辨率(通常 4ms 左右),且属于 “宏任务 (macrotask)” 的下一轮事件循环执行。
- 这意味着并不是真的 0ms 后执行,而是等主线程空闲后、进入下一次循环时,再执行这段回调。
4.2 实际开发中常见的用法
-
将某些操作推迟到下一次事件循环:
- 例如你在函数里想先更新一些状态,然后等这段函数结束后再执行另一段逻辑,“脱离”当前调用栈。
- 这可以防止阻塞或避免和当前同步逻辑“抢”资源。
-
出栈:
- 如果递归调用太深或调用栈快爆了,可以通过
setTimeout(fn, 0)
暂时“跳”出当前栈,下一个事件循环再继续。(当然,现代 JS 也有更好的结构方式。)
- 如果递归调用太深或调用栈快爆了,可以通过
-
类似语义:
queueMicrotask()
或Promise.then()
- 如果你要比
setTimeout(fn, 0)
更快(在同一轮事件循环里)执行回调,可以用微任务 (microtask
) 机制,比如Promise.resolve().then(...)
或queueMicrotask(...)
。 - 它们会在当前宏任务结束后、下一个渲染前执行,不需要等到下一轮事件循环。
- 如果你要比
一个直观对比
console.log('start');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('promise.then'));
console.log('end');
常见输出顺序:
start
end
promise.then
setTimeout
promise.then
(微任务)在同一轮事件循环里、宏任务之后立即执行;setTimeout(fn, 0)
(宏任务)要等到下一轮事件循环才执行。
五、实战案例:并行处理 + 保持顺序 + “延后执行”
下面给出一个更贴近实战的示例场景,把上面讲的要点结合起来:
5.1 需求场景
- 有一批“站点”信息(
sites
数组),每个站点对应一个接口请求,获取后再生成 HTML 插入页面。 - 我们想并行请求所有站点的数据,等都拿到后再插入页面,这样比一个个串行请求更快。
- 插入页面时必须保持与原数组顺序一致。
- 插入完毕后,我们再用
setTimeout(fn, 0)
做点其他小事,比如写个日志,不想阻塞插入过程。
5.2 代码实现
// 假设这是一批站点数据
const sites = [
{ id: 1, name: 'Site A' },
{ id: 2, name: 'Site B' },
{ id: 3, name: 'Site C' }
];
// 异步请求函数,模拟每个站点 API
function fetchSiteData(site) {
return new Promise((resolve, reject) => {
// 随机耗时
const delay = Math.random() * 2000;
setTimeout(() => {
// 简单返回
resolve({
siteId: site.id,
content: `${site.name} 的一些内容`
});
}, delay);
});
}
async function buildAllSites() {
// 1. 并行请求
const promises = sites.map(site => fetchSiteData(site));
// 2. 保持顺序:Promise.all 返回的数组,顺序与 sites 一一对应
const results = await Promise.all(promises);
// 3. 一次性插入 DOM,保证和 sites 同一顺序
let htmlSimple = '';
let htmlFull = '';
results.forEach(result => {
htmlSimple += `<div>[简] ${result.siteId} - ${result.content}</div>`;
htmlFull += `<div>[全] ${result.siteId} - 详细内容... </div>`;
});
document.querySelector('#siteGridSimple').innerHTML = htmlSimple;
document.querySelector('#siteGrid').innerHTML = htmlFull;
// 4. 其他操作延后处理
setTimeout(() => {
console.log('所有站点已完成渲染:', results);
}, 0);
}
// 页面加载后执行
buildAllSites();
5.2.1 流程解析
fetchSiteData(site)
是异步的,会在后台并行处理各个站点 API 请求。Promise.all(promises)
等所有请求完后,依照sites
的顺序返回结果数组results
。- 我们把全部内容一次性插入到
#siteGridSimple
、#siteGrid
中,以减少频繁 DOM 操作。 - 最后,通过
setTimeout(..., 0)
在主线程的下一个宏任务中执行日志或其他收尾逻辑,不阻塞前面的渲染。
5.2.2 若想“先返回先展示”?
- 上面的例子是“都准备好后一次插入”。
- 如果你想让用户尽快看到部分返回内容,但又必须在视觉上保持顺序,可先在页面上按顺序插入“占位容器”,等每个请求完成后再填充对应占位区。这也是另一种常见方案。
- 当然,具体如何展示取决于产品需求。
六、总结
-
JavaScript 并发 I/O:
- 主要通过 Promise /
async/await
+Promise.all()
来实现,直接并发发起多个请求或操作,大幅提升效率。 - 一定要避免不必要的串行
await
(一个结束才发下一个),否则浪费时间。
- 主要通过 Promise /
-
真正的多线程并行:
- JS 主线程是单线程,如果要做 CPU 密集运算,可以用 Web Worker(浏览器)或 Worker Threads(Node.js)开子线程,避免主线程卡死。
- 子线程与主线程通过消息传递来交互。
-
setTimeout(fn, 0)
用法:- 并非“立即执行”,而是“推到事件循环的下一次宏任务”执行,浏览器通常有最小延时 4ms 左右。
- 常用于“异步化”某段逻辑,脱离当前调用栈;若要更快时机,可考虑
queueMicrotask()
或Promise.then()
(微任务)。
-
实战要点:
- 并行请求:注意错误处理(
catch
或Promise.allSettled()
)并保持结果顺序(Promise.all
自带序保持)。 - CPU 计算:务必考虑 Web Worker/Worker Threads,否则阻塞 UI 或阻塞后续异步。
- “延后执行” 通常用
setTimeout(fn, 0)
或微任务,以控制执行顺序与避免阻塞。
- 并行请求:注意错误处理(
希望通过这篇博文,你能更透彻地掌握:
- JavaScript 是如何在单线程模型下实现“并行处理”(主要指并发 I/O 或多线程子进程)。
setTimeout(fn, 0)
的实际意义(宏任务调度),以及和微任务 (Promise.then
,queueMicrotask
) 的区别。
在实际项目中,综合这些方法,就能够写出既高效又清晰的异步代码,最大化地发挥 JavaScript 的能力。