[譯] 避免那些可惡的 "cannot read property of undefined" 錯誤

LeviDing發表於2019-03-07

Uncaught TypeError: Cannot read property 'foo' of undefined. 是一個我們在 JavaScript 開發中都遇到過的可怕錯誤。或許是某個 API 返回了意料外的空值,又或許是其它什麼原因,這個錯誤是如此的普遍而廣泛以至於我們無法判斷。

我最近遇到了一個問題,某一環境變數出於某種原因沒有被載入,導致各種各樣的報錯夾雜著這個錯誤擺在我面前。不論什麼原因,放著這個錯誤不處理都會是災難性的。所以我們該怎麼從源頭阻止這個問題發生呢?

讓我們一起來找出解決方案。

工具庫

如果你已經在專案裡用到一些工具庫,很有可能庫裡已經有了預防這個問題發生的函式。lodash 裡的 _.get文件) 或者 Ramda 裡的 R.path(文件)都能確保你安全使用物件。

如果你已經使用了工具庫,那麼這看起來已經是最簡單的方法了。如果你沒有使用工具庫,繼續讀下去吧!

使用 && 短路

JavaScript 裡有一個關於邏輯運算子的有趣事實就是它不總是返回布林值。根據說明,『&& 或者 || 運算子的返回值並不一定是布林值。而是兩個操作表示式的其中之一。』

舉個 && 運算子的例子,如果第一個表示式的布林值是 false,那麼該值就會被返回。否則,第二個表示式的值就會被使用。這說明表示式 0 && 1 會返回 0(一個 false 值),而表示式 2 && 3 會返回 3。如果多個 && 表示式連在一起,它們將會返回第一個 false 植或最後一個值。舉個例子,1 && 2 && 3 && null && 4 會返回 null,而 1 && 2 && 3 會返回 3

那麼如何安全的獲取巢狀物件內的屬性呢?JavaScript 裡的邏輯運算子會『短路』。在這個 && 的例子中,這表示表示式會在到達第一個假值時停下來。

const foo = false && destroyAllHumans();
console.log(foo); // false,人類安全了
複製程式碼

在這個例子中,destroyAllHumans 不會被呼叫,因為 && 停止了所有在 false 之後的運算

這可以被用於安全地獲取巢狀物件的屬性。

const meals = {
  breakfast: null, // 我跳過了一天中最重要的一餐! :(
  lunch: {
    protein: 'Chicken',
    greens: 'Spinach',
  },
  dinner: {
    protein: 'Soy',
    greens: 'Kale',
  },
};

const breakfastProtein = meals.breakfast && meals.breakfast.protein; // null
const lunchProtein = meals.lunch && meals.lunch.protein; // 'Chicken'
複製程式碼

除了簡單,這個方法的一個主要優勢就是在處理較少巢狀時十分簡潔。然而,當訪問深層的物件時,它會變得十分冗長。

const favorites = {
  video: {
    movies: ['Casablanca', 'Citizen Kane', 'Gone With The Wind'],
    shows: ['The Simpsons', 'Arrested Development'],
    vlogs: null,
  },
  audio: {
    podcasts: ['Shop Talk Show', 'CodePen Radio'],
    audiobooks: null,
  },
  reading: null, // 開玩笑的 — 我熱愛閱讀
};

const favoriteMovie = favorites.video && favorites.video.movies && favorites.video.movies[0];
// Casablanca
const favoriteVlog = favorites.video && favorites.video.vlogs && favorites.video.vlogs[0];
// null
複製程式碼

物件巢狀的越深,它就變得越笨重。

『或單元』

Oliver Steele 提出這個方法並且在他釋出的部落格裡探究了更多的細節,『單元第一章:或單元』我會試著在這裡給出一個簡要的解釋。

const favoriteBook = ((favorites.reading||{}).books||[])[0]; // undefined
const favoriteAudiobook = ((favorites.audio||{}).audiobooks||[])[0]; // undefined
const favoritePodcast = ((favorites.audio||{}).podcasts||[])[0]; // 'Shop Talk Show'
複製程式碼

與上面的短路例子類似,這個方法通過檢查值是否為假來生效。如果值為假,它會嘗試取得空物件的屬性。在上面的例子中,favorites.reading 的值是 null,所以從一個空物件上獲得books屬性。這會返回一個 undefined 結果,所以0會被用於獲取空陣列中的成員。

這個方法相較於 && 方法的優勢是它避免了屬性名的重複。在深層巢狀的物件中,這會成為顯著的優勢。而主要的缺點在於可讀性 — 這不是一個普通的模式,所以這或許需要閱讀者花一點時間理解它是怎麼運作的。

try/catch

JavaScript 裡的 try...catch 是另一個安全獲取屬性的方法。

