潜修的时光(假期)真是短暂啊,昨天还在海边捕捉海兽(赶海),明天就要回去上阵杀敌了(赶需求改Bug)。

上阵之际,先闲聊下 JavaScript 异步演进史,随后再分享下修炼多年的异步函数健身操,助道友巩固修为。赶时间的道友可直接进入健身操环节
JavaScript 异步演进史
蛮荒时期,JavaScript 异步全靠回调函数支撑,修炼该功法时稍不留神便会坠入"回调地狱",层层叠式的代码既难阅读又难维护,而且错误也难以捕捉
js
const fs = require("fs");
fs.readFile("a.txt", (errA, dataA) => {
if (errA) throw errA;
fs.readFile("b.txt", (errB, dataB) => {
if (errB) throw errB;
fs.writeFile("c.txt", dataA + dataB, (errC) => {
if (errC) throw errC;
console.log("终于完成了!");
});
});
});
ES6 时期,Promise
和 Generator
的出现,让无数深陷"回调地狱"的修士感受到了久违的轻松。
Promise
通过链式调用的方式,在写法上缓解了回调函数的嵌套死结,并提供 catch 方法用于捕捉错误,但处理复杂逻辑时仍存在链式嵌套等问题,治标不治本。
js
import { readFile, writeFile } from "node:fs/promises";
readFile("a.txt")
.then((dataA) => {
console.log("readFile a", dataA);
return readFile("b.txt").then((dataB) => {
console.log("readFile b", dataB);
return writeFile("c.txt", dataA + dataB);
});
})
.catch((err) => console.error("操作异常", err))
.finally(() => {
console.log("操作完成");
});
Generator
可以以同步的方式编写异步代码,使用 yield
关键字暂停代码执行,使用 next
控制代码继续执行,写法上一目了然,就是执行方式令人诟病,以至于出现 co
这样的自执行库。
js
import { readFile, writeFile } from "node:fs/promises";
function* mergeFilesGenerator() {
const dataA = yield readFile("a.txt");
const dataB = yield readFile("b.txt");
const dataC = dataA + dataB;
return writeFile("c.txt", dataC);
}
// 手动执行
const generator = mergeFilesGenerator();
const readFileA = generator.next();
readFileA.value.then((dataA) => {
console.log("readFile a", dataA);
const readFileB = generator.next(dataA);
readFileB.value.then((dataB) => {
console.log("readFile b", dataB);
const writeFileC = generator.next(dataB);
writeFileC.value.then(() => {
console.log("done", generator.next());
});
});
});
// 配合第三方库(如co库)执行
co(mergeFilesGenerator).then(() => console.log('writeFile success'))
直到 ES2017 时期,async
函数的出现,其使用 async
关键字标识函数内有异步操作,使用 await
标识其后面的表达式需要等待结果。简洁的语法、清晰的结构,众修士欢呼。
js
import { readFile, writeFile } from "node:fs/promises";
async function mergeFiles() {
const dataA = await readFile("a.txt");
console.log("readFile a", dataA);
const dataB = await readFile("b.txt");
console.log("readFile b", dataB);
const dataC = dataA + dataB;
await writeFile("c.txt", dataC);
console.log("writeFile c", dataC);
}
mergeFiles()
闲聊至此,健身操现在开始
第一节: Promise 化的回调函数
动作要领是将传统回调函数封装为 Promise,支持现代化异步控制。
动作1. 延迟执行
js
function sleep(ms = 80) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
// 支持取消
function sleepPro(ms = 80) {
let timer;
const promise = new Promise((resolve) => {
timer = setTimeout(resolve, ms);
});
const cleanup = () => {
clearTimeout(timer);
timer = null;
};
return [promise, cleanup];
}
常用于延后执行某些任务,例如资源请求超过 3s 才展示 loading,避免 loading 和内容区快速切换而产生视觉闪烁,以下为示例代码:
js
const delayLoading = (delay = 3000) => {
const [promise, cleanup] = sleepPro(delay);
promise.then(() => {
console.log("begin loading");
});
return () => {
cleanup();
console.log("stop loading");
};
};
const fakeFetch = (ms = 7000) =>
new Promise((resolve) => setTimeout(resolve, ms));
// 7秒后才完成请求,3秒后展示loading
const stopLoading = delayLoading();
try {
await fakeFetch(7000);
} finally {
stopLoading();
}
// 2秒后完成请求不展示loading
const stopLoading2 = delayLoading();
try {
await fakeFetch(2000);
} finally {
stopLoading2();
}
动作2. 超时限制
js
function timeout(ms = 3000, msg = "超时") {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(msg)), ms);
});
}
// 支持取消
function timeoutPro(ms = 3000, msg = "超时") {
let timer;
const promise = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(msg)), ms);
});
const cleanup = () => {
clearTimeout(timer);
timer = null;
};
return [promise, cleanup];
}
// 超时柯里化
function timeoutCurry(fn, options) {
const { ms = 5000, msg = "操作超时" } = options || {};
return (...args) => {
const [timeoutPromise, cleanup] = timeoutPro(ms, msg);
return Promise.race([fn(...args), timeoutPromise]).finally(cleanup);
};
}
常与异步操作组合使用,避免异步操作长时间无响应而阻塞业务流程
js
// 模拟大文件读取
const readLargeFileFake = (ms = 6000) => new Promise((resolve) => setTimeout(resolve, ms))
// 限制 5s 超时
const readLargeFile = timeoutCurry(readLargeFileFake, { ms: 5000 })
// 4s 内读取完成,read success
readLargeFile(4000).then(res => console.log('read success'));
// 读取时长超过 5s,read err Error: 操作超时
readLargeFile().catch(err => console.log('read err', err));
练习完上面两个动作,相信道友们已经掌握了回调函数的 Promise 化封装,但在实际应用时还是要先了解某回调函数是否已提供 Promise 化的版本,如 fs 模块存在 Promise 版的 node:fs/promises
,就没必要多此一举了。
假如实在需要手动封装,为了提高开发效率,还可借助成熟的轮子,如nodejs
内置的util.promisify
函数、开源的pify
等。
第二节:可控的资源加载
动作要领是使用 JavaScript API 进行资源加载,并封装为异步函数,使得加载状态可控,亦可对加载后资源进行特殊处理
动作1. 可控的 script
js
// script 方式加载 js
function loadScript(src) {
return new Promise((resolve, reject) => {
// 去重
const existScript = document.querySelector(`script[src='${src}']`);
if (existScript) {
return resolve();
}
const script = document.createElement("script");
script.type = "text/javascript";
script.src = src;
script.onload = resolve;
// 加载失败,移除 script
script.onerror = (err) => {
reject(err);
document.head.removeChild(script);
};
document.head.appendChild(script);
});
}
// fetch 方式加载 js,常见于中大型游戏脚本加载,将脚本内容缓存在 indexdb
async function fetchScript(url) {
return fetch(url).then(res => res.text()).then(eval)
}
第三方库的 CDN 通常使用 script 标签引入,使用时再通过全局变量判断是否存在,缺乏灵活性,通过上述函数可实现动态加载,状态可控,以下为使用示例
js
loadScript("https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.core.js")
.then(() => {
console.log("load lodash success", window._);
});
动作2. 可控的 style
js
const StyleManager = {
add(href) {
return new Promise((resolve, reject) => {
// 去重
const existLink = document.querySelector(`link[href='${href}']`);
if (existLink) {
return resolve();
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.type = "text/css";
link.href = href;
link.onload = () => {
resolve();
};
link.onerror = (err) => {
reject(err);
};
document.head.appendChild(link);
});
},
remove(href) {
const link = document.querySelector(`link[href='${href}']`);
if (link) {
link.parentNode.removeChild(link);
}
},
};
通过动态样式插入和移除,灵活控制样式表,还可以实现换肤效果
js
// style.css
// body {
// background-color: red;
// }
// 加载红色背景
StyleManager.add('./style.css')
// 3s后移除
setTimeout(() => {
StyleManager.remove('./style.css')
}, 3000)
动作3. 可控的 img
js
const ImageUtils = {
/**
* 加载图片
* @param {string} url 图片地址
* @param {object} options Image属性
* @param {string} options.crossOrigin 加载选项
* @returns {Promise<HTMLImageElement>} 图片对象
*/
loadImage(url, options = {}) {
return new Promise((resolve, reject) => {
const { crossOrigin } = options;
const img = new Image();
if (crossOrigin) {
img.crossOrigin = crossOrigin;
}
img.onload = () => {
resolve(img);
};
img.onerror = () => {
reject(new Error("图片加载失败"));
};
img.src = url;
});
},
// 获取图片尺寸
async getImageSize(url, options = {}) {
const img = await ImageUtils.loadImage(url, options);
return {
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
width: img.width,
height: img.height,
};
},
/**
* 预加载图片
* @param {string[]} urls 图片地址数组
* @param {object} options 加载选项
* @param {string} options.crossOrigin 加载选项
* @returns {Promise<PromiseSettledResult<HTMLImageElement>[]>} 图片对象数组
*/
preloadImages(urls, options = {}) {
return Promise.allSettled(
urls.map((url) => ImageUtils.loadImage(url, options))
);
},
};、
基于 Image 对象封装 Promise 化的工具类,实现了图片动态加载、预加载等功能,当然还能结合 canvas 拓展更丰富的功能,如图片裁剪(cropperjs)、图片压缩(compressorjs)等。
第三节:可控的异步流程
动作要领是使用某些技巧改变异步执行流程,实现并发、单例、重试、可取消等效果
动作1. 并发控制
js
/**
* 异步并发
* @param {function[]} tasks
* @param {number} concurrency
* @returns {Promise<PromiseSettledResult<any>[]>}
*/
async function asyncConcurrent(tasks, concurrency = 2) {
// 存储任务结果
const results = [];
// 并发池
const executePool = new Set();
for (const task of tasks) {
// promise 化 task
const p = Promise.resolve()
.then(() => task())
.finally(() => {
// task 完成后从并发池删除
executePool.delete(p);
});
// 存储执行中的任务
executePool.add(p);
results.push(p);
// 当执行池满载开始任务
if (executePool.size >= concurrency) {
// 捕捉 task 错误,防止中断后面的任务
try {
await Promise.race(executePool);
} catch (err) {}
}
}
return Promise.allSettled(results);
}
并发控制最常用的场景就是大文件分片上传,通过控制分片并发上传数,提升上传效率且能避免过多请求占用系统资源
js
const fakeBreakUpload = (ms = 7000) =>
new Promise((resolve) => setTimeout(() => resolve(ms), ms));
const tasks = [
() => fakeBreakUpload(4000),
() => fakeBreakUpload(2000),
() => fakeBreakUpload(),
() => fakeBreakUpload(3000),
() => new Promise((resolve, reject) => setTimeout(() => reject(1000), 1000)),
];
const results = await asyncConcurrent(tasks, 4);
// 得出结果后,可以针对失败的分片重新上传
动作2. 异步单例
js
/**
* 异步单例
* @param {Function} asyncFn - 原始的异步函数
* @param {boolean} cacheResult - 是否缓存结果
* @returns {Function} - 包装后的异步单例函数
*/
function asyncSingleton(asyncFn, cacheResult = false) {
let singletonPromise = null;
const executor = async (...args) => {
// 存在执行中的异步操作,直接返回操作中的 Promise
if (singletonPromise) {
return singletonPromise;
}
try {
singletonPromise = asyncFn.apply(this, args);
const res = await singletonPromise;
if (!cacheResult) {
singletonPromise = null;
}
return res;
} catch (error) {
// 如果异步操作抛出错误,重置单例 Promise
singletonPromise = null;
throw error;
}
};
executor.clear = () => {
singletonPromise = null;
};
return executor;
}
异步单例能确保异步函数同时只能执行一个,并且共享执行结果,常用于接口请求限流、避免程序重复初始化等
js
const asyncFunction = async () => {
console.log("开始执行异步操作");
return new Promise((resolve) =>
setTimeout(() => resolve(`操作结果:${Math.random()}`), 1000)
);
};
const singletonAsyncFunction = asyncSingleton(asyncFunction, true);
const run = () =>
singletonAsyncFunction().then(console.log).catch(console.error);
run();
run();
setTimeout(() => {
run();
singletonAsyncFunction.clear(); // 清除单例缓存
setTimeout(() => {
run();
}, 2000);
}, 3000);
动作3. 失败重试
js
/**
* 异步重试
* @param {Function} asyncFn - 原始的异步函数
* @param {Object} options - 配置选项
* @param {number} options.retries - 最大重试次数,默认为 3
* @param {number} options.delay - 重试之间的延迟时间,默认为 1000 毫秒
* @param {AbortSignal} options.signal - 用于取消操作的 AbortSignal 对象
* @returns {Promise<any>} - 异步函数的返回值
*/
async function asyncRetry(asyncFn, options = {}) {
const { retries = 3, delay = 1000, signal } = options;
let attempt = 0;
while (attempt < retries) {
try {
signal?.throwIfAborted();
const res = await asyncFn();
signal?.throwIfAborted();
return res;
} catch (error) {
if (error.name === "AbortError") {
throw error;
}
attempt++;
if (attempt >= retries) {
console.log(`Retry up to the maximum number of times`);
throw error;
}
console.log(
`Retry attempt ${attempt} failed. Retrying in ${delay} ms...`
);
// 延后重试
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
signal?.throwIfAborted();
}
}
}
失败重试是个比较常见的功能,如加载远程资源失败重试、文件上传失败重试等
js
const fakeFetch = (ms = 5000) =>
new Promise((resolve, reject) =>
setTimeout(() => {
if (Math.random() > 0.5) {
reject(new Error("Fetch failed"));
} else {
resolve(ms);
}
}, ms)
);
const controller = new AbortController();
asyncRetry(fakeFetch, { retries: 3, delay: 1000, signal: controller.signal })
.then(console.log)
setTimeout(() => {
controller.abort();
}, 2000);
小结
经过三小节的健身操,相信道友们对异步函数的应用已经了然于心,但上文示例只是冰山一角,还想深入修炼的道友,推荐看看 sindresorhus 大神的 promise-fun,更多花式体操等着你。
时候不早了,道友们下期再会。
