從 await-to-js 到 try-run-js

jump__jump發表於2022-12-20

之前在做 code review 時候發現有同事使用 try catch 包裝了一堆非同步程式碼,於是個人就覺得很奇怪,難道不應該只 catch 可能出問題的程式碼嗎?同事告訴我說 try catch 太細的話會出現內外作用域不一致,需要提前宣告變數。

let res: Data[] = [];

try {
  res = await fetchData();
} catch (err) {
  // 錯誤操作或者終止
  // return
}

// 繼續執行正常邏輯

的確,一方面開發者不應該大範圍包裹非異常程式碼,另一方面提前宣告變數會讓程式碼不連貫同時也會打斷思路。其中一個方式是直接使用原生 Promie 而不是 async。

fetchData().then((res) => {
}).catch((err) => {
});

這樣對於單個非同步請求當然沒有任何問題,如果是具有依賴性的非同步請求。雖然可以再 Promise 中返回另外的 Promise 請求,但是這樣處理 catch 卻只能有一個。

fetchData().then((res) => {
  // 業務處理
  return fetchData2(res);
}).then((res) => {
  // 業務處理
}).catch((err) => {
  // 只能做一個通用的錯誤處理了
});

如果需要多個 catch 處理,我們就需要這樣寫。

fetchData().then((res) => {
  // 業務處理
  return fetchData2(res);
}).catch((err) => {
  // 錯誤處理並且返回 null
  return null;
}).then((res) => {
  if (res === null) {
    return;
  }
  // 業務處理
}).catch((err) => {
  // 錯誤處理
});

這時候開發者也要考慮 fetchData2 會不會返回 null 的問題。於是個人開始找一些方法來幫助我們解決這個問題。

await-to-js

await-to-js 是一個輔助開發者處理非同步錯誤的庫。我們先來看看該庫是如何解決我們問題的。

import to from "await-to-js";

const [fetch1Err, fetch1Result] = await to(fetchData());
if (fetch1Err) {
  // 錯誤操作或者終止
  // return
}

const [fetch2Err, fetch1Result] = await to(fetchData2(fetch1Result));
if (fetch2Err) {
  // 錯誤操作或者終止
  // return
}

原始碼非常簡單。

export function to(
  promise,
  errorExt,
) {
  return promise
    .then((data) => [null, data])
    .catch((err) => {
      if (errorExt) {
        const parsedError = Object.assign({}, err, errorExt);
        return [parsedError, undefined];
      }
      return [err, undefined];
    });
}

使用 try-run-js

看到 await-to-js 將錯誤作為正常流程的一部分,於是個人想到是不是能透過 try catch 解決一些非同步程式碼問題呢?

我立刻想到了需要獲取 DOM 節點的需求。現有框架都使用了資料驅動的思路,但是 DOM 具體什麼時候渲染是未知的,於是個人想到之前程式碼,Vue 需要獲取 ref 並進行回撥處理。

function resolveRef(refName, callback, time: number = 1) {
  // 超過 10 次跳出遞迴
  if (time > 10) throw new Error(`cannot find ref: ${refName}`);
  // 
  const self = this;
  // 獲取 ref 節點
  const ref = this.$refs[refName];
  if (ref) {
    callback(ref);
  } else {
    // 沒有節點就下一次
    this.$nextTick(() => {
      resolveRef.call(self, refName, callback, time + 1);
    });
  }
}

當然了,上述程式碼的確可以解決此類的問題,在處理此類問題時候我們可以替換 ref 和 nextTick 的程式碼。於是 await-to-js 的邏輯下,個人開發了 try-run-js 庫。我們先看一下該庫如何使用。

import tryRun from "try-run-js";

tryRun(() => {
  // 直接嘗試使用正常邏輯程式碼
  // 千萬不要新增 ?.
  // 程式碼不會出錯而不會重試
  this.$refs.navTree.setCurrentKey("xxx");
}, {
  // 重試次數
  retryTime: 10,
  // 下次操作前需要的延遲時間
  timeout: () => {
    new Promise((resolve) => {
      this.$nextTick(resolve);
    });
  },
});

我們也可以獲取錯誤資料和結果。

import tryRun from "try-run-js";

const getDomStyle = async () => {
  // 獲取一步的
  const { error: domErr, result: domStyle } = await tryRun(() => {
    // 返回 dom 節點樣式,不用管是否存在 ppt
    // 千萬不要新增 ?.
    // 程式碼不會出錯而返回 undefined
    return document.getElementById("domId").style;
  }, {
    // 重試次數
    retryTime: 3,
    // 返回數字的話,函式會使用 setTimeout
    // 引數為當前重試的次數,第一次重試 100 ms,第二次 200
    timeout: (time) => time * 100,
    // 還可以直接返回數字,不傳遞預設為 333
    // timeout: 333
  });

  if (domErr) {
    return {};
  }

  return domStyle;
};

當然了,該庫也是支援返回元組以及 await-to-js 的 Promise 錯誤處理的功能的。

