JavaScript 从入门到进阶:一篇覆盖核心知识、运行机制与工程实践

JavaScript 入门很快:声明变量、写一个函数、操作 DOM,几天就能完成一个页面。但从"能写"到"写得可靠",中间隔着类型转换、作用域、闭包、this、原型链、事件循环、Promise、模块化、内存管理和工程设计等一整套知识。

这篇文章尝试建立一张由浅入深的 JavaScript 知识地图。它不会只罗列 API,而是对每个重要主题回答四个问题:

  1. 它是什么?
  2. 它为什么这样运行?
  3. 工作中什么时候使用?
  4. 最容易踩什么坑?

JavaScript 的标准语言部分由 ECMAScript 规范定义;DOM、fetchsetTimeoutlocalStorage 等属于宿主环境提供的 Web API。理解两者的边界,是学习 JavaScript 的第一步。

一、先建立 JavaScript 的整体地图

可以把 JavaScript 能力分成五层:

text 复制代码
语法基础
  变量、类型、运算符、流程控制、函数
        ↓
核心机制
  作用域、闭包、this、对象、原型、类
        ↓
数据与抽象
  Array、Map、Set、Iterator、Generator、Proxy
        ↓
运行时
  调用栈、事件循环、Promise、async/await、内存管理
        ↓
工程实践
  模块化、网络请求、DOM、错误处理、性能、安全、测试

初学时不需要先读完规范,但要知道:语法只是入口,运行机制决定代码在真实项目里是否可靠。

二、变量、声明与执行前准备

0. Script、Module 与严格模式

JavaScript 源码可以作为传统 Script 或 ES Module 执行。Module 默认处于严格模式,并拥有独立模块作用域。

传统脚本可以显式开启严格模式:

js 复制代码
'use strict'

function run() {
  // 未声明变量、重复参数等不安全行为会更早暴露
}

严格模式的典型变化:

  • 普通函数直接调用时 thisundefined,不再自动指向全局对象
  • 给只读属性赋值会抛错
  • 禁止意外创建全局变量
  • 一些容易产生歧义的旧语法会报错

现代项目使用 ESM 时通常不需要再手写 'use strict'

1. var、let 和 const

现代 JavaScript 默认优先使用 const,确实需要重新赋值时使用 let,通常不再使用 var

js 复制代码
const API_TIMEOUT = 10_000
let retryCount = 0

retryCount += 1

三者的主要区别:

特性 var let const
作用域 函数作用域 块级作用域 块级作用域
重复声明 允许 不允许 不允许
声明前访问 得到 undefined 暂时性死区 暂时性死区
重新赋值 允许 允许 不允许
挂到全局对象 脚本顶层可能会 不会 不会

const 限制的是变量绑定不能改变,不代表对象不能修改:

js 复制代码
const user = { name: 'Alice' }
user.name = 'Bob' // 合法

// user = {} // TypeError

如果需要真正防止浅层修改,可以使用:

js 复制代码
const config = Object.freeze({ timeout: 3000 })

Object.freeze 是浅冻结,嵌套对象仍可修改。

2. 提升与暂时性死区

声明在代码执行前会进入对应环境记录,但不同声明的初始化时机不同。

js 复制代码
console.log(a) // undefined
var a = 1

console.log(b) // ReferenceError
let b = 2

不要简单记成"let 不提升"。更准确的说法是:letconst 的绑定已经创建,但在执行声明语句前没有初始化,这段区域称为暂时性死区(TDZ)。

函数声明也会被提前建立,因此可以先调用:

js 复制代码
sayHello()

function sayHello() {
  console.log('hello')
}

函数表达式遵循变量自己的初始化规则:

js 复制代码
// run() // ReferenceError
const run = function () {}

工作场景: 避免依赖提升组织代码。模块对外导出的主函数可以放在文件上方,辅助函数通过函数声明放在下方,既方便阅读又不会产生初始化问题。

三、数据类型:值到底是什么

JavaScript 有 7 种原始类型:

text 复制代码
undefined、null、boolean、number、bigint、string、symbol

除此之外都是对象,函数也是可调用的特殊对象。

1. undefined 与 null

  • undefined 常表示"尚未赋值"或"没有返回值"。
  • null 常表示开发者主动表达"这里没有对象"。
js 复制代码
let selectedUser        // undefined
const deletedUser = null

工作中可以建立统一约定:可选字段缺失用 undefined,明确清空后端字段用 null。关键不是选哪一个,而是前后端契约一致。

2. boolean

JavaScript 条件判断会把值转换为布尔值。以下值是假值:

text 复制代码
false、0、-0、0n、''、null、undefined、NaN

空数组和空对象是真值:

js 复制代码
Boolean([]) // true
Boolean({}) // true

因此不要用 if (list) 判断数组是否有内容:

js 复制代码
if (Array.isArray(list) && list.length > 0) {
  renderList(list)
}

3. number、NaN 与浮点数

JavaScript 的普通数字主要使用 IEEE 754 双精度浮点数表示。

js 复制代码
0.1 + 0.2 === 0.3 // false

金额计算不能直接依赖浮点数精确相等。常见做法是使用最小货币单位:

js 复制代码
const priceInCents = 1999
const count = 3
const totalInCents = priceInCents * count
const total = totalInCents / 100

NaN 表示某次数字运算无法得到有效数字。它有一个特殊性质:

js 复制代码
NaN === NaN // false
Number.isNaN(NaN) // true

优先使用 Number.isNaN,因为全局 isNaN 会先做类型转换:

js 复制代码
isNaN('hello') // true
Number.isNaN('hello') // false

安全整数范围:

js 复制代码
Number.MIN_SAFE_INTEGER
Number.MAX_SAFE_INTEGER
Number.isSafeInteger(value)

后端返回超过安全整数范围的 ID 时,最好直接使用字符串,而不是先解析成 number

4. bigint

BigInt 用于超出安全整数范围的整数:

js 复制代码
const orderId = 900719925474099312345n

它不能和 number 直接混算:

js 复制代码
// 1n + 1 // TypeError
1n + BigInt(1)

JSON.stringify 默认不能序列化 BigInt,接口传输通常仍应转换为字符串。

5. string

字符串是不可变值。调用方法不会修改原字符串:

js 复制代码
const name = ' JavaScript '
const normalized = name.trim().toLowerCase()

模板字符串支持插值和多行文本:

js 复制代码
const message = `用户 ${user.name} 有 ${orders.length} 个订单`

注意用户输入不能直接拼入 HTML,否则可能产生 XSS。

6. symbol

每次调用 Symbol() 都得到唯一值:

js 复制代码
const cacheKey = Symbol('cacheKey')
const obj = { [cacheKey]: 'private-like value' }

常见用途:

  • 避免对象属性名冲突
  • 定义协议,如 Symbol.iterator
  • 框架或库内部元数据

它不是绝对私有属性,Object.getOwnPropertySymbols 仍可读取。

7. typeof 的边界

js 复制代码
typeof undefined // 'undefined'
typeof true      // 'boolean'
typeof 1         // 'number'
typeof 1n        // 'bigint'
typeof 'x'       // 'string'
typeof Symbol()  // 'symbol'
typeof function () {} // 'function'
typeof null      // 'object',历史遗留问题
typeof []        // 'object'

更可靠的判断方式:

js 复制代码
Array.isArray(value)
value === null
value instanceof Date
Object.prototype.toString.call(value)

跨 iframe 时 instanceof Array 可能失败,数组判断应使用 Array.isArray

四、值、引用、复制与相等性

1. 参数传递始终是按值传递

对象变量保存的是一个引用值。函数接收的是这个引用值的副本:

js 复制代码
function rename(user) {
  user.name = 'Bob'
}

const user = { name: 'Alice' }
rename(user)
console.log(user.name) // Bob

但给参数重新赋值不会替换外部变量:

js 复制代码
function replace(user) {
  user = { name: 'Carol' }
}

replace(user)
console.log(user.name) // Bob

2. 浅拷贝与深拷贝

展开语法和 Object.assign 都是浅拷贝:

js 复制代码
const state = {
  user: { name: 'Alice' },
  page: 1,
}

const next = { ...state }
next.user.name = 'Bob'

console.log(state.user.name) // Bob

不可变更新需要复制发生变化的每一层:

js 复制代码
const nextState = {
  ...state,
  user: {
    ...state.user,
    name: 'Carol',
  },
}

现代浏览器可用 structuredClone 深拷贝许多结构:

js 复制代码
const cloned = structuredClone({
  date: new Date(),
  map: new Map([['a', 1]]),
})

它不能克隆函数、DOM 节点等全部对象,也不会保留自定义类的所有行为。JSON.parse(JSON.stringify(value)) 会丢失 undefinedSymbol、函数,并把 Date 变成字符串,不应作为通用深拷贝方案。

3. ==、=== 与 Object.is

=== 不做隐式类型转换,应作为默认选择:

js 复制代码
0 === false // false
'' === false // false

Object.is=== 只有少数差异:

js 复制代码
Object.is(NaN, NaN) // true
Object.is(0, -0)    // false

业务代码中可以把 value == null 作为少数有意使用宽松相等的场景,它同时匹配 nullundefined

js 复制代码
if (value == null) {
  // value 是 null 或 undefined
}

前提是团队明确理解这个约定。

五、类型转换与运算符

1. 显式转换优先

js 复制代码
const count = Number(input.value)
const id = String(rawId)
const enabled = Boolean(value)

数字转换常见结果:

js 复制代码
Number('')      // 0
Number('  ')    // 0
Number('12px')  // NaN
parseInt('12px', 10) // 12

表单数字不能为空时,不能只判断 Number(value) 是否为真,因为合法值 0 也是假值。

2. + 运算符的双重身份

+ 同时负责数字相加和字符串拼接:

js 复制代码
1 + 2       // 3
'1' + 2     // '12'
1 + 2 + '3' // '33'

接口字段类型不稳定时,这类 bug 很常见。进入业务计算前先规范化数据:

js 复制代码
const quantity = Number(response.quantity)
const price = Number(response.price)
const total = quantity * price

3. 短路运算

js 复制代码
isAdmin && showAdminPanel()
const displayName = nickname || username
const pageSize = inputPageSize ?? 20

|| 返回第一个真值,会把 0''false 当作缺失;?? 只把 nullundefined 当作缺失。

js 复制代码
0 || 20  // 20
0 ?? 20  // 0

配置默认值通常优先使用 ??

4. 可选链

js 复制代码
const city = user?.address?.city
const result = callback?.(data)

可选链适合"数据确实允许缺失"的地方,不应该用来掩盖本应存在的数据。核心配置缺失时,主动抛错比一路返回 undefined 更容易定位问题。

5. 解构、展开与剩余参数

js 复制代码
const { id, name, ...extra } = user
const [first, second] = list

function sum(...numbers) {
  return numbers.reduce((total, n) => total + n, 0)
}

对象展开只复制自身可枚举属性,并且后面的同名属性覆盖前面的属性:

js 复制代码
const options = {
  timeout: 3000,
  ...userOptions,
}

这常用于默认配置,但要明确调用方是否应该有覆盖权限。

6. 运算符优先级与括号

不要依赖读者记住复杂优先级。表达式包含多种运算符时,用括号表达意图:

js 复制代码
const total = price * (count + giftCount)
const allowed = isAdmin || (isOwner && !isLocked)

逻辑运算符返回的是操作数,不一定是布尔值:

js 复制代码
'hello' && 123 // 123
'' || 'default' // 'default'

需要真正布尔值时显式转换:

js 复制代码
const hasItems = Boolean(items.length)

7. 位运算

位运算会把普通数字转换为 32 位整数:

js 复制代码
5 & 3  // 1,按位与
5 | 3  // 7,按位或
5 ^ 3  // 6,按位异或
1 << 3 // 8,左移
8 >> 2 // 2,有符号右移

工作中常见于权限位、颜色处理和二进制协议:

js 复制代码
const READ = 1 << 0   // 0001
const WRITE = 1 << 1  // 0010
const DELETE = 1 << 2 // 0100

const permissions = READ | WRITE
const canWrite = (permissions & WRITE) === WRITE

如果权限数量多、需要后端可读配置或可能超过 32 位,使用 Set、字符串数组或 BigInt 位掩码通常更安全。

六、流程控制与错误处理基础

1. if、switch 与查表

分支少时直接使用 if

js 复制代码
if (score >= 90) return 'A'
if (score >= 80) return 'B'
return 'C'

固定枚举映射更适合查表:

js 复制代码
const STATUS_TEXT = {
  pending: '待处理',
  success: '成功',
  failed: '失败',
}

const text = STATUS_TEXT[status] ?? '未知状态'

switch 要注意 break,也可以有意利用贯穿,但需要注释说明。

2. 循环如何选择

  • for:需要索引、复杂步进或高控制力
  • for...of:遍历可迭代对象的值
  • for...in:遍历对象可枚举属性键,不建议用于数组
  • forEach:执行副作用,不能 break,也不会等待异步回调
  • map:生成等长新数组
  • filter:筛选
  • reduce:聚合

错误示例:

js 复制代码
await list.forEach(async (item) => {
  await save(item)
})

串行执行:

js 复制代码
for (const item of list) {
  await save(item)
}

并行执行:

js 复制代码
await Promise.all(list.map((item) => save(item)))

3. try、catch、finally

js 复制代码
async function loadUser(id) {
  try {
    return await requestUser(id)
  } catch (error) {
    reportError(error)
    throw new Error('加载用户失败', { cause: error })
  } finally {
    stopLoading()
  }
}

finally 无论成功还是失败都会执行,适合关闭 loading、释放锁和清理资源。不要在 finallyreturn,否则可能覆盖原来的返回值或异常。

七、函数:JavaScript 的第一等公民

函数可以赋给变量、作为参数传入、从另一个函数返回。

js 复制代码
function createValidator(rule) {
  return (value) => rule(value)
}

const isPositive = createValidator((value) => value > 0)

1. 声明、表达式与箭头函数

js 复制代码
function add(a, b) {
  return a + b
}

const subtract = function (a, b) {
  return a - b
}

const multiply = (a, b) => a * b

箭头函数没有自己的 thisargumentssupernew.target,也不能作为构造函数。

2. 参数默认值

js 复制代码
function request(url, { timeout = 5000, retry = 0 } = {}) {
  // ...
}

request('/users')
request('/users', { timeout: 10_000 })

对象参数比多个布尔参数更清晰:

js 复制代码
// 不清楚 true、false 的含义
createUser(data, true, false)

createUser(data, {
  sendEmail: true,
  activateImmediately: false,
})

3. 纯函数与副作用

纯函数在相同输入下得到相同输出,并且不修改外部状态:

js 复制代码
function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price * item.count, 0)
}

网络请求、DOM 修改、日志、时间和随机数都是副作用。实际项目不可能没有副作用,重点是把副作用集中在边界层,让核心计算保持可测试。

4. 高阶函数

接收函数或返回函数的函数称为高阶函数:

js 复制代码
function withLogging(fn) {
  return (...args) => {
    console.log('start', args)
    const result = fn(...args)
    console.log('end', result)
    return result
  }
}

工作中的权限包装、埋点、重试、中间件、React HOC 都属于这个思路。

5. 防抖与节流

防抖:连续触发时只执行最后一次,适合搜索框。

js 复制代码
function debounce(fn, delay) {
  let timer

  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, delay)
  }
}

节流:一段时间最多执行一次,适合滚动、拖拽和窗口尺寸变化。

js 复制代码
function throttle(fn, interval) {
  let lastTime = 0

  return function (...args) {
    const now = Date.now()
    if (now - lastTime >= interval) {
      lastTime = now
      fn.apply(this, args)
    }
  }
}

生产实现还要考虑首调用、尾调用、取消定时器和组件卸载清理。

八、作用域、执行上下文与闭包

1. 词法作用域

JavaScript 使用词法作用域:函数能访问哪些变量,由函数定义位置决定,而不是调用位置决定。

js 复制代码
const name = 'global'

function outer() {
  const name = 'outer'

  return function inner() {
    console.log(name)
  }
}

const fn = outer()
fn() // outer

2. 执行上下文与调用栈

执行全局代码或调用函数时,运行时会创建对应执行上下文。当前正在执行的上下文位于调用栈顶部。

js 复制代码
function first() {
  second()
}

function second() {
  console.trace()
}

first()

递归没有终止条件时会持续压栈,最终出现栈溢出。

3. 闭包

闭包是函数和它创建时可访问的词法环境的组合。只要内部函数仍被引用,它就能继续访问外部变量。

js 复制代码
function createCounter() {
  let count = 0

  return {
    increment() {
      count += 1
      return count
    },
    getValue() {
      return count
    },
  }
}

const counter = createCounter()
counter.increment() // 1
counter.increment() // 2

真实场景:

  • 保存模块私有状态
  • 工厂函数
  • 事件处理器记住上下文
  • 防抖和节流中的定时器
  • React Hook 中的状态捕获
  • 函数柯里化与缓存

闭包也可能导致旧值问题:

js 复制代码
let count = 0

function createLogger() {
  const message = `count is ${count}`
  return () => console.log(message)
}

const log = createLogger()
count = 10
log() // count is 0

闭包保存的是它引用的变量关系,但上例中的 message 已经在创建时计算成字符串。前端框架里的 stale closure 本质上也与创建时捕获的环境有关。

4. 循环中的闭包

js 复制代码
for (var i = 0; i < 3; i += 1) {
  setTimeout(() => console.log(i), 0)
}
// 3 3 3

var 只有一个函数级绑定。改用 let 后,每轮循环都有独立绑定:

js 复制代码
for (let i = 0; i < 3; i += 1) {
  setTimeout(() => console.log(i), 0)
}
// 0 1 2

九、this、call、apply、bind 与 new

普通函数的 this 主要由调用方式决定。

1. 四种常见绑定

js 复制代码
function show() {
  console.log(this.name)
}

默认调用:

js 复制代码
show()

对象方法调用:

js 复制代码
const user = { name: 'Alice', show }
user.show() // Alice

显式绑定:

js 复制代码
show.call({ name: 'Bob' })
show.apply({ name: 'Carol' }, [])
const boundShow = show.bind({ name: 'David' })

