JavaScript 中的隐秘问题:那些容易踩的坑

这一篇收集 JavaScript 中那些容易让人困惑、出错的地方:
从类型转换的陷阱,到作用域和闭包的坑,再到原型链、this 绑定等让人头疼的问题。

== 会进行类型转换,=== 不会。但 == 的转换规则有时候很反直觉:

 1// 这些都为 true
 20 == false;        // true
 3'' == false;       // true
 4[] == false;       // true
 5[0] == false;      // true
 6null == undefined; // true
 7
 8// 但这些都是 false
 90 === false;       // false
10'' === false;      // false
11[] === false;      // false
12[0] === false;     // false
13null === undefined; // false
14
15// 更奇怪的
16[] == ![];        // true
17[] == [];         // false
18{} == {};         // false

建议:总是使用 ===!==,除非你真的需要类型转换。

 1[] + [];           // ""
 2[] + {};           // "[object Object]"
 3{} + [];           // 0 (在浏览器控制台)
 4{} + {};           // "[object Object][object Object]"
 5
 6// 数组转数字
 7Number([]);        // 0
 8Number([1]);       // 1
 9Number([1, 2]);    // NaN
10
11// 数组转字符串
12String([]);        // ""
13String([1]);       // "1"
14String([1, 2]);    // "1,2"
 1// 这些都为 false(falsy 值)
 2Boolean(0);
 3Boolean('');
 4Boolean(null);
 5Boolean(undefined);
 6Boolean(NaN);
 7Boolean(false);
 8
 9// 其他所有值都是 true
10Boolean('0');      // true
11Boolean('false');  // true
12Boolean([]);       // true
13Boolean({});       // true
 1parseInt('08');    // 8 (ES5+)
 2parseInt('08', 10); // 8 (明确指定基数)
 3
 4// 但要注意
 5parseInt('0x10');  // 16 (十六进制)
 6parseInt('010');    // 10 (ES5+),但在旧浏览器可能是 8
 7
 8// 更奇怪的
 9parseInt('123abc'); // 123 (会忽略非数字字符)
10parseInt('abc123'); // NaN
 1for (var i = 0; i < 3; i++) {
 2  setTimeout(() => {
 3    console.log(i); // 输出 3, 3, 3
 4  }, 100);
 5}
 6
 7// 因为 var 是函数作用域,不是块作用域
 8// 等价于
 9var i;
10for (i = 0; i < 3; i++) {
11  setTimeout(() => {
12    console.log(i); // 所有闭包共享同一个 i
13  }, 100);
14}
15
16// 解决方案:使用 let
17for (let i = 0; i < 3; i++) {
18  setTimeout(() => {
19    console.log(i); // 输出 0, 1, 2
20  }, 100);
21}
 1console.log(x);     // undefined,不是 ReferenceError
 2var x = 5;
 3
 4// 等价于
 5var x;
 6console.log(x);
 7x = 5;
 8
 9// 但 let 和 const 有暂时性死区(TDZ)
10console.log(y);     // ReferenceError: Cannot access 'y' before initialization
11let y = 5;
 1foo(); // "Hello"
 2
 3function foo() {
 4  console.log('Hello');
 5}
 6
 7// 但函数表达式不会提升
 8bar(); // TypeError: bar is not a function
 9var bar = function() {
10  console.log('World');
11};
 1const obj = {
 2  name: 'John',
 3  greet: function() {
 4    console.log(this.name);
 5  }
 6};
 7
 8obj.greet(); // "John"
 9
10const greet = obj.greet;
11greet();     // undefined (在严格模式下) 或 window.name (非严格模式)
12
13// 解决方案:使用箭头函数或 bind
14const obj2 = {
15  name: 'John',
16  greet: () => {
17    console.log(this.name); // this 指向外层作用域
18  }
19};
20
21const obj3 = {
22  name: 'John',
23  greet: function() {
24    console.log(this.name);
25  }.bind(this)
26};
 1class Button {
 2  constructor() {
 3    this.text = 'Click me';
 4  }
 5  
 6  click() {
 7    console.log(this.text);
 8  }
 9  
10  setup() {
11    // 错误:this 丢失
12    document.addEventListener('click', this.click);
13    
14    // 解决方案1:使用箭头函数
15    document.addEventListener('click', () => this.click());
16    
17    // 解决方案2:使用 bind
18    document.addEventListener('click', this.click.bind(this));
19    
20    // 解决方案3:在类中定义箭头函数
21    // click = () => { console.log(this.text); }
22  }
23}
 1const obj = {
 2  name: 'John',
 3  regular: function() {
 4    console.log(this.name); // "John"
 5    
 6    const inner = function() {
 7      console.log(this.name); // undefined
 8    };
 9    inner();
10    
11    const arrow = () => {
12      console.log(this.name); // "John" (继承外层 this)
13    };
14    arrow();
15  }
16};
 1const arr = [];
 2arr[5] = 'five';
 3console.log(arr.length);    // 6
 4console.log(arr);           // [empty × 5, "five"]
 5console.log(arr[0]);       // undefined
 6
 7// 但这样遍历会有问题
 8arr.forEach(item => {
 9  console.log(item);       // 只输出 "five"
10});
11
12// 使用 for...of 也会跳过空位
13for (const item of arr) {
14  console.log(item);       // 只输出 "five"
15}
16
17// 使用传统 for 循环会输出 undefined
18for (let i = 0; i < arr.length; i++) {
19  console.log(arr[i]);     // undefined, undefined, ..., "five"
20}
1const arr = [1, 2, 3];
2arr.length = 5;
3console.log(arr);          // [1, 2, 3, empty × 2]
4
5arr.length = 2;
6console.log(arr);          // [1, 2] (元素被删除)
7
8arr.length = 0;
9console.log(arr);          // [] (清空数组)
 1const arr = [1, 2, 3];
 2
 3// 返回新数组(不修改原数组)
 4arr.map(x => x * 2);       // [2, 4, 6]
 5arr.filter(x => x > 1);    // [2, 3]
 6arr.slice(1);              // [2, 3]
 7arr.concat([4, 5]);        // [1, 2, 3, 4, 5]
 8
 9// 修改原数组(返回修改后的数组)
