[譯] 避免那些可惡的 "cannot read property of undefined" 錯誤
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"); 複製程式碼