构造调用:

js 复制代码
function User(name) {
  this.name = name
}

const alice = new User('Alice')

2. call、apply、bind 的区别

  • call(thisArg, a, b):立即调用,参数逐个传
  • apply(thisArg, [a, b]):立即调用,参数用数组传
  • bind(thisArg, a):返回新函数,可以预设参数
js 复制代码
function greet(prefix, suffix) {
  return `${prefix}${this.name}${suffix}`
}

greet.call({ name: 'Alice' }, 'Hi ', '!')
greet.apply({ name: 'Alice' }, ['Hi ', '!'])
const sayHi = greet.bind({ name: 'Alice' }, 'Hi ')
sayHi('!')

3. 箭头函数的 this

箭头函数不创建自己的 this,而是使用外层词法环境中的 this

js 复制代码
const timer = {
  seconds: 0,
  start() {
    setInterval(() => {
      this.seconds += 1
    }, 1000)
  },
}

不要用箭头函数定义需要动态 this 的对象方法:

js 复制代码
const user = {
  name: 'Alice',
  // show: () => console.log(this.name) // 通常不是 user
}

4. new 做了什么

执行 new User('Alice') 可以近似理解为:

  1. 创建新对象。
  2. 把新对象的原型连接到 User.prototype
  3. 以新对象作为 this 调用 User
  4. 如果构造函数没有显式返回对象,就返回这个新对象。

十、对象、属性描述符与常用操作

1. 对象属性

js 复制代码
const key = 'role'
const user = {
  id: 1,
  name: 'Alice',
  [key]: 'admin',
  greet() {
    return `Hi, ${this.name}`
  },
}

属性访问:

js 复制代码
user.name
user['name']
user[key]

动态属性名必须使用方括号。

2. 判断属性是否存在

js 复制代码
'name' in user // 包含原型链
Object.hasOwn(user, 'name') // 只判断自身属性

不要直接使用 user.hasOwnProperty('name'),因为对象可能没有这个方法或覆盖了它。

3. 遍历对象

js 复制代码
Object.keys(user)
Object.values(user)
Object.entries(user)

for (const [key, value] of Object.entries(user)) {
  console.log(key, value)
}

Object.keys 只返回自身可枚举字符串键,不包含 Symbol 键。

4. 属性描述符

每个属性都有描述符:

js 复制代码
Object.defineProperty(user, 'id', {
  value: 1,
  writable: false,
  enumerable: true,
  configurable: false,
})
  • writable:是否可赋值
  • enumerable:是否出现在常规遍历中
  • configurable:是否可删除或重新配置

访问器属性:

js 复制代码
const product = {
  price: 100,
  count: 2,
  get total() {
    return this.price * this.count
  },
  set discount(value) {
    this.price *= 1 - value
  },
}

getter 不应执行昂贵计算或产生隐藏副作用,否则一次普通属性读取会变得不可预测。

5. 防止对象变化

js 复制代码
Object.preventExtensions(obj) // 不允许新增
Object.seal(obj)              // 不允许新增、删除或重新配置
Object.freeze(obj)            // 再加上数据属性不可写

这些方法都是浅层操作。

十一、原型链、构造函数与 class

1. 原型链查找

读取对象属性时,运行时先查对象自身;没找到就沿着内部 [[Prototype]] 链向上查,直到 null

js 复制代码
const animal = {
  speak() {
    return 'sound'
  },
}

const dog = Object.create(animal)
dog.name = 'Lucky'

dog.speak() // 从 animal 找到

检查原型:

js 复制代码
Object.getPrototypeOf(dog) === animal // true

不要在性能敏感路径频繁使用 Object.setPrototypeOf 动态修改原型。

2. 构造函数模式

js 复制代码
function User(name) {
  this.name = name
}

User.prototype.greet = function () {
  return `Hi, ${this.name}`
}

const user = new User('Alice')

把共享方法放在原型上,避免每个实例创建一份相同函数。

3. class 是原型机制的语法抽象

js 复制代码
class User {
  #token

  constructor(name, token) {
    this.name = name
    this.#token = token
  }

  greet() {
    return `Hi, ${this.name}`
  }

  getToken() {
    return this.#token
  }

  static createGuest() {
    return new User('Guest', '')
  }
}

私有字段 #token 由语言强制限制,和命名约定 _token 不同。

继承:

js 复制代码
class Admin extends User {
  constructor(name, token, permissions) {
    super(name, token)
    this.permissions = permissions
  }
}

工作中优先考虑组合而不是建立很深的继承树。继承适合稳定的"is-a"关系;能力组合、策略对象和小函数通常更灵活。

4. instanceof 的含义

js 复制代码
user instanceof User

它判断 User.prototype 是否出现在 user 的原型链上,不是检查静态类型。跨 iframe、原型被修改或手动构造原型链时都可能得到意外结果。

十二、数组与函数式数据处理

1. 数组的本质

数组是带有特殊索引和 length 行为的对象:

js 复制代码
const list = ['a', 'b']
list[5] = 'f'
console.log(list.length) // 6

中间会形成稀疏位置。业务数据尽量避免稀疏数组。

2. 会修改原数组的方法

text 复制代码
push、pop、shift、unshift、splice、sort、reverse、fill、copyWithin

不修改原数组的常见方法:

text 复制代码
map、filter、slice、concat、flat、flatMap、toSorted、toReversed、toSpliced、with

sort 默认按字符串排序:

js 复制代码
[2, 10, 3].sort() // [10, 2, 3]
[2, 10, 3].sort((a, b) => a - b) // [2, 3, 10]

不希望修改状态数组时使用:

js 复制代码
const sorted = users.toSorted((a, b) => b.score - a.score)

3. map、filter、reduce

js 复制代码
const enabledUsers = users
  .filter((user) => user.enabled)
  .map((user) => ({
    id: user.id,
    label: user.name,
  }))

reduce 适合真正的聚合:

js 复制代码
const total = items.reduce(
  (sum, item) => sum + item.price * item.count,
  0,
)

分组:

js 复制代码
const usersByRole = users.reduce((result, user) => {
  const role = user.role
  result[role] ??= []
  result[role].push(user)
  return result
}, {})

如果 reduce 写成很难理解的一大段,普通循环通常更清晰。

4. 查找与判断

js 复制代码
users.find((user) => user.id === targetId)
users.findIndex((user) => user.id === targetId)
users.some((user) => user.enabled)
users.every((user) => user.valid)
users.includes(targetUser)

find 返回元素,filter 返回数组;只需要一项时不要用 filter()[0]

5. 去重

原始值去重:

js 复制代码
const uniqueIds = [...new Set(ids)]

对象按 ID 去重:

js 复制代码
const uniqueUsers = [
  ...new Map(users.map((user) => [user.id, user])).values(),
]

后出现的相同 ID 会覆盖前面的对象。

十三、Map、Set、WeakMap 与 WeakSet

1. Map

Map 的键可以是任意值,且保留插入顺序:

js 复制代码
const cache = new Map()
cache.set(user, { permissions: ['read'] })
cache.get(user)
cache.has(user)
cache.delete(user)

适用场景:

  • 键不是字符串
  • 频繁增删
  • 需要可靠的 size
  • 数据本身就是键值集合,不是领域对象

普通对象更适合表达有固定字段的业务实体。

2. Set

js 复制代码
const selectedIds = new Set()
selectedIds.add('1001')
selectedIds.has('1001')
selectedIds.delete('1001')

适合选择状态、权限集合、已访问节点和去重。

集合运算可以这样表达:

js 复制代码
const intersection = new Set(
  [...a].filter((value) => b.has(value)),
)

3. WeakMap 与 WeakSet

Weak 集合只接受对象作为键或成员,不阻止对象被垃圾回收,也不可枚举。

js 复制代码
const metadata = new WeakMap()

function getMetadata(element) {
  if (!metadata.has(element)) {
    metadata.set(element, { clicks: 0 })
  }
  return metadata.get(element)
}

适合给 DOM、实例或第三方对象附加生命周期一致的元数据。

4. JSON 序列化

JSON 只支持对象、数组、字符串、数字、布尔值和 null。它不是 JavaScript 对象的完整表示。

js 复制代码
const text = JSON.stringify({ id: 1, name: 'Alice' })
const data = JSON.parse(text)

序列化时的典型变化:

js 复制代码
JSON.stringify({
  empty: undefined, // 对象属性被忽略
  fn: () => {},     // 被忽略
  symbol: Symbol(), // 被忽略
  date: new Date(), // 转为 ISO 字符串
  invalid: NaN,     // 转为 null
})

数组中的 undefined、函数和 Symbol 通常会变成 null。循环引用会抛出异常,BigInt 默认也无法序列化。

可以使用 replacer 和 reviver 控制转换:

js 复制代码
const json = JSON.stringify(data, (key, value) => {
  if (typeof value === 'bigint') return `${value}n`
  return value
})

const parsed = JSON.parse(json, (key, value) => {
  if (typeof value === 'string' && /^\d+n$/.test(value)) {
    return BigInt(value.slice(0, -1))
  }
  return value
})

解析外部 JSON 时仍要做运行时校验。JSON.parse 只能保证语法合法,不能保证字段符合业务类型。

5. ArrayBuffer、TypedArray 与 DataView

ArrayBuffer 表示一段固定长度的原始二进制内存。TypedArray 提供按某种数字格式读写这段内存的视图:

js 复制代码
const buffer = new ArrayBuffer(8)
const bytes = new Uint8Array(buffer)

bytes[0] = 255
bytes[1] = 16

常见 TypedArray:

text 复制代码
Uint8Array、Uint8ClampedArray、Int16Array、Uint32Array、
Float32Array、Float64Array、BigInt64Array

DataView 适合按不同类型和字节序读取协议数据:

js 复制代码
const view = new DataView(buffer)
view.setUint32(0, 0x12345678, false) // false 表示大端序
const value = view.getUint32(0, false)

真实场景:

  • 文件读取和上传分片
  • Canvas 像素
  • WebSocket 二进制消息
  • 音视频处理
  • 压缩和加密
  • WebAssembly 数据交换

TypedArray 的 slice 通常复制数据,subarray 创建共享底层缓冲区的视图。修改共享视图可能影响原数据,使用时要明确是否需要复制。

6. SharedArrayBuffer 与 Atomics

普通 Worker 的消息数据通常通过结构化克隆或转移传递;SharedArrayBuffer 则允许多个执行代理查看同一块内存。

js 复制代码
const shared = new SharedArrayBuffer(4)
const counter = new Int32Array(shared)

Atomics.add(counter, 0, 1)
console.log(Atomics.load(counter, 0))

共享内存会带来真正的并发问题,因此需要 Atomics 提供原子读写、比较交换、等待和唤醒等能力。它适合并行计算、底层运行时和 WebAssembly,不适合普通表单和页面状态。

浏览器启用 SharedArrayBuffer 通常还要求页面满足跨源隔离条件。使用前应确认部署响应头、浏览器支持和安全模型。

十四、字符串、正则、日期与国际化

1. 字符串实用方法

js 复制代码
text.trim()
text.includes(keyword)
text.startsWith('https://')
text.replaceAll('-', '/')
text.padStart(2, '0')

Unicode 下"字符长度"不总等于 string.length

js 复制代码
'😀'.length // 2
[...'😀'].length // 1

更复杂的用户可见字符还可能由多个码点组成,需要 Intl.Segmenter 才能按字素处理。

2. 正则表达式

js 复制代码
const phonePattern = /^1\d{10}$/
phonePattern.test('13800138000')

常用标志:

  • g:全局匹配
  • i:忽略大小写
  • m:多行模式
  • s:让 . 匹配换行
  • u:Unicode 模式
  • y:粘连匹配

命名捕获组:

js 复制代码
const result = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/.exec(
  '2026-08-13',
)

result.groups.year // '2026'

不要试图用一个巨大正则完整验证邮箱、URL 或 HTML。工作中优先使用结构化解析器和浏览器内置 URL

3. Date

Date 内部表示一个时间点,但解析和展示会涉及时区。

js 复制代码
const now = new Date()
const timestamp = Date.now()
const iso = now.toISOString()

避免解析不明确格式:

js 复制代码
// 不同环境可能有歧义
new Date('08/09/2026')

// 使用 ISO 8601
new Date('2026-08-09T10:00:00+08:00')

接口推荐传 ISO 字符串、时间戳或明确约定时区。日期选择器、数据库和接口必须使用一致语义。

4. Intl

不要手写货币、日期和数字分隔格式:

js 复制代码
const currency = new Intl.NumberFormat('zh-CN', {
  style: 'currency',
  currency: 'CNY',
})

currency.format(123456.78)
js 复制代码
const dateFormatter = new Intl.DateTimeFormat('zh-CN', {
  dateStyle: 'long',
  timeStyle: 'short',
  timeZone: 'Asia/Shanghai',
})

排序和搜索时可用 Intl.Collator,复数规则可用 Intl.PluralRules

十五、迭代器与生成器

1. 可迭代协议

只要对象实现 [Symbol.iterator]() 并返回迭代器,就能被 for...of、展开语法等消费。

js 复制代码
const range = {
  from: 1,
  to: 3,
  [Symbol.iterator]() {
    let current = this.from
    const end = this.to

    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false }
        }
        return { value: undefined, done: true }
      },
    }
  },
}

[...range] // [1, 2, 3]

2. Generator

生成器用更直观的方式创建迭代器:

js 复制代码
function* range(from, to) {
  for (let current = from; current <= to; current += 1) {
    yield current
  }
}

[...range(1, 3)]

真实场景:

  • 惰性处理大量数据
  • 分页数据流
  • 状态机
  • 自定义遍历结构
  • Redux Saga 一类流程控制

异步生成器:

js 复制代码
async function* loadPages() {
  let page = 1
  let hasNext = true

  while (hasNext) {
    const result = await requestPage(page)
    yield result.list
    hasNext = result.hasNext
    page += 1
  }
}

for await (const list of loadPages()) {
  render(list)
}

十六、异步基础:JavaScript 如何处理等待

JavaScript 代码通常在一个主线程上执行,但浏览器可以把定时器、网络、用户事件等工作交给宿主环境。完成后,相应回调进入任务队列等待调用栈空闲。

1. 同步代码与调用栈

js 复制代码
console.log('A')
console.log('B')
console.log('C')

按顺序输出 A、B、C。同步长任务会阻塞点击、输入和渲染。

2. 回调与回调地狱

js 复制代码
getUser(id, (user) => {
  getOrders(user.id, (orders) => {
    getDetails(orders[0].id, (detail) => {
      console.log(detail)
    })
  })
})

嵌套回调的问题不只是缩进,还包括错误传递、取消和流程组合困难。Promise 用统一状态模型解决这类问题。

十七、Promise 详解

Promise 有三种状态:

text 复制代码
pending -> fulfilled
pending -> rejected

状态一旦确定就不会再次改变。

js 复制代码
const promise = new Promise((resolve, reject) => {
  doSomething((error, value) => {
    if (error) {
      reject(error)
      return
    }
    resolve(value)
  })
})

1. then 返回新 Promise

js 复制代码
fetchUser(id)
  .then((user) => fetchOrders(user.id))
  .then((orders) => orders.filter((order) => order.paid))
  .then(renderOrders)
  .catch(showError)
  .finally(stopLoading)

then 回调:

  • 返回普通值:下一个 Promise fulfilled
  • 返回 Promise:等待它的结果
  • 抛出异常:下一个 Promise rejected

忘记 return 是常见错误:

js 复制代码
fetchUser(id).then((user) => {
  return fetchOrders(user.id)
})

2. Promise 静态方法

Promise.all:全部成功才成功,任一失败立即拒绝。

js 复制代码
const [user, permissions] = await Promise.all([
  fetchUser(id),
  fetchPermissions(id),
])

适合互不依赖的并行请求。

Promise.allSettled:等待全部结束,保留每项结果。

js 复制代码
const results = await Promise.allSettled(tasks)
const failed = results.filter((item) => item.status === 'rejected')

适合批量上传、批量通知等允许部分失败的场景。

Promise.race:第一个确定状态的结果决定最终结果。

js 复制代码
await Promise.race([request(), timeout(5000)])

它不会自动取消输掉的任务。

Promise.any:第一个成功的结果;全部失败时抛 AggregateError。适合多个镜像源或备用服务。

3. Promise 不是让 CPU 任务并行

js 复制代码
Promise.resolve().then(() => heavyCalculation())

这仍然会在主线程执行并阻塞页面。CPU 密集工作应拆分、使用 Web Worker 或移到服务端。

十八、async/await、并发与取消

async 函数一定返回 Promise:

js 复制代码
async function getValue() {
  return 1
}

getValue().then(console.log)

1. 顺序执行与并行执行

顺序执行:

js 复制代码
const user = await fetchUser()
const orders = await fetchOrders(user.id)

第二个请求依赖第一个结果,必须顺序执行。

并行执行:

js 复制代码
const userPromise = fetchUser()
const configPromise = fetchConfig()

const [user, config] = await Promise.all([
  userPromise,
  configPromise,
])

两个请求无依赖时,不要先后 await 增加总耗时。

2. 控制并发数量

一次向服务端发送几千个请求会压垮浏览器和接口。一个简单并发池:

js 复制代码
async function runWithConcurrency(tasks, limit = 5) {
  const results = new Array(tasks.length)
  let nextIndex = 0

  async function worker() {
    while (nextIndex < tasks.length) {
      const currentIndex = nextIndex
      nextIndex += 1
      results[currentIndex] = await tasks[currentIndex]()
    }
  }

  const workers = Array.from(
    { length: Math.min(limit, tasks.length) },
    () => worker(),
  )

  await Promise.all(workers)
  return results
}

适合批量上传、数据迁移、图片处理和批量接口请求。

3. AbortController

Promise 本身没有统一取消协议,但许多 Web API 支持 AbortSignal

js 复制代码
const controller = new AbortController()

fetch('/api/search?q=javascript', {
  signal: controller.signal,
})

controller.abort()

搜索联想场景应取消旧请求,避免旧响应晚到后覆盖新结果:

js 复制代码
let searchController

async function search(keyword) {
  searchController?.abort()
  searchController = new AbortController()

  const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
    signal: searchController.signal,
  })

  return response.json()
}

