处理 undefined 值的7个建议

2,182 阅读12分钟

阅读 7 tips to handle undefined in JavaScript
Dmitri Pavlutin | 15 Apr 2017
一文之后的小结,有删减。

引言

undefined 是 JavaScript 中的一个特殊值,另一个特殊值则是 null,这两个特殊值还分别对应了一种数据类型:Undefined 和 Null。这两个值都定义了“空值”,但两个又不是完全相同。

对 JavaScript 而言,当访问一个尚未初始化的变量或不存在的对象属性时,会返回 undefined。例如:

let company;  
company;    // => undefined  
let person = { name: 'John Smith' };  
person.age; // => undefined  

null 则代表一个缺失的对象引用。JavaScript 本身不将变量或对象属性设置为null。例如:

let array = null;  
array;                // => null  
let movie = { name: 'Starship Troopers',  musicBy: null };  
movie.musicBy;        // => null  
'abc'.match(/[0-9]/); // => null  

undefinednull 作比较时,也会返回不同的结果:

undefined == null // => true
undefined === null // => false

为什么会出现这种结果呢?简单查阅下 Abstract Equality ComparisonStrict Equality Comparison 在规范中的定义便可。

由于 JavaScript 的语法比较宽松,开发人员是可以访问未初始化的值,我也犯过这样的错误。通常这样的危险行为会产生 undefined 的相关错误,终止脚本的执行。常见的相关错误消息是:

  • TypeError: 'undefined' is not a function
  • TypeError: Cannot read property '' of undefined

JavaScript 开发者可以理解这个笑话的讽刺之处:

function undefined() {  
  // problem solved
}

为了减少此类错误的风险,必须先了解产生 undefined 的情况,然后在应用程序中阻止这种情况的出现,提高代码的稳定性。

1.什么是 undefined

JavaScript 的数据类型可以简单分为两类:简单数据类型和复杂数据类型。简单数据类型包含6种基本值类型:

  • Boolean: true or false
  • Number: 1, 6.7, 0xFF
  • String: "Gorilla and banana"
  • Symbol: Symbol("name") (ES2015新增)
  • Null: null
  • Undefined: undefined.

复杂数据类型即为 JavaScript 中的对象类型:{name: "Dmitri"}, ["apple", "orange"]等。

根据 ECMAScript规范undefined 是类型 Undefined 的唯一值:

The Undefined type has exactly one value, called undefined. Any variable that has not been assigned a value has the value undefined.

根据标准定义,当访问任何未被赋值的变量时均将返回 undefined 值。例如访问未初始化的变量、对象未定义的属性或者不存在的数组元素等:

let number;  
number;     // => undefined

let movie = { name: 'Interstellar' };  
movie.year; // => undefined

let movies = ['Interstellar', 'Alexander'];  
movies[3];  // => undefined  

上面的例子演示了访问:

  • 未初始化的变量 number
  • 对象未定义的属性 movie.year
  • 不存在的数组元素 movies[3]

并且都返回了 undefined。在这种情况下,对于 undefined 值,typeof 操作符会返回字符串 'undefined':

typeof undefined === 'undefined'; // => true  

因为可以使用 typeof 来验证一个变量值是否是 undefined

let nothing;  
typeof nothing === 'undefined';   // => true

2.产生 undefined 值的常见场景

2.1 未初始化的变量

声明的变量没有赋值,则其默认值是 undefined

一个简单的示例:

let myVariable;  
myVariable; // => undefined 

声明的变量 myVariable 没有赋值,访问 myVariable 时则返回默认的值 undefined。避免这种情况的有效方式则是在声明变量赋予一个默认值。

Tip 1: 优先使用 const,其次是 let,对 var 说再见

在我看来,ECMAScript 2015的一个最好的特性是使用 constlet 来声明变量。这是向前迈出的一大步,这些声明是块范围的(var 的声明是函数作用域的),并且存在于一个暂时性死区

