潜修的时光(假期)真是短暂啊,昨天还在海边捕捉海兽(赶海),明天就要回去上阵杀敌了(赶需求改Bug)。
上阵之际分享三招异步函数实用小技巧,助道友巩固修为,早日下班。
第一招: Promise 化的回调函数
动作要领是将传统回调函数封装为 Promise,支持现代化异步控制。
动作1. 延迟执行
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 和内容区快速切换而产生视觉闪烁,以下为示例代码:
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. 超时限制
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);
};
}
常与异步操作组合使用,避免异步操作长时间无响应而阻塞业务流程
// 模拟大文件读取
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
// 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 标签引入,使用时再通过全局变量判断是否存在,缺乏灵活性,通过上述函数可实现动态加载,状态可控,以下为使用示例
loadScript("https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.core.js")
.then(() => {
console.log("load lodash success", window._);
});
动作2. 可控的 style
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);
}
},
};
通过动态样式插入和移除,灵活控制样式表,还可以实现换肤效果
// style.css
// body {
// background-color: red;
// }
// 加载红色背景
StyleManager.add('./style.css')
// 3s后移除
setTimeout(() => {
StyleManager.remove('./style.css')
}, 3000)
动作3. 可控的 img
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. 并发控制
/**
* 异步并发
* @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);
}
并发控制最常用的场景就是大文件分片上传,通过控制分片并发上传数,提升上传效率且能避免过多请求占用系统资源
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. 异步单例
/**
* 异步单例
* @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;
}
异步单例能确保异步函数同时只能执行一个,并且共享执行结果,常用于接口请求限流、避免程序重复初始化等
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. 失败重试
/**
* 异步重试
* @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();
}
}
}
失败重试是个比较常见的功能,如加载远程资源失败重试、文件上传失败重试等
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,更多花式体操等着你。
时候不早了,道友们下期再会。