10arr.push(4);               // 返回 4 (新长度)
11arr.pop();                  // 返回 4 (被删除的元素)
12arr.reverse();              // [3, 2, 1] (原数组被修改)
13arr.sort();                 // [1, 2, 3] (原数组被修改)
1// sort 默认按字符串排序
2[10, 2, 1].sort();         // [1, 10, 2] (不是 [1, 2, 10]!)
3
4// 需要提供比较函数
5[10, 2, 1].sort((a, b) => a - b); // [1, 2, 10]
 1const obj = {
 2  '123': 'number key',
 3  'true': 'boolean key',
 4  null: 'null key',
 5  undefined: 'undefined key'
 6};
 7
 8// 这些键都会被转换为字符串
 9console.log(obj[123]);        // "number key"
10console.log(obj[true]);       // "boolean key"
11console.log(obj[null]);       // "null key"
12console.log(obj[undefined]);  // "undefined key"
 1const obj = {
 2  a: 1,
 3  b: 2
 4};
 5
 6Object.defineProperty(obj, 'c', {
 7  value: 3,
 8  enumerable: false
 9});
10
11console.log(obj.c);           // 3
12console.log(Object.keys(obj)); // ['a', 'b'] (不包含 c)
13
14// for...in 也会跳过不可枚举属性
15for (const key in obj) {
16  console.log(key);           // 'a', 'b'
17}
 1{} == {};           // false
 2{} === {};          // false
 3
 4// 对象比较的是引用,不是值
 5const obj1 = { a: 1 };
 6const obj2 = { a: 1 };
 7const obj3 = obj1;
 8
 9console.log(obj1 === obj2);   // false
10console.log(obj1 === obj3);   // true
 10.1 + 0.2;          // 0.30000000000000004
 20.1 + 0.2 === 0.3;  // false
 3
 4// 解决方案:使用小数位数比较
 5function isEqual(a, b, epsilon = 0.0001) {
 6  return Math.abs(a - b) < epsilon;
 7}
 8
 9isEqual(0.1 + 0.2, 0.3); // true
10
11// 或者使用整数运算
12(0.1 * 10 + 0.2 * 10) / 10 === 0.3; // true
 1const obj = {};
 2obj.__proto__.polluted = 'yes';
 3
 4const obj2 = {};
 5console.log(obj2.polluted);  // "yes" (所有对象都被污染了)
 6
 7// 更安全的做法
 8Object.setPrototypeOf(obj, { polluted: 'yes' });
 9// 或者
10Object.create({ polluted: 'yes' });
 1// 在原型链上查找属性比直接属性慢
 2function createObject() {
 3  return { a: 1, b: 2, c: 3 };
 4}
 5
 6function createObjectWithProto() {
 7  const proto = { a: 1, b: 2, c: 3 };
 8  return Object.create(proto);
 9}
10
11// createObject 的性能通常更好
 1const obj = Object.create(null);
 2obj.a = 1;
 3
 4// 错误:obj 没有 hasOwnProperty 方法
 5obj.hasOwnProperty('a'); // TypeError
 6
 7// 解决方案
 8Object.prototype.hasOwnProperty.call(obj, 'a'); // true
 9// 或者
10Object.hasOwn(obj, 'a'); // true (ES2022)
 1// 错误:所有请求几乎同时完成,i 已经是 3
 2for (var i = 0; i < 3; i++) {
 3  setTimeout(() => {
 4    console.log(i); // 输出 3, 3, 3
 5  }, 100);
 6}
 7
 8// 解决方案1:使用 let
 9for (let i = 0; i < 3; i++) {
10  setTimeout(() => {
11    console.log(i); // 输出 0, 1, 2
12  }, 100);
13}
14
15// 解决方案2:使用闭包
16for (var i = 0; i < 3; i++) {
17  (function(j) {
18    setTimeout(() => {
19      console.log(j); // 输出 0, 1, 2
20    }, 100);
21  })(i);
22}
 1// 错误:未捕获的错误
 2Promise.resolve()
 3  .then(() => {
 4    throw new Error('Oops');
 5  })
 6  .then(() => {
 7    console.log('不会执行');
 8  });
 9// UnhandledPromiseRejectionWarning
