JS 语言核心
数据类型
7 个原始类型 + 1 个引用类型:
原始:undefined、null、Boolean、Number、String、Symbol(ES6)、BigInt(ES2020)
引用:Object(含 Array、Function、Date、RegExp、Map、Set...)
存储位置:原始类型存在栈上(值本身),引用类型的值存在堆上,栈里存的是指向堆的地址。这解释了为什么对象赋值是「引用传递」。
类型判断的四种方式
| 方式 | 适用 | 坑 |
|---|---|---|
typeof | 判断原始类型和 function | typeof 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,所以被判定为对象。修复会破坏大量现有代码,所以保留至今。
== 与 ===
=== 不做类型转换。== 的转换规则(按顺序):
null == undefined→true(且它们和其他任何值都不相等)NaN和任何值(包括自己)都不相等- 原始类型 vs 原始类型 → 都转成 Number 比较
- 对象 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__—— 每个对象都有,指向它的构造函数的prototypeconstructor—— 原型对象上的属性,指回构造函数
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 的区别(高频):
class | function | |
|---|---|---|
| 提升 | 声明提升但不初始化(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 的区别:
var | let | const | |
|---|---|---|---|
| 作用域 | 函数 | 块 | 块 |
| 提升 | 提升且初始化为 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 判断来源。好处是减少监听器数量、动态添加的子元素自动生效。详见浏览器原理。