为什么你始终看不懂JavaScript?

18 阅读3分钟

看似简单的语法背后,隐藏着令人费解的行为逻辑——这是无数前端开发者共同的困惑,本文将深入探讨 JavaScript 的两面性与其“诡异行为”。

JavaScript的“两面性”陷阱

作为一名前端工程师,我经常听到有人抱怨:“JavaScript 的语法明明很简单,为什么写起来总是踩坑?”这正是 JavaScript 最迷人的地方,也是它最令人困惑的地方——简单语法与复杂行为的强烈反差。

让我们先从一个经典的“诡异”例子开始:

console.log(1 + "2");        // "12" 还是 3?
console.log(2 - "1");        // 1 还是 报错?
console.log(true + false);   // 1?还是true?
console.log([] + []);        // ""?还是[]?
console.log([] + {});        // "[object Object]"?为什么?

这些看似简单的表达式,结果却常常出人意料。接下来,我们将从JavaScript的设计哲学入手,揭示这种“双重人格”背后的秘密。

语言设计哲学:妥协与进化

诞生背景:10天创造的“应急语言”

JavaScript 诞生于1995年,网景公司为了在浏览器中添加简单的交互功能,仅用10天就设计了这门语言。这种“速成”背景决定了它的一些特性:

1. 向后兼容的代价

typeof null === "object"  // 著名的设计错误,但已无法修改

2. 弱类型带来的灵活性和混乱

let x = 10;    // 现在是数字
x = "hello";   // 突然变成字符串
x = function() { return 42; };  // 又变成函数

双重身份:函数式与面向对象的混合体

在 JavaScript 中,它同时支持两种编程范式,这既是优势,也是产生困惑的源头:

面向对象的方式

class Person {
  constructor(name) {
    this.name = name;
  }
  greet() {
    return `Hello, ${this.name}`;
  }
}

函数式的方式

const createPerson = (name) => ({
  name,
  greet: () => `Hello, ${name}`
});

函数式与面向对象混用

const person = {
  name: "zhangsan",
  greet() {
    return `Hello, ${this.name}`;
  },
  // 函数式的方法
  toUpperCase: function() {
    return this.name.toUpperCase();
  }
};

编译-执行双阶段模型:理解诡异行为的关键

这是本文的核心重点!JavaScript 的行为之所以令人困惑,很大程度上是因为它的双阶段执行模型。

编译阶段(预解析)

在这个阶段,JavaScript引擎会做三件重要的事情:

1. 变量提升

我们先来看一段简单的代码:

console.log(a);
var a = 10;
console.log(b);
let b = 20;

上述代码在编辑阶段会发生什么呢?

var a;  // var声明被提升,初始化为 undefined
// let b; // let声明也被提升,但不会被初始化(暂时性死区)

在部分资料中提到 var 与 let/const 的区别,中间会有一点:let/const不会出现变量提升。这种说法是不准确的。其实 let/const 也会出现变量提升,只是在提升后并不会被初始化,在这个阶段,直接调用变量程序会报错,因此被称为:暂时性死区

2. 函数提升

sayHello();  // 可以正常调用!

function sayHello() {
  console.log("Hello!");
}

我们可以看到函数提升是可以正常调用的,这又是为什么呢?原来,在 JavaScript 中,函数提升,会把整个函数声明(包括函数体)都提升到顶部,其实际执行过程如下:

function sayHello() {  // 整个函数声明(包括函数体)提升到顶部
  console.log("Hello!");
}
sayHello();  // "Hello!"
函数提升的变种:函数表达式

在函数定义时,我们也可以将函数复制给一个变量,即函数表达式,这种情况下,又会产生新的问题:

sayHello();  // TypeError: foo is not a function

var sayHello = function() {
  console.log("Hello!");
}

可以看到这种情况下,又出现了新的问题。其本质仍然在于关键字 var,将整个函数作为了变量进行处理。

3. 作用域链建立

function outer() {
  var x = 10;
  function inner() {
    // 编译时就知道可以访问x
    console.log(x);
  }
  return inner;
}

关于作用域链,在后面的文章中会详细讲解!

执行阶段

执行阶段按顺序运行代码,但此时作用域、变量状态都已确定。还是看看变量提升的例子:

console.log(a);
var a = 5;
console.log(a);

