Categories
程式開發

如何在JavaScript中處理null和undefined?


在 JavaScript 的開發工作中,許多開發人員都頭疼的一個問題就是處理可選值。怎樣才能最大程度減少由 null、undefined,或在運行時未初始化的值所引發的錯誤,有哪些最佳策略呢?

有些語言針對這類情況有內置的解決方案。在某些靜態類型的語言中,你可以認定 null 和 undefined 是非法值,並且讓你的編程語言在編譯時拋出 TypeError。但即使在這種語言中,也不能阻止 null 輸入在運行時流入程序。

為了更好地處理這種問題,我們需要了解這些值的來源。以下是一些最常見的來源:

  • 用戶輸入;
  • 數據庫 / 網絡記錄;
  • 未初始化狀態;
  • 無法返回任何內容的函數。

用戶輸入

在處理用戶輸入時,驗證是第一道也是最好的防線。我經常依靠 schema 驗證器來完成這項工作。比如,你可以試試 react-jsonschema-form

對輸入的記錄做 hydrate 處理

我總是會把從網絡、數據庫或用戶輸入中獲得的輸入傳遞給一個 hydrating 函數。例如,我會使用可以處理 undefined 值的 redux 動作創建者來 hydrate 用戶記錄:

https://medium.com/javascript-scene/10-tips-for-better-redux-architecture-69250425af44

const setUser = ({ name = 'Anonymous', avatar = 'anon.png' } = {}) => ({
  type: setUser.type,
  payload: {
    name,
    avatar
  }
});
setUser.type = 'userReducer/setUser';

有時,你需要根據數據的當前狀態顯示不同的內容。如果頁面可以在所有數據初始化完畢之前顯示,就可能會遇到這種情況。例如,當你向用戶顯示資金餘額時,有時可能會在加載數據之前顯示余額為零。這種事情我見過很多次,這會讓用戶感到不安。你可以創建一些自定義數據類型,這些數據類型根據當前狀態生成不同的輸出:

const createBalance = ({
  // 默认状态
  state = 'uninitialized',
  value = createBalance.empty
} = {}) => createBalance.isValidState(state) && ({
  __proto__: {
    uninitialized: () => '--',
    initialized: () => value,
    format () {
      return thisthis.getState();
    },
    getState: () => state,
    set: value => {
      const test = Number(value);
      assert(!Number.isNaN(test), `setBalance Invalid value: ${ value }`);
      return createBalance({
        state: 'initialized',
        value
      });
    }
  }
});
createBalance.empty = '0';
createBalance.isValidState = state => {
  if (!('uninitialized', 'initialized').includes(state)) {
    throw new Error(`createBalance Invalid state: ${ state }`);
  }
  return true;
};
const setBalance = value => createBalance().set(value);
const emptyBalanceForDisplay = createBalance()
  .format();
console.log(emptyBalanceForDisplay); // '--'
const balanceForDisplay = setBalance('25')
  .format(balance);
console.log(balanceForDisplay); // '25'
// 取消下列调用的注释前缀就能看到错误示例:
// setBalance('foo'); // Error: setBalance Invalid value: foo
// Error: createBalance Invalid state: THIS IS NOT VALID
// createBalance({ state: 'THIS IS NOT VALID', value: '0' });

上面的代碼是一個狀態機,其設計無法顯示無效狀態。首次創建餘額數字時,它將被設置為一個 unintitialized 狀態。如果你嘗試在 uninitialized 狀態時顯示余額,則只會獲得一個佔位符值(“–”)。要調整這個設置,你必須調用.set 方法,或調用我們在 createBalance 下面定義的 setBalance 捷徑來顯式設置一個值。

該狀態本身經過封裝,以保護其免受外界干擾,這樣其他函數就無法捕獲它並將其設置為無效狀態了。

