- 原文地址:Avoiding those dang cannot read property of undefined errors
- 原文作者:Adam Giese
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:Xcco
- 校對者:hanxiansen, Mirosalva
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 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。