10
11// 正确:总是处理错误
12Promise.resolve()
13  .then(() => {
14    throw new Error('Oops');
15  })
16  .catch(error => {
17    console.error(error);
18  });
 1// 错误:未捕获的错误
 2async function fetchData() {
 3  const response = await fetch('/api/data');
 4  return response.json();
 5}
 6
 7fetchData(); // 如果出错,会变成未处理的 Promise rejection
 8
 9// 正确:使用 try/catch 或 .catch()
10async function fetchData() {
11  try {
12    const response = await fetch('/api/data');
13    return response.json();
14  } catch (error) {
15    console.error(error);
16    throw error;
17  }
18}
19
20// 或者
21fetchData().catch(error => {
22  console.error(error);
23});
1typeof null;        // "object" (历史遗留问题)
2typeof [];          // "object"
3typeof new Date();  // "object"
4typeof /regex/;     // "object"
5
6// 更准确的类型检查
7Object.prototype.toString.call(null);      // "[object Null]"
8Object.prototype.toString.call([]);        // "[object Array]"
9Object.prototype.toString.call(new Date()); // "[object Date]"
 1NaN === NaN;        // false
 2NaN == NaN;         // false
 3
 4// 检查 NaN
 5Number.isNaN(NaN);  // true
 6Number.isNaN('abc'); // false
 7
 8// 注意 isNaN 和 Number.isNaN 的区别
 9isNaN('abc');       // true (会先转换类型)
10Number.isNaN('abc'); // false (不会转换类型)
1'5' + 3;            // "53" (字符串拼接)
2'5' - 3;            // 2 (数字运算)
3'5' * 3;            // 15 (数字运算)
4'5' / 3;            // 1.666... (数字运算)
5
6// 但要注意
7+'5' + 3;           // 8 (一元 + 转换为数字)
 1function foo(a = 1, b = a) {
 2  return [a, b];
 3}
 4
 5foo();              // [1, 1]
 6foo(2);             // [2, 2]
 7foo(2, 3);          // [2, 3]
 8
 9// 但要注意作用域
10function bar(a = b, b = 2) {
11  return [a, b];
12}
13
14bar();              // ReferenceError: Cannot access 'b' before initialization
1// 解构的默认值只在 undefined 时生效
2function foo({ a = 1, b = 2 } = {}) {
3  return [a, b];
4}
5
6foo();              // [1, 2]
7foo({});            // [1, 2]
8foo({ a: 3 });      // [3, 2]
9foo({ a: 3, b: null }); // [3, null] (null 不是 undefined)
 1JSON.stringify({ a: undefined, b: function() {}, c: Symbol('foo') });
 2// "{}" (undefined、函数、Symbol 会被忽略)
 3
 4JSON.stringify({ a: NaN, b: Infinity, c: -Infinity });
 5// "{"a":null,"b":null,"c":null}" (NaN 和 Infinity 会变成 null)
 6
 7// 自定义序列化
 8JSON.stringify({ a: undefined, b: function() {} }, (key, value) => {
 9  if (typeof value === 'function') {
10    return value.toString();
11  }
12  return value;
13});
 1const regex = /test/g;
 2regex.test('test'); // true
 3regex.test('test'); // false (lastIndex 已经移动)
 4
 5// 解决方案:每次创建新正则或重置 lastIndex
 6/test/g.test('test'); // true
 7/test/g.test('test'); // true
 8
 9// 或者
10regex.lastIndex = 0;
11regex.test('test'); // true
1'use strict';
2
3// 严格模式下的一些行为更安全
4function foo() {
5  this.a = 1; // 在严格模式下,this 是 undefined,不会意外创建全局变量
6}
  • 使用 letconst 代替 var
  • 使用箭头函数处理 this 绑定
  • 使用 ===!== 代替 ==!=
  • 使用模板字符串代替字符串拼接
  • ESLint:检测潜在问题
  • TypeScript:提供类型检查
  • Prettier:统一代码格式
  • 理解原型链、闭包、作用域
  • 理解异步编程模型
  • 理解类型转换规则

JavaScript 中有很多容易踩的坑:

  • 类型转换== 的隐式转换、数组和对象的转换规则
  • 作用域var 的函数作用域、变量提升、暂时性死区
  • this 绑定:普通函数和箭头函数的区别、回调中的 this 丢失
  • 数组和对象:稀疏数组、length 属性、原型链
  • 浮点数精度:0.1 + 0.2 不等于 0.3
  • 异步编程:循环中的异步、错误处理
  • 其他陷阱typeof nullNaN 比较、JSON 序列化等

理解这些问题,可以帮助你写出更健壮的代码,避免在生产环境中遇到这些隐秘的 bug。