注意:想知道為什麼我們要使用字符串而不是數字嗎?那是因為我用了精度很高的大數字符串來表示貨幣類型,以避免舍入錯誤,並能準確地表示加密貨幣交易中的數值(這類交易中的數值精度要求可能會非常高) 。

如果你使用 Redux 或 Redux 架構,則可以使用 Redux-DSM 來聲明狀態機。

避免創建 null 和 undefined 值

在你自己的函數中,你可以盡量避免創建 null 或 undefined 值。我想到了很多 JavaScript 的內置方法來做到這一點,見下文。

避免 null

我從未在 JavaScript 中顯式創建 null 值,因為我覺得用兩個不同的原始值來表示”這個值不存在”,實在是沒什麼意義的事情。

自 2015 年開始 JavaScript 就支持默認值了,當你沒有為相關參數或屬性提供值時,它們就會填入默認值。這些默認值不適用於 null 值。根據我的經驗,這通常會導致一個錯誤。為了避免這種陷阱,請不要在 JavaScript 中使用 null。

如果你希望處理未初始化的值或空值這類特殊情況,狀態機是更好的選擇,如前所述。

新的 JavaScript 功能

有幾個功能可以幫助你處理 null 或 undefined 值。在撰寫本文時,下面兩個功能都是第 3 階段的提案,將來你看到本文時這兩個功能可能已經正式發布了。

目前,可選鏈(optional chaining)是第 3 階段的提案。它的工作機制是這樣的:

const foo = {};
// console.log(foo.bar.baz); // throws error
console.log(foo.bar?.baz) // undefined