十九、事件循环、宏任务与微任务

浏览器一次典型事件循环可简化为:

text 复制代码
取出一个任务执行
  -> 调用栈清空
  -> 清空微任务队列
  -> 浏览器可能渲染
  -> 进入下一轮任务

常见任务来源:

  • 首次执行脚本
  • setTimeout / setInterval
  • DOM 事件
  • 网络事件回调
  • postMessage

常见微任务来源:

  • Promise 的 then/catch/finally
  • queueMicrotask
  • MutationObserver

经典示例:

js 复制代码
console.log('script start')

setTimeout(() => console.log('timeout'), 0)

Promise.resolve().then(() => console.log('promise'))

queueMicrotask(() => console.log('microtask'))

console.log('script end')

输出:

text 复制代码
script start
script end
promise
microtask
timeout

Promise 回调和 queueMicrotask 按进入微任务队列的顺序执行。

微任务饥饿

微任务可以继续创建微任务,事件循环会持续清空队列:

js 复制代码
function loop() {
  queueMicrotask(loop)
}

这会阻止后续任务和渲染。不要用递归微任务执行无限工作。

setTimeout(fn, 0) 不是立即执行

它表示达到最小延迟后把回调排入任务队列,仍要等待当前代码、微任务和前面的任务完成。

浏览器与 Node.js 的区别

两者都有事件循环,但阶段和 API 细节不同。Node.js 还有 process.nextTicksetImmediate 等机制。不要把浏览器中的执行顺序结论不加区分地套到 Node.js。

二十、模块化:从全局变量到 ESM

1. ES Module

js 复制代码
// math.js
export function add(a, b) {
  return a + b
}

export const PI = 3.14159
js 复制代码
// app.js
import { add, PI } from './math.js'

默认导出:

js 复制代码
export default function createApp() {}
import createApp from './createApp.js'

具名导出更方便重构和自动导入,默认导出适合模块确实只有一个主要概念的场景。

2. ESM 的特点

  • 静态结构,工具能在运行前分析依赖
  • 模块默认严格模式
  • 顶层变量不会自动污染全局
  • 导入是 live binding,不是简单值拷贝
  • 支持动态 import()
js 复制代码
const module = await import('./heavy-editor.js')
module.openEditor()

动态导入适合路由懒加载、重型编辑器和低频功能。

3. 循环依赖

text 复制代码
a.js -> b.js -> a.js

ESM 能处理一部分循环依赖,但初始化顺序可能让某些绑定暂时不可用。出现循环依赖通常说明模块职责可以重新划分:提取共享类型、常量或底层函数到第三个模块。

4. CommonJS 与 ESM

CommonJS 使用 requiremodule.exports,Node.js 历史项目中仍很常见。ESM 使用 import/export。两者在加载时机、绑定语义和互操作上有差异,工程配置中不要随意混用。

5. Tree Shaking

构建工具可以基于 ESM 静态结构删除未使用导出。前提是代码无不可判断的副作用,并正确配置包的 sideEffects。Tree Shaking 是构建工具行为,不是 JavaScript 运行时自动完成的。

二十一、DOM、事件与浏览器交互

1. 查询与修改 DOM

js 复制代码
const button = document.querySelector('[data-action="save"]')
button.textContent = '保存'
button.classList.add('primary')

创建节点优先使用 DOM API,不要把用户输入直接放进 innerHTML

js 复制代码
const item = document.createElement('li')
item.textContent = userInput
list.append(item)

2. 事件传播

事件通常经历捕获、目标、冒泡三个阶段。

js 复制代码
element.addEventListener('click', handler, {
  capture: false,
  once: true,
  passive: true,
  signal: controller.signal,
})
  • preventDefault():阻止默认行为
  • stopPropagation():阻止继续传播
  • stopImmediatePropagation():还阻止同元素后续监听器

不要把阻止冒泡作为默认做法,它会破坏上层事件委托和分析逻辑。

3. 事件委托

大量动态列表项不需要逐个绑定事件:

js 复制代码
list.addEventListener('click', (event) => {
  const button = event.target.closest('[data-delete-id]')
  if (!button || !list.contains(button)) return

  removeItem(button.dataset.deleteId)
})

适合表格行操作、菜单和动态列表。closest 后再检查容器范围,避免匹配到容器之外的节点。

4. DOMContentLoaded 与 load

  • DOMContentLoaded:HTML 解析完成,DOM 可用
  • load:页面依赖资源也加载完成

模块脚本通常会延迟到文档解析完成后执行,现代框架一般不需要手写这两个监听。

5. requestAnimationFrame

视觉更新应尽量和浏览器渲染节奏同步:

js 复制代码
function animate() {
  updatePosition()
  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

它适合动画,不适合精确定时或后台任务;页面不可见时浏览器可能降低执行频率。

二十二、网络请求、HTTP 与数据边界

1. fetch 基础

js 复制代码
async function requestJSON(url, options = {}) {
  const response = await fetch(url, {
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
    ...options,
  })

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }

  return response.json()
}

fetch 遇到 404、500 不会自动 reject;通常只有网络层失败或请求被取消才 reject,所以必须检查 response.ok

2. GET 与 POST

  • GET 用于读取,参数通常在 URL 中,可被缓存
  • POST 用于提交或创建,数据通常在请求体中
  • PUT 常表达整体替换
  • PATCH 常表达部分更新
  • DELETE 表达删除

实际语义由服务端契约决定,但保持 HTTP 语义能让缓存、监控和调试更清楚。

3. URL 与查询参数

js 复制代码
const url = new URL('/api/users', location.origin)
url.searchParams.set('page', '1')
url.searchParams.set('keyword', keyword)

不要手工拼接并忘记编码:

js 复制代码
encodeURIComponent(keyword)

4. CORS

CORS 是浏览器执行的跨源访问控制机制。服务端通过响应头声明哪些源、方法和请求头可以访问。前端不能通过设置一个魔法参数绕过 CORS。

复杂跨源请求可能先发送预检 OPTIONS。开发环境代理能解决本地联调,但生产环境仍要正确配置网关或服务端。

5. 竞态条件

js 复制代码
let requestId = 0

async function load(keyword) {
  const currentId = ++requestId
  const result = await search(keyword)

  if (currentId !== requestId) return
  render(result)
}

更好的方式通常是取消旧请求,但请求 ID 也能防止过期结果覆盖新状态。

6. 重试

只对适合重试的错误和幂等请求重试,并使用指数退避:

js 复制代码
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

async function retry(fn, times = 3) {
  let lastError

  for (let attempt = 0; attempt < times; attempt += 1) {
    try {
      return await fn()
    } catch (error) {
      lastError = error
      await sleep(2 ** attempt * 300)
    }
  }

  throw lastError
}

支付、创建订单等非幂等操作不能盲目重试,需要服务端幂等键。

1. localStorage

js 复制代码
localStorage.setItem('theme', 'dark')
localStorage.getItem('theme')
localStorage.removeItem('theme')

特点:同步、持久、同源共享、只能存字符串。不要存密码、长期令牌或敏感个人信息。大量读写会阻塞主线程。

对象需要序列化并处理异常:

js 复制代码
function readJSON(key, fallback) {
  try {
    const value = localStorage.getItem(key)
    return value ? JSON.parse(value) : fallback
  } catch {
    return fallback
  }
}

2. sessionStorage

API 类似 localStorage,通常绑定当前标签页会话,关闭标签页后清除。

Cookie 会按规则随请求发送,常用于服务端会话。重要属性:

  • HttpOnly:JavaScript 不能读取,降低令牌被 XSS 窃取的风险
  • Secure:只通过 HTTPS 发送
  • SameSite:限制跨站发送,帮助缓解 CSRF
  • Expires / Max-Age:有效期

认证 Cookie 应由服务端设置合适的安全属性。

4. IndexedDB

适合大量结构化数据、离线缓存和文件 Blob。它是异步数据库,不应因为 API 比 localStorage 复杂就用同步存储硬塞大数据。

二十四、Proxy、Reflect 与元编程

1. Proxy

Proxy 可以拦截对象的基础操作:

js 复制代码
const state = new Proxy(
  { count: 0 },
  {
    get(target, key, receiver) {
      console.log('get', key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      console.log('set', key, value)
      return Reflect.set(target, key, value, receiver)
    },
  },
)

真实场景:

  • 响应式系统
  • 数据校验
  • API mock
  • 访问日志
  • 默认值代理

Proxy 会改变对象行为,过度使用会让调试困难。对单个属性的简单计算,普通 getter 更直接。

2. Reflect

Reflect 提供与许多对象内部操作对应的方法:

js 复制代码
Reflect.get(target, key, receiver)
Reflect.set(target, key, value, receiver)
Reflect.has(target, key)
Reflect.ownKeys(target)

在 Proxy trap 中使用 Reflect,可以保留默认语义并正确传递 receiver

3. 常用 Symbol 协议

  • Symbol.iterator:定义遍历
  • Symbol.asyncIterator:定义异步遍历
  • Symbol.toPrimitive:自定义原始值转换
  • Symbol.toStringTag:影响对象标签
  • Symbol.hasInstance:自定义 instanceof
js 复制代码
const money = {
  amount: 100,
  [Symbol.toPrimitive](hint) {
    return hint === 'string' ? `¥${this.amount}` : this.amount
  },
}

这种能力适合库设计,不建议在普通业务对象上制造出乎意料的隐式行为。

二十五、错误模型与可靠性设计

1. Error 类型

常见内置错误:

  • SyntaxError
  • ReferenceError
  • TypeError
  • RangeError
  • URIError
  • AggregateError

自定义业务错误:

js 复制代码
class ValidationError extends Error {
  constructor(message, field) {
    super(message)
    this.name = 'ValidationError'
    this.field = field
  }
}

调用方可以按错误类型决定行为:

js 复制代码
try {
  await submit(form)
} catch (error) {
  if (error instanceof ValidationError) {
    focusField(error.field)
    return
  }
  throw error
}

2. 不要吞掉异常

js 复制代码
try {
  await save()
} catch (error) {
  console.error(error)
}

如果调用方需要知道失败,上面还不够。应该返回明确结果或重新抛出:

js 复制代码
catch (error) {
  reportError(error)
  throw error
}

3. 预期错误与程序错误

  • 预期错误:表单不合法、权限不足、资源不存在
  • 程序错误:访问 undefined 属性、不可能状态、代码缺陷

预期错误需要友好提示;程序错误需要记录上下文并尽快暴露,不能统一弹一句"操作失败"后彻底吞掉。

4. 全局错误监听

浏览器可以监听:

js 复制代码
window.addEventListener('error', (event) => {
  reportError(event.error)
})

window.addEventListener('unhandledrejection', (event) => {
  reportError(event.reason)
})

全局监听是最后防线,不是替代局部错误处理。上报时应包括版本、路由、用户操作上下文,但要移除令牌和敏感数据。

二十六、内存管理与常见泄漏

JavaScript 自动分配和回收内存,但"自动"不等于不需要关心。只要对象仍能从根对象沿引用链访问,垃圾回收器通常就不能释放它。

1. 生命周期

text 复制代码
分配 -> 使用 -> 不再可达 -> 垃圾回收

2. 常见泄漏来源

未清理定时器:

js 复制代码
const timer = setInterval(refresh, 5000)

// 页面销毁时
clearInterval(timer)

未移除事件:

js 复制代码
const controller = new AbortController()

window.addEventListener('resize', handleResize, {
  signal: controller.signal,
})

controller.abort()

闭包长期引用大对象:

js 复制代码
function createHandler(largeData) {
  return () => largeData.id
}

如果只需要 ID,就不要让闭包保留整个大对象。

无限增长缓存:

js 复制代码
const cache = new Map()

缓存需要容量上限、过期策略或 LRU。对象键缓存可考虑 WeakMap

游离 DOM:节点从页面移除后,如果 JavaScript 仍持有引用,它仍不能回收。

3. 如何排查

  • Chrome DevTools Memory Heap Snapshot
  • Allocation instrumentation
  • Performance 面板观察长期增长
  • 重复执行"进入页面 -> 操作 -> 离开页面",比较对象数量

不要只看一次内存高低,重点看相同操作后内存是否持续无法回落。

4. 显式资源管理

垃圾回收负责内存,不负责所有外部资源。文件句柄、锁、订阅和连接通常需要显式释放。

通用写法是 try/finally

js 复制代码
const connection = await openConnection()

try {
  await connection.query(sql)
} finally {
  await connection.close()
}

较新的 ECMAScript 还定义了显式资源管理协议:同步资源实现 Symbol.dispose,异步资源实现 Symbol.asyncDispose,并配合 using / await using。使用前需要检查运行环境和构建工具支持情况。

js 复制代码
class Subscription {
  [Symbol.dispose]() {
    this.unsubscribe()
  }
}

// 在支持该语法的环境中:
// using subscription = createSubscription()

它解决的是"离开作用域时可靠释放资源",不是替代垃圾回收。

5. WeakRef 与 FinalizationRegistry

WeakRef 可以创建不阻止垃圾回收的弱引用,FinalizationRegistry 可以在对象被回收后得到非确定性的清理通知。

这两个 API 适合底层库、缓存和诊断工具,不适合普通业务流程,因为垃圾回收何时发生不可预测。不能把关键保存、网络上报或业务状态更新放进最终化回调。

二十七、性能优化:先测量,再优化

1. 时间复杂度

数组查找通常是 O(n),Map 查找通常可视为平均 O(1)。

错误的嵌套查找:

js 复制代码
const result = orders.map((order) => ({
  ...order,
  user: users.find((user) => user.id === order.userId),
}))

数据量大时先建立索引:

js 复制代码
const userMap = new Map(users.map((user) => [user.id, user]))

const result = orders.map((order) => ({
  ...order,
  user: userMap.get(order.userId),
}))

2. 长任务

主线程上超过几十毫秒的连续工作可能造成明显卡顿。方案包括:

  • 分批处理并让出主线程
  • Web Worker
  • 虚拟列表
  • 减少 DOM 数量
  • 避免重复布局计算

3. 布局抖动

读取布局后修改样式,再读取再修改,可能迫使浏览器重复布局:

js 复制代码
// 尽量批量读取
const widths = elements.map((el) => el.offsetWidth)

// 再批量写入
elements.forEach((el, index) => {
  el.style.width = `${widths[index] * 2}px`
})

4. 缓存计算

js 复制代码
function memoize(fn) {
  const cache = new Map()

  return (key) => {
    if (cache.has(key)) return cache.get(key)
    const value = fn(key)
    cache.set(key, value)
    return value
  }
}

缓存不是免费优化。要考虑键是否稳定、内存是否增长、结果是否会过期,以及计算本身是否真的昂贵。

5. Web Worker

js 复制代码
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), {
  type: 'module',
})

worker.postMessage(largeData)
worker.onmessage = (event) => render(event.data)

Worker 不能直接操作 DOM,数据通过结构化克隆或 transferable object 传递。适合大数据计算、压缩、解析和图像处理。

二十八、安全:前端必须知道的 JavaScript 风险

1. XSS

用户输入被当作 HTML 或脚本执行:

js 复制代码
container.innerHTML = userInput // 危险

优先:

js 复制代码
container.textContent = userInput

确实需要富文本时,使用成熟的白名单清洗方案,并配置 CSP。仅靠替换 <script> 不足以防御 XSS。

2. eval 与动态代码

js 复制代码
eval(userInput)
new Function(userInput)()

会带来代码注入、CSP 冲突和优化困难。普通业务代码不应使用。

3. 原型污染

把不可信键递归合并到普通对象可能修改原型:

js 复制代码
// 需要防范 __proto__、constructor、prototype 等特殊键

使用经过审计的合并实现、键白名单,或用 Object.create(null) 保存纯字典数据。

4. CSRF

浏览器可能自动携带 Cookie,攻击站点可以诱导用户向目标站点发请求。常见防御:

  • SameSite Cookie
  • CSRF Token
  • 校验 Origin / Referer
  • 关键操作二次确认

CSRF 防御主要需要服务端配合。

5. 敏感信息

前端代码、Source Map、网络请求和浏览器存储都可被用户查看。任何放到前端的永久密钥都不能视为秘密。真正的密钥和权限校验必须留在服务端。

二十九、工程化中的 JavaScript

1. 目录与职责

text 复制代码
api/        网络边界
domain/     业务规则
components/ UI
hooks/      状态与生命周期组合
utils/      无业务或弱业务工具
constants/  稳定常量

不要把所有逻辑都放进 utils。有明确业务语义的计算应该靠近领域模块。

2. 数据在边界处规范化

js 复制代码
function normalizeUser(raw) {
  return {
    id: String(raw.id),
    name: String(raw.name ?? ''),
    enabled: Boolean(raw.enabled),
    createdAt: new Date(raw.created_at),
  }
}

页面内部使用稳定模型,比每个组件分别处理 '0'0null 更可靠。

3. 不可变数据

不可变更新让状态变化更容易比较、撤销和调试:

js 复制代码
const nextUsers = users.map((user) =>
  user.id === id ? { ...user, enabled: true } : user,
)

不是所有局部变量都必须不可变。函数内部为性能构建临时 Map 或数组是合理的,关键是不要意外修改共享状态和函数输入。

4. ESLint、格式化与类型

  • ESLint 发现未使用变量、危险 Promise、错误依赖等问题
  • 格式化工具消除无意义的样式争论
  • TypeScript 提供静态类型,但运行时数据仍需校验

TypeScript 不会改变 JavaScript 运行规则。闭包、原型、事件循环和类型转换仍然必须理解。

5. 测试层次

纯函数单元测试:

js 复制代码
import { describe, expect, it } from 'vitest'

describe('calculateTotal', () => {
  it('calculates price times count', () => {
    expect(
      calculateTotal([{ price: 10, count: 3 }]),
    ).toBe(30)
  })
})

还应根据风险增加:

  • 集成测试:多个模块协作
  • 组件测试:用户交互和展示
  • E2E:登录、支付、发布等关键流程

测试实现行为,不要过度绑定内部函数调用次数和 DOM 结构细节。

三十、常用设计模式在 JavaScript 中的落地

1. 工厂模式