import { tryRunForTuple } from "try-run-js";

const [error, result] = await tryRunForTuple(fetchData());

try-run-js 專案演進

try-run-js 核心在於 try catch 的處理,下面是關於 try-run-js 的編寫思路。希望能對大家有一些幫助

支援 await-to-js

const isObject = (val: any): val is Object =>
  val !== null &&
  (typeof val === "object" || typeof val === "function");

const isPromise = <T>(val: any): val is Promise<T> => {
  // 繼承了 Promise
  // 擁有 then 和 catch 函式,對應手寫的 Promise
  return val instanceof Promise || (
    isObject(val) &&
    typeof val.then === "function" &&
    typeof val.catch === "function"
  );
};

const tryRun = async <T>(
  // 函式或者 promise
  promiseOrFun: Promise<T> | Function,
  // 配置專案
  options?: TryRunOptions,
): Promise<TryRunResultRecord<T>> => {
  // 當前引數是否為 Promise
  const runParamIsPromise = isPromise(promiseOrFun);
  const runParamIsFun = typeof promiseOrFun === "function";

  // 既不是函式也不是 Promise 直接返回錯誤
  if (!runParamIsFun && !runParamIsPromise) {
    const paramsError = new Error("first params must is a function or promise");
    return { error: paramsError } as TryRunResultRecord<T>;
  }

  if (runParamIsPromise) {
    // 直接使用 await-to-js 程式碼
    return runPromise(promiseOrFun as Promise<T>);
  }
};

執行錯誤重試

接下來我們開始利用 try catch 捕獲函式的錯誤並且重試。

// 預設 timeout
const DEFAULT_TIMEOUT: number = 333

// 非同步等待
const sleep = (timeOut: number) => {
  return new Promise<void>(resolve => {
    setTimeout(() => {
      resolve()
    }, timeOut)
  })
}

const tryRun = async <T>(
  promiseOrFun: Promise<T> | Function,
  options?: TryRunOptions,
): Promise<TryRunResultRecord<T>> => {
  const { retryTime = 0, timeout = DEFAULT_TIMEOUT } = {
    ...DEFAULT_OPTIONS,
    ...options,
  };

  // 當前第幾次重試
  let currentTime: number = 0;
  // 是否成功
  let isSuccess: boolean = false;

  let result;
  let error: Error;

  while (currentTime <= retryTime && !isSuccess) {
    try {
      result = await promiseOrFun();
      // 執行完並獲取結果後認為當前是成功的
      isSuccess = true;
    } catch (err) {
      error = err as Error;
      // 嘗試次數加一
      currentTime++;

      // 注意這裡,筆者在這裡犯了一些錯誤
      // 如果沒有處理好就會執行不需要處理的 await
      // 1.如果當前不需要重新請求(重試次數為 0),直接跳過
      // 2.最後一次也失敗了(重試完了)也是要跳過的
      if (retryTime > 0 && currentTime <= retryTime) {
        // 獲取時間
        let finalTimeout: number | Promise<any> = typeof timeout === "number"
          ? timeout
          : DEFAULT_TIMEOUT;
        
        // 如果是函式執行函式
        if (typeof timeout === "function") {
          finalTimeout = timeout(currentTime);
        }

        // 當前返回 Promise 直接等待
        if (isPromise(finalTimeout)) {
          await finalTimeout;
        } else {
          // 如果最終結果不是 number,改為預設資料
          if (typeof finalTimeout !== "number") {
            finalTimeout = DEFAULT_TIMEOUT;
          }
          // 這裡我嘗試使用了 NaN、 -Infinity、Infinity 
          // 發現 setTimeout 都進行了處理,下面是瀏覽器的處理方式
          // If timeout is an Infinity value, a Not-a-Number (NaN) value, or negative, let timeout be zero.
          // 負數,無窮大以及 NaN 都會變成 0
          await sleep(finalTimeout);
        }
      }
    }
  }

  // 成功或者失敗的返回
  if (isSuccess) {
    return { result, error: null };
  }

  return { error: error!, result: undefined };
};

這樣,我們基本完成了 try-run-js.

新增 tryRunForTuple 函式

這個就很簡單了,直接直接 tryRun 並改造其結果:

const tryRunForTuple = <T>(
  promiseOrFun: Promise<T> | Function,
  options?: TryRunOptions): Promise<TryRunResultTuple<T>> => {
  return tryRun<T>(promiseOrFun, options).then(res => {
    const { result, error } = res
    if (error) {
      return [error, undefined] as [any, undefined]
    }
    return [null, result] as [null, T]
  })
}

程式碼都在 try-run-js 中,大家還會在什麼情況下使用 try-run-js 呢?同時也歡迎各位提交 issue 以及 pr。

鼓勵一下

如果你覺得這篇文章不錯,希望可以給與我一些鼓勵,在我的 github 部落格下幫忙 star 一下。

部落格地址

參考資料

await-to-js

try-run-js

相關文章