JavaScript 语言基础

深入解析 JavaScript 语言本质:动态类型系统、原始类型与引用类型的内存模型、类型转换规则,以及 var/let/const 的实际差异。

为什么理解 JavaScript 类型系统是根基

JavaScript 是门经常被低估的语言。它的类型系统看似简单——六种原始类型加一种引用类型——但背后的机制充满了细节和陷阱。很多高级面试题,比如 0.1 + 0.2 !== 0.3[] == ![]、或者 typeof null === 'object',都源于对类型系统底层规则的误解。

理解 JavaScript 的类型系统,不只是为了面试,更是为了写出可预测的代码。在大型前端项目中,类型相关的 bug 往往最难排查——它们通常不会抛异常,而是悄无声息地产生错误的结果。


一、JavaScript 是一门动态类型语言

1.1 动态类型 vs 静态类型

JavaScript 是**动态类型(dynamically typed)**语言,变量本身没有类型约束:

let x = 42;      // x 是 number
x = "hello";     // x 变成 string — 完全合法
x = true;        // x 变成 boolean

相比之下,TypeScript、Java、C++ 是静态类型语言,变量声明时就确定了类型,编译器可以在编译阶段捕获类型错误。

动态类型的代价:类型错误推迟到运行时才暴露。

1.2 解释型 vs 编译型

JavaScript 常被描述为"解释型语言",但这个说法在现代并不完全准确:

  • 传统浏览器中的 JS:源代码被浏览器引擎(如 V8)解析后,直接在解释器中执行
  • 现代 V8 的实际行为:V8 采用 JIT(Just-In-Time)编译,先解释执行,热点代码被编译为机器码后直接执行
flowchart TD
    A["源代码<br/>Source Code"] --> B["Parser<br/>解析"]
    B --> C["AST<br/>抽象语法树"]
    C --> D["Interpreter<br/>解释器 Ignition"]
    D --> E{热点检测}
    E -->|是| F["Compiler<br/>编译器 TurboFan"]
    E -->|否| G["继续解释执行"]
    F --> H["Machine Code<br/>机器码"]
    H --> I["执行"]
    G --> I

V8 的这种混合策略让它既能快速启动(解释执行),又能高效运行热点代码(JIT 编译)。这与 Java 的 JVM 类似,但 Java 需要显式编译,JavaScript 的编译发生在运行时。


二、原始类型(Primitive Types)

2.1 七种原始类型

JavaScript 有七种原始类型:

// number — 64位双精度浮点数
const age = 25;
const price = 19.99;

// string — UTF-16 编码的字符序列
const name = "Alice";
const emoji = "👍";

// boolean — true / false
const isActive = true;

// undefined — 未赋值
let pending;
console.log(pending); // undefined

// null — 故意 отсутствие值
const data = null;

// symbol — 唯一标识符
const id = Symbol("userId");

// bigint — 任意精度整数
const big = 9007199254740991n;

typeof 操作符的返回值

typeof 42;           // "number"
typeof "hello";      // "string"
typeof true;         // "boolean"
typeof undefined;    // "undefined"
typeof null;         // "object" ← 历史 bug
typeof Symbol("x");  // "symbol"
typeof 42n;          // "bigint"
typeof {};           // "object"
typeof [];           // "object"
typeof function(){}; // "function"

2.2 原始类型的包装对象

原始类型本身不是对象,但 JavaScript 为 number、string、boolean 提供了包装对象:

const primitive = "hello";
const wrapper = new String("hello");

console.log(primitive === wrapper);        // false
console.log(typeof primitive);              // "string"
console.log(typeof wrapper);                // "object"

console.log(primitive.length);              // 5 — 自动装箱
console.log(wrapper.length);                // 5

当你访问原始类型的属性时(如 "hello".length),JavaScript 会临时创建一个包装对象,访问完毕后立即销毁——这就是自动装箱(autoboxing)


三、引用类型(Reference Types)

3.1 堆与栈的内存模型

JavaScript 的内存分为栈(Stack)堆(Heap)

flowchart LR
    subgraph Stack["栈(Stack)— 快速但有限"]
        A1["变量名: count<br/>值: 42"]
        A2["变量名: name<br/>值: 'Alice'"]
        A3["变量名: arr<br/>指针: @0x1001"]
    end

    subgraph Heap["堆(Heap)— 大但慢"]
        B1["@0x1001<br/>[1, 2, 3, 4, 5]"]
        B2["@0x2001<br/>{age: 30, city: 'Beijing'}"]
    end

    A3 --> B1