当变量仅接收一次赋值时,我建议使用 const 声明,它创建了一个不可变的绑定。另外,使用 const 声明变量时,你必须指定一个初始值。

看一个验证一个单词是否为回文的函数:

function isPalindrome(word) {  
  const length = word.length;
  const half = Math.floor(length / 2);
  for (let index = 0; index < half; index++) {
    if (word[index] !== word[length - index - 1]) {
      return false;
    }
  }
  return true;
}
isPalindrome('madam'); // => true  
isPalindrome('hello'); // => false  

在上述代码中,变量 lengthhalf 仅在声明的时候赋值了一次,因而使用 const 进行声明,因为之后没有再次进行赋值。

如果你需要对变量进行多次赋值,可以使用 let 进行声明。但无论何时都给变量定义一个初始值,如 let index = 0

那对于 var 声明呢?如果你是使用 ES2015 进行开发,我的建议是完全停止使用它

Do-not-write-var

var 声明的变量是基于函数作用域的,并存在变量(作用域)提升。你可以在函数范围的末尾处声明一个 var 变量,但是它仍然可以在声明之前访问:你将得到一个 undefined 值。

function bigFunction() {  
  // code...
  myVariable; // => undefined
  // code...
  var myVariable = 'Initial value';
  // code...
  myVariable; // => 'Initial value'
}
bigFunction();  

而使用 let 或者 const 进行变量声明则不会存在作用域提升的问题,因为变量在声明之前处于一个暂时性死区,在声明之前访问变量会抛出一个 ReferenceError。

Tip 2: 增强(代码的)内聚性

内聚性描述了模块的元素(名称空间、类、方法、代码块)属于同一类的程度。内聚性的测量通常被描述为高内聚或低内聚。

高内聚是更好的选择,因为它建议设计模块的元素,只关注单个任务。它使得模块:

  • 集中和可理解:更容易理解模块的功能
  • 可维护和更易于重构:模块中的更改会影响更少的模块
  • 可重用:专注于单个任务,使模块更易于重用。
  • 可测试的:可以更轻松地测试一个专注于单个任务的模块

CouplingVsCohesion

高内聚和低耦合是设计良好的系统的特点。

代码块本身可以被认为是一个小模块。为了从高内聚的好处中获益,你需要尽可能地将变量保持在使用它们的代码块中。

例如我们要遍历一个数组,代码可能如下:

function someFunc(array) {  
  var index, item, length = array.length;
  // some code...
  // some code...
  for (index = 0; index < length; index++) {
    item = array[index];
    // some code...
  }
  return 'some result';
}

在函数的顶部声明了三个:indexitemlength,前两个变量未初始化,则默认值是 undefined,第三个变量的初始值是数组长度。然而,这三个变量只在函数尾才进行了调用。那么这种方法的问题是什么呢?

如果在 varfor 语句之间还有很多代码,会有一些潜在的问题:

  • 增加直接访问 undefined 值的风险
  • 变量的定义和调用没有集中,降低了代码的可读性和可维护性
  • 增加了变量被重复定义的风险

一种更好的方法是将这些变量尽可能地移到它们的使用位置:

function someFunc(array) {  
  // some code...
  // some code...
  const length = array.length;
  for (let index = 0; index < length; index++) {
    const item = array[index];
    // some 
  }
  return 'some result';
}

修改后的代码则能有效避免上述潜在的问题,并增强了代码的内聚性,使得代码块更容易重构和提取到分离的函数中。

2.2 访问对象不存在的属性

当访问一个不存在的对象属性时,JavaScript 会返回 undefined

让我们在一个例子中演示一下:

let favoriteMovie = {  
  title: 'Blade Runner'
};
favoriteMovie.actors; // => undefined  

favoriteMovie 是一个只有 title 属性的对象,当访问一个不存在的属性 actors 时,结果返回了 undefined

