看似简单的语法背后,隐藏着令人费解的行为逻辑——这是无数前端开发者共同的困惑,本文将深入探讨 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"
原型链查找
上述过程存在一个原型链查找过程:
-
dog.hasOwnProperty('name'):true,直接调用
-
dog.hasOwnProperty('speak'):false,往原型上查找
-
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 呢?
-
var 声明的变量 i 是函数作用域(或全局作用域)
- 所有
setTimeout 共享同一个 i
- 当
setTimeout 回调执行时,循环已结束,i=5 ,所以输出都是 5 。
这种问题应该如何解决呢?
- 使用let(块级作用域):
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // 0,1,2,3,4
}, 100);
}
- 使用闭包:
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function() {
console.log(j); // 0,1,2,3,4
}, 100);
})(i);
}
结语
JavaScript的“诡异”行为并非缺陷,而是其灵活性和强大功能的副产品。理解它的双阶段模型、作用域链、原型系统和事件循环,是掌握这门语言的关键。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!