栈的特点

  • 存储原始类型的值和引用类型的指针
  • 变量按顺序压栈,函数返回时按顺序弹栈
  • 访问速度极快
  • 空间有限(通常几 MB)

堆的特点

  • 存储引用类型的实际数据(对象、数组)
  • 需要通过指针间接访问
  • 空间比栈大得多
  • 需要垃圾回收

3.2 原始类型 vs 引用类型的赋值差异

// 原始类型 — 值拷贝
let a = 10;
let b = a;  // b 是 a 的副本
b = 20;     // 修改 b 不影响 a
console.log(a); // 10

// 引用类型 — 引用拷贝
let obj1 = { x: 1 };
let obj2 = obj1;  // obj2 持有同一引用
obj2.x = 2;        // 通过 obj2 修改
console.log(obj1.x); // 2 — obj1 也变了

3.3 函数参数传递:按值传递

JavaScript 的函数参数传递始终是按值传递

function modifyPrimitive(x) {
  x = x + 10;  // 修改的是局部变量
}

let n = 5;
modifyPrimitive(n);
console.log(n); // 5 — 原始值未变

function modifyObject(obj) {
  obj.x = 100;      // 通过引用修改对象
  obj = { x: 200 }; // 重新赋值局部变量
}

let o = { x: 1 };
modifyObject(o);
console.log(o.x); // 100 — 对象被修改
                  // 注意:o 本身没有被重新赋值

四、类型转换(Type Coercion)

4.1 显式转换 vs 隐式转换

// 显式转换 — 意图清晰
const num = Number("42");
const str = String(42);
const bool = Boolean(1);

// 隐式转换 — 容易产生意外
const result = "5" + 3;  // "53" — 数字被转字符串
const flag = !"hello";    // false — 真值转布尔

4.2 抽象相等(Abstract Equality)与严格相等

== 允许类型转换,=== 不允许:

console.log(5 == "5");     // true — 字符串转数字
console.log(5 === "5");   // false — 类型不同

console.log(null == undefined);  // true — 特殊规则
console.log(null === undefined); // false

console.log(0 == false);   // true
console.log(0 === false);  // false

规范原文(ECMA-262 §IsLooselyEqual):null == undefined 为 true,但它们与其他任何值都不相等。

4.3 ToPrimitive 抽象操作

当对象参与 == 运算或算术运算时,JavaScript 调用 ToPrimitive 将对象转为原始值:

// [] == ![] 的解析
// 1. ![] 被转成布尔 → false
// 2. [] == false → Number([]) == Number(false)
// 3. Number([]) = 0, Number(false) = 0
// 4. 0 == 0 → true

console.log([] == ![]);    // true
console.log({} == ![{}]); // true

ToPrimitive 的完整规则:

function ToPrimitive(input, preferredType) {
  if (typeof input === "object") {
    const exoticToPrim = input[Symbol.toPrimitive];
    if (exoticToPrim !== undefined) {
      const result = exoticToPrim(preferredType);
      if (typeof result !== "object") return result;
      throw TypeError();
    }
    if (preferredType === "string") {
      return input.toString();  // 先 toString
    } else {
      return input.valueOf();    // 先 valueOf
    }
  }
  return input;
}

4.4 常见隐式转换的坑

// 算术运算中的隐式转换
console.log("5" - 3);     // 2 — "5" 转数字
console.log("5" + 3);     // "53" — 数字转字符串
console.log([1, 2] + [3, 4]); // "1,23,4" — 数组转字符串

// 比较运算中的隐式转换
console.log("10" > "2");  // false — 字符串比较 "1" < "2"
console.log("10" > 2);    // true — "10" 转数字

// 逻辑运算中的隐式转换
console.log(!!"");        // false — 空字符串转布尔
console.log(!!"hello");   // true
console.log(Boolean([])); // true — 空数组也是 truthy

五、typeof 和 instanceof

5.1 typeof 的真实行为

typeof 是操作符,不是函数,返回字符串表示类型:

// 常见类型的 typeof
typeof 42;                // "number"
typeof "hello";           // "string"
typeof true;              // "boolean"
typeof undefined;         // "undefined"
typeof Symbol();          // "symbol"
typeof 42n;               // "bigint"
typeof {};                // "object"
typeof [];                // "object" — 数组也是对象
typeof function(){};      // "function"
typeof null;               // "object" — 历史遗留 bug

// 特殊场景
typeof document.all;      // "undefined" — 故意如此,检测 IE

typeof 的局限性:无法区分对象、数组、null、正则等。

5.2 instanceof 的原理

instanceof 检查构造函数的 prototype 是否在对象的原型链上:

[] instanceof Array;           // true
{} instanceof Object;          // true
new Date() instanceof Date;    // true

