跳到主要内容

JS 语言核心

数据类型​

7 个原始类型 + 1 个引用类型:

原始:undefined、null、Boolean、Number、String、Symbol(ES6)、BigInt(ES2020)
引用:Object(含 Array、Function、Date、RegExp、Map、Set...)

存储位置:原始类型存在栈上(值本身),引用类型的值存在堆上,栈里存的是指向堆的地址。这解释了为什么对象赋值是「引用传递」。

类型判断的四种方式​

方式适用坑
typeof判断原始类型和 functiontypeof null === 'object'(历史 bug,无法修复);数组和对象都返回 'object'
instanceof判断引用类型顺原型链查找,所以 [] instanceof Object 也是 true;跨 iframe / 跨 realm 失效(原型链不同)
constructor判断构造函数可以被篡改;null / undefined 没有
Object.prototype.toString.call()最可靠返回 '[object Xxx]',能区分所有类型
Object.prototype.toString.call(null) // '[object Null]'
Object.prototype.toString.call([]) // '[object Array]'
Object.prototype.toString.call(new Date()) // '[object Date]'

typeof null 为什么是 'object':JS 最初用低 3 位标记类型,对象是 000,而 null 是全零的机器码,低 3 位也是 000,所以被判定为对象。修复会破坏大量现有代码,所以保留至今。

== 与 ===​

=== 不做类型转换。== 的转换规则(按顺序):

  1. null == undefined → true(且它们和其他任何值都不相等)
  2. NaN 和任何值(包括自己)都不相等
  3. 原始类型 vs 原始类型 → 都转成 Number 比较
  4. 对象 vs 原始类型 → 对象先调 valueOf() / toString() 转成原始值(ToPrimitive),再比较

经典题:

[] == false // true:[] → '' → 0,false → 0
[] == ![] // true:![] 是 false,同上
'' == 0 // true
null == 0 // false!null 只和 undefined 相等
NaN == NaN // false
{} + [] // 0({} 被当成代码块)或 '[object Object]'(在表达式位置)

实践结论:永远用 ===,除了判断 null/undefined 时可以用 x == null(一次判两个)。

🔥 原型与原型链​

三个核心概念:

  • prototype —— 函数才有的属性,指向一个对象(原型对象)
  • __proto__ —— 每个对象都有,指向它的构造函数的 prototype
  • constructor —— 原型对象上的属性,指回构造函数
function Person(name) { this.name = name }
const p = new Person('张三')

p.__proto__ === Person.prototype // true
Person.prototype.constructor === Person // true
p.__proto__.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // 原型链终点

原型链的作用:访问一个属性时,先在对象自身找,找不到就顺着 __proto__ 往上找,直到 Object.prototype,再往上是 null 就返回 undefined。这是 JS 实现继承的机制。

:::tip 两个容易绕的点 Function.prototype.__proto__ === Object.prototype —— 函数也是对象。

Object.__proto__ === Function.prototype —— Object 本身是一个函数(构造函数),所以它的 __proto__ 指向 Function.prototype。

所以「所有函数都是 Function 的实例,包括 Function 自己」:Function.__proto__ === Function.prototype。这是一个自引用,画图比背结论容易理解。 :::

__proto__ 已废弃,规范推荐用 Object.getPrototypeOf() / Object.setPrototypeOf()。

继承的实现方式​

// ES5 的标准做法:寄生组合继承
function Child(name, age) {
Parent.call(this, name) // ① 继承实例属性
this.age = age
}
Child.prototype = Object.create(Parent.prototype) // ② 继承原型方法
Child.prototype.constructor = Child // ③ 修回 constructor

// ES6
class Child extends Parent {
constructor(name, age) {
super(name) // 必须先调 super 才能用 this
this.age = age
}
}

为什么 ② 不写 Child.prototype = Parent.prototype —— 那样改子类原型会影响父类。

为什么 ② 不写 Child.prototype = new Parent() —— 会多调用一次父类构造函数,且父类的实例属性会跑到子类原型上被所有实例共享(引用类型会互相影响)。Object.create 只继承原型,不执行构造函数。

class 和 function 的区别(高频):

classfunction
提升声明提升但不初始化(TDZ)完整提升,可提前调用
必须 new✅ 不 new 直接报错❌ 可以直接调用
严格模式内部总是严格模式取决于上下文
方法可枚举❌ 原型方法不可枚举✅

🔥 this 指向​

this 在运行时绑定,取决于调用方式,不是定义位置。优先级从高到低:

new 绑定 > 显式绑定(call/apply/bind) > 隐式绑定(obj.fn()) > 默认绑定(全局/undefined)
function fn() { console.log(this.a) }
const obj = { a: 1, fn }

fn() // 默认绑定:非严格模式下 this 是 window,严格模式是 undefined
obj.fn() // 隐式绑定:this 是 obj
fn.call({a: 2}) // 显式绑定:this 是 {a: 2}
new fn() // new 绑定:this 是新创建的对象

