JavaScript 异步函数健身操

潜修的时光(假期)真是短暂啊,昨天还在海边捕捉海兽(赶海),明天就要回去上阵杀敌了(赶需求改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 时期,PromiseGenerator 的出现,让无数深陷"回调地狱"的修士感受到了久违的轻松。

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,更多花式体操等着你。

时候不早了,道友们下期再会。

相关推荐
kite01212 小时前
浏览器工作原理06 [#]渲染流程(下):HTML、CSS和JavaScript是如何变成页面的
javascript·css·html
крон2 小时前
【Auto.js例程】华为备忘录导出到其他手机
开发语言·javascript·智能手机
coding随想4 小时前
JavaScript ES6 解构:优雅提取数据的艺术
前端·javascript·es6
年老体衰按不动键盘4 小时前
快速部署和启动Vue3项目
java·javascript·vue
小小小小宇4 小时前
一个小小的柯里化函数
前端
灵感__idea4 小时前
JavaScript高级程序设计(第5版):无处不在的集合
前端·javascript·程序员
小小小小宇4 小时前
前端双Token机制无感刷新
前端
小小小小宇4 小时前
重提React闭包陷阱
前端
小小小小宇5 小时前
前端XSS和CSRF以及CSP
前端
UFIT5 小时前
NoSQL之redis哨兵
java·前端·算法