js 复制代码
function createLogger(type) {
  if (type === 'remote') return new RemoteLogger()
  return new ConsoleLogger()
}

适合根据配置创建不同实现。

2. 策略模式

js 复制代码
const discountStrategies = {
  vip: (price) => price * 0.8,
  newUser: (price) => price - 20,
  default: (price) => price,
}

function calculatePrice(price, type) {
  return (discountStrategies[type] ?? discountStrategies.default)(price)
}

它比不断增加 if/else 更容易扩展。

3. 发布订阅

js 复制代码
function createEventBus() {
  const events = new Map()

  return {
    on(type, handler) {
      const handlers = events.get(type) ?? new Set()
      handlers.add(handler)
      events.set(type, handlers)
      return () => handlers.delete(handler)
    },
    emit(type, payload) {
      events.get(type)?.forEach((handler) => handler(payload))
    },
  }
}

适合低耦合通知,但滥用全局事件总线会让数据流难以追踪。能通过明确函数调用或状态管理完成时,不必引入事件总线。

4. 中间件

js 复制代码
const middlewares = [authMiddleware, logMiddleware, requestMiddleware]

function compose(middlewares) {
  return (context) => {
    function dispatch(index) {
      const middleware = middlewares[index]
      if (!middleware) return Promise.resolve()
      return Promise.resolve(
        middleware(context, () => dispatch(index + 1)),
      )
    }

    return dispatch(0)
  }
}

Express、Koa、Redux 等工具都能看到中间件思想。

三十一、常见面试题背后的真实知识

1. 为什么 typeof null 是 object

这是 JavaScript 早期实现遗留行为,不能据此认为 null 真的是对象。实际判断使用 value === null

2. 为什么 \[\] == !\[\] 是 true

js 复制代码
[] == ![]

大致过程:

  1. [] 是真值,所以 ![] 得到 false
  2. 宽松相等比较对象和布尔值时发生类型转换。
  3. false 转为 0
  4. [] 转为原始值 '',再转为 0
  5. 最终比较 0 == 0

实际工作中的结论不是背结果,而是默认使用 ===

3. 输出题

js 复制代码
console.log(1)

setTimeout(() => console.log(2), 0)

Promise.resolve()
  .then(() => console.log(3))
  .then(() => console.log(4))

console.log(5)

输出:1 5 3 4 2。关键是同步调用栈、Promise 微任务链和定时器任务。

4. this 丢失

js 复制代码
const user = {
  name: 'Alice',
  show() {
    console.log(this.name)
  },
}

const show = user.show
show()

函数脱离对象后,调用位置不再是 user.show(),因此不会自动绑定 user。可以使用 bind 或包装函数:

js 复制代码
const showUser = user.show.bind(user)
const showUser2 = () => user.show()

5. 深拷贝为什么难

完整深拷贝要考虑:

  • 循环引用
  • Date、RegExp、Map、Set、ArrayBuffer
  • Symbol 键
  • 属性描述符
  • 原型
  • 函数和闭包
  • DOM 与宿主对象

因此应先问业务到底要复制什么,而不是默认手写万能 deepClone

三十二、推荐的学习与排查顺序

遇到 JavaScript 问题时,可以按下面顺序定位:

  1. 值是什么类型? 是否发生隐式转换?
  2. 变量来自哪个作用域? 是否被闭包捕获?
  3. 函数如何被调用? this 到底绑定到谁?
  4. 对象属性来自自身还是原型?
  5. 代码是同步、微任务还是任务?
  6. 数据是否被意外修改? 是浅拷贝还是深层共享?
  7. 资源是否被清理? 定时器、事件、请求、缓存是否仍被引用?
  8. 问题属于语言还是宿主 API?

这套顺序比直接搜索某个输出结果更容易形成长期能力。

三十三、知识清单

学完后可以用这份清单自查:

text 复制代码
基础
□ var / let / const、提升、TDZ
□ 7 种原始类型、对象、typeof
□ 显式与隐式类型转换
□ == / === / Object.is
□ 运算符、可选链、空值合并
□ 条件、循环、异常

函数与对象
□ 函数声明、表达式、箭头函数
□ 参数、剩余参数、高阶函数、纯函数
□ 作用域、执行上下文、调用栈、闭包
□ this、call、apply、bind、new
□ 属性描述符、getter/setter
□ 原型链、构造函数、class、私有字段

数据结构
□ Array 的修改与非修改方法
□ Map、Set、WeakMap、WeakSet
□ String、RegExp、Date、Intl
□ Iterator、Generator、异步迭代

异步
□ 回调、Promise 状态与链式调用
□ all / allSettled / race / any
□ async/await、串行、并行、并发池
□ AbortController、竞态条件
□ 事件循环、任务、微任务、渲染

浏览器与工程
□ ESM、动态导入、循环依赖、Tree Shaking
□ DOM、事件传播、事件委托
□ fetch、HTTP、CORS、重试
□ Cookie、Storage、IndexedDB
□ Proxy、Reflect、Symbol 协议
□ 错误上报、内存泄漏、性能分析
□ XSS、CSRF、原型污染、敏感信息
□ 模块职责、数据规范化、测试与类型

总结

JavaScript 的难点不在 API 数量,而在几组会彼此影响的核心规则:类型转换决定表达式结果,词法作用域和调用方式决定变量与 this,原型链决定属性查找,任务与微任务决定异步顺序,可达性决定内存是否释放。

真正掌握 JavaScript,不是记住所有输出题,而是能从这些规则推导结果,并把规则转化成工程约束:默认使用严格相等,在边界规范化数据,明确串行和并行,主动取消过期请求,清理事件与定时器,不把敏感信息放到前端,用测量结果指导性能优化。

当这些基础成为习惯,再学习 React、Vue、Node.js 或任何框架时,就不会只停留在"API 怎么调用",而能理解框架为什么这样设计,以及问题到底发生在哪一层。

参考资料

这篇文章尝试建立一张由浅入深的 JavaScript 知识地图。它不会只罗列 API,而是对每个重要主题回答四个问题:

  1. 它是什么?
  2. 它为什么这样运行?
  3. 工作中什么时候使用?
  4. 最容易踩什么坑?

JavaScript 的标准语言部分由 ECMAScript 规范定义;DOM、fetchsetTimeoutlocalStorage 等属于宿主环境提供的 Web API。理解两者的边界,是学习 JavaScript 的第一步。

一、先建立 JavaScript 的整体地图

可以把 JavaScript 能力分成五层:

text 复制代码
语法基础
  变量、类型、运算符、流程控制、函数
        ↓
核心机制
  作用域、闭包、this、对象、原型、类
        ↓
数据与抽象
  Array、Map、Set、Iterator、Generator、Proxy
        ↓
运行时
  调用栈、事件循环、Promise、async/await、内存管理
        ↓
工程实践
  模块化、网络请求、DOM、错误处理、性能、安全、测试

初学时不需要先读完规范,但要知道:语法只是入口,运行机制决定代码在真实项目里是否可靠。

阅读这篇文章时要先分清三个层次

很多 JavaScript 问题难以理解,是因为把语言、运行时和框架混在了一起:

  1. ECMAScript 语言层:变量、类型、函数、对象、Promise、模块等规则。
  2. 宿主环境层 :浏览器提供 DOM、fetch、定时器;Node.js 提供文件、进程、网络等 API。
  3. 工程工具层:TypeScript、Babel、Vite、Webpack、ESLint 等负责类型检查、语法转换、打包和约束。

例如,setTimeout 不是 ECMAScript 语言的一部分,而是宿主提供的计时能力;Promise 是语言能力,但 Promise 回调何时得到执行,需要宿主的事件循环配合;TypeScript 能在编译期发现类型问题,却不会改变 JavaScript 最终的运行规则。

因此,遇到问题时先问一句:"这是语言规则、浏览器行为,还是构建工具行为?"定位会快很多。

二、变量、声明与执行前准备

0. Script、Module 与严格模式

JavaScript 源码可以作为传统 Script 或 ES Module 执行。Module 默认处于严格模式,并拥有独立模块作用域。

传统脚本可以显式开启严格模式:

js 复制代码
'use strict'

function run() {
  // 未声明变量、重复参数等不安全行为会更早暴露
}

严格模式的典型变化:

  • 普通函数直接调用时 thisundefined,不再自动指向全局对象
  • 给只读属性赋值会抛错
  • 禁止意外创建全局变量
  • 一些容易产生歧义的旧语法会报错

现代项目使用 ESM 时通常不需要再手写 'use strict'

1. var、let 和 const

现代 JavaScript 默认优先使用 const,确实需要重新赋值时使用 let,通常不再使用 var

js 复制代码
const API_TIMEOUT = 10_000
let retryCount = 0

retryCount += 1

三者的主要区别:

特性 var let const
作用域 函数作用域 块级作用域 块级作用域
重复声明 允许 不允许 不允许
声明前访问 得到 undefined 暂时性死区 暂时性死区
重新赋值 允许 允许 不允许
挂到全局对象 脚本顶层可能会 不会 不会

const 限制的是变量绑定不能改变,不代表对象不能修改:

js 复制代码
const user = { name: 'Alice' }
user.name = 'Bob' // 合法

// user = {} // TypeError

如果需要真正防止浅层修改,可以使用:

js 复制代码
const config = Object.freeze({ timeout: 3000 })

Object.freeze 是浅冻结,嵌套对象仍可修改。

2. 提升与暂时性死区

声明在代码执行前会进入对应环境记录,但不同声明的初始化时机不同。

js 复制代码
console.log(a) // undefined
var a = 1

console.log(b) // ReferenceError
let b = 2

不要简单记成"let 不提升"。更准确的说法是:letconst 的绑定已经创建,但在执行声明语句前没有初始化,这段区域称为暂时性死区(TDZ)。

函数声明也会被提前建立,因此可以先调用:

js 复制代码
sayHello()

function sayHello() {
  console.log('hello')
}

函数表达式遵循变量自己的初始化规则:

js 复制代码
// run() // ReferenceError
const run = function () {}

工作场景: 避免依赖提升组织代码。模块对外导出的主函数可以放在文件上方,辅助函数通过函数声明放在下方,既方便阅读又不会产生初始化问题。

三、数据类型:值到底是什么

JavaScript 有 7 种原始类型:

text 复制代码
undefined、null、boolean、number、bigint、string、symbol

除此之外都是对象,函数也是可调用的特殊对象。

原始值与对象最根本的区别,不是"存栈还是存堆"。栈和堆是具体引擎的实现细节,不应该当成语言定义。语言层面更可靠的理解是:

  • 原始值本身不可变,比较时通常比较值。
  • 对象是属性的集合,具有身份,比较时比较是否为同一个对象。
  • 变量中保存的始终是一个值;对象变量保存的那个值可以理解为指向对象的引用。
js 复制代码
const a = { id: 1 }
const b = { id: 1 }
const c = a

console.log(a === b) // false:内容一样,但不是同一个对象
console.log(a === c) // true:二者引用同一个对象

字符串看起来可以调用方法,是因为引擎在需要时会进行包装处理,并不代表字符串变成了可修改对象:

js 复制代码
const text = 'hello'
text.toUpperCase()       // 返回新的字符串 'HELLO'
console.log(text)        // 'hello'
console.log(text.length) // 5

1. undefined 与 null

  • undefined 常表示"尚未赋值"或"没有返回值"。
  • null 常表示开发者主动表达"这里没有对象"。
js 复制代码
let selectedUser        // undefined
const deletedUser = null

工作中可以建立统一约定:可选字段缺失用 undefined,明确清空后端字段用 null。关键不是选哪一个,而是前后端契约一致。

还要区分"属性不存在"和"属性存在但值为 undefined":

js 复制代码
const a = {}
const b = { value: undefined }

console.log(a.value) // undefined
console.log(b.value) // undefined

console.log(Object.hasOwn(a, 'value')) // false
console.log(Object.hasOwn(b, 'value')) // true

直接读取时结果相同,但序列化、属性遍历、默认值合并和补丁接口可能把它们视为不同状态。比如 PATCH 接口中,"不传字段"通常表示保持不变,"传 null"可能表示清空字段。

2. boolean

JavaScript 条件判断会把值转换为布尔值。以下值是假值:

text 复制代码
false、0、-0、0n、''、null、undefined、NaN

空数组和空对象是真值:

js 复制代码
Boolean([]) // true
Boolean({}) // true

因此不要用 if (list) 判断数组是否有内容:

js 复制代码
if (Array.isArray(list) && list.length > 0) {
  renderList(list)
}

3. number、NaN 与浮点数

JavaScript 的普通数字主要使用 IEEE 754 双精度浮点数表示。

js 复制代码
0.1 + 0.2 === 0.3 // false

原因不是 JavaScript 算错了,而是很多十进制小数无法用有限位二进制精确表示。0.10.2 存储的是邻近近似值,相加后误差刚好可见:

js 复制代码
console.log(0.1 + 0.2) // 0.30000000000000004

function nearlyEqual(a, b, epsilon = Number.EPSILON) {
  return Math.abs(a - b) <= epsilon * Math.max(1, Math.abs(a), Math.abs(b))
}

nearlyEqual(0.1 + 0.2, 0.3) // true

Number.EPSILON 适合解释浮点误差,但金额业务通常还有舍入规则、税率和多币种问题。工程上更稳妥的是使用整数最小单位,或者采用经过业务验证的十进制定点方案。

金额计算不能直接依赖浮点数精确相等。常见做法是使用最小货币单位:

js 复制代码
const priceInCents = 1999
const count = 3
const totalInCents = priceInCents * count
const total = totalInCents / 100

NaN 表示某次数字运算无法得到有效数字。它有一个特殊性质:

js 复制代码
NaN === NaN // false
Number.isNaN(NaN) // true

优先使用 Number.isNaN,因为全局 isNaN 会先做类型转换:

js 复制代码
isNaN('hello') // true
Number.isNaN('hello') // false

安全整数范围:

js 复制代码
Number.MIN_SAFE_INTEGER
Number.MAX_SAFE_INTEGER
Number.isSafeInteger(value)

后端返回超过安全整数范围的 ID 时,最好直接使用字符串,而不是先解析成 number

js 复制代码
const unsafe = 9007199254740993
console.log(unsafe) // 9007199254740992,精度已在解析时丢失

这意味着后端即使最终想让前端使用字符串,也不能先把超长 ID 作为 JSON 数字交给浏览器再转字符串,因为精度已经丢了。接口一开始就应该返回字符串。

4. bigint

BigInt 用于超出安全整数范围的整数:

js 复制代码
const orderId = 900719925474099312345n

它不能和 number 直接混算:

js 复制代码
// 1n + 1 // TypeError
1n + BigInt(1)

JSON.stringify 默认不能序列化 BigInt,接口传输通常仍应转换为字符串。

5. string

字符串是不可变值。调用方法不会修改原字符串:

js 复制代码
const name = ' JavaScript '
const normalized = name.trim().toLowerCase()

模板字符串支持插值和多行文本:

js 复制代码
const message = `用户 ${user.name} 有 ${orders.length} 个订单`

注意用户输入不能直接拼入 HTML,否则可能产生 XSS。

6. symbol

每次调用 Symbol() 都得到唯一值:

js 复制代码
const cacheKey = Symbol('cacheKey')
const obj = { [cacheKey]: 'private-like value' }

常见用途:

  • 避免对象属性名冲突
  • 定义协议,如 Symbol.iterator
  • 框架或库内部元数据

它不是绝对私有属性,Object.getOwnPropertySymbols 仍可读取。

7. typeof 的边界

js 复制代码
typeof undefined // 'undefined'
typeof true      // 'boolean'
typeof 1         // 'number'
typeof 1n        // 'bigint'
typeof 'x'       // 'string'
typeof Symbol()  // 'symbol'
typeof function () {} // 'function'
typeof null      // 'object',历史遗留问题
typeof []        // 'object'

更可靠的判断方式:

js 复制代码
Array.isArray(value)
value === null
value instanceof Date
Object.prototype.toString.call(value)

跨 iframe 时 instanceof Array 可能失败,数组判断应使用 Array.isArray

四、值、引用、复制与相等性

1. 参数传递始终是按值传递

对象变量保存的是一个引用值。函数接收的是这个引用值的副本:

js 复制代码
function rename(user) {
  user.name = 'Bob'
}

const user = { name: 'Alice' }
rename(user)
console.log(user.name) // Bob

可以把过程拆成四步:

  1. 外部变量 user 保存一个引用值,记作 R1
  2. 调用 rename(user) 时,把 R1 复制给参数 user
  3. 两个变量虽然彼此独立,但保存的都是 R1
  4. 通过任一变量修改 R1 指向的对象,另一边都能观察到。

但给参数重新赋值不会替换外部变量:

js 复制代码
function replace(user) {
  user = { name: 'Carol' }
}

replace(user)
console.log(user.name) // Bob

replace 中的重新赋值,只是把函数内部参数从 R1 改成了另一个引用 R2,外部变量仍保存 R1。因此 JavaScript 不是"原始类型按值传递、对象按引用传递",而是所有参数都按值传递,只是对象对应的值恰好是引用。

2. 浅拷贝与深拷贝

展开语法和 Object.assign 都是浅拷贝:

js 复制代码
const state = {
  user: { name: 'Alice' },
  page: 1,
}

const next = { ...state }
next.user.name = 'Bob'

console.log(state.user.name) // Bob

不可变更新需要复制发生变化的每一层:

js 复制代码
const nextState = {
  ...state,
  user: {
    ...state.user,
    name: 'Carol',
  },
}

现代浏览器可用 structuredClone 深拷贝许多结构:

js 复制代码
const cloned = structuredClone({
  date: new Date(),
  map: new Map([['a', 1]]),
})

它不能克隆函数、DOM 节点等全部对象,也不会保留自定义类的所有行为。JSON.parse(JSON.stringify(value)) 会丢失 undefinedSymbol、函数,并把 Date 变成字符串,不应作为通用深拷贝方案。

3. ==、=== 与 Object.is

=== 不做隐式类型转换,应作为默认选择:

js 复制代码
0 === false // false
'' === false // false

Object.is=== 只有少数差异:

js 复制代码
Object.is(NaN, NaN) // true
Object.is(0, -0)    // false

业务代码中可以把 value == null 作为少数有意使用宽松相等的场景,它同时匹配 nullundefined

js 复制代码
if (value == null) {
  // value 是 null 或 undefined
}

前提是团队明确理解这个约定。

五、类型转换与运算符

1. 显式转换优先

js 复制代码
const count = Number(input.value)
const id = String(rawId)
const enabled = Boolean(value)

数字转换常见结果:

js 复制代码
Number('')      // 0
Number('  ')    // 0
Number('12px')  // NaN
parseInt('12px', 10) // 12

表单数字不能为空时,不能只判断 Number(value) 是否为真,因为合法值 0 也是假值。

隐式转换虽然规则多,但可以抓住三个抽象操作:

  • ToBoolean:条件判断时把值变成布尔值。
  • ToNumber:数学运算或数字比较时尝试变成数字。
  • ToPrimitive:对象参与原始值运算时,先尝试得到原始值。
js 复制代码
Boolean('0') // true:非空字符串
Number(null) // 0
Number(undefined) // NaN
String(null) // 'null'

对象转原始值时,通常会尝试 Symbol.toPrimitive,再根据场景尝试 valueOftoString

js 复制代码
const amount = {
  value: 100,
  valueOf() {
    return this.value
  },
}

console.log(amount + 20) // 120

业务对象不建议大量自定义隐式转换,因为阅读 amount + 20 时很难看出隐藏行为。显式写成 amount.value + 20 更清楚。

1.1 宽松相等到底做了什么

== 不是简单地"把两边都转成字符串"或"都转成数字",而是根据两边类型选择不同分支。例如:

js 复制代码
'0' == 0          // true:字符串转数字
false == 0        // true:布尔值转数字
null == undefined // true:规范定义的特殊匹配
null == 0         // false

对象参与比较时还会先转为原始值:

js 复制代码
[] == ''  // true:[] -> ''
[] == 0   // true:[] -> '' -> 0
[1] == 1 // true:[1] -> '1' -> 1

这些规则可以推导面试输出题,但业务代码默认使用 ===,真正需要转换时显式调用 NumberStringBoolean,能把意图直接写在代码里。

2. + 运算符的双重身份

+ 同时负责数字相加和字符串拼接:

js 复制代码
1 + 2       // 3
'1' + 2     // '12'
1 + 2 + '3' // '33'

接口字段类型不稳定时,这类 bug 很常见。进入业务计算前先规范化数据:

js 复制代码
const quantity = Number(response.quantity)
const price = Number(response.price)
const total = quantity * price

3. 短路运算

js 复制代码
isAdmin && showAdminPanel()
const displayName = nickname || username
const pageSize = inputPageSize ?? 20

|| 返回第一个真值,会把 0''false 当作缺失;?? 只把 nullundefined 当作缺失。

js 复制代码
0 || 20  // 20
0 ?? 20  // 0

配置默认值通常优先使用 ??

4. 可选链

js 复制代码
const city = user?.address?.city
const result = callback?.(data)

可选链适合"数据确实允许缺失"的地方,不应该用来掩盖本应存在的数据。核心配置缺失时,主动抛错比一路返回 undefined 更容易定位问题。

5. 解构、展开与剩余参数

js 复制代码
const { id, name, ...extra } = user
const [first, second] = list

function sum(...numbers) {
  return numbers.reduce((total, n) => total + n, 0)
}

对象展开只复制自身可枚举属性,并且后面的同名属性覆盖前面的属性:

js 复制代码
const options = {
  timeout: 3000,
  ...userOptions,
}

这常用于默认配置,但要明确调用方是否应该有覆盖权限。

6. 运算符优先级与括号

不要依赖读者记住复杂优先级。表达式包含多种运算符时,用括号表达意图:

js 复制代码
const total = price * (count + giftCount)
const allowed = isAdmin || (isOwner && !isLocked)

逻辑运算符返回的是操作数,不一定是布尔值:

js 复制代码
'hello' && 123 // 123
'' || 'default' // 'default'

需要真正布尔值时显式转换:

js 复制代码
const hasItems = Boolean(items.length)

7. 位运算

位运算会把普通数字转换为 32 位整数:

js 复制代码
5 & 3  // 1,按位与
5 | 3  // 7,按位或
5 ^ 3  // 6,按位异或
1 << 3 // 8,左移
8 >> 2 // 2,有符号右移

工作中常见于权限位、颜色处理和二进制协议:

js 复制代码
const READ = 1 << 0   // 0001
const WRITE = 1 << 1  // 0010
const DELETE = 1 << 2 // 0100

const permissions = READ | WRITE
const canWrite = (permissions & WRITE) === WRITE

如果权限数量多、需要后端可读配置或可能超过 32 位,使用 Set、字符串数组或 BigInt 位掩码通常更安全。

六、流程控制与错误处理基础

1. if、switch 与查表

分支少时直接使用 if

js 复制代码
if (score >= 90) return 'A'
if (score >= 80) return 'B'
return 'C'

固定枚举映射更适合查表:

js 复制代码
const STATUS_TEXT = {
  pending: '待处理',
  success: '成功',
  failed: '失败',
}

const text = STATUS_TEXT[status] ?? '未知状态'

switch 要注意 break,也可以有意利用贯穿,但需要注释说明。

2. 循环如何选择

  • for:需要索引、复杂步进或高控制力
  • for...of:遍历可迭代对象的值
  • for...in:遍历对象可枚举属性键,不建议用于数组
  • forEach:执行副作用,不能 break,也不会等待异步回调
  • map:生成等长新数组
  • filter:筛选
  • reduce:聚合

错误示例:

js 复制代码
await list.forEach(async (item) => {
  await save(item)
})

串行执行:

js 复制代码
for (const item of list) {
  await save(item)
}

并行执行:

js 复制代码
await Promise.all(list.map((item) => save(item)))

3. try、catch、finally

js 复制代码
async function loadUser(id) {
  try {
    return await requestUser(id)
  } catch (error) {
    reportError(error)
    throw new Error('加载用户失败', { cause: error })
  } finally {
    stopLoading()
  }
}

finally 无论成功还是失败都会执行,适合关闭 loading、释放锁和清理资源。不要在 finallyreturn,否则可能覆盖原来的返回值或异常。

七、函数:JavaScript 的第一等公民

函数可以赋给变量、作为参数传入、从另一个函数返回。

js 复制代码
function createValidator(rule) {
  return (value) => rule(value)
}

const isPositive = createValidator((value) => value > 0)

函数同时具有两种身份:它是可以调用的代码,也是可以保存属性、传递和返回的对象。

js 复制代码
function request() {}

request.description = '加载用户数据'
const tasks = [request]

function execute(task) {
  return task()
}

"第一等公民"正是高阶函数、回调、中间件、装饰器思想和函数式编程能够成立的基础。

1. 声明、表达式与箭头函数

js 复制代码
function add(a, b) {
  return a + b
}

const subtract = function (a, b) {
  return a - b
}

const multiply = (a, b) => a * b

箭头函数没有自己的 thisargumentssupernew.target,也不能作为构造函数。

函数的 length 表示第一个默认参数之前的形参数量,name 通常能反映函数名;函数内部的 arguments 是类数组对象,不是真数组。现代代码通常使用剩余参数,因为类型和语义更明确:

js 复制代码
function legacy() {
  return Array.from(arguments)
}

function modern(...args) {
  return args
}

JavaScript 不会按形参数量限制实参。少传的参数是 undefined,多传的参数仍会被接收,只是除非通过剩余参数或 arguments,函数不会主动使用它们。

2. 参数默认值

js 复制代码
function request(url, { timeout = 5000, retry = 0 } = {}) {
  // ...
}

request('/users')
request('/users', { timeout: 10_000 })

对象参数比多个布尔参数更清晰:

js 复制代码
// 不清楚 true、false 的含义
createUser(data, true, false)

createUser(data, {
  sendEmail: true,
  activateImmediately: false,
})

3. 纯函数与副作用

纯函数在相同输入下得到相同输出,并且不修改外部状态:

js 复制代码
function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price * item.count, 0)
}

网络请求、DOM 修改、日志、时间和随机数都是副作用。实际项目不可能没有副作用,重点是把副作用集中在边界层,让核心计算保持可测试。

4. 高阶函数

接收函数或返回函数的函数称为高阶函数:

js 复制代码
function withLogging(fn) {
  return (...args) => {
    console.log('start', args)
    const result = fn(...args)
    console.log('end', result)
    return result
  }
}

工作中的权限包装、埋点、重试、中间件、React HOC 都属于这个思路。

5. 防抖与节流

防抖:连续触发时只执行最后一次,适合搜索框。

js 复制代码
function debounce(fn, delay) {
  let timer

  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, delay)
  }
}

节流:一段时间最多执行一次,适合滚动、拖拽和窗口尺寸变化。

js 复制代码
function throttle(fn, interval) {
  let lastTime = 0

  return function (...args) {
    const now = Date.now()
    if (now - lastTime >= interval) {
      lastTime = now
      fn.apply(this, args)
    }
  }
}

生产实现还要考虑首调用、尾调用、取消定时器和组件卸载清理。

八、作用域、执行上下文与闭包

1. 词法作用域

JavaScript 使用词法作用域:函数能访问哪些变量,由函数定义位置决定,而不是调用位置决定。

js 复制代码
const name = 'global'

function outer() {
  const name = 'outer'

  return function inner() {
    console.log(name)
  }
}

const fn = outer()
fn() // outer

2. 执行上下文与调用栈

执行全局代码或调用函数时,运行时会创建对应执行上下文。当前正在执行的上下文位于调用栈顶部。

js 复制代码
function first() {
  second()
}

function second() {
  console.trace()
}

first()

递归没有终止条件时会持续压栈,最终出现栈溢出。

一次函数调用可以概念化为两个阶段:

  1. 创建阶段 :建立参数、局部声明、词法环境、this 等运行所需信息。
  2. 执行阶段:按顺序执行函数体,读取和修改环境中的绑定。

变量查找不是扫描全部代码,而是从当前词法环境开始,沿外层环境引用逐级查找:

text 复制代码
inner 环境 -> outer 环境 -> 模块/全局环境 -> 未找到则 ReferenceError

遮蔽发生在内层声明了同名变量时:

js 复制代码
const value = 'global'

function run() {
  const value = 'local'
  console.log(value) // local
}

外层变量没有消失,只是在当前查找路径中先命中了内层绑定。调试同名变量问题时,DevTools Scope 面板比只看 Console 更直观。

3. 闭包

闭包是函数和它创建时可访问的词法环境的组合。只要内部函数仍被引用,它就能继续访问外部变量。

js 复制代码
function createCounter() {
  let count = 0

  return {
    increment() {
      count += 1
      return count
    },
    getValue() {
      return count
    },
  }
}

const counter = createCounter()
counter.increment() // 1
counter.increment() // 2

真实场景:

  • 保存模块私有状态
  • 工厂函数
  • 事件处理器记住上下文
  • 防抖和节流中的定时器
  • React Hook 中的状态捕获
  • 函数柯里化与缓存

闭包也可能导致旧值问题:

js 复制代码
let count = 0

function createLogger() {
  const message = `count is ${count}`
  return () => console.log(message)
}

const log = createLogger()
count = 10
log() // count is 0

闭包保存的是它引用的变量关系,但上例中的 message 已经在创建时计算成字符串。前端框架里的 stale closure 本质上也与创建时捕获的环境有关。

闭包并不等于"函数套函数",也不要求必须 return。只要一个函数被带到原词法作用域之外执行,并仍访问原作用域变量,闭包就产生了:

js 复制代码
function bindSave(button, orderId) {
  button.addEventListener('click', () => {
    saveOrder(orderId)
  })
}

事件回调在 bindSave 返回很久后才执行,却仍能访问 orderId。代价是:只要监听器存在,对应环境里仍被引用的数据就可能继续存活。因此组件卸载时不仅要移除监听器,也要避免闭包无意保留大型列表、DOM 节点或缓存。

4. 循环中的闭包

js 复制代码
for (var i = 0; i < 3; i += 1) {
  setTimeout(() => console.log(i), 0)
}
// 3 3 3

var 只有一个函数级绑定。改用 let 后,每轮循环都有独立绑定:

js 复制代码
for (let i = 0; i < 3; i += 1) {
  setTimeout(() => console.log(i), 0)
}
// 0 1 2

九、this、call、apply、bind 与 new

普通函数的 this 主要由调用方式决定。

判断 this 时不要看函数"写在哪里",而要看普通函数"如何被调用"。可以按下面的优先级判断:

text 复制代码
new 绑定 > call/apply/bind 显式绑定 > 对象方法隐式绑定 > 默认绑定

箭头函数不参与这套动态绑定规则,它直接使用定义位置外层的 this

1. 四种常见绑定

js 复制代码
function show() {
  console.log(this.name)
}

默认调用:

js 复制代码
show()

对象方法调用:

js 复制代码
const user = { name: 'Alice', show }
user.show() // Alice

显式绑定:

js 复制代码
show.call({ name: 'Bob' })
show.apply({ name: 'Carol' }, [])
const boundShow = show.bind({ name: 'David' })

构造调用:

js 复制代码
function User(name) {
  this.name = name
}

const alice = new User('Alice')

2. call、apply、bind 的区别

  • call(thisArg, a, b):立即调用,参数逐个传
  • apply(thisArg, [a, b]):立即调用,参数用数组传
  • bind(thisArg, a):返回新函数,可以预设参数
js 复制代码
function greet(prefix, suffix) {
  return `${prefix}${this.name}${suffix}`
}

greet.call({ name: 'Alice' }, 'Hi ', '!')
greet.apply({ name: 'Alice' }, ['Hi ', '!'])
const sayHi = greet.bind({ name: 'Alice' }, 'Hi ')
sayHi('!')

3. 箭头函数的 this

箭头函数不创建自己的 this,而是使用外层词法环境中的 this

js 复制代码
const timer = {
  seconds: 0,
  start() {
    setInterval(() => {
      this.seconds += 1
    }, 1000)
  },
}

不要用箭头函数定义需要动态 this 的对象方法:

js 复制代码
const user = {
  name: 'Alice',
  // show: () => console.log(this.name) // 通常不是 user
}

4. new 做了什么

执行 new User('Alice') 可以近似理解为:

  1. 创建新对象。
  2. 把新对象的原型连接到 User.prototype
  3. 以新对象作为 this 调用 User
  4. 如果构造函数没有显式返回对象,就返回这个新对象。

构造函数显式返回值还有一个容易忽略的规则:返回对象会替代新实例,返回原始值则被忽略。

js 复制代码
function A() {
  this.name = 'A'
  return { name: 'other' }
}

function B() {
  this.name = 'B'
  return 123
}

new A().name // other
new B().name // B

普通业务中不要依赖这种写法,它会破坏读者对构造过程的直觉。

十、对象、属性描述符与常用操作

1. 对象属性

js 复制代码
const key = 'role'
const user = {
  id: 1,
  name: 'Alice',
  [key]: 'admin',
  greet() {
    return `Hi, ${this.name}`
  },
}

属性访问:

js 复制代码
user.name
user['name']
user[key]

动态属性名必须使用方括号。

2. 判断属性是否存在

js 复制代码
'name' in user // 包含原型链
Object.hasOwn(user, 'name') // 只判断自身属性

不要直接使用 user.hasOwnProperty('name'),因为对象可能没有这个方法或覆盖了它。

3. 遍历对象

js 复制代码
Object.keys(user)
Object.values(user)
Object.entries(user)

for (const [key, value] of Object.entries(user)) {
  console.log(key, value)
}

Object.keys 只返回自身可枚举字符串键,不包含 Symbol 键。

4. 属性描述符

每个属性都有描述符:

js 复制代码
Object.defineProperty(user, 'id', {
  value: 1,
  writable: false,
  enumerable: true,
  configurable: false,
})
  • writable:是否可赋值
  • enumerable:是否出现在常规遍历中
  • configurable:是否可删除或重新配置

访问器属性:

js 复制代码
const product = {
  price: 100,
  count: 2,
  get total() {
    return this.price * this.count
  },
  set discount(value) {
    this.price *= 1 - value
  },
}

getter 不应执行昂贵计算或产生隐藏副作用,否则一次普通属性读取会变得不可预测。

5. 防止对象变化

js 复制代码
Object.preventExtensions(obj) // 不允许新增
Object.seal(obj)              // 不允许新增、删除或重新配置
Object.freeze(obj)            // 再加上数据属性不可写

这些方法都是浅层操作。

十一、原型链、构造函数与 class

1. 原型链查找

读取对象属性时,运行时先查对象自身;没找到就沿着内部 [[Prototype]] 链向上查,直到 null

js 复制代码
const animal = {
  speak() {
    return 'sound'
  },
}

const dog = Object.create(animal)
dog.name = 'Lucky'

dog.speak() // 从 animal 找到

检查原型:

js 复制代码
Object.getPrototypeOf(dog) === animal // true

需要分清三个容易混淆的概念:

  • dog 的内部原型是 Object.getPrototypeOf(dog)
  • 函数对象 User 自己也有原型,因为函数本身是对象。
  • User.prototype 是执行 new User() 时,新实例会连接到的对象。
js 复制代码
function User(name) {
  this.name = name
}

const alice = new User('Alice')

Object.getPrototypeOf(alice) === User.prototype // true
Object.getPrototypeOf(User) === Function.prototype // true

属性读取过程可以写成伪代码:

js 复制代码
function conceptualGet(object, key) {
  let current = object

  while (current !== null) {
    if (Object.hasOwn(current, key)) return current[key]
    current = Object.getPrototypeOf(current)
  }

  return undefined
}

给对象赋值时通常会创建或修改自身属性,并不会直接修改原型上的同名数据属性。这就是"读取可继承、写入会遮蔽"的常见表现。

不要在性能敏感路径频繁使用 Object.setPrototypeOf 动态修改原型。

2. 构造函数模式

js 复制代码
function User(name) {
  this.name = name
}

User.prototype.greet = function () {
  return `Hi, ${this.name}`
}

const user = new User('Alice')

把共享方法放在原型上,避免每个实例创建一份相同函数。