(關於可選鏈的更多細節,可以參考前端之巔之前的一篇文章《了解 JavaScript 新特性:Optional Chaining》。

空位合併運算符

這也是準備添加到規範中的第 3 階段提案,“空位合併運算符(Nullish Coalescing Operator)”基本上是“回退值運算符”的一種高大上的說法。如果左側的值是 undefined 或 null,則其會等於右側的值。它的工作機制是這樣的:

let baz;
console.log(baz); // undefined
console.log(baz ?? 'default baz');
// default baz
// Combine with optional chaining:
console.log(foo.bar?.baz ?? 'default baz');
// default baz

目前提案尚未正式進入規範,所以你需要安裝 @babel/plugin-proposal-optional-chaining 和 @ babel/plugin-proposal-nullish-coalescing-operator。

使用 Promise 實現異步 Either

如果某個函數可能不返回值,則最好將其包裝在一個 Either 中。在函數式編程中,Either monad 是一種特殊的抽像數據類型,它允許你附加兩個不同的代碼路徑:成功路徑或失敗路徑。 JavaScript 具有內置的異步 Either monad-ish 數據類型,稱為 Promise。你可以用它對 undefined 值進行聲明式錯誤分支:

const exists = x => x != null;
const ifExists = value => exists(value) ?
  Promise.resolve(value) :
  Promise.reject(`Invalid value: ${ value }`);
ifExists(null).then(log).catch(log); // Invalid value: null
ifExists('hello').then(log).catch(log); // hello

你可以根據需要編寫一個同步版本,但這裡我還用不到那一步,就留給你做練習吧。如果你在 functor 和 monad 方面有良好的基礎,那麼這個過程會很容易。如果這聽起來很嚇人,那也不用擔心,只用 Promise 即可。它們是內置的,並且在大多數情況下都可以正常工作。

使用數組實現 Maybe

數組實現了一個 map 方法,這個方法會使用一個函數應用在數組的每個元素上。如果數組為空,則這個函數永遠不會被調用。換句話說,JavaScript 中的數組可以充當 Haskell 等語言中的 Maybe 角色。

Maybe 是什麼?

Maybe 是一種特殊的抽像數據類型,它封裝了一個可選值。數據類型有兩種形式:

  • Just——包含一個值的 Maybe;

  • Nothing——沒有值的 Maybe。

下面是具體的機制:

const log = x => console.log(x);
const exists = x => x != null;
const Just = value => ({
  map: f => Just(f(value)),
});
const Nothing = () => ({
  map: () => Nothing(),
});
const Maybe = value => exists(value) ?
  Just(value) :
  Nothing();
const empty = undefined;
Maybe(empty).map(log); // does not log

這只是一個示例,用來演示這個概念。你可以圍繞 maybe 建立一整套有用的函數庫,實現 flatMap 和 flat 之類的操作(例如,在編寫多個返回 Maybe 的函數時避免 Just(Just(value)) 這種情況)。但是 JavaScript 已經有一種數據類型可以直接實現這些功能,因此我通常會這樣做:使用數組。

如果你要創建一個可能會,或可能不會產生結果的函數(尤其是可能有多個結果的情況下),那麼這種情況下最好的方法可能就是返回一個數組。

const log = x => console.log(x);
const exists = x => x != null;
const arr = (1,2,3);
const find = (p, list) => (list.find(p)).filter(exists);
find(x => x > 3, arr).map(log); // does not log anything
find(x => x < 3, arr).map(log); // logs 1

我發現在空列表上不會調用map,這對避免null 和undefined 值來說非常有用,但是請記住,如果數組包含null 和undefined 值,它將使用這些值調用該函數,因此如果你在運行的函數可能會產生null 或undefined,你需要將其從返回的數組中過濾出來,如上所示。這可能會改變集合的長度。

在 Haskell 中,有一個函數 maybe(就像 map 一樣)將一個函數應用於一個值上。但是該值是可選的,並封裝在 Maybe 中。我們可以使用 JavaScript 的 Array 數據類型做基本上相同的事情:

// maybe = b => (a => b) => (a) => b
const maybe = (fallback, f = () => {}) => arr =>
  arr.map(f)(0) || fallback;
// turn a value (or null/undefined) into a maybeArray
const toMaybeArray = value => (value).filter(exists);
// maybe multiply the contents of an array by 2,
// default to 0 if the array is empty
const maybeDouble = maybe(0, x => x * 2);
const emptyArray = toMaybeArray(null);
const maybe2 = toMaybeArray(2);
// logs: "maybeDouble with fallback: 0"
console.log('maybeDouble with fallback: ', maybeDouble(emptyArray));
// logs: "maybeDouble(maybe2): 4"
console.log('maybeDouble(maybe2): ', maybeDouble(maybe2));

maybe 需要一個回退值,然後是一個映射到maybe 數組上的函數,然後是一個maybe 數組(包含一個值,或者為空的數組),最後返回將該函數應用於數組內容的結果,或者在數組為空時返回回退值。為了方便起見,我還定義了 toMaybeArray 函數,並 curry 了 maybe 函數來讓它更顯眼一些,方便展示。

如果你想在生產代碼中執行類似的操作,我已經創建了一個經過單元測試的開源庫,可以簡化你的工作。這個庫叫 Maybearray。與其他 JavaScript Maybe 庫相比,Maybearray 的優勢在於它使用原生 JavaScript 數組來表示值,因此你不必對其進行任何特殊處理,也用不著來迴轉換。當你在調試中遇到 Maybe 數組時,你不必問“這是什麼奇怪的類型?!”,它只是一個值的數組或一個空數組,你已經看過一百萬遍了。

作者介紹:

Eric Elliott 是《撰寫軟件》和《編寫 JavaScript 應用程序》這兩本書的作者。他是 EricElliottJS.com 和 DevAnywhere.io 的共同創始人,並教授開發人員基本的軟件開發技能。他創建並指導數字貨幣項目的開發團隊,並為 Adob​​e Systems、Zumba Fitness、《華爾街日報》、ESPN、BBC 和包括 Usher、Frank Ocean、Metallica 等在內的頂級唱片藝術家貢獻了自己的軟件經驗。他與漂亮的妻子一起過著隱士般的生活。

原文鏈接:
https://medium.com/javascript-scene/handling-null-and-undefined-in-javascript-1500c65d51ae