其执行过程是什么样的呢?

var x;          // 编译阶段:声明提升,初始化为 undefined
console.log(x); // 执行阶段:输出 undefined
x = 5;          // 执行阶段:赋值 5
console.log(x); // 执行阶段:输出 5

执行上下文与闭包的秘密

理解执行上下文是掌握JavaScript的关键:

function createCounter() {
  let count = 0;  // 这个变量会被"闭包"捕获
  
  return {
    increment: function() {
      count++;
      return count;
    },
    decrement: function() {
      count--;
      return count;
    }
  };
}

const counter = createCounter();
console.log(counter.increment());  // 1
console.log(counter.increment());  // 2
// count变量"神奇地"被记住了,即使createCounter已经执行完毕

关于执行上下文与闭包的相关内容,在后面的文章中,会详细讲解!

解释型语言的动态特性

运行时类型检查与转换

JavaScript 的类型系统在运行时动态工作,这导致了许多“魔幻”般的行为。还记得我们文章开头的那个例子吗,其正确的输出结果是:

console.log(1 + "2");        // "12"
console.log(2 - "1");        // 1 
console.log(true + false);   // 1
console.log([] + []);        // ""
console.log([] + {});        // "[object Object]"

这中间其实存在许多隐式转换规则:

console.log(0 == false);    // true
console.log("" == false);   // true
console.log([] == false);   // true
console.log(null == undefined); // true
console.log("0" == false);  // true

因此,我们在实际开发中,推荐使用 === 进行判断,防止类型转换带来的问题。

console.log([] + {}); 为什么输出结果是 [object Object] 呢?这又涉及到 Object 对象的原型链方法。这段代码等价于:console.log([].toString() + ({}).toString()); 。其中:[] 会被转成空串 ""{}会被当做一个对象,被转成 [object Object] 。(这是 Object.prototype.toString() 的默认实现)

原型链:JavaScript的继承机制

这是JavaScript最独特也最令人困惑的特性之一:

// 原型链示例
function Animal(name) {
  this.name = name;
}

Animal.prototype.speak = function() {
  console.log(`${this.name} makes a noise`);
};

function Dog(name) {
  Animal.call(this, name);  // 调用父类构造函数
}

// 设置原型链
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.speak = function() {
  console.log(`${this.name} barks`);
};

const dog = new Dog("Rex");
dog.speak();  // "Rex barks"

原型链查找

上述过程存在一个原型链查找过程:

  1. dog.hasOwnProperty('name'):true,直接调用
  2. dog.hasOwnProperty('speak'):false,往原型上查找
  3. dog.__proto__.hasOwnProperty('speak'):true

关于原型和原型链的内容,在后面的文章中会详细讲解。

异步编程模型:事件循环

JavaScript 的单线程异步模型,这又是另一个难点了,我们先来看一道经典的面试题:

console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

Promise.resolve().then(() => {
  console.log("3");
});

console.log("4");

上述代码的输出结果是:1 4 3 2

我们再看一道事件循环的微观队列与宏观队列的代码:

console.log("start");

setTimeout(() => {
  console.log("setTimeout");
}, 0);

Promise.resolve()
  .then(() => {
    console.log("P1");
  })
  .then(() => {
    console.log("P2");
  });

console.log("end");

上述代码的输出结果是:start end P1 P2 setTimeout

关于 JavaScript 异步编程的相关内容,在我的另外一个专栏里: Promise详解 有详细介绍!

实战:理解一道经典面试题

让我们用今天学到的知识解析一道经典面试题:

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 100);
}

上述代码的输出结果是:5 5 5 5 5 。为什么不是 0 1 2 3 4 呢?

  1. var 声明的变量 i 是函数作用域(或全局作用域)
  2. 所有 setTimeout 共享同一个 i
  3. setTimeout 回调执行时,循环已结束,i=5 ,所以输出都是 5 。

这种问题应该如何解决呢?

  1. 使用let(块级作用域):
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);  // 0,1,2,3,4
  }, 100);
}
  1. 使用闭包:
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j);  // 0,1,2,3,4
    }, 100);
  })(i);
}

结语

JavaScript的“诡异”行为并非缺陷,而是其灵活性和强大功能的副产品。理解它的双阶段模型、作用域链、原型系统和事件循环,是掌握这门语言的关键。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!