访问一个自身不存在的属性,它不会抛出一个错误;但试图从一个不存在的属性值获取数据时,就会抛出 TypeError,得到的常见错误类型:

TypeError: Cannot read property of undefined.

让我们稍微修改一下前面的代码片段,以说明一个类型错误抛出:

let favoriteMovie = {  
  title: 'Blade Runner'
};
favoriteMovie.actors[0];  
// TypeError: Cannot read property '0' of undefined

favoriteMovie 没有 actors 属性,所以 favoriteMovie.actors 返回 undefined,因此,通过 favoriteMovie.actors[0] 表达式访问 undefined 的第一项就会抛出 TypeError。

Tip 3: 检查属性的存在

幸运的是,JavaScript提供了一组方法来确定对象是否具有特定的属性:

  • obj.prop !== undefined: 和 undefined 直接比较
  • typeof obj.prop !== 'undefined': 验证属性值类型
  • obj.hasOwnProperty('prop'): 验证对象是否有自己的属性
  • 'prop' in obj: 验证对象是否有自己的或继承的属性

我的推荐是使用 in 操作符,不仅语法简介,其也表明了一个明确的意图:即检查一个对象是否具有特定的属性,而不需要访问实际的属性值。

check prop

obj.hasOwnProperty('prop') 也是一个不错的解决方案,它比 in 运算符稍微长一点,但只在对象的自身属性中验证。

Tip 4: 使用解构来访问对象属性

当访问对象属性时,如果属性不存在的话,有时必须指出一个默认值。你可以使用 in 和三元运算符来完成这个任务:

const object = { };  
const prop = 'prop' in object ? object.prop : 'default';  
prop; // => 'default' 

随着对象属性数量的增加,这种方式就会变得不优雅了。因为需要有一种更优雅的方式来代替 in 和三元运算符,那就是使用 ES2015 的新特性:对象解构。

对象解构 允许将对象属性值直接提取到变量中,并在属性不存在的情况下设置默认值。这是一种非常方便的语法,避免直接处理 undefined 的问题。

现在使用对象结构来提取属性,使得代码看起来更短,也很有意义:

const object = {  };  
const { prop = 'default' } = object;  
prop; // => 'default' 

Tip 5: 使用默认属性来填充对象

使用对象解构时,需要声明一些属性变量;如果想省去变量声明,则可以使用 Object.assign(target, source1, source2, ...) 方法,该方法会将所有可枚举的属性值从一个或多个源对象复制到目标对象中,并返回目标对象。

例如,你可能需要访问一个未包含所有属性集的 unsafeOptions 对象,为避免访问 unsafeOptions 对象中不存在的属性返回的 undefined 值,可以先定义一个包含所有属性集的默认对象 defaults

const unsafeOptions = {  
  fontSize: 18
};
const defaults = {  
  fontSize: 16,
  color: 'black'
};
const options = Object.assign({}, defaults, unsafeOptions);  
options.fontSize; // => 18  
options.color;    // => 'black'  

unsafeOptions 对象仅包含 fontSize 属性,defaults 对象则定义了 fontSizecolor 属性的默认值。这样,返回的 options 对象则可以安全的访问 color 属性了。

如果不想使用 Object.assign,则可以使用更简洁的对象扩展运行符:

const unsafeOptions = {  
  fontSize: 18
};
const defaults = {  
  fontSize: 16,
  color: 'black'
};
const options = {  
  ...defaults,
  ...unsafeOptions
};
options.fontSize; // => 18  
options.color;    // => 'black'  

2.3 函数的参数

函数的参数隐式默认为 undefined

通常情况下,函数定义的参数个数和调用函数时传入的参数个数应该保持一致,以得到正确的返回结果:

function multiply(a, b) {  
  a; // => 5
  b; // => 3
  return a * b;
}
multiply(5, 3); // => 15  

但是,当调用函数省略了一些参数时,则省略的参数的默认值就是 undefined 了:

function multiply(a, b) {  
  a; // => 5
  b; // => undefined
  return a * b;
}
multiply(5); // => NaN  

函数 multiply 定义了两个参数 a 和 b,但是调用的时候只传入了一个参数(a=5),因为忽略的参数(b)的值就是 undefined

Tip 6: 使用默认参数值

有时调用函数时并不需要传递所有的参数列表,此时,给没有传参的参数设置一个默认值即可:

function multiply(a, b) {  
  if (b === undefined) {
    b = 2;
  }
  a; // => 5
  b; // => 2
  return a * b;
}
multiply(5); // => 10  

对于之前的示例简单修改了一下,如果 bundefined,则赋予默认值为 2。如果你使用 ES2015,则可进一步简化如下:

function multiply(a, b = 2) {  
  a; // => 5
  b; // => 2
  return a * b;
}
multiply(5);            // => 10  
multiply(5, undefined); // => 10  

2.4 函数的返回值

如果没有 return 语句,JavaScript 函数默认返回 undefined

在 JavaScript 中,一个没有 return 语句的函数会隐式返回 undefined:

function square(x) {  
  const res = x * x;
}
square(2); // => undefined  

square() 函数没有返回任何计算结果,当其被调用时,就默认返回了 undefined

Tip 7: 不要信任分号自动插入

在 JavaScript 中,下列语句必须以分号结尾:

  • 空语句
  • let, const, var, import, export等声明
  • 表达式语句
  • debugger 语句
  • continue 和 break 语句
  • throw 语句
  • return 语句

如果你使用了上述语句,确保在语句末尾添加分号:

function getNum() {  
  // Notice the semicolons at the end
  let num = 1; 
  return num;
}
getNum(); // => 1  

如果没有添加分号,ECMAScript 提供了 ASI 机制( Automatic Semicolon Insertion),会这这些语句后面自动插入分号。

由于 ASI,你也可以删掉上述代码中的分号:

function getNum() {  
  // Notice that semicolons are missing
  let num = 1
  return num
}
getNum() // => 1 

这看上去可以减小源码文件的大小,但同时也埋了隐患。看如下代码:

function getPrimeNumbers() {  
  return 
    [ 2, 3, 5, 7, 11, 13, 17 ]
}
getPrimeNumbers() 

return 语句和数组字面量表达式之间存在一个换行,由于 ASI 机制,JavaScript 会在 return 语句后插入一个分号,于是就等同于如下代码:

function getPrimeNumbers() {  
  return; 
  [ 2, 3, 5, 7, 11, 13, 17 ];
}
getPrimeNumbers(); // => undefined 

此时,当调用函数 getPrimeNumbers ,得到的结果是 undefined 而不是期望的数组。解决这个问题的方式也比较简单,移除换行就行:

function getPrimeNumbers() {  
  return [ 
    2, 3, 5, 7, 11, 13, 17 
  ];
}
getPrimeNumbers(); // => [2, 3, 5, 7, 11, 13, 17]  

谨记,不要在 return 语句及其之后的表达式语句之间插入换行。

2.5 void 操作符

void 表达式会对表达式进行求值,但无论求值结果是什么,它均是返回 undefined

void 1;                    // => undefined  
void (false);              // => undefined  
void {name: 'John Smith'}; // => undefined  
void Math.min(1, 3);    

3. 数组中的 undefined

进行数组访问时,如果索引越界了,返回的结果也是 undefined:

const colors = ['blue', 'white', 'red'];  
colors[5];  // => undefined  
colors[-1]; // => undefined 

数组 colors 仅三个元素,合法的索引值是 0,1,2,索引值 5 和 -1 则是越界的,得到的值会是 undefined

总结

本文讨论了会出现 undefined 值的情形,总结如下:

  • 未初始化的变量
  • 访问对象不存在的属性
  • 数组越界访问
  • 缺省的函数参数
  • 调用没有任何返回值的函数
  • void 操作符

欢迎纠正、评论和补充。