3. class 是原型机制的语法抽象

js 复制代码
class User {
  #token

  constructor(name, token) {
    this.name = name
    this.#token = token
  }

  greet() {
    return `Hi, ${this.name}`
  }

  getToken() {
    return this.#token
  }

  static createGuest() {
    return new User('Guest', '')
  }
}

私有字段 #token 由语言强制限制,和命名约定 _token 不同。

上面的类方法实际仍位于 User.prototype,不会为每个实例复制一遍:

js 复制代码
const a = User.createGuest()
const b = User.createGuest()

a.greet === b.greet // true
Object.hasOwn(a, 'greet') // false
Object.hasOwn(User.prototype, 'greet') // true

类字段则通常成为实例自身属性。箭头函数类字段会捕获实例 this,适合直接作为回调,但每个实例会创建一个函数,应在绑定便利性与实例数量之间权衡。

继承:

js 复制代码
class Admin extends User {
  constructor(name, token, permissions) {
    super(name, token)
    this.permissions = permissions
  }
}

工作中优先考虑组合而不是建立很深的继承树。继承适合稳定的"is-a"关系;能力组合、策略对象和小函数通常更灵活。

4. instanceof 的含义

js 复制代码
user instanceof User

它判断 User.prototype 是否出现在 user 的原型链上,不是检查静态类型。跨 iframe、原型被修改或手动构造原型链时都可能得到意外结果。

十二、数组与函数式数据处理

1. 数组的本质

数组是带有特殊索引和 length 行为的对象:

js 复制代码
const list = ['a', 'b']
list[5] = 'f'
console.log(list.length) // 6

中间会形成稀疏位置。业务数据尽量避免稀疏数组。

2. 会修改原数组的方法

text 复制代码
push、pop、shift、unshift、splice、sort、reverse、fill、copyWithin

不修改原数组的常见方法:

text 复制代码
map、filter、slice、concat、flat、flatMap、toSorted、toReversed、toSpliced、with

sort 默认按字符串排序:

js 复制代码
[2, 10, 3].sort() // [10, 2, 3]
[2, 10, 3].sort((a, b) => a - b) // [2, 3, 10]

不希望修改状态数组时使用:

js 复制代码
const sorted = users.toSorted((a, b) => b.score - a.score)

3. map、filter、reduce

js 复制代码
const enabledUsers = users
  .filter((user) => user.enabled)
  .map((user) => ({
    id: user.id,
    label: user.name,
  }))

reduce 适合真正的聚合:

js 复制代码
const total = items.reduce(
  (sum, item) => sum + item.price * item.count,
  0,
)

分组:

js 复制代码
const usersByRole = users.reduce((result, user) => {
  const role = user.role
  result[role] ??= []
  result[role].push(user)
  return result
}, {})

如果 reduce 写成很难理解的一大段,普通循环通常更清晰。

4. 查找与判断

js 复制代码
users.find((user) => user.id === targetId)
users.findIndex((user) => user.id === targetId)
users.some((user) => user.enabled)
users.every((user) => user.valid)
users.includes(targetUser)

find 返回元素,filter 返回数组;只需要一项时不要用 filter()[0]

5. 去重

原始值去重:

js 复制代码
const uniqueIds = [...new Set(ids)]

对象按 ID 去重:

js 复制代码
const uniqueUsers = [
  ...new Map(users.map((user) => [user.id, user])).values(),
]

后出现的相同 ID 会覆盖前面的对象。

十三、Map、Set、WeakMap 与 WeakSet

1. Map

Map 的键可以是任意值,且保留插入顺序:

js 复制代码
const cache = new Map()
cache.set(user, { permissions: ['read'] })
cache.get(user)
cache.has(user)
cache.delete(user)

适用场景:

  • 键不是字符串
  • 频繁增删
  • 需要可靠的 size
  • 数据本身就是键值集合,不是领域对象

普通对象更适合表达有固定字段的业务实体。

2. Set

js 复制代码
const selectedIds = new Set()
selectedIds.add('1001')
selectedIds.has('1001')
selectedIds.delete('1001')

适合选择状态、权限集合、已访问节点和去重。

集合运算可以这样表达:

js 复制代码
const intersection = new Set(
  [...a].filter((value) => b.has(value)),
)

3. WeakMap 与 WeakSet

Weak 集合只接受对象作为键或成员,不阻止对象被垃圾回收,也不可枚举。

js 复制代码
const metadata = new WeakMap()

function getMetadata(element) {
  if (!metadata.has(element)) {
    metadata.set(element, { clicks: 0 })
  }
  return metadata.get(element)
}

适合给 DOM、实例或第三方对象附加生命周期一致的元数据。

4. JSON 序列化

JSON 只支持对象、数组、字符串、数字、布尔值和 null。它不是 JavaScript 对象的完整表示。

js 复制代码
const text = JSON.stringify({ id: 1, name: 'Alice' })
const data = JSON.parse(text)

序列化时的典型变化:

js 复制代码
JSON.stringify({
  empty: undefined, // 对象属性被忽略
  fn: () => {},     // 被忽略
  symbol: Symbol(), // 被忽略
  date: new Date(), // 转为 ISO 字符串
  invalid: NaN,     // 转为 null
})

数组中的 undefined、函数和 Symbol 通常会变成 null。循环引用会抛出异常,BigInt 默认也无法序列化。

可以使用 replacer 和 reviver 控制转换:

js 复制代码
const json = JSON.stringify(data, (key, value) => {
  if (typeof value === 'bigint') return `${value}n`
  return value
})

const parsed = JSON.parse(json, (key, value) => {
  if (typeof value === 'string' && /^\d+n$/.test(value)) {
    return BigInt(value.slice(0, -1))
  }
  return value
})

解析外部 JSON 时仍要做运行时校验。JSON.parse 只能保证语法合法,不能保证字段符合业务类型。

5. ArrayBuffer、TypedArray 与 DataView

ArrayBuffer 表示一段固定长度的原始二进制内存。TypedArray 提供按某种数字格式读写这段内存的视图:

js 复制代码
const buffer = new ArrayBuffer(8)
const bytes = new Uint8Array(buffer)

bytes[0] = 255
bytes[1] = 16

常见 TypedArray:

text 复制代码
Uint8Array、Uint8ClampedArray、Int16Array、Uint32Array、
Float32Array、Float64Array、BigInt64Array

DataView 适合按不同类型和字节序读取协议数据:

js 复制代码
const view = new DataView(buffer)
view.setUint32(0, 0x12345678, false) // false 表示大端序
const value = view.getUint32(0, false)

真实场景:

  • 文件读取和上传分片
  • Canvas 像素
  • WebSocket 二进制消息
  • 音视频处理
  • 压缩和加密
  • WebAssembly 数据交换

TypedArray 的 slice 通常复制数据,subarray 创建共享底层缓冲区的视图。修改共享视图可能影响原数据,使用时要明确是否需要复制。

6. SharedArrayBuffer 与 Atomics

普通 Worker 的消息数据通常通过结构化克隆或转移传递;SharedArrayBuffer 则允许多个执行代理查看同一块内存。

js 复制代码
const shared = new SharedArrayBuffer(4)
const counter = new Int32Array(shared)

Atomics.add(counter, 0, 1)
console.log(Atomics.load(counter, 0))

共享内存会带来真正的并发问题,因此需要 Atomics 提供原子读写、比较交换、等待和唤醒等能力。它适合并行计算、底层运行时和 WebAssembly,不适合普通表单和页面状态。

浏览器启用 SharedArrayBuffer 通常还要求页面满足跨源隔离条件。使用前应确认部署响应头、浏览器支持和安全模型。

十四、字符串、正则、日期与国际化

1. 字符串实用方法

js 复制代码
text.trim()
text.includes(keyword)
text.startsWith('https://')
text.replaceAll('-', '/')
text.padStart(2, '0')

Unicode 下"字符长度"不总等于 string.length

js 复制代码
'😀'.length // 2
[...'😀'].length // 1

更复杂的用户可见字符还可能由多个码点组成,需要 Intl.Segmenter 才能按字素处理。

2. 正则表达式

js 复制代码
const phonePattern = /^1\d{10}$/
phonePattern.test('13800138000')

常用标志:

  • g:全局匹配
  • i:忽略大小写
  • m:多行模式
  • s:让 . 匹配换行
  • u:Unicode 模式
  • y:粘连匹配

命名捕获组:

js 复制代码
const result = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/.exec(
  '2026-08-13',
)

result.groups.year // '2026'

不要试图用一个巨大正则完整验证邮箱、URL 或 HTML。工作中优先使用结构化解析器和浏览器内置 URL

3. Date

Date 内部表示一个时间点,但解析和展示会涉及时区。

js 复制代码
const now = new Date()
const timestamp = Date.now()
const iso = now.toISOString()

避免解析不明确格式:

js 复制代码
// 不同环境可能有歧义
new Date('08/09/2026')

// 使用 ISO 8601
new Date('2026-08-09T10:00:00+08:00')

接口推荐传 ISO 字符串、时间戳或明确约定时区。日期选择器、数据库和接口必须使用一致语义。

4. Intl

不要手写货币、日期和数字分隔格式:

js 复制代码
const currency = new Intl.NumberFormat('zh-CN', {
  style: 'currency',
  currency: 'CNY',
})

currency.format(123456.78)
js 复制代码
const dateFormatter = new Intl.DateTimeFormat('zh-CN', {
  dateStyle: 'long',
  timeStyle: 'short',
  timeZone: 'Asia/Shanghai',
})

排序和搜索时可用 Intl.Collator,复数规则可用 Intl.PluralRules

十五、迭代器与生成器

1. 可迭代协议

只要对象实现 [Symbol.iterator]() 并返回迭代器,就能被 for...of、展开语法等消费。

js 复制代码
const range = {
  from: 1,
  to: 3,
  [Symbol.iterator]() {
    let current = this.from
    const end = this.to

    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false }
        }
        return { value: undefined, done: true }
      },
    }
  },
}

[...range] // [1, 2, 3]

2. Generator

生成器用更直观的方式创建迭代器:

js 复制代码
function* range(from, to) {
  for (let current = from; current <= to; current += 1) {
    yield current
  }
}

[...range(1, 3)]

真实场景:

  • 惰性处理大量数据
  • 分页数据流
  • 状态机
  • 自定义遍历结构
  • Redux Saga 一类流程控制

异步生成器:

js 复制代码
async function* loadPages() {
  let page = 1
  let hasNext = true

  while (hasNext) {
    const result = await requestPage(page)
    yield result.list
    hasNext = result.hasNext
    page += 1
  }
}

for await (const list of loadPages()) {
  render(list)
}

十六、异步基础:JavaScript 如何处理等待

JavaScript 代码通常在一个主线程上执行,但浏览器可以把定时器、网络、用户事件等工作交给宿主环境。完成后,相应回调进入任务队列等待调用栈空闲。

这里的"单线程"主要指同一个 JavaScript 执行上下文不会同时执行两段普通业务代码,不代表浏览器整个进程只有一个线程。网络、定时器、渲染、Worker 等可以由其他线程或系统能力处理。JavaScript 主线程负责在合适时机执行对应回调。

以网络请求为例:

text 复制代码
JavaScript 调用 fetch
  -> 浏览器网络模块处理请求
  -> JavaScript 继续执行后面的同步代码
  -> 响应可用后,浏览器安排 Promise 后续反应
  -> 调用栈空闲且轮到对应微任务时执行 then/await 后续代码

异步的价值不是"让一段代码神奇地同时运行",而是等待外部结果时不占住主线程。

1. 同步代码与调用栈

js 复制代码
console.log('A')
console.log('B')
console.log('C')

按顺序输出 A、B、C。同步长任务会阻塞点击、输入和渲染。

2. 回调与回调地狱

js 复制代码
getUser(id, (user) => {
  getOrders(user.id, (orders) => {
    getDetails(orders[0].id, (detail) => {
      console.log(detail)
    })
  })
})

嵌套回调的问题不只是缩进,还包括错误传递、取消和流程组合困难。Promise 用统一状态模型解决这类问题。

十七、Promise 详解

Promise 有三种状态:

text 复制代码
pending -> fulfilled
pending -> rejected

状态一旦确定就不会再次改变。

要分清 resolvedfulfilled。Promise 用另一个 Promise 进行 resolve 时,会采用后者的最终状态,此时可能仍在等待:

js 复制代码
const inner = new Promise((resolve) => {
  setTimeout(() => resolve('done'), 1000)
})

const outer = new Promise((resolve) => {
  resolve(inner)
})

outer.then(console.log) // 约 1 秒后输出 done

resolve(inner) 不是把 inner 对象直接当成功值,而是让 outer 跟随 inner。这类"吸收 thenable 状态"的规则让 Promise 链能够自动展平,而不是得到 Promise 套 Promise。

js 复制代码
const promise = new Promise((resolve, reject) => {
  doSomething((error, value) => {
    if (error) {
      reject(error)
      return
    }
    resolve(value)
  })
})

Promise 构造器传入的 executor 会立即同步执行 ,只有 thencatchfinally 注册的反应是异步微任务:

js 复制代码
console.log('A')

new Promise((resolve) => {
  console.log('B')
  resolve()
}).then(() => console.log('C'))

console.log('D')

// A B D C

因此不要把耗时计算放进 new Promise(...) 并期待它不阻塞,executor 仍在当前调用栈执行。

1. then 返回新 Promise

js 复制代码
fetchUser(id)
  .then((user) => fetchOrders(user.id))
  .then((orders) => orders.filter((order) => order.paid))
  .then(renderOrders)
  .catch(showError)
  .finally(stopLoading)

then 回调:

  • 返回普通值:下一个 Promise fulfilled
  • 返回 Promise:等待它的结果
  • 抛出异常:下一个 Promise rejected

可以把链式调用理解为"每次 then 都安装两个处理器,并立刻返回一张新的结果凭证"。前一步结果决定哪一个处理器执行,处理器的执行结果又决定新 Promise 的状态:

js 复制代码
Promise.resolve(1)
  .then((value) => value + 1)      // 返回 2
  .then((value) => Promise.resolve(value * 3)) // 吸收后得到 6
  .then(() => {
    throw new Error('failed')      // 链转为 rejected
  })
  .catch((error) => {
    console.log(error.message)
    return 0                       // 错误被恢复,链重新 fulfilled
  })
  .then(console.log)               // 0

catch(fn) 基本等价于 then(undefined, fn)。如果 catch 不再抛错或返回 rejected Promise,错误就被视为已处理,后面的成功分支会继续执行。

finally 不接收成功值或失败原因,通常也不会改变原结果:

js 复制代码
Promise.resolve('data')
  .finally(() => stopLoading())
  .then((value) => console.log(value)) // data

但如果 finally 自己抛错或返回 rejected Promise,新错误会覆盖原结果,所以清理逻辑也要保持可靠。

忘记 return 是常见错误:

js 复制代码
fetchUser(id).then((user) => {
  return fetchOrders(user.id)
})

如果忘记返回,请求仍会发出,但当前 then 返回的 Promise 会以 undefined 成功,后续链不会等待内部请求:

js 复制代码
fetchUser(id)
  .then((user) => {
    fetchOrders(user.id) // 没有 return
  })
  .then((orders) => {
    console.log(orders) // undefined
  })

2. Promise 静态方法

Promise.all:全部成功才成功,任一失败立即拒绝。

js 复制代码
const [user, permissions] = await Promise.all([
  fetchUser(id),
  fetchPermissions(id),
])

适合互不依赖的并行请求。

Promise.allSettled:等待全部结束,保留每项结果。

js 复制代码
const results = await Promise.allSettled(tasks)
const failed = results.filter((item) => item.status === 'rejected')

适合批量上传、批量通知等允许部分失败的场景。

Promise.race:第一个确定状态的结果决定最终结果。

js 复制代码
await Promise.race([request(), timeout(5000)])

它不会自动取消输掉的任务。

Promise.any:第一个成功的结果;全部失败时抛 AggregateError。适合多个镜像源或备用服务。

3. Promise 不是让 CPU 任务并行

js 复制代码
Promise.resolve().then(() => heavyCalculation())

这仍然会在主线程执行并阻塞页面。CPU 密集工作应拆分、使用 Web Worker 或移到服务端。

十八、async/await、并发与取消

async 函数一定返回 Promise:

js 复制代码
async function getValue() {
  return 1
}

getValue().then(console.log)

await expression 可以概念化为:先把表达式交给 Promise.resolve,当前 async 函数暂停并把控制权交回调用方;等结果确定后,再通过微任务恢复函数。它不会阻塞整个线程:

js 复制代码
async function run() {
  console.log('run:start')
  const value = await Promise.resolve(1)
  console.log('run:end', value)
}

console.log('script:start')
run()
console.log('script:end')

// script:start
// run:start
// script:end
// run:end 1

await 后面的同步异常或 Promise 拒绝,会在 async 函数内部表现为抛出异常,因此可以使用 try/catch

js 复制代码
async function load() {
  try {
    return await fetchData()
  } catch (error) {
    throw new Error('加载数据失败', { cause: error })
  }
}

这里的 return awaittry/catch 中有意义:它让 Promise 的拒绝发生在当前函数的 catch 范围内。普通透传函数则可以直接 return fetchData()

1. 顺序执行与并行执行

顺序执行:

js 复制代码
const user = await fetchUser()
const orders = await fetchOrders(user.id)

第二个请求依赖第一个结果,必须顺序执行。

并行执行:

js 复制代码
const userPromise = fetchUser()
const configPromise = fetchConfig()

const [user, config] = await Promise.all([
  userPromise,
  configPromise,
])

两个请求无依赖时,不要先后 await 增加总耗时。

假设两个请求分别耗时 800ms 和 600ms:顺序等待约需 1400ms,并行启动后总耗时约由较慢的 800ms 决定。并行不是越多越好,它仍受浏览器连接、服务端容量、内存和限流策略影响,所以大批量任务要使用并发池。

2. 控制并发数量

一次向服务端发送几千个请求会压垮浏览器和接口。一个简单并发池:

js 复制代码
async function runWithConcurrency(tasks, limit = 5) {
  const results = new Array(tasks.length)
  let nextIndex = 0

  async function worker() {
    while (nextIndex < tasks.length) {
      const currentIndex = nextIndex
      nextIndex += 1
      results[currentIndex] = await tasks[currentIndex]()
    }
  }

  const workers = Array.from(
    { length: Math.min(limit, tasks.length) },
    () => worker(),
  )

  await Promise.all(workers)
  return results
}

适合批量上传、数据迁移、图片处理和批量接口请求。

3. AbortController

Promise 本身没有统一取消协议,但许多 Web API 支持 AbortSignal

js 复制代码
const controller = new AbortController()

fetch('/api/search?q=javascript', {
  signal: controller.signal,
})

controller.abort()

搜索联想场景应取消旧请求,避免旧响应晚到后覆盖新结果:

js 复制代码
let searchController

async function search(keyword) {
  searchController?.abort()
  searchController = new AbortController()

  const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
    signal: searchController.signal,
  })

  return response.json()
}

十九、事件循环、宏任务与微任务

浏览器一次典型事件循环可简化为:

text 复制代码
取出一个任务执行
  -> 调用栈清空
  -> 清空微任务队列
  -> 浏览器可能渲染
  -> 进入下一轮任务

"宏任务"是社区常用说法,HTML 规范更常使用 task。理解顺序时需要抓住三条规则:

  1. 当前调用栈中的同步代码必须先完整执行。
  2. 每个任务结束后,浏览器会执行微任务检查点,通常清空当前微任务队列,包括执行过程中新增的微任务。
  3. 微任务完成后浏览器才有机会更新渲染,然后再取下一个任务;"有机会"不代表每轮一定绘制。

一个点击事件处理器整体属于一个任务。处理器里安排的 Promise 回调会在这个事件任务结束后、下一任务之前执行:

js 复制代码
button.addEventListener('click', () => {
  console.log('click')

  Promise.resolve().then(() => {
    console.log('promise')
  })

  setTimeout(() => {
    console.log('timeout')
  }, 0)
})

// 一次点击后:click -> promise -> timeout

不同来源的任务如何调度存在宿主细节,不应仅凭"都是宏任务"推导所有来源之间的绝对先后;可靠结论是同一队列内遵循其规则,微任务检查点先于进入下一任务。

常见任务来源:

  • 首次执行脚本
  • setTimeout / setInterval
  • DOM 事件
  • 网络事件回调
  • postMessage

常见微任务来源:

  • Promise 的 then/catch/finally
  • queueMicrotask
  • MutationObserver

经典示例:

js 复制代码
console.log('script start')

setTimeout(() => console.log('timeout'), 0)

Promise.resolve().then(() => console.log('promise'))

queueMicrotask(() => console.log('microtask'))

console.log('script end')

输出:

text 复制代码
script start
script end
promise
microtask
timeout

Promise 回调和 queueMicrotask 按进入微任务队列的顺序执行。

微任务中还能继续安排微任务:

js 复制代码
Promise.resolve().then(() => {
  console.log(1)
  queueMicrotask(() => console.log(3))
})

queueMicrotask(() => console.log(2))

// 1 2 3

执行第一个微任务时,3 被追加到队尾;原本已经排队的 2 先执行。

微任务饥饿

微任务可以继续创建微任务,事件循环会持续清空队列:

js 复制代码
function loop() {
  queueMicrotask(loop)
}

这会阻止后续任务和渲染。不要用递归微任务执行无限工作。

setTimeout(fn, 0) 不是立即执行

它表示达到最小延迟后把回调排入任务队列,仍要等待当前代码、微任务和前面的任务完成。

浏览器与 Node.js 的区别

两者都有事件循环,但阶段和 API 细节不同。Node.js 还有 process.nextTicksetImmediate 等机制。不要把浏览器中的执行顺序结论不加区分地套到 Node.js。

二十、模块化:从全局变量到 ESM

1. ES Module

js 复制代码
// math.js
export function add(a, b) {
  return a + b
}

export const PI = 3.14159
js 复制代码
// app.js
import { add, PI } from './math.js'

默认导出:

js 复制代码
export default function createApp() {}
import createApp from './createApp.js'

具名导出更方便重构和自动导入,默认导出适合模块确实只有一个主要概念的场景。

2. ESM 的特点

  • 静态结构,工具能在运行前分析依赖
  • 模块默认严格模式
  • 顶层变量不会自动污染全局
  • 导入是 live binding,不是简单值拷贝
  • 支持动态 import()

所谓 live binding,是导入方观察的是导出绑定的当前值,而不是导入瞬间复制出的快照:

js 复制代码
// counter.js
export let count = 0
export function increment() {
  count += 1
}
js 复制代码
// app.js
import { count, increment } from './counter.js'

console.log(count) // 0
increment()
console.log(count) // 1

// count = 10 // TypeError:导入绑定只读

模块大致经历解析、实例化/链接、求值几个阶段。静态 import 会先建立模块依赖图和绑定关系,再执行模块顶层代码。这也是导入声明必须位于模块顶层、工具可以静态分析依赖的原因。

js 复制代码
const module = await import('./heavy-editor.js')
module.openEditor()

动态导入适合路由懒加载、重型编辑器和低频功能。

3. 循环依赖

text 复制代码
a.js -> b.js -> a.js

ESM 能处理一部分循环依赖,但初始化顺序可能让某些绑定暂时不可用。出现循环依赖通常说明模块职责可以重新划分:提取共享类型、常量或底层函数到第三个模块。

4. CommonJS 与 ESM

CommonJS 使用 requiremodule.exports,Node.js 历史项目中仍很常见。ESM 使用 import/export。两者在加载时机、绑定语义和互操作上有差异,工程配置中不要随意混用。

5. Tree Shaking

构建工具可以基于 ESM 静态结构删除未使用导出。前提是代码无不可判断的副作用,并正确配置包的 sideEffects。Tree Shaking 是构建工具行为,不是 JavaScript 运行时自动完成的。

二十一、DOM、事件与浏览器交互

1. 查询与修改 DOM

js 复制代码
const button = document.querySelector('[data-action="save"]')
button.textContent = '保存'
button.classList.add('primary')

DOM 是浏览器维护的对象模型,不是 HTML 字符串本身。修改 DOM 后,浏览器可能需要依次进行样式计算、布局、绘制与合成,但具体哪些阶段发生取决于改动内容:

  • 改变元素尺寸或位置,通常可能触发布局和绘制。
  • 只改变颜色,通常不需要重新布局,但可能需要绘制。
  • 合理使用 transformopacity 的动画,往往更容易交给合成阶段处理。

这不是要求业务开发死记浏览器流水线,而是理解"每次赋值都可能产生后续渲染成本",批量更新和减少无意义 DOM 操作会更稳定。

创建节点优先使用 DOM API,不要把用户输入直接放进 innerHTML

js 复制代码
const item = document.createElement('li')
item.textContent = userInput
list.append(item)

2. 事件传播

事件通常经历捕获、目标、冒泡三个阶段。

text 复制代码
捕获阶段:window -> document -> ... -> 目标父元素
目标阶段:到达目标元素
冒泡阶段:目标父元素 -> ... -> document -> window
js 复制代码
element.addEventListener('click', handler, {
  capture: false,
  once: true,
  passive: true,
  signal: controller.signal,
})
  • preventDefault():阻止默认行为
  • stopPropagation():阻止继续传播
  • stopImmediatePropagation():还阻止同元素后续监听器

event.target 是事件真正起点,event.currentTarget 是当前正在执行监听器的元素:

js 复制代码
list.addEventListener('click', (event) => {
  console.log(event.target)        // 实际被点中的按钮、图标等
  console.log(event.currentTarget) // list
})

preventDefault() 取消链接跳转、表单提交等默认行为;stopPropagation() 处理传播路径。两者解决的问题不同,阻止默认行为不会自动阻止冒泡。

不要把阻止冒泡作为默认做法,它会破坏上层事件委托和分析逻辑。

3. 事件委托

大量动态列表项不需要逐个绑定事件:

js 复制代码
list.addEventListener('click', (event) => {
  const button = event.target.closest('[data-delete-id]')
  if (!button || !list.contains(button)) return

  removeItem(button.dataset.deleteId)
})

适合表格行操作、菜单和动态列表。closest 后再检查容器范围,避免匹配到容器之外的节点。

4. DOMContentLoaded 与 load

  • DOMContentLoaded:HTML 解析完成,DOM 可用
  • load:页面依赖资源也加载完成

模块脚本通常会延迟到文档解析完成后执行,现代框架一般不需要手写这两个监听。

5. requestAnimationFrame

视觉更新应尽量和浏览器渲染节奏同步:

js 复制代码
function animate() {
  updatePosition()
  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

它适合动画,不适合精确定时或后台任务;页面不可见时浏览器可能降低执行频率。

二十二、网络请求、HTTP 与数据边界

1. fetch 基础

js 复制代码
async function requestJSON(url, options = {}) {
  const response = await fetch(url, {
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
    ...options,
  })

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }

  return response.json()
}

fetch 遇到 404、500 不会自动 reject;通常只有网络层失败或请求被取消才 reject,所以必须检查 response.ok

一次稳健请求至少要区分四类失败:

  1. 网络层失败:断网、DNS、连接中断,fetch Promise 会拒绝。
  2. HTTP 失败:服务端返回 400/500,Promise 通常仍成功,需要检查 response.ok
  3. 解析失败:响应不是期望的 JSON,response.json() 会拒绝。
  4. 业务失败:HTTP 200,但 { code: 1001 } 表示业务未成功。
js 复制代码
async function requestBusinessData(url, options) {
  const response = await fetch(url, options)

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }

  const data = await response.json()

  if (data.code !== 0) {
    throw new Error(data.message || '业务请求失败')
  }

  return data.result
}

响应体是流,通常只能消费一次。调用 response.json() 后再调用 response.text() 会失败;确实需要读取两次时可以先 response.clone(),但大响应会带来额外缓冲成本。

2. GET 与 POST

  • GET 用于读取,参数通常在 URL 中,可被缓存
  • POST 用于提交或创建,数据通常在请求体中
  • PUT 常表达整体替换
  • PATCH 常表达部分更新
  • DELETE 表达删除

实际语义由服务端契约决定,但保持 HTTP 语义能让缓存、监控和调试更清楚。

3. URL 与查询参数

js 复制代码
const url = new URL('/api/users', location.origin)
url.searchParams.set('page', '1')
url.searchParams.set('keyword', keyword)

不要手工拼接并忘记编码:

js 复制代码
encodeURIComponent(keyword)

4. CORS

CORS 是浏览器执行的跨源访问控制机制。服务端通过响应头声明哪些源、方法和请求头可以访问。前端不能通过设置一个魔法参数绕过 CORS。

复杂跨源请求可能先发送预检 OPTIONS。开发环境代理能解决本地联调,但生产环境仍要正确配置网关或服务端。

"跨源"由协议、主机和端口共同决定,任一不同都属于不同源:

text 复制代码
https://example.com:443
http://example.com:80     协议不同
https://api.example.com   主机不同
https://example.com:8443  端口不同

CORS 并不是阻止请求一定被发送,而是控制浏览器是否允许前端脚本读取响应。因此它不是服务端鉴权机制:攻击者可以绕开浏览器直接调用接口,服务端仍必须验证身份和权限。

携带 Cookie 的跨源请求还要同时满足客户端 credentials 和服务端允许凭证的响应头要求,而且允许源不能简单写成 *

5. 竞态条件

js 复制代码
let requestId = 0

async function load(keyword) {
  const currentId = ++requestId
  const result = await search(keyword)

  if (currentId !== requestId) return
  render(result)
}

更好的方式通常是取消旧请求,但请求 ID 也能防止过期结果覆盖新状态。

6. 重试

只对适合重试的错误和幂等请求重试,并使用指数退避:

js 复制代码
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

async function retry(fn, times = 3) {
  let lastError

  for (let attempt = 0; attempt < times; attempt += 1) {
    try {
      return await fn()
    } catch (error) {
      lastError = error
      await sleep(2 ** attempt * 300)
    }
  }

  throw lastError
}

支付、创建订单等非幂等操作不能盲目重试,需要服务端幂等键。

1. localStorage

js 复制代码
localStorage.setItem('theme', 'dark')
localStorage.getItem('theme')
localStorage.removeItem('theme')

特点:同步、持久、同源共享、只能存字符串。不要存密码、长期令牌或敏感个人信息。大量读写会阻塞主线程。

对象需要序列化并处理异常:

js 复制代码
function readJSON(key, fallback) {
  try {
    const value = localStorage.getItem(key)
    return value ? JSON.parse(value) : fallback
  } catch {
    return fallback
  }
}

2. sessionStorage

API 类似 localStorage,通常绑定当前标签页会话,关闭标签页后清除。

Cookie 会按规则随请求发送,常用于服务端会话。重要属性:

  • HttpOnly:JavaScript 不能读取,降低令牌被 XSS 窃取的风险
  • Secure:只通过 HTTPS 发送
  • SameSite:限制跨站发送,帮助缓解 CSRF
  • Expires / Max-Age:有效期

认证 Cookie 应由服务端设置合适的安全属性。

4. IndexedDB

适合大量结构化数据、离线缓存和文件 Blob。它是异步数据库,不应因为 API 比 localStorage 复杂就用同步存储硬塞大数据。

二十四、Proxy、Reflect 与元编程

1. Proxy

Proxy 可以拦截对象的基础操作:

js 复制代码
const state = new Proxy(
  { count: 0 },
  {
    get(target, key, receiver) {
      console.log('get', key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      console.log('set', key, value)
      return Reflect.set(target, key, value, receiver)
    },
  },
)

真实场景:

  • 响应式系统
  • 数据校验
  • API mock
  • 访问日志
  • 默认值代理

Proxy 会改变对象行为,过度使用会让调试困难。对单个属性的简单计算,普通 getter 更直接。

2. Reflect

Reflect 提供与许多对象内部操作对应的方法:

js 复制代码
Reflect.get(target, key, receiver)
Reflect.set(target, key, value, receiver)
Reflect.has(target, key)
Reflect.ownKeys(target)

在 Proxy trap 中使用 Reflect,可以保留默认语义并正确传递 receiver

3. 常用 Symbol 协议

  • Symbol.iterator:定义遍历
  • Symbol.asyncIterator:定义异步遍历
  • Symbol.toPrimitive:自定义原始值转换
  • Symbol.toStringTag:影响对象标签
  • Symbol.hasInstance:自定义 instanceof
js 复制代码
const money = {
  amount: 100,
  [Symbol.toPrimitive](hint) {
    return hint === 'string' ? `¥${this.amount}` : this.amount
  },
}

这种能力适合库设计,不建议在普通业务对象上制造出乎意料的隐式行为。

二十五、错误模型与可靠性设计

1. Error 类型

常见内置错误:

  • SyntaxError
  • ReferenceError
  • TypeError
  • RangeError
  • URIError
  • AggregateError

自定义业务错误:

js 复制代码
class ValidationError extends Error {
  constructor(message, field) {
    super(message)
    this.name = 'ValidationError'
    this.field = field
  }
}

调用方可以按错误类型决定行为:

js 复制代码
try {
  await submit(form)
} catch (error) {
  if (error instanceof ValidationError) {
    focusField(error.field)
    return
  }
  throw error
}

2. 不要吞掉异常

js 复制代码
try {
  await save()
} catch (error) {
  console.error(error)
}

如果调用方需要知道失败,上面还不够。应该返回明确结果或重新抛出:

js 复制代码
catch (error) {
  reportError(error)
  throw error
}

3. 预期错误与程序错误

  • 预期错误:表单不合法、权限不足、资源不存在
  • 程序错误:访问 undefined 属性、不可能状态、代码缺陷

预期错误需要友好提示;程序错误需要记录上下文并尽快暴露,不能统一弹一句"操作失败"后彻底吞掉。

4. 全局错误监听

浏览器可以监听:

js 复制代码
window.addEventListener('error', (event) => {
  reportError(event.error)
})

window.addEventListener('unhandledrejection', (event) => {
  reportError(event.reason)
})

全局监听是最后防线,不是替代局部错误处理。上报时应包括版本、路由、用户操作上下文,但要移除令牌和敏感数据。

二十六、内存管理与常见泄漏

JavaScript 自动分配和回收内存,但"自动"不等于不需要关心。只要对象仍能从根对象沿引用链访问,垃圾回收器通常就不能释放它。

常见根对象包括全局对象、当前调用栈中的局部变量、活动闭包以及宿主内部持有的引用。现代引擎通常基于"可达性"判断对象是否仍有用,而不是简单统计引用次数,因为引用计数无法独立解决循环引用:

js 复制代码
let a = {}
let b = {}

a.other = b
b.other = a

a = null
b = null

两个对象彼此引用,但已经无法从根访问,因此仍可以被回收。真正的泄漏通常不是"存在循环引用",而是某条从根出发的引用链本不该继续存在。

1. 生命周期

text 复制代码
分配 -> 使用 -> 不再可达 -> 垃圾回收

2. 常见泄漏来源

未清理定时器:

js 复制代码
const timer = setInterval(refresh, 5000)

// 页面销毁时
clearInterval(timer)

未移除事件:

js 复制代码
const controller = new AbortController()

window.addEventListener('resize', handleResize, {
  signal: controller.signal,
})

controller.abort()

闭包长期引用大对象:

js 复制代码
function createHandler(largeData) {
  return () => largeData.id
}

如果只需要 ID,就不要让闭包保留整个大对象。

无限增长缓存:

js 复制代码
const cache = new Map()

缓存需要容量上限、过期策略或 LRU。对象键缓存可考虑 WeakMap

游离 DOM:节点从页面移除后,如果 JavaScript 仍持有引用,它仍不能回收。

一个典型引用链可能是:

text 复制代码
window
  -> 全局事件监听器
  -> 回调闭包
  -> 页面状态对象
  -> 已经移除的 DOM 节点

只删除 DOM 节点并不能切断后面的 JavaScript 引用。页面或组件销毁时应把定时器、监听器、观察器、订阅和未完成请求看作同一类"生命周期资源"统一清理。

js 复制代码
function mountPage() {
  const controller = new AbortController()
  const timer = setInterval(refresh, 5000)
  const observer = new ResizeObserver(handleResize)

  window.addEventListener('online', refresh, {
    signal: controller.signal,
  })
  observer.observe(panel)

  return () => {
    controller.abort()
    clearInterval(timer)
    observer.disconnect()
  }
}

3. 如何排查

  • Chrome DevTools Memory Heap Snapshot
  • Allocation instrumentation
  • Performance 面板观察长期增长
  • 重复执行"进入页面 -> 操作 -> 离开页面",比较对象数量

不要只看一次内存高低,重点看相同操作后内存是否持续无法回落。

一次更可靠的排查流程是:

  1. 刷新页面并等待稳定,拍第一份 Heap Snapshot。
  2. 重复执行可疑操作多次,例如进入并离开详情页 10 次。
  3. 手动触发垃圾回收仅用于辅助实验,再拍第二份快照。
  4. 按 retained size、constructor 和对象增长数量比较。
  5. 从可疑对象查看 Retainers,找出是谁仍在持有它。

堆内存短期上升并不等于泄漏,引擎可能延迟回收或保留已申请内存。持续重复同一操作后对象数量和保留大小单调增长,才是更强信号。

4. 显式资源管理

垃圾回收负责内存,不负责所有外部资源。文件句柄、锁、订阅和连接通常需要显式释放。

通用写法是 try/finally

js 复制代码
const connection = await openConnection()

try {
  await connection.query(sql)
} finally {
  await connection.close()
}

较新的 ECMAScript 还定义了显式资源管理协议:同步资源实现 Symbol.dispose,异步资源实现 Symbol.asyncDispose,并配合 using / await using。使用前需要检查运行环境和构建工具支持情况。

js 复制代码
class Subscription {
  [Symbol.dispose]() {
    this.unsubscribe()
  }
}

// 在支持该语法的环境中:
// using subscription = createSubscription()

它解决的是"离开作用域时可靠释放资源",不是替代垃圾回收。

5. WeakRef 与 FinalizationRegistry

WeakRef 可以创建不阻止垃圾回收的弱引用,FinalizationRegistry 可以在对象被回收后得到非确定性的清理通知。

这两个 API 适合底层库、缓存和诊断工具,不适合普通业务流程,因为垃圾回收何时发生不可预测。不能把关键保存、网络上报或业务状态更新放进最终化回调。

二十七、性能优化:先测量,再优化

性能问题至少分为加载性能、运行时响应、渲染流畅度和内存稳定性。优化前先把主观的"有点卡"变成可复现步骤与指标:

  • 页面首次可见慢:检查资源瀑布、关键请求和脚本执行。
  • 点击后反馈慢:检查主线程长任务、同步计算和不必要渲染。
  • 滚动或动画掉帧:检查每帧工作、布局与绘制。
  • 使用越久越慢:检查监听器、缓存、DOM 数量和内存增长。

浏览器 Performance 面板、Network 面板、Memory 面板和真实用户监控解决的问题不同。先定位瓶颈属于网络、JavaScript、渲染还是内存,再选择方案。

1. 时间复杂度

数组查找通常是 O(n),Map 查找通常可视为平均 O(1)。

错误的嵌套查找:

js 复制代码
const result = orders.map((order) => ({
  ...order,
  user: users.find((user) => user.id === order.userId),
}))

数据量大时先建立索引:

js 复制代码
const userMap = new Map(users.map((user) => [user.id, user]))

const result = orders.map((order) => ({
  ...order,
  user: userMap.get(order.userId),
}))

2. 长任务

主线程上超过几十毫秒的连续工作可能造成明显卡顿。方案包括:

  • 分批处理并让出主线程
  • Web Worker
  • 虚拟列表
  • 减少 DOM 数量
  • 避免重复布局计算

把大循环拆批时,需要真正让出主线程。queueMicrotask 不适合切分长期工作,因为微任务会在渲染前被持续清空;可以按场景使用任务、requestAnimationFrame、空闲调度或 Worker:

js 复制代码
async function processInChunks(items, chunkSize = 500) {
  for (let index = 0; index < items.length; index += chunkSize) {
    processBatch(items.slice(index, index + chunkSize))

    await new Promise((resolve) => {
      setTimeout(resolve, 0)
    })
  }
}

切片会增加总调度时间,但能换来输入和绘制机会。CPU 密集且数据量大的任务优先考虑 Worker,而不是无限细分主线程任务。

3. 布局抖动

读取布局后修改样式,再读取再修改,可能迫使浏览器重复布局:

js 复制代码
// 尽量批量读取
const widths = elements.map((el) => el.offsetWidth)

// 再批量写入
elements.forEach((el, index) => {
  el.style.width = `${widths[index] * 2}px`
})

4. 缓存计算

js 复制代码
function memoize(fn) {
  const cache = new Map()

  return (key) => {
    if (cache.has(key)) return cache.get(key)
    const value = fn(key)
    cache.set(key, value)
    return value
  }
}

缓存不是免费优化。要考虑键是否稳定、内存是否增长、结果是否会过期,以及计算本身是否真的昂贵。

5. Web Worker

js 复制代码
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), {
  type: 'module',
})

worker.postMessage(largeData)
worker.onmessage = (event) => render(event.data)

Worker 不能直接操作 DOM,数据通过结构化克隆或 transferable object 传递。适合大数据计算、压缩、解析和图像处理。

二十八、安全:前端必须知道的 JavaScript 风险

1. XSS

用户输入被当作 HTML 或脚本执行:

js 复制代码
container.innerHTML = userInput // 危险

优先:

js 复制代码
container.textContent = userInput

确实需要富文本时,使用成熟的白名单清洗方案,并配置 CSP。仅靠替换 <script> 不足以防御 XSS。

XSS 不只来自 <script> 标签。事件属性、危险 URL、SVG、样式上下文和第三方依赖都可能成为注入入口。防御的核心是根据输出上下文采用正确编码,并尽量使用不会解释 HTML 的 API:

js 复制代码
link.textContent = userProvidedLabel

const url = new URL(userProvidedUrl, location.origin)
if (!['http:', 'https:'].includes(url.protocol)) {
  throw new Error('不支持的 URL 协议')
}
link.href = url.href

HttpOnly Cookie 能降低令牌被脚本直接读取的风险,但不能阻止 XSS 以当前用户身份操作页面。CSP 是纵深防御,也不能替代源头编码和 HTML 清洗。

2. eval 与动态代码

js 复制代码
eval(userInput)
new Function(userInput)()

会带来代码注入、CSP 冲突和优化困难。普通业务代码不应使用。

3. 原型污染

把不可信键递归合并到普通对象可能修改原型:

js 复制代码
// 需要防范 __proto__、constructor、prototype 等特殊键

使用经过审计的合并实现、键白名单,或用 Object.create(null) 保存纯字典数据。

危险通常出现在"把外部可控路径递归写入普通对象"的工具函数:

js 复制代码
function safeAssign(target, key, value) {
  const blockedKeys = new Set(['__proto__', 'prototype', 'constructor'])
  if (blockedKeys.has(key)) {
    throw new Error(`不允许的属性:${key}`)
  }
  target[key] = value
}

真实项目不应只靠这一小段示例实现通用深合并,还要限制路径每一段、对象类型和允许字段。最稳妥的原则是:业务输入按 schema 白名单构造新对象,不把任意键直接合并进配置或权限对象。

4. CSRF

浏览器可能自动携带 Cookie,攻击站点可以诱导用户向目标站点发请求。常见防御:

  • SameSite Cookie
  • CSRF Token
  • 校验 Origin / Referer
  • 关键操作二次确认

CSRF 防御主要需要服务端配合。

5. 敏感信息

前端代码、Source Map、网络请求和浏览器存储都可被用户查看。任何放到前端的永久密钥都不能视为秘密。真正的密钥和权限校验必须留在服务端。

二十九、工程化中的 JavaScript

1. 目录与职责

text 复制代码
api/        网络边界
domain/     业务规则
components/ UI
hooks/      状态与生命周期组合
utils/      无业务或弱业务工具
constants/  稳定常量

不要把所有逻辑都放进 utils。有明确业务语义的计算应该靠近领域模块。

2. 数据在边界处规范化

js 复制代码
function normalizeUser(raw) {
  return {
    id: String(raw.id),
    name: String(raw.name ?? ''),
    enabled: Boolean(raw.enabled),
    createdAt: new Date(raw.created_at),
  }
}

页面内部使用稳定模型,比每个组件分别处理 '0'0null 更可靠。

这里还缺少一步:转换之前先校验外部值。TypeScript 类型只存在于编译阶段,接口响应、URL 参数、localStoragepostMessage 和用户输入在运行时都可能不符合声明。

js 复制代码
function parseUser(raw) {
  if (!raw || typeof raw !== 'object') {
    throw new TypeError('用户数据必须是对象')
  }

  if (typeof raw.id !== 'string' && typeof raw.id !== 'number') {
    throw new TypeError('用户 ID 无效')
  }

  return {
    id: String(raw.id),
    name: typeof raw.name === 'string' ? raw.name : '',
    enabled: raw.enabled === true,
  }
}

校验负责拒绝不合法状态,规范化负责把多个合法输入形态转换成内部唯一形态。把两者放在系统边界,内部代码就不必到处写防御性分支。

3. 不可变数据

不可变更新让状态变化更容易比较、撤销和调试:

js 复制代码
const nextUsers = users.map((user) =>
  user.id === id ? { ...user, enabled: true } : user,
)

不是所有局部变量都必须不可变。函数内部为性能构建临时 Map 或数组是合理的,关键是不要意外修改共享状态和函数输入。

4. ESLint、格式化与类型

  • ESLint 发现未使用变量、危险 Promise、错误依赖等问题
  • 格式化工具消除无意义的样式争论
  • TypeScript 提供静态类型,但运行时数据仍需校验

TypeScript 不会改变 JavaScript 运行规则。闭包、原型、事件循环和类型转换仍然必须理解。

5. 测试层次

纯函数单元测试:

js 复制代码
import { describe, expect, it } from 'vitest'

describe('calculateTotal', () => {
  it('calculates price times count', () => {
    expect(
      calculateTotal([{ price: 10, count: 3 }]),
    ).toBe(30)
  })
})

还应根据风险增加:

  • 集成测试:多个模块协作
  • 组件测试:用户交互和展示
  • E2E:登录、支付、发布等关键流程

测试实现行为,不要过度绑定内部函数调用次数和 DOM 结构细节。

6. 可维护性来自明确契约

一个函数是否容易维护,通常不取决于用了多少高级语法,而取决于输入、输出、副作用和失败方式是否明确:

js 复制代码
/**
 * 根据订单明细计算分为单位的应付金额。
 * 输入不合法时抛 ValidationError,不读取或修改外部状态。
 */
function calculatePayableAmount(items) {
  // 纯业务计算
}

工作中可以用四个问题审查公共函数:

  1. 输入允许哪些类型、空值和边界值?
  2. 返回的是值、Promise,还是可取消任务?
  3. 是否修改参数、缓存、DOM 或全局状态?
  4. 失败时抛错、返回空值,还是返回结果对象?

契约模糊时,调用方只能通过阅读实现猜测;契约清晰时,重构、测试和多人协作都会简单很多。

三十、常用设计模式在 JavaScript 中的落地

1. 工厂模式

js 复制代码
function createLogger(type) {
  if (type === 'remote') return new RemoteLogger()
  return new ConsoleLogger()
}

适合根据配置创建不同实现。

2. 策略模式

js 复制代码
const discountStrategies = {
  vip: (price) => price * 0.8,
  newUser: (price) => price - 20,
  default: (price) => price,
}

function calculatePrice(price, type) {
  return (discountStrategies[type] ?? discountStrategies.default)(price)
}

它比不断增加 if/else 更容易扩展。

3. 发布订阅

js 复制代码
function createEventBus() {
  const events = new Map()

  return {
    on(type, handler) {
      const handlers = events.get(type) ?? new Set()
      handlers.add(handler)
      events.set(type, handlers)
      return () => handlers.delete(handler)
    },
    emit(type, payload) {
      events.get(type)?.forEach((handler) => handler(payload))
    },
  }
}

适合低耦合通知,但滥用全局事件总线会让数据流难以追踪。能通过明确函数调用或状态管理完成时,不必引入事件总线。

4. 中间件

js 复制代码
const middlewares = [authMiddleware, logMiddleware, requestMiddleware]

function compose(middlewares) {
  return (context) => {
    function dispatch(index) {
      const middleware = middlewares[index]
      if (!middleware) return Promise.resolve()
      return Promise.resolve(
        middleware(context, () => dispatch(index + 1)),
      )
    }

    return dispatch(0)
  }
}

Express、Koa、Redux 等工具都能看到中间件思想。

三十一、常见面试题背后的真实知识

1. 为什么 typeof null 是 object

这是 JavaScript 早期实现遗留行为,不能据此认为 null 真的是对象。实际判断使用 value === null

2. 为什么 \[\] == !\[\] 是 true

js 复制代码
[] == ![]

大致过程:

  1. [] 是真值,所以 ![] 得到 false
  2. 宽松相等比较对象和布尔值时发生类型转换。
  3. false 转为 0
  4. [] 转为原始值 '',再转为 0
  5. 最终比较 0 == 0

实际工作中的结论不是背结果,而是默认使用 ===

3. 输出题

js 复制代码
console.log(1)

setTimeout(() => console.log(2), 0)

Promise.resolve()
  .then(() => console.log(3))
  .then(() => console.log(4))

console.log(5)

输出:1 5 3 4 2。关键是同步调用栈、Promise 微任务链和定时器任务。

4. this 丢失

js 复制代码
const user = {
  name: 'Alice',
  show() {
    console.log(this.name)
  },
}

const show = user.show
show()

函数脱离对象后,调用位置不再是 user.show(),因此不会自动绑定 user。可以使用 bind 或包装函数:

js 复制代码
const showUser = user.show.bind(user)
const showUser2 = () => user.show()

5. 深拷贝为什么难

完整深拷贝要考虑:

  • 循环引用
  • Date、RegExp、Map、Set、ArrayBuffer
  • Symbol 键
  • 属性描述符
  • 原型
  • 函数和闭包
  • DOM 与宿主对象

因此应先问业务到底要复制什么,而不是默认手写万能 deepClone

三十二、推荐的学习与排查顺序

遇到 JavaScript 问题时,可以按下面顺序定位:

  1. 值是什么类型? 是否发生隐式转换?
  2. 变量来自哪个作用域? 是否被闭包捕获?
  3. 函数如何被调用? this 到底绑定到谁?
  4. 对象属性来自自身还是原型?
  5. 代码是同步、微任务还是任务?
  6. 数据是否被意外修改? 是浅拷贝还是深层共享?
  7. 资源是否被清理? 定时器、事件、请求、缓存是否仍被引用?
  8. 问题属于语言还是宿主 API?

这套顺序比直接搜索某个输出结果更容易形成长期能力。

三十三、知识清单

学完后可以用这份清单自查:

text 复制代码
基础
□ var / let / const、提升、TDZ
□ 7 种原始类型、对象、typeof
□ 显式与隐式类型转换
□ == / === / Object.is
□ 运算符、可选链、空值合并
□ 条件、循环、异常

函数与对象
□ 函数声明、表达式、箭头函数
□ 参数、剩余参数、高阶函数、纯函数
□ 作用域、执行上下文、调用栈、闭包
□ this、call、apply、bind、new
□ 属性描述符、getter/setter
□ 原型链、构造函数、class、私有字段

数据结构
□ Array 的修改与非修改方法
□ Map、Set、WeakMap、WeakSet
□ String、RegExp、Date、Intl
□ Iterator、Generator、异步迭代

异步
□ 回调、Promise 状态与链式调用
□ all / allSettled / race / any
□ async/await、串行、并行、并发池
□ AbortController、竞态条件
□ 事件循环、任务、微任务、渲染

浏览器与工程
□ ESM、动态导入、循环依赖、Tree Shaking
□ DOM、事件传播、事件委托
□ fetch、HTTP、CORS、重试
□ Cookie、Storage、IndexedDB
□ Proxy、Reflect、Symbol 协议
□ 错误上报、内存泄漏、性能分析
□ XSS、CSRF、原型污染、敏感信息
□ 模块职责、数据规范化、测试与类型

总结

JavaScript 的难点不在 API 数量,而在几组会彼此影响的核心规则:类型转换决定表达式结果,词法作用域和调用方式决定变量与 this,原型链决定属性查找,任务与微任务决定异步顺序,可达性决定内存是否释放。

真正掌握 JavaScript,不是记住所有输出题,而是能从这些规则推导结果,并把规则转化成工程约束:默认使用严格相等,在边界规范化数据,明确串行和并行,主动取消过期请求,清理事件与定时器,不把敏感信息放到前端,用测量结果指导性能优化。

当这些基础成为习惯,再学习 React、Vue、Node.js 或任何框架时,就不会只停留在"API 怎么调用",而能理解框架为什么这样设计,以及问题到底发生在哪一层。

参考资料

相关推荐
windliang2 小时前
Claude Code 源码分析(十一):一段会话怎样保存、恢复与续写
前端·javascript·人工智能
小小尚@5 小时前
AE脚本-AE Actions v1.1.8 操作动作记录器
开发语言·前端·javascript·jupyter·postman
大家的林语冰6 小时前
👍 超越 ESLint,Oxc 优先采用 TypeScript 7,Rust 和 Go 梦幻联动!
前端·javascript·typescript
胡萝卜术8 小时前
在浏览器中跑 DeepSeek-R1:WebGPU 推理全流程深度解析
前端·javascript·面试
xiaominlaopodaren8 小时前
three.js地图数学基础(六):齐次坐标与矩阵
javascript·gis·three.js
AI砖家8 小时前
React Native 开发规范与完整流程指南
javascript·react native·react.js
小林ixn9 小时前
全栈项目实战:前端独立开发,不再傻等后端接口
前端·javascript·react.js
Asize9 小时前
前端接口工程:axios + mock,前端不再傻等后端
前端·javascript
张元清9 小时前
React useElementSize Hook:用 ResizeObserver 实时追踪元素宽高 (2026)
javascript·react.js