try {
  console.log(favorites.reading.magazines[0]);
} catch (error) {
  console.log("No magazines have been favorited.");
}
複製程式碼

不幸的是,在 JavaScript 裡,try...catch 宣告不是表示式,它們不會像某些語言裡那樣計算值。這導致不能用一個簡潔的 try 宣告來作為設定變數的方法。

有一種選擇就是在 try...catch 前定義一個 let 變數。

let favoriteMagazine;
try { 
  favoriteMagazine = favorites.reading.magazines[0]; 
} catch (error) { 
  favoriteMagazine = null; /* 任意預設值都可以被使用 */
};
複製程式碼

雖然這很冗長,但這對設定單一變數起作用(就是說,如果變數還沒有嚇跑你的話)然而,把它們寫在一塊就會出問題。

let favoriteMagazine, favoriteMovie, favoriteShow;
try {
  favoriteMovie = favorites.video.movies[0];
  favoriteShow = favorites.video.shows[0];
  favoriteMagazine = favorites.reading.magazines[0];
} catch (error) {
  favoriteMagazine = null;
  favoriteMovie = null;
  favoriteShow = null;
};

console.log(favoriteMovie); // null
console.log(favoriteShow); // null
console.log(favoriteMagazine); // null
複製程式碼

如果任意一個獲取屬性的嘗試失敗了,這會導致它們全部返回預設值。

一個可選的方法是用一個可複用的工具函式封裝 try...catch

const tryFn = (fn, fallback = null) => {
  try {
    return fn();
  } catch (error) {
    return fallback;
  }
} 

const favoriteBook = tryFn(() => favorites.reading.book[0]); // null
const favoriteMovie = tryFn(() => favorites.video.movies[0]); // "Casablanca"
複製程式碼

通過一個函式包裹獲取物件屬性的行為,你可以延後『不安全』的程式碼,並且把它傳入 try...catch

這個方法的主要優勢在於它十分自然地獲取了屬性。只要屬性被封裝在一個函式中,屬性就可以被安全訪問,同時可以為不存在的路徑返回指定的預設值。

與預設物件合併

通過將物件與相近結構的『預設』物件合併,我們能確保獲取屬性的路徑是安全的。

const defaults = {
  position: "static",
  background: "transparent",
  border: "none",
};

const settings = {
  border: "1px solid blue",
};

const merged = { ...defaults, ...settings };

console.log(merged); 
/*
  {
    position: "static",
    background: "transparent",
    border: "1px solid blue"
  }
*/
複製程式碼

然而,需要注意並非單個屬性,而是整個巢狀物件都會被覆寫。

const defaults = {
  font: {
    family: "Helvetica",
    size: "12px",
    style: "normal",
  },        
  color: "black",
};

const settings = {
  font: {
    size: "16px",
  }
};

const merged = { 
  ...defaults, 
  ...settings,
};

console.log(merged.font.size); // "16px"
console.log(merged.font.style); // undefined
複製程式碼

不!為了解決這點,我們需要類似地複製每一個巢狀物件。

const merged = { 
  ...defaults, 
  ...settings,
  font: {
    ...defaults.font,
    ...settings.font,
  },
};

console.log(merged.font.size); // "16px"
console.log(merged.font.style); // "normal"
複製程式碼

好多了!

這種模式在這類外掛或元件中很常見,它們接受一個包含預設值得大型可配置物件。

這種方式的一個額外好處就是通過編寫一個預設物件,我們引入了文件來介紹這個物件。不幸的是,按照資料的大小和結構,複製每一個巢狀物件進行合併有可能造成汙染。

未來:可選鏈式呼叫

目前 TC39 提案中有一個功能叫『可選鏈式呼叫』。這個新的運算子看起來像這樣:

console.log(favorites?.video?.shows[0]); // 'The Simpsons'
console.log(favorites?.audio?.audiobooks[0]); // undefined
複製程式碼

?. 運算子通過短路方式運作:如果 ?. 運算子的左側計算值為 null 或者 undefined,則整個表示式會返回 undefined 並且右側不會被計算。

為了有一個自定義的預設值,我們可以使用 || 運算子以應對未定義的情況。

console.log(favorites?.audio?.audiobooks[0] || "The Hobbit");
複製程式碼

我們該使用哪一種方法?

答案或許你已經猜到了,正是那句老話『看情況而定』。如果可選鏈式呼叫已經被加到語言中並且獲得了必要的瀏覽器支援,這或許是最好的選擇。然而,如果你不來自未來,那麼你有更多需要考慮的。你在使用工具庫嗎?你的物件巢狀有多深?你是否需要指定預設值?我們需要根據不同的場景採用不同的方法。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章