// 原理
function myInstanceOf(obj, Constructor) {
  let proto = Object.getPrototypeOf(obj);
  while (proto !== null) {
    if (proto === Constructor.prototype) return true;
    proto = Object.getPrototypeOf(proto);
  }
  return false;
}

instanceof 的问题

// 跨 iframe 的对象
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const ArrayConstructor = iframe.contentWindow.Array;
const arr = [1, 2, 3];
console.log(arr instanceof ArrayConstructor); // false — 不同全局环境

5.3 更可靠的类型检测

// 通用方案:Object.prototype.toString
function getType(value) {
  return Object.prototype.toString.call(value);
}

console.log(getType([]));      // "[object Array]"
console.log(getType({}));      // "[object Object]"
console.log(getType(new Date)); // "[object Date]"

// ES2022: Symbol.toStringTag
class Person {
  get [Symbol.toStringTag]() { return "Person"; }
}
console.log(new Person().toString()); // "[object Person]"

六、变量声明:var / let / const

6.1 函数作用域 vs 块作用域

var 是函数作用域,let/const 是块作用域:

function test() {
  if (true) {
    var x = 1;    // 函数作用域
    let y = 2;    // 块作用域
    const z = 3;  // 块作用域
  }
  console.log(x); // 1 — 可以访问
  // console.log(y); // ReferenceError
  // console.log(z); // ReferenceError
}

6.2 变量提升(Hoisting)

console.log(a); // undefined — var 提升
var a = 1;

console.log(b); // ReferenceError — let 不提升
let b = 2;

var 提升的真相:var 声明被提升到函数顶部,但赋值留在原地。

function varTest() {
  console.log(x); // undefined
  if (false) {
    var x = 1;  // 提升但未执行
  }
  console.log(x); // undefined
}

6.3 暂时性死区(Temporal Dead Zone)

letconst 存在 TDZ:在声明之前的区域访问会抛出 ReferenceError:

{
  // TDZ 开始
  // console.log(x); // ReferenceError
  let x = 1; // TDZ 结束
  console.log(x); // 1
}

6.4 const 的"不变性"

const 保证的是绑定不变,不是值不可变

const obj = { x: 1 };
obj.x = 2;        // OK — 可以修改属性
// obj = {};      // TypeError — 不能重新赋值

const arr = [1, 2];
arr.push(3);      // OK
// arr = [1, 2, 3]; // TypeError

七、严格模式(Strict Mode)

7.1 开启严格模式

// 方式一:脚本顶层
"use strict";
function test() { }

// 方式二:函数内
function test() {
  "use strict";
}

// 方式三:ES Module 自动开启
// ES Module 中的代码默认处于严格模式

7.2 严格模式禁止的行为

"use strict";

// 禁止未声明的变量
// x = 1; // ReferenceError

// 禁止删除不可删除的属性
// delete Object.prototype; // TypeError

// 禁止函数参数名重复
// function f(a, a) {} // SyntaxError

// 禁止 with 语句
// with (obj) {} // SyntaxError

// 禁止 0 开头的八进制(ES6 已禁止)
// var n = 010; // SyntaxError

7.3 严格模式对 this 的影响

function foo() {
  console.log(this);
}

foo(); // 非严格:window,严格:undefined

// 构造函数的 this 行为也不同
function Person() {
  this.name = "Alice";
}
// new Person(); // 始终正确绑定

7.4 严格模式在现代 JavaScript 中的地位

ES Module 默认处于严格模式,因此 "use strict" 在模块中不是必需的。


八、面试高频问题

Q: JavaScript 是解释型还是编译型语言?

回答要点:传统上被认为是解释型,但现代引擎如 V8 采用 JIT 编译策略。源代码先被解析为 AST,然后解释执行,热点代码被 TurboFan 编译为机器码直接执行。这种混合策略让 JavaScript 兼具快速启动和高效运行的特点。

Q: 原始类型和引用类型的区别是什么?

回答要点:原始类型存储在栈上,包括 number、string、boolean、null、undefined、symbol、bigint,赋值时是值拷贝。引用类型存储在堆上,栈上只存储指针,赋值时是引用拷贝。函数参数传递时,原始类型传值(拷贝),引用类型传引用(共享),但这仍然是按值传递——传的是指针的拷贝。

Q: typeof null 为什么是 'object'?

回答要点:这是 JavaScript 最早的设计 bug。在 JS 的早期实现中,null 的机器码全零被错误地当成了对象指针。虽然后来修复了,但为了向后兼容,这个 bug 被保留至今。这是一个历史遗留问题,而非设计决策。


延展阅读