隐式丢失是最常见的坑:

const f = obj.fn // 赋值后丢失了调用对象
f() // this 是 window,不是 obj

setTimeout(obj.fn, 0) // 同理,传的是函数引用
arr.forEach(obj.fn) // 同理

箭头函数没有自己的 this —— 它捕获定义时所在的词法作用域的 this,且无法被 call / apply / bind 改变。

const obj = {
a: 1,
fn1() { return () => this.a }, // 箭头函数继承 fn1 的 this
fn2: () => this.a // this 是定义时的外层(通常是 window)
}
obj.fn1()() // 1
obj.fn2() // undefined

箭头函数的其他限制:没有 arguments(用剩余参数 ...args)、不能用 new、没有 prototype、不能用作 generator。

所以对象方法不要用箭头函数,但 React 类组件的事件处理器用箭头函数正好(自动绑定实例)。

闭包​

定义:函数和它的词法作用域的组合。实际表现是函数可以访问其定义时所在作用域的变量,即使该函数在别处执行。

function counter() {
let count = 0
return () => ++count // 这个函数闭包了 count
}
const c = counter()
c() // 1
c() // 2 —— count 没有被回收

原理:函数创建时会保存一个指向其词法环境的引用([[Environment]])。只要这个函数还可达,它引用的词法环境就不会被 GC 回收。

用途:

  • 数据私有化 —— 模块模式、构造私有变量
  • 保存状态 —— 计数器、缓存(memoize)、防抖节流保存 timer
  • 函数工厂 —— 柯里化、偏函数

经典题(循环中的闭包):

for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0) // 3 3 3
}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0) // 0 1 2
}

var 没有块级作用域,三个回调闭包的是同一个 i;let 在每次迭代创建新的块级作用域,三个回调各闭包一个独立的 i。

var 版本要修复就用 IIFE 手动创建作用域:(function(j){ setTimeout(() => console.log(j)) })(i)。

:::warning 闭包导致内存泄漏 闭包本身不是泄漏,不必要的长期持有才是。典型场景:

function leak() {
const bigData = new Array(1000000)
return () => console.log('hi') // 不用 bigData,但有些引擎仍会保留整个作用域
}

常见的真实泄漏:未清理的 setInterval、未解绑的事件监听器(闭包持有 DOM 引用)、全局变量缓存无限增长。 :::

作用域与变量提升​

三种作用域:全局、函数、块级(ES6 的 let/const)。

var / let / const 的区别:

varletconst
作用域函数块块
提升提升且初始化为 undefined提升但不初始化(TDZ)同 let
重复声明允许报错报错
必须初始化❌❌✅
挂到 window✅❌❌

TDZ(暂时性死区) —— let/const 声明的变量从作用域开始到声明语句之间不可访问,访问报 ReferenceError。这不是「没有提升」,而是「提升了但禁止访问」:

console.log(a) // undefined(var 提升并初始化)
var a = 1

console.log(b) // ReferenceError: Cannot access 'b' before initialization
let b = 1

const 只保证绑定不变,不保证值不变 —— const obj = {} 之后 obj.a = 1 是合法的。要真正冻结用 Object.freeze()(但它是浅冻结)。

函数提升优先于变量提升:

console.log(fn) // function fn(){}
var fn = 1
function fn() {}

其他高频点​

new 做了什么(手写题常考,见手写题集):① 创建空对象;② 把它的 __proto__ 指向构造函数的 prototype;③ 用这个对象作为 this 执行构造函数;④ 如果构造函数返回对象就返回它,否则返回新创建的对象。

浅拷贝 vs 深拷贝 —— 浅拷贝(Object.assign、展开运算符、slice、concat)只复制第一层;深拷贝要递归。JSON.parse(JSON.stringify(x)) 是最简单的深拷贝但有四个问题:丢失 undefined / 函数 / Symbol、Date 变字符串、RegExp 变空对象、循环引用直接报错。现代方案是 structuredClone()(浏览器原生,支持循环引用,但不支持函数)。

Map vs 对象 —— Map 的键可以是任意类型(对象也行)、有序、有 size、可直接迭代、删除性能更好。对象的键只能是字符串/Symbol。频繁增删用 Map。

Set 的去重局限 —— 用的是 SameValueZero,所以 NaN 能去重(和 === 不同),但对象按引用比较,[{a:1}, {a:1}] 去不掉。

可选链和空值合并 —— a?.b?.c 遇到 null/undefined 短路返回 undefined;a ?? b 只在 a 是 null/undefined 时取 b(和 || 的区别是 0、''、false 不会被覆盖)。

事件委托 —— 把子元素的监听器绑到父元素上,靠事件冒泡 + e.target 判断来源。好处是减少监听器数量、动态添加的子元素自动生效。详见浏览器原理。