避免那些恼人的“无法读取未定义属性”错误

Avatar of Adam Giese
Adam Giese

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

​​​​Uncaught TypeError: Cannot read property 'foo' of undefined.​ 我们在 JavaScript 开发中总会遇到的可怕错误。可能是 API 返回的空状态与预期不同。也可能是其他原因。我们不知道,因为错误本身过于笼统和广泛。

​​我最近遇到一个问题,某些环境变量由于某种原因没有被提取,导致各种奇怪的错误,这个错误一直困扰着我。无论是什么原因,如果不对其进行处理,它都可能成为一个灾难性的错误,那么我们如何才能在第一时间预防它呢?

​​让我们一起解决它。

​​实用程序库

​​如果您已经在项目中使用了实用程序库,那么很有可能它包含一个用于防止此错误的功能。lodash 中的 _.get​ (文档) 或 Ramda 中的 R.path (文档) 允许安全地访问对象。
​​
​​如果您已经在使用实用程序库,这可能是最简单的解决方案。如果您没有使用实用程序库,请继续阅读!

​​

使用 && 进行短路

​​​​JavaScript 中逻辑运算符的一个有趣事实是,它们并不总是返回布尔值。根据规范,“&&​||​ 运算符产生的值不一定是布尔类型。产生的值始终是两个操作数表达式之一的值。”
​​
​​​​对于 &&​ 运算符,如果第一个表达式是“假值”,则将使用第一个表达式。否则,将使用第二个表达式。这意味着表达式 0 && 1​ 将被评估为 0​(假值),而表达式 2 && 3​ 将被评估为 3​。如果多个 &&​ 表达式链接在一起,它们将评估为第一个假值或最后一个值。例如,1 && 2 && 3 && null && 4​ 将评估为 null​,而 1 && 2 && 3​ 将评估为 3​。

​​​​这对于安全地访问嵌套对象属性有什么用?JavaScript 中的逻辑运算符将“短路”。在这种情况下 &&​,这意味着表达式在到达第一个假值后将停止继续执行。

​​​​

​​const foo = false && destroyAllHumans();
​​console.log(foo); // false, and humanity is safe

​​在此示例中,由于 &&​ 操作数在 false​ 之后停止了所有计算,因此 destroyAllHumans 从未被调用。

​​这可用于安全地访问嵌套属性。

​​

​​const meals = {
​​  breakfast: null, // I skipped the most important meal of the day! :(
​​  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, // Just kidding -- I love to read
​​};
​​
​​const favoriteMovie = favorites.video && favorites.video.movies && favorites.video.movies[0];
​​// Casablanca
​​const favoriteVlog = favorites.video && favorites.video.vlogs && favorites.video.vlogs[0];
​​// null

​​对象嵌套得越深,它就越难处理。

​​
​​

“Maybe Monad”

​​Oliver Steele 提出了一种方法,并在他的博文中更详细地介绍了它,“廉价的单子 I:Maybe Monad。” 我将在这里尝试简要说明。

​​

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​ 语句作为设置变量的一种方式。

​​一种选择是使用一个 let​ 变量,该变量在 try...catch​ 上方的代码块中定义。

​​

let favoriteMagazine;
​​try { 
​​  favoriteMagazine = favorites.reading.magazines[0]; 
​​} catch (error) { 
​​  favoriteMagazine = null; /* any default can be used */
​​};

​​尽管它很冗长,但这适用于设置单个变量(也就是说,如果可变变量不会吓跑你)。但是,如果批量执行,可能会出现问题。

​​

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​,并且右侧将保持未计算。

​​为了获得自定义默认值,我们可以在 undefined 的情况下使用 ||​ 运算符。

​​

console.log(favorites?.audio?.audiobooks[0] || "The Hobbit");

​​

您应该使用哪种方法?

​​答案,正如您可能猜到的那样,是那个老生常谈的答案……“视情况而定”。如果可选链运算符已添加到语言中并具有必要的浏览器支持,那么它可能是最佳选择。但是,如果您不是来自未来,则需要考虑更多因素。您是否正在使用实用程序库?您的对象嵌套了多深?您是否需要指定默认值?不同的情况可能需要不同的方法。