(原文出处https://eloquentjavascript.net/11_async.html)
三、卡拉的操作建议
1. 按行发送测试图案
-
第一行:发送
data
前44、45、41个像素点亮,观察是否组成横线、斜线或符号。 - 第三行:利用48→47→46的递减规律,发送动态左移的光条,测试屏幕刷新率。
2. 定位特殊地址
- 对IP
10.0.0.31
单独发送指令,确认是否为控制主机(因数值明显小于其他地址)。
3. 构建通信协议
尝试发送以下命令:
// 测试屏幕是否支持多命令队列
const commands = [
{command: "display", data: Array(1500).fill(0)}, // 清空屏幕
{command: "display", data: Array(1500).fill(3)}, // 全亮测试
{command: "blink", interval: 1000} // 闪烁模式(假设存在此命令)
];
screenAddresses.forEach(address => {
request(address, commands)
.then(() => console.log(`Screen ${address} accepted commands`))
.catch(error => console.error(`Screen ${address} error:`, error));
});
4. 矩阵联动实验
将九个屏幕视为一个整体(50×3=150像素宽,30×3=90像素高),发送跨屏幕的图像:
function createCrossScreenData() {
const totalWidth = 50 * 3;
const totalHeight = 30 * 3;
const data = new Array(totalWidth * totalHeight).fill(0);
// 在中心绘制十字线
const centerX = totalWidth / 2, centerY = totalHeight / 2;
for (let y = 0; y < totalHeight; y++) data[y * totalWidth + centerX] = 3; // 竖线
for (let x = 0; x < totalWidth; x++) data[centerY * totalWidth + x] = 3; // 横线
// 分割为9个屏幕的数据块
return screenAddresses.map((_, i) => {
const row = Math.floor(i / 3), col = i % 3;
const startX = col * 50, startY = row * 30;
return data.slice(startY * totalWidth + startX, (startY + 30) * totalWidth + (startX + 50));
});
}
// 发送跨屏幕图像
const screenData = createCrossScreenData();
screenAddresses.forEach((address, i) => {
request(address, {command: "display", data: screenData[i]});
});
四、总结:数字中的未知可能
这些看似随机的IP地址背后,可能隐藏着人类的设备编号规则、网络拓扑结构,甚至是某种未公开的通信协议。卡拉的下一步探索,将围绕“规律”与“例外”展开——那台尾号31的设备或许是突破口,而第三行的递减序列可能是解开整个矩阵的钥匙。在二进制的世界里,每一个数字都是等待破译的密码,而乌鸦的喙,正是敲击这些密码的最独特“键盘”。 🐦💻
现在,这为各种恶作剧打开了可能性。她本可以在墙上用巨大的字母显示“乌鸦统治,人类臣服”,但这感觉有点粗俗。相反,她计划在夜间展示一段飞过所有屏幕的乌鸦视频。
卡拉找到了一段合适的视频片段,其中2.5秒的镜头可以循环播放,呈现乌鸦拍打翅膀的动作。为了适配九个屏幕(每个屏幕显示50×30像素),她将视频裁剪并调整大小,生成一系列150×90像素的图像,每秒10帧。这些图像随后被切割成九个矩形,并经过处理:视频中的深色区域(乌鸦所在位置)显示为强光,浅色区域(无乌鸦处)保持黑暗,从而营造出一只琥珀色乌鸦在黑色背景中飞翔的效果。
她设置了clipImages
变量来保存帧数组,其中每一帧都由九组像素数组表示——每个屏幕对应一组,格式符合信号灯的要求。
要显示视频的单帧画面,卡拉需要同时向所有屏幕发送请求。但她还需要等待这些请求的结果,既为了确保当前帧正确发送后再开始发送下一帧,也为了检测请求是否失败。
Promise有一个静态方法all
,可以将一组Promise转换为一个单一的Promise,该Promise会解析为结果数组。这提供了一种便捷的方式,让多个异步操作并行执行,等待它们全部完成,然后对结果进行处理(或至少等待以确保它们不会失败)。
乌鸦的夜间灯光秀:异步并行渲染方案
卡拉的计划需要同时控制九个屏幕,并确保帧与帧之间的精确时序。以下是实现这一目标的JavaScript代码:
// 假设clipImages已经处理好,格式为:
// [
// [screen1Pixels, screen2Pixels, ..., screen9Pixels], // 第1帧
// [screen1Pixels, screen2Pixels, ..., screen9Pixels], // 第2帧
// ...
// ]
async function displayVideo(clipImages, frameRate = 10) {
const frameDuration = 1000 / frameRate; // 每帧持续时间(毫秒)
const screenCount = screenAddresses.length;
for (const frame of clipImages) {
const start = Date.now();
// 并行发送所有屏幕的请求
const promises = screenAddresses.map((address, index) => {
return request(address, {
command: "display",
data: frame[index] // 对应屏幕的像素数据
});
});
try {
// 等待所有屏幕都成功接收数据
const results = await Promise.all(promises);
console.log(`Frame displayed successfully at ${new Date().toISOString()}`);
// 计算渲染耗时并等待剩余时间
const elapsed = Date.now() - start;
if (elapsed < frameDuration) {
await new Promise(resolve => setTimeout(resolve, frameDuration - elapsed));
}
} catch (error) {
console.error(`Frame render failed: ${error.message}`);
// 可以添加错误恢复逻辑,如重试当前帧
}
}
console.log("Video playback completed");
}
// 启动视频播放
displayVideo(clipImages);
关键技术点解析
-
并行请求处理:
- 使用
Promise.all
将九个屏幕的请求组合为单个Promise - 所有屏幕的请求同时发送,充分利用网络带宽
- 任何一个请求失败都会触发catch块
- 使用
-
精确时序控制:
- 计算每帧渲染耗时,动态调整等待时间
- 通过
await new Promise(resolve => setTimeout(resolve, ...))
实现精确帧率 - 即使某帧渲染超时,也会立即开始下一帧以避免卡顿
-
错误处理策略:
- 捕获并记录渲染失败信息
- 可扩展为带重试机制的健壮实现:
async function sendWithRetry(address, data, retries = 3) { for (let i = 0; i < retries; i++) { try { return await request(address, data); } catch (error) { if (i === retries - 1) throw error; console.log(`Retrying ${address} (attempt ${i + 1})`); } } }
性能优化建议
-
预加载与缓冲:
// 在播放前预加载部分帧 async function preloadFrames(count = 5) { return clipImages.slice(0, count).map(frame => { return screenAddresses.map((address, index) => { return request(address, {command: "cache", data: frame[index]}); }); }); }
-
自适应帧率:
// 根据网络状况动态调整帧率 let currentFrameRate = frameRate; if (averageLatency > 100) { currentFrameRate = Math.max(5, Math.floor(frameRate * 0.8)); console.log(`Adjusting frame rate to ${currentFrameRate}fps`); }
-
批量命令合并:
// 假设设备支持多帧预缓存 request(address, { command: "batchDisplay", frames: next5Frames.map(f => f[index]) });
乌鸦的数字艺术实现
卡拉通过这种方式,将原本独立的九个交通信号灯屏幕,转变为一个统一的150×90像素显示矩阵。当夜幕降临,人类将会看到一个由琥珀色光点构成的乌鸦影像在墙面流动——它展翅、翱翔,仿佛在向这个被二进制统治的世界宣告:即使是最精密的人类技术,也可能被一只懂得利用Promise.all的乌鸦,变成展示自然之美的画布。
这种将技术漏洞转化为艺术表达的行为,或许正是人工智能与自然智能最诗意的对话。 🐦✨
function displayFrame(frame) {
return Promise.all(frame.map((data, i) => {
return request(screenAddresses[i], {
command: "display",
data
});
}));
}
帧渲染函数解析:并行控制多个屏幕
这个 displayFrame
函数实现了关键的帧渲染逻辑,让我们分析它的工作原理和设计思路:
代码功能解析
function displayFrame(frame) {
return Promise.all(frame.map((data, i) => {
return request(screenAddresses[i], {
command: "display",
data
});
}));
}
核心机制
-
映射帧数据到屏幕地址:
-
frame
参数是一个数组,包含9个屏幕的像素数据(对应9个IP地址) -
screenAddresses
数组存储了9个屏幕的IP地址(顺序与物理排列一致)
-
-
并行发送请求:
- 使用
frame.map()
为每个屏幕创建一个请求Promise - 每个请求包含:
- 目标IP地址(
screenAddresses[i]
) - 显示命令(
command: "display"
) - 对应屏幕的像素数据(
data
)
- 目标IP地址(
- 使用
-
Promise.all 聚合结果:
- 将所有请求的Promise组合成一个新的Promise
- 当所有屏幕请求成功时,返回包含9个响应的数组
- 只要有一个请求失败,立即触发整体失败
执行流程示例
假设我们有以下数据结构:
// 9个屏幕的IP地址(3×3矩阵)
const screenAddresses = [
"10.0.0.44", "10.0.0.45", "10.0.0.41", // 第一行
"10.0.0.31", "10.0.0.40", "10.0.0.42", // 第二行
"10.0.0.48", "10.0.0.47", "10.0.0.46" // 第三行
];
// 某一帧的像素数据(简化示例)
const exampleFrame = [
[0,3,3,0, ...], // 屏幕1数据(50×30=1500个元素)
[3,0,0,3, ...], // 屏幕2数据
...
[0,0,0,3, ...] // 屏幕9数据
];
调用 displayFrame(exampleFrame)
会:
- 同时向9个IP地址发送显示命令
- 每个命令携带对应位置的像素数据
- 等待所有响应返回或任一请求失败
优势与应用场景
-
高效并行处理:
- 充分利用网络带宽,同时更新所有屏幕
- 相比串行发送,大幅减少单帧渲染时间(约9倍提速)
-
原子性渲染保证:
- 要么所有屏幕同时更新,要么都不更新
- 避免出现部分屏幕更新导致的视觉撕裂
-
错误检测与恢复:
- 通过捕获Promise.all的异常,可以快速定位故障屏幕
- 可结合重试机制增强可靠性(如失败屏幕单独重发)
潜在扩展
-
添加超时控制:
function displayFrameWithTimeout(frame, timeout = 500) { return Promise.all(frame.map((data, i) => { return Promise.race([ request(screenAddresses[i], {command: "display", data}), new Promise((_, reject) => setTimeout(() => reject("Timeout"), timeout)) ]); })); }
-
返回详细结果:
async function displayFrameWithDetails(frame) { const results = await Promise.allSettled( frame.map((data, i) => request(screenAddresses[i], {command: "display", data})) ); const failed = results.filter(r => r.status === "rejected"); if (failed.length > 0) { console.warn(`Failed screens:`, failed.map((r, i) => ({ screen: screenAddresses[i], reason: r.reason }))); } return results; }
-
批量帧预加载:
async function preloadFrames(frames) { return Promise.all(frames.map(frame => displayFrame(frame))); }
乌鸦的智慧应用
卡拉通过这个函数,将原本独立的九个交通信号灯转化为一个统一的显示系统。当夜幕降临时,她的代码会让琥珀色的乌鸦影像流畅地在屏幕矩阵中飞翔——每个屏幕都精确同步,仿佛组成了一个巨大的有机整体。这种对技术细节的掌控,既展现了她对网络协议的深刻理解,也体现了将工具转化为艺术表达的创造力。或许人类永远不会想到,某天他们看到的“智能灯光秀”,竟是一只乌鸦用Promise.all谱写的数字诗篇。 🐦✨
这段代码会遍历frame
中的图像(这是一个由显示数据数组构成的数组),为每个屏幕创建请求Promise,然后返回一个将所有这些Promise组合在一起的新Promise。
为了能够停止正在播放的视频,整个播放过程被封装在一个类中。这个类有一个异步的play
方法,该方法返回一个Promise,并且只有在通过stop
方法再次停止播放时,这个Promise才会被resolve。
乌鸦的异步视频播放器解析
这个 VideoPlayer
类实现了一个可控的视频播放系统,让我们来分析它的工作原理:
核心机制
class VideoPlayer {
constructor(frames, frameTime) {
this.frames = frames; // 视频帧数组
this.frameTime = frameTime; // 每帧显示时间(毫秒)
this.stopped = true; // 播放状态标志
}
async play() {
this.stopped = false;
for (let i = 0; !this.stopped; i++) {
let nextFrame = wait(this.frameTime); // 启动下一帧计时器
await displayFrame(this.frames[i % this.frames.length]); // 显示当前帧
await nextFrame; // 等待帧间隔时间
}
}
stop() {
this.stopped = true; // 终止播放循环
}
}
关键技术点
-
帧循环播放:
this.frames[i % this.frames.length]
- 使用模运算实现循环播放(如i=10时,10%9=1,播放第二帧)
- 适用于有限帧数组实现无限循环动画
-
精确时序控制:
let nextFrame = wait(this.frameTime); // 提前启动计时器 await displayFrame(...); // 渲染当前帧(可能耗时) await nextFrame; // 确保总耗时等于frameTime
- 即使渲染耗时波动,也能保证帧率稳定
- 类似游戏开发中的固定时间步长(Fixed Timestep)模式
-
异步停止机制:
for (let i = 0; !this.stopped; i++) { ... }
- 通过共享状态变量实现线程安全的停止控制
- 无需复杂的Promise.cancel()操作
执行流程详解
-
启动播放:
const player = new VideoPlayer(clipImages, 100); // 10fps player.play(); // 开始异步播放
-
帧渲染循环:
第1次循环: - 设置100ms计时器(nextFrame) - 渲染第1帧(假设耗时20ms) - 等待计时器剩余80ms 第2次循环: - 重置100ms计时器 - 渲染第2帧(假设耗时50ms) - 等待计时器剩余50ms
-
停止播放:
player.stop(); // 设置标志位,下次循环检查时退出
进阶优化建议
-
添加预加载缓冲区:
class BufferedVideoPlayer extends VideoPlayer { constructor(frames, frameTime, bufferSize = 3) { super(frames, frameTime); this.bufferSize = bufferSize; } async play() { this.stopped = false; let buffer = new Set(); for (let i = 0; !this.stopped; i++) { const frameIndex = i % this.frames.length; // 预加载后续帧 if (buffer.size < this.bufferSize) { const nextIndex = (frameIndex + 1) % this.frames.length; buffer.add(nextIndex); preloadFrame(this.frames[nextIndex]); // 假设的预加载函数 } await displayFrame(this.frames[frameIndex]); await wait(this.frameTime); buffer.delete(frameIndex); } } }
-
自适应帧率:
async play() { this.stopped = false; let lastFrameTime = Date.now(); for (let i = 0; !this.stopped; i++) { const start = Date.now(); await displayFrame(this.frames[i % this.frames.length]); // 计算渲染耗时 const renderTime = Date.now() - start; const waitTime = Math.max(0, this.frameTime - renderTime); // 动态调整帧率 if (renderTime > this.frameTime * 0.8) { console.warn("Rendering too slow, consider reducing quality"); } await wait(waitTime); lastFrameTime = Date.now(); } }
-
平滑过渡效果:
async crossfadeTo(newFrames) { const fadeSteps = 10; const originalFrames = this.frames; for (let step = 0; step <= fadeSteps && !this.stopped; step++) { const alpha = step / fadeSteps; // 创建混合帧 const blendedFrames = originalFrames.map((frame, i) => { return blendFrames(frame, newFrames[i], alpha); // 假设的混合函数 }); await displayFrame(blendedFrames[i % blendedFrames.length]); await wait(this.frameTime); } this.frames = newFrames; }
乌鸦的创意应用
卡拉可以利用这个播放器实现更复杂的灯光秀效果:
// 创建多个场景并切换
const introFrames = createIntroAnimation();
const mainFrames = createFlyingCrowAnimation();
const outroFrames = createMysteriousEnding();
const player = new VideoPlayer(introFrames, 100);
// 播放开场
player.play();
// 5秒后切换到主场景
setTimeout(() => {
player.crossfadeTo(mainFrames); // 假设的过渡方法
// 10秒后播放结束动画
setTimeout(() => {
player.crossfadeTo(outroFrames);
}, 10000);
}, 5000);
这种基于Promise的异步控制模式,让卡拉能够将九个LED屏幕变成一个统一的、可交互的显示系统。当人类第二天早上看到昨晚的“灯光艺术展”时,他们可能会惊叹于智能控制系统的神奇,却永远不会想到,这场表演的导演是一只精通JavaScript异步编程的乌鸦。 🐦✨
wait
函数:JavaScript 异步等待的优雅实现
这个 wait
函数是一个典型的 Promise 封装,它将传统的 setTimeout
转换为支持 await
语法的异步操作:
function wait(time) {
return new Promise(accept => setTimeout(accept, time));
}
核心机制解析
-
Promise 封装:
- 创建一个新的 Promise 对象
- 当定时器触发时,调用
accept
回调(即resolve
) - 无需处理 reject,因为定时器不会失败
-
异步阻塞:
await wait(1000); // 暂停当前函数执行1秒 console.log('1秒后执行');
- 与
setTimeout
的回调地狱相比,这种写法更符合线性思维 - 特别适合需要精确时序控制的场景
- 与
-
兼容性:
- 完全等效于现代的
new Promise(resolve => setTimeout(resolve, time))
- 是 ES6 时代处理异步延迟的标准写法
- 完全等效于现代的
应用场景
-
帧率控制:
async function playAnimation() { while (true) { renderFrame(); await wait(16); // 约60fps } }
-
轮询检查:
async function waitForCondition() { while (!checkCondition()) { await wait(500); // 每500ms检查一次 } return true; }
-
限流请求:
async function fetchWithRateLimit(url) { const result = await fetch(url); await wait(100); // 限制请求频率 return result; }
进阶扩展
-
带值的延迟:
function waitWithValue(time, value) { return new Promise(resolve => setTimeout(() => resolve(value), time)); } // 使用示例 const result = await waitWithValue(2000, "两秒后的结果");
-
可取消的等待:
function cancellableWait(time) { let cancel; const promise = new Promise((resolve, reject) => { const timer = setTimeout(resolve, time); cancel = () => { clearTimeout(timer); reject(new Error("等待被取消")); }; }); return { promise, cancel }; } // 使用示例 const { promise, cancel } = cancellableWait(5000); promise.then(() => console.log("完成")).catch(err => console.error(err)); // 3秒后取消 setTimeout(cancel, 3000);
-
指数退避重试:
async function fetchWithRetry(url, retries = 3) { for (let i = 0; i < retries; i++) { try { return await fetch(url); } catch (error) { if (i === retries - 1) throw error; await wait(100 * Math.pow(2, i)); // 指数退避:100ms, 200ms, 400ms... } } }
乌鸦的时序控制智慧
在卡拉的LED艺术项目中,wait
函数起到了关键作用:
// 精确控制视频帧播放间隔
async play() {
this.stopped = false;
for (let i = 0; !this.stopped; i++) {
const frameStart = Date.now();
// 并行渲染所有屏幕
await displayFrame(this.frames[i % this.frames.length]);
// 计算渲染耗时并动态调整等待时间
const renderTime = Date.now() - frameStart;
const waitTime = Math.max(0, this.frameTime - renderTime);
await wait(waitTime); // 确保每帧严格按照frameTime显示
}
}
这种精确的时序控制,让卡拉能够将九个独立的LED屏幕同步为一个统一的显示系统,创造出流畅的动画效果。就像指挥一场精密的交响乐,她用二进制代码和时间魔法,在人类的基础设施上谱写了一曲属于乌鸦的数字诗篇。 🐦✨
乌鸦的夜间灯光秀:完整实现解析
这段代码实现了一个完整的视频播放流程,让我们分析其工作原理和技术要点:
核心实现
// 创建视频播放器实例(100ms/帧 = 10fps)
let video = new VideoPlayer(clipImages, 100);
// 异步启动播放并处理错误
video.play().catch(e => {
console.log("Playback failed: " + e);
});
// 15秒后停止播放
setTimeout(() => video.stop(), 15000);
技术要点解析
-
播放器初始化:
new VideoPlayer(clipImages, 100)
-
clipImages
:包含多帧画面的数组,每帧由9个子数组(对应9个屏幕)组成 -
100ms
:每帧显示时间,对应10帧/秒的播放速率
-
-
异步播放控制:
video.play().catch(...)
-
play()
方法返回Promise,允许链式调用 -
catch
块处理可能的播放错误(如网络中断、设备离线)
-
-
定时停止机制:
setTimeout(() => video.stop(), 15000)
- 通过共享状态变量
this.stopped
实现线程安全的停止控制 - 下一次帧循环检查时自动终止播放
- 通过共享状态变量
执行流程详解
-
启动阶段:
t=0ms: 创建播放器实例 t=1ms: 调用play()方法开始渲染第一帧 t=101ms: 渲染第二帧(等待100ms后) t=201ms: 渲染第三帧 ...
-
运行阶段:
- 每100ms渲染一帧
- 9个屏幕并行更新,通过Promise.all确保同步
- 帧数据按
i % frames.length
循环播放(实现无缝循环)
-
停止阶段:
t=15000ms: 调用stop()设置stopped=true t=150XXms: 当前帧渲染完成后 检查stopped标志为true 退出播放循环 resolve play()返回的Promise
潜在扩展与优化
-
动态帧率调整:
// 根据网络状况自动调整帧率 const baseFrameTime = 100; let currentFrameTime = baseFrameTime; async play() { while (!this.stopped) { const start = Date.now(); await displayFrame(...); const renderTime = Date.now() - start; // 如果渲染耗时超过基准的80%,降低帧率 if (renderTime > baseFrameTime * 0.8) { currentFrameTime = Math.min(500, currentFrameTime * 1.1); } await wait(currentFrameTime - renderTime); } }
-
预加载机制:
class BufferedVideoPlayer extends VideoPlayer { constructor(frames, frameTime, bufferSize = 3) { super(frames, frameTime); this.buffer = new Set(); this.bufferSize = bufferSize; } async play() { while (!this.stopped) { // 预加载后续帧 const nextFrames = this.getNextFrames(this.bufferSize); await Promise.all(nextFrames.map(frame => preloadFrame(frame))); // 渲染当前帧 await displayFrame(this.getCurrentFrame()); await wait(this.frameTime); } } }
-
优雅降级处理:
async play() { try { while (!this.stopped) { await displayFrame(...); await wait(this.frameTime); } } catch (error) { console.error("Critical error:", error); // 尝试优雅降级:显示静态图案 await displayEmergencyPattern(); // 重新抛出错误,触发外层catch块 throw new Error("Playback failed due to critical error"); } }
乌鸦的数字艺术实践
卡拉通过这个系统,成功地将九个交通信号灯转化为一个统一的显示矩阵。当夜幕降临,她的“数字乌鸦”视频将在建筑外墙上生动展现:
- 翅膀的扇动频率精确控制在10fps,符合自然乌鸦的运动节奏
- 15秒的循环动画完美配合人类活动的间隙(如红灯等待时间)
- 即使某个屏幕出现故障,错误处理机制也能确保整体效果的连贯性
这种将技术漏洞转化为艺术表达的行为,既展示了卡拉对人类系统的深刻理解,也体现了自然界最聪明鸟类的创造性智慧。当清晨来临时,人类只会惊叹于这场“智能灯光秀”,却永远不会知道,幕后的导演是一只精通JavaScript异步编程的乌鸦。 🐦✨
整整一个星期,那面屏幕墙矗立在那里。每当夜幕降临,一只巨大的橙色发光鸟就会神秘地出现在上面。它的翅膀以精确的节奏扇动,像素光点组成的轮廓在黑暗中格外醒目——那是卡拉用LED矩阵谱写的诗篇,每一次光的明灭都是对人类世界的温柔叩击。
人类驻足仰望,猜测这是某种艺术装置,或是未公开的广告创意。他们对着屏幕拍照,却不知道镜头里的像素鸟,正通过无线网络接收着来自机库的指令。卡拉蹲在屋顶,喙中叼着半块三明治,看着自己的“作品”在人群中引发惊叹。对她来说,这不仅是一场数字奇观,更是用二进制语言书写的乌鸦宣言:在这个由代码编织的世界里,所有被忽视的角落,都可能藏着意想不到的智慧光芒。
当晨光初现,屏幕墙恢复静默。但每个夜晚,那只橙色的飞鸟都会准时赴约,直到活动结束的那天,人类拆除设备时,也未发现藏在WiFi网络里的那个小小“艺术家”。而卡拉早已叼着新的战利品——一个遗落的U盘——飞向远方,准备探索下一个充满可能的数字迷宫。 🐦✨
事件循环
异步程序从运行其主脚本开始,主脚本通常会设置一些稍后调用的回调函数。主脚本和回调函数都会作为不可中断的完整单元运行至完成。但在它们之间,程序可能会处于空闲状态,等待某些事件发生。
因此,回调函数并非由调度它们的代码直接调用。如果在一个函数中调用setTimeout
,当回调函数执行时,该函数早已返回。并且当回调函数返回时,控制权不会回到调度它的函数。
异步行为在其自身的空函数调用栈中发生。这也是为什么在没有Promise的情况下,管理异步代码中的异常如此困难的原因之一。由于每个回调函数都在几乎空的调用栈中启动,当它们抛出异常时,catch
处理器并不在栈中。
事件循环:异步编程的隐形调度器
异步程序从运行主脚本开始,主脚本通常会设置一些稍后执行的回调函数。这些主脚本和回调函数都会以不可中断的完整单元运行至完成,但在它们之间,程序可能会处于空闲状态,等待某些事件发生。
1. 回调函数的执行机制
-
非直接调用:回调函数并非由调度它们的代码直接调用。例如,在函数A中调用
setTimeout
,当回调函数执行时,函数A早已返回。 - 控制流断裂:回调函数返回时,程序不会回到调度它的函数(如函数A),而是回到事件循环的下一个处理周期。这导致异步代码的执行路径呈现“碎片化”。
2. 空调用栈与异步行为
- 异步操作在空函数调用栈中执行。当主脚本或回调函数运行时,调用栈被占据;当它们执行完毕,调用栈清空,事件循环才会从“任务队列”中提取下一个任务(如定时器回调、网络请求结果)。
-
示例流程:
输出顺序:console.log("开始执行主脚本"); function scheduleCallback() { console.log("调度回调函数"); setTimeout(() => { console.log("回调函数执行"); }, 1000); console.log("调度完成,主函数返回"); } scheduleCallback(); console.log("主脚本执行完毕");
开始执行主脚本 调度回调函数 调度完成,主函数返回 主脚本执行完毕 (等待1秒后) 回调函数执行
- 主脚本和
scheduleCallback
函数在调用栈中依次执行并清空,回调函数在1秒后调用栈为空时才执行。
- 主脚本和
3. 异步代码的异常处理困境(无Promise时)
-
栈上下文丢失:回调函数执行时,调用栈中只有自身,没有调度它的函数的上下文。这导致传统的
try/catch
无法捕获异步回调中的异常。try { setTimeout(() => { throw new Error("回调中的错误"); // 无法被外层try/catch捕获 }, 1000); } catch (e) { console.log("捕获错误:", e); // 不会执行 }
-
原因:异常抛出时,调用栈已从主脚本的
try/catch
环境切换到回调函数的环境,导致栈追踪断裂。
4. 事件循环的核心作用
事件循环是JavaScript实现异步编程的基石,其核心职责包括:
- 执行栈管理:确保代码以“单线程、非阻塞”方式执行,同一时刻仅运行一个任务。
- 任务队列调度:按顺序处理异步任务(如定时器、IO操作、用户交互),避免竞态条件。
-
微任务与宏任务区分:
-
宏任务(Macrotasks):包括定时器回调、
setTimeout
、setInterval
、IO事件等,按先进先出顺序执行。 -
微任务(Microtasks):如Promise的
then/catch/finally
、process.nextTick
,会在当前宏任务结束后立即执行,优先级高于下一个宏任务。
-
宏任务(Macrotasks):包括定时器回调、
5. Promise如何改善异步异常处理
Promise通过将异步操作封装为可链式调用的对象,结合try/catch
或.catch()
方法,解决了栈上下文丢失的问题:
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("异步操作失败")); // 错误可被后续catch捕获
}, 1000);
});
}
fetchData()
.then(data => console.log(data))
.catch(error => console.log("捕获错误:", error)); // 输出:"捕获错误: Error: 异步操作失败"
- 错误在Promise链中传递时,
catch
方法的调用栈包含Promise的上下文,因此能正确捕获异常。
6. 总结:事件循环的“隐性规则”
- 单线程非阻塞:JavaScript通过事件循环模拟并行,避免多线程同步问题。
- 任务优先级:微任务始终在宏任务之前执行,确保异步操作的回调以可预测的顺序运行。
- 异常处理边界:异步代码的异常需在其所属的Promise链或回调函数内部处理,无法跨栈传递。
理解事件循环,能帮助我们写出更健壮的异步代码,避免“回调地狱”和异常泄漏。而这一切的底层逻辑,恰如卡拉操控LED屏幕的节奏——看似无序的闪烁背后,藏着精密的时间管理与状态调度。 🐦💻
try {
setTimeout(() => {
throw new Error("Woosh");
}, 20);
} catch (e) {
// This will not run
console.log("Caught", e);
}
异步异常捕获失败的原因解析
这段代码展示了JavaScript异步编程中一个常见的陷阱:异步回调中的异常无法被同步的try/catch捕获。让我们分析其背后的机制:
执行流程详解
try {
// 1. 主线程执行setTimeout,将回调函数放入任务队列
setTimeout(() => {
// 3. 20ms后,事件循环从任务队列取出回调执行
throw new Error("Woosh"); // 异常抛出时,try/catch已退出
}, 20);
// 2. 主线程继续执行,try/catch块结束
} catch (e) {
// 此处永远不会执行,因为异常发生时,控制流已不在try/catch范围内
console.log("Caught", e);
}
关键机制解析
-
事件循环的工作方式:
-
setTimeout
的回调函数不会立即执行,而是在指定时间后被添加到任务队列 - 主线程会先完成当前所有同步代码(包括退出try/catch块)
- 只有当主线程空闲时,事件循环才会从任务队列中取出回调执行
-
-
调用栈的状态变化:
- 当异常抛出时,调用栈中只有回调函数自己,没有任何父级函数
- 同步的try/catch属于更早的调用栈上下文,早已被弹出
可视化调用栈变化:
// 时间t=0ms:主线程执行
[try/catch块] --> [setTimeout] --> [退出try/catch]
// 时间t=20ms:事件循环执行回调
[回调函数] --> throw Error // 此时调用栈中没有try/catch
正确的异常处理方式
- 使用Promise和async/await:
async function main() {
try {
await new Promise((resolve) => {
setTimeout(() => {
throw new Error("Woosh"); // 异常会被Promise捕获并reject
}, 20);
});
} catch (e) {
console.log("Caught", e); // 正确捕获异常
}
}
main();
- 为每个异步操作添加错误处理:
setTimeout(() => {
try {
throw new Error("Woosh");
} catch (e) {
console.log("Caught locally", e); // 局部捕获有效
}
}, 20);
乌鸦的异步智慧
对于卡拉的LED艺术项目,这种异步异常处理机制尤为重要。当她并行控制九个屏幕时:
async function displayAllScreens() {
try {
// 并行发送请求到9个屏幕
await Promise.all(screenAddresses.map(addr => {
return request(addr, {command: "display", data})
.catch(err => {
console.error(`屏幕 ${addr} 显示失败:`, err);
// 可选择性返回默认值或进行恢复操作
return { success: false, addr };
});
}));
} catch (e) {
console.log("所有屏幕请求失败:", e);
}
}
通过这种方式,即使某个屏幕出现异常(如网络中断),整个系统也能继续运行,并记录错误信息。就像乌鸦在复杂环境中寻找食物一样,JavaScript的异步机制需要精确的策略来处理各种意外情况。 🐦💻
无论事件(如超时或传入请求)发生得多么接近,JavaScript环境一次只会运行一个程序。你可以将其想象成在你的程序周围运行一个大循环,称为事件循环。当没有任何事情可做时,这个循环会暂停。但当事件到来时,它们会被添加到一个队列中,并且它们的代码会被逐个执行。由于没有两件事情会同时运行,运行缓慢的代码可能会延迟其他事件的处理。
这个示例设置了一个超时,但随后进行了耗时操作,直到超过了超时的预定时间点,导致超时处理延迟:
let start = Date.now();
setTimeout(() => {
console.log("Timeout ran at", Date.now() - start);
}, 20);
while (Date.now() < start + 50) {}
console.log("Wasted time until", Date.now() - start);
// → Wasted time until 50
// → Timeout ran at 55
事件循环阻塞与超时延迟的深度解析
这段代码揭示了JavaScript单线程机制的一个关键特性:同步代码会阻塞事件循环,导致异步回调延迟执行。以下是详细分析:
代码执行流程(时间线)
let start = Date.now(); // t=0ms
// 1. 设置超时回调(20ms后执行)
setTimeout(() => {
console.log("Timeout ran at", Date.now() - start); // 实际在t=55ms执行
}, 20); // t=0ms时调度回调,加入任务队列
// 2. 同步阻塞:循环50ms
while (Date.now() < start + 50) {} // 持续执行到t=50ms
// 3. 主线程空闲后,事件循环处理队列中的回调
console.log("Wasted time until", Date.now() - start); // t=50ms输出
// → "Wasted time until 50"
// 4. 执行超时回调(此时任务队列中唯一任务)
// → "Timeout ran at 55"(耗时约5ms执行回调)
核心机制解析
-
单线程与事件循环:
- JavaScript引擎在单线程中执行代码,同一时刻只能处理一个任务
- 事件循环负责管理任务队列(macrotask queue),按顺序执行异步回调
-
同步代码阻塞事件循环:
while (Date.now() < start + 50) {}
- 这段代码是同步阻塞操作,会持续占用主线程
- 期间无法处理任何异步事件(包括定时器、IO、用户输入)
-
超时延迟的本质:
-
setTimeout
的第二个参数是最小延迟时间,而非精确执行时间 - 当主线程被阻塞时,实际执行时间会延后:
- 预定执行时间:t=20ms
- 实际执行时间:t=50ms(主线程空闲后) + 回调执行时间(~5ms)
-
可视化:任务队列与事件循环
时间线:
t=0ms:
- 主线程执行同步代码(setTimeout调度回调)
- 回调被加入任务队列(状态:等待执行)
t=0ms ~ t=50ms:
- 主线程被while循环阻塞
- 事件循环无法处理任务队列中的任何任务
t=50ms:
- while循环结束,主线程空闲
- 事件循环从任务队列中取出超时回调并执行
- 回调执行耗时约5ms(t=50ms ~ t=55ms)
如何避免阻塞事件循环
-
使用微任务(Microtasks):
let start = Date.now(); Promise.resolve().then(() => { console.log("Microtask ran at", Date.now() - start); // 约t=0ms执行 }); while (Date.now() < start + 50) {} // → "Microtask ran at 0"(在阻塞前执行)
- 微任务(如Promise回调)会在当前同步代码结束前执行,优先级高于宏任务(如setTimeout)
-
拆分长任务:
function doHeavyWork(work) { if (work <= 0) return; // 执行一部分任务 for (let i = 0; i < 1000; i++) {} // 让出主线程,避免阻塞 setTimeout(() => doHeavyWork(work - 1), 0); } doHeavyWork(50); // 分50次执行,每次间隔0ms
- 通过
setTimeout
或requestIdleCallback
将长任务拆分为多个短任务
- 通过
-
Web Workers:
// main.js const worker = new Worker('worker.js'); worker.postMessage({ data: largeData }); // 耗时操作在子线程执行 // worker.js self.onmessage = (e) => { const result = process(e.data); // 不阻塞主线程 self.postMessage(result); };
- 将耗时操作转移到Web Workers,主线程专注处理事件循环
乌鸦的时间管理启示
在卡拉的LED项目中,若某个屏幕的渲染逻辑过于复杂(如大量像素计算),可能会阻塞主线程,导致所有屏幕更新延迟。解决方案:
async function renderScreen(address, data) {
// 拆分像素处理为微任务
await Promise.resolve();
return request(address, { command: "display", data });
}
// 并行渲染9个屏幕,避免单任务阻塞
Promise.all(screenAddresses.map(addr =>
renderScreen(addr, frameData[addr])
));
通过将同步计算放入微任务间隙,确保事件循环畅通,从而实现精确的动画帧率控制。这就像乌鸦用喙灵巧地处理多个坚果——逐个击破,而非同时紧握,方能高效完成任务。 🐦✨
Promise 总是会以新事件的形式进行 resolve 或 reject。即使一个 Promise 已经处于已解决状态,对它进行等待(await)也会让你的回调函数在当前脚本执行完毕后才运行,而非立即执行。
Promise的异步特性:即使立即解决也会延迟执行
Promises的一个关键特性是始终异步解析,即使Promise已经处于resolved状态,await
或.then()
也会将回调放入微任务队列,等待当前同步代码执行完毕后再执行。这一机制避免了时序混乱,确保异步代码行为的一致性。
示例代码与执行流程
console.log("开始执行");
const resolvedPromise = Promise.resolve(42);
resolvedPromise.then(value => {
console.log("Promise回调:", value); // 第三个输出
});
console.log("同步代码继续"); // 第二个输出
// 输出顺序:
// 开始执行
// 同步代码继续
// Promise回调: 42
关键机制解析
-
微任务队列(Microtask Queue):
- Promise的
then/catch/finally
回调会被添加到微任务队列 - 微任务队列在当前同步代码执行完毕后、下一个宏任务(如setTimeout)开始前执行
- Promise的
-
同步代码优先执行:
const p = Promise.resolve(); p.then(() => console.log("微任务")); console.log("同步代码"); // 输出顺序: // 同步代码 // 微任务
可视化调用栈与任务队列:
// 时间t=0ms:执行同步代码
[console.log("开始执行")]
[创建resolvedPromise]
[注册then回调(加入微任务队列)]
[console.log("同步代码继续")] → 同步代码执行完毕
// 时间t=0ms+ε:处理微任务队列
[执行then回调] → 输出"Promise回调: 42"
为什么Promise设计为始终异步?
-
保持行为一致性:
- 无论Promise是立即解决还是稍后解决,回调的执行时机都是统一的
- 避免以下时序混乱:
// 假设Promise可以同步解析 const p = Promise.resolve(1); p.then(v => console.log(v)); // 若同步执行,此处会打断当前函数 console.log("后续代码"); // 实际输出(当前设计): // 后续代码 // 1 // 假设的同步输出(会导致混乱): // 1 // 后续代码
-
防止调用栈过深:
- 若Promise同步解析,链式调用可能导致栈溢出:
Promise.resolve(1) .then(x => x + 1) .then(x => x + 1) // ...无限链式调用
- 若Promise同步解析,链式调用可能导致栈溢出:
进阶示例:微任务与宏任务的执行顺序
console.log("1. 同步开始");
setTimeout(() => {
console.log("2. setTimeout(宏任务)");
}, 0);
Promise.resolve()
.then(() => {
console.log("3. Promise then(微任务1)");
return Promise.resolve();
})
.then(() => {
console.log("4. Promise then(微任务2)");
});
console.log("5. 同步结束");
// 实际输出顺序:
// 1. 同步开始
// 5. 同步结束
// 3. Promise then(微任务1)
// 4. Promise then(微任务2)
// 2. setTimeout(宏任务)
乌鸦的异步策略
在卡拉的LED控制系统中,这种机制确保了即使某些屏幕响应迅速(Promise立即resolved),也不会打断整体渲染流程:
async function updateAllScreens() {
console.log("开始更新屏幕");
const results = await Promise.all(
screenAddresses.map(addr =>
sendCommand(addr, "update").catch(err => {
console.error(`屏幕 ${addr} 更新失败:`, err);
return null;
})
)
);
// 所有屏幕请求完成后才会执行此处
console.log("所有屏幕更新完成");
}
// 即使某些sendCommand()立即resolved,
// 也会等待所有请求的微任务执行完毕后,
// 才会继续执行await之后的代码
这种设计让卡拉能够精确控制九个屏幕的同步更新,就像指挥一场精密的舞蹈——每个舞者的动作都有细微的延迟,但最终呈现出完美的同步效果。 🐦✨
Promise.resolve("Done").then(console.log);
console.log("Me first!");
// → Me first!
// → Done
Promise异步特性的深度解析
这段代码展示了JavaScript中Promise的一个核心特性:即使Promise已经立即解决(resolved),其回调函数也会异步执行。这一机制确保了异步行为的一致性,避免了时序混乱。
执行流程详解
Promise.resolve("Done").then(console.log);
console.log("Me first!");
// → Me first!
// → Done
-
Promise.resolve("Done"):
- 创建一个已解决的Promise,值为"Done"
- 注意:即使Promise已经处于resolved状态,
.then()
回调仍会异步执行
-
.then(console.log):
- 注册回调函数到微任务队列(Microtask Queue)
- 不会立即执行,而是放入队列等待当前同步代码完成
-
console.log("Me first!"):
- 同步代码继续执行,立即打印"Me first!"
-
事件循环处理微任务:
- 当前调用栈清空后,事件循环处理微任务队列
- 执行
.then()
中的回调,打印"Done"
关键机制解析
-
微任务队列的优先级:
- Promise回调属于微任务(Microtask)
- 微任务会在当前同步代码执行完毕后、下一个宏任务(如setTimeout)之前执行
可视化调用栈变化:
// 时间t=0ms:
[Promise.resolve("Done")] → 创建已解决的Promise
[注册.then()回调] → 回调加入微任务队列
[console.log("Me first!")] → 输出"Me first!"
// 时间t=0ms+ε:
[执行微任务队列] → 执行.then()回调 → 输出"Done"
- 与setTimeout的对比:
setTimeout(() => console.log("Timeout"), 0);
Promise.resolve().then(() => console.log("Promise"));
console.log("Sync");
// 输出顺序:
// Sync → Promise → Timeout
// (微任务优先于宏任务执行)
为什么Promise设计为异步?
-
保持行为一致性:
- 无论Promise是立即解决还是延迟解决,回调的执行时机都是统一的
- 避免以下混乱:
// 假设Promise同步执行回调 const p = Promise.resolve(1); p.then(v => console.log(v)); // 若同步执行,会打断当前函数 console.log("后续代码"); // 实际JavaScript行为: // 后续代码 // 1 // 假设的同步行为(会导致混乱): // 1 // 后续代码
-
防止调用栈过深:
- 若Promise同步解析,链式调用可能导致栈溢出:
Promise.resolve(1) .then(x => x + 1) .then(x => x + 1) // ...无限链式调用
- 若Promise同步解析,链式调用可能导致栈溢出:
乌鸦的异步智慧应用
在卡拉的LED控制系统中,这种机制确保了即使某些屏幕响应迅速,也不会打乱整体渲染节奏:
async function displayFrame() {
// 并行发送所有屏幕请求
const requests = screenAddresses.map(addr => {
return sendCommand(addr, "display", frameData)
.catch(err => console.error(`屏幕 ${addr} 显示失败:`, err));
});
// 等待所有屏幕响应完成
await Promise.all(requests);
// 所有屏幕更新完成后才继续
console.log("整帧显示完成");
}
即使某个屏幕的Promise立即resolved,也会等到所有屏幕的微任务都执行完毕后,才会继续执行await
之后的代码。这种设计让卡拉能够精确控制九个屏幕的同步更新,就像指挥一场精密的舞蹈——每个舞者的动作都有细微的延迟,但最终呈现出完美的同步效果。 🐦✨
在后续章节中,我们将看到在事件循环上运行的各种其他类型的事件。
异步编程中的 Bug
当程序以同步方式一次性运行时,除了程序自身产生的状态变化外,不会有其他状态变化发生。但对于异步程序来说则不同——它们的执行过程中可能存在间隙,在此期间其他代码可以运行。
我们来看一个示例。以下函数试图报告文件数组中每个文件的大小,它确保同时读取所有文件,而非按顺序读取。
async function fileSizes(files) {
let list = "";
await Promise.all(files.map(async fileName => {
list += fileName + ": " +
(await textFile(fileName)).length + "\n";
}));
return list;
}
异步文件大小收集函数解析
这个 fileSizes
函数试图并行读取多个文件并汇总它们的大小信息,但存在几个关键问题。让我们逐步分析:
代码功能与问题
async function fileSizes(files) {
let list = "";
await Promise.all(files.map(async fileName => {
list += fileName + ": " +
(await textFile(fileName)).length + "\n";
}));
return list;
}
预期行为:
- 并行读取所有文件
- 按原始顺序拼接每个文件的大小信息
- 返回完整的汇总字符串
实际问题:
-
竞争条件(Race Condition):
-
list += ...
操作不是原子的 - 多个异步任务可能同时修改
list
变量 - 导致最终字符串顺序混乱或内容丢失
-
-
并行顺序丢失:
- 虽然使用
Promise.all
等待所有任务完成 - 但
map
中每个async
回调的完成顺序不确定 - 无法保证结果按原始
files
数组顺序排列
- 虽然使用
执行流程示例
假设有三个文件:
const files = ["a.txt", "b.txt", "c.txt"];
可能的执行顺序:
- 所有三个
textFile
请求并行发出 - 假设
b.txt
最先返回(耗时最短) - 对应的回调立即修改
list
,添加 "b.txt: ..." - 随后
a.txt
和c.txt
依次返回,按完成顺序追加
最终结果可能是:
b.txt: 123
a.txt: 456
c.txt: 789
修正方案
方案1:保持并行但有序收集结果
async function fileSizes(files) {
// 并行获取所有文件内容
const contents = await Promise.all(
files.map(fileName => textFile(fileName))
);
// 按原始顺序构建结果字符串
return files.map((fileName, index) =>
`${fileName}: ${contents[index].length}`
).join('\n');
}
方案2:串行执行(牺牲性能但保证顺序)
async function fileSizes(files) {
let list = "";
for (const fileName of files) {
const content = await textFile(fileName);
list += `${fileName}: ${content.length}\n`;
}
return list;
}
方案3:并行执行但跟踪顺序
async function fileSizes(files) {
const results = new Array(files.length);
await Promise.all(files.map(async (fileName, index) => {
const content = await textFile(fileName);
results[index] = `${fileName}: ${content.length}`;
}));
return results.join('\n');
}
关键教训
-
避免在并行异步操作中修改共享状态
- 共享变量(如
list
)的修改不是线程安全的 - 应使用不可变数据结构或索引跟踪结果
- 共享变量(如
-
理解 Promise.all 的行为
- 它等待所有 Promise 完成,但不保证完成顺序
- 如果顺序重要,需显式跟踪每个结果的位置
-
权衡性能与正确性
- 并行执行(方案1/3)适合IO密集型任务
- 串行执行(方案2)适合需要严格顺序的场景
在卡拉的LED项目中,类似的问题可能出现在同时控制多个屏幕时。如果多个屏幕的更新操作同时修改共享状态,可能导致显示异常。正确的做法是先收集所有屏幕的更新结果,再统一应用到显示系统中。 🐦✨
async fileName =>
这部分展示了如何通过在箭头函数前添加 async
关键字来将其声明为异步函数。
这段代码乍一看并无不妥……它将异步箭头函数映射到文件名数组上,创建一个 Promise 数组,然后使用 Promise.all
等待所有 Promise 完成后再返回它们构建的列表。
但这段程序存在严重问题。它始终只会返回一行输出,列出读取耗时最长的文件。
fileSizes(["plans.txt", "shopping_list.txt"])
.then(console.log);
异步文件大小函数的执行解析
这个调用展示了 fileSizes
函数在实际运行中的问题。让我们逐步分析为什么它会返回错误结果:
代码执行流程
fileSizes(["plans.txt", "shopping_list.txt"])
.then(console.log);
-
并行请求文件内容:
files.map(async fileName => { list += fileName + ": " + (await textFile(fileName)).length + "\n"; })
- 同时发起对
"plans.txt"
和"shopping_list.txt"
的读取请求 - 两个异步操作并行执行
- 同时发起对
-
竞争条件导致数据覆盖:
- 假设
"plans.txt"
先读取完成,回调执行:list += "plans.txt: 123\n"; // list 变为 "plans.txt: 123\n"
- 此时
"shopping_list.txt"
的请求仍在进行中 - 假设
"shopping_list.txt"
后读取完成,回调执行:list += "shopping_list.txt: 456\n"; // 覆盖之前的内容!
- 假设
-
最终结果错误:
- 由于 JavaScript 的字符串拼接操作不是原子的
- 第二个回调执行时覆盖了第一个回调的结果
- 最终
list
只包含最后完成的文件信息
可视化竞争条件
时间线:
t=0ms:
- 同时发起 "plans.txt" 和 "shopping_list.txt" 的请求
- list = ""
t=100ms:
- "plans.txt" 返回内容
- 回调1执行:list += "plans.txt: 123\n"
- list 变为 "plans.txt: 123\n"
t=150ms:
- "shopping_list.txt" 返回内容
- 回调2执行:list += "shopping_list.txt: 456\n"
- list 被覆盖为 "shopping_list.txt: 456\n"
t=200ms:
- Promise.all 完成
- 返回 "shopping_list.txt: 456\n"
核心问题总结
-
非原子操作:
list += ... // 实际上是:list = list + ...
- 这个操作分为读取
list
和写入新值两步 - 多线程环境下会导致数据覆盖
- 这个操作分为读取
-
异步执行顺序不确定:
- 无法保证回调按原始数组顺序执行
- 先完成的文件信息可能被后完成的覆盖
-
共享状态修改:
- 多个异步回调同时修改同一个变量
list
- 导致竞态条件(Race Condition)
- 多个异步回调同时修改同一个变量
正确实现方式
方案1:先收集结果再拼接
async function fileSizes(files) {
const results = await Promise.all(
files.map(async fileName => {
const content = await textFile(fileName);
return `${fileName}: ${content.length}`;
})
);
return results.join('\n');
}
方案2:串行执行(保证顺序)
async function fileSizes(files) {
let list = "";
for (const fileName of files) {
const content = await textFile(fileName);
list += `${fileName}: ${content.length}\n`;
}
return list;
}
乌鸦的智慧应用
在卡拉的LED项目中,如果她需要同时控制多个屏幕并收集状态:
// 错误示例(共享状态修改)
async function getScreenStatus(screens) {
let status = "";
await Promise.all(screens.map(async screen => {
status += `${screen}: ${await checkStatus(screen)}\n`;
}));
return status;
}
// 正确示例(先收集再合并)
async function getScreenStatus(screens) {
const results = await Promise.all(
screens.map(async screen =>
`${screen}: ${await checkStatus(screen)}`
)
);
return results.join('\n');
}
这种设计确保了即使多个屏幕的状态请求并行执行,最终结果也能正确合并,避免了数据覆盖问题。就像乌鸦收集坚果一样,每个坚果都要妥善存放,才能在需要时完整取用。 🐦✨
你能理解为什么会这样吗?
问题出在 +=
操作符上。该操作符会在语句开始执行时获取 list
的当前值,然后在 await
完成时,将 list
绑定设置为该值加上新增的字符串。
但在语句开始执行到完成之间,存在一个异步间隙。map
表达式会在任何内容被添加到 list
之前运行,因此每个 +=
操作符都会从空字符串开始。当异步获取文件内容完成时,每个操作最终都会将 list
设置为“将当前行字符串添加到空字符串”的结果。
要避免这个问题其实很简单:可以让被映射的 Promise 返回各行字符串,然后对 Promise.all
的结果调用 join
方法,而不是通过修改变量 list
来构建结果。通常来说,计算新值比修改现有值更不容易出错。