JavaScript 入门很快:声明变量、写一个函数、操作 DOM,几天就能完成一个页面。但从"能写"到"写得可靠",中间隔着类型转换、作用域、闭包、this、原型链、事件循环、Promise、模块化、内存管理和工程设计等一整套知识。
这篇文章尝试建立一张由浅入深的 JavaScript 知识地图。它不会只罗列 API,而是对每个重要主题回答四个问题:
- 它是什么?
- 它为什么这样运行?
- 工作中什么时候使用?
- 最容易踩什么坑?
JavaScript 的标准语言部分由 ECMAScript 规范定义;DOM、
fetch、setTimeout、localStorage等属于宿主环境提供的 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() {
// 未声明变量、重复参数等不安全行为会更早暴露
}
严格模式的典型变化:
- 普通函数直接调用时
this是undefined,不再自动指向全局对象 - 给只读属性赋值会抛错
- 禁止意外创建全局变量
- 一些容易产生歧义的旧语法会报错
现代项目使用 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 不提升"。更准确的说法是:let 和 const 的绑定已经创建,但在执行声明语句前没有初始化,这段区域称为暂时性死区(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)) 会丢失 undefined、Symbol、函数,并把 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 作为少数有意使用宽松相等的场景,它同时匹配 null 和 undefined:
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 当作缺失;?? 只把 null 和 undefined 当作缺失。
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、释放锁和清理资源。不要在 finally 中 return,否则可能覆盖原来的返回值或异常。
七、函数: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
箭头函数没有自己的 this、arguments、super 和 new.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') 可以近似理解为:
- 创建新对象。
- 把新对象的原型连接到
User.prototype。 - 以新对象作为
this调用User。 - 如果构造函数没有显式返回对象,就返回这个新对象。
十、对象、属性描述符与常用操作
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 queueMicrotaskMutationObserver
经典示例:
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.nextTick、setImmediate 等机制。不要把浏览器中的执行顺序结论不加区分地套到 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 使用 require 和 module.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
}
支付、创建订单等非幂等操作不能盲目重试,需要服务端幂等键。
二十三、浏览器存储与 Cookie
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,通常绑定当前标签页会话,关闭标签页后清除。
3. Cookie
Cookie 会按规则随请求发送,常用于服务端会话。重要属性:
HttpOnly:JavaScript 不能读取,降低令牌被 XSS 窃取的风险Secure:只通过 HTTPS 发送SameSite:限制跨站发送,帮助缓解 CSRFExpires/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 类型
常见内置错误:
SyntaxErrorReferenceErrorTypeErrorRangeErrorURIErrorAggregateError
自定义业务错误:
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'、0、null 更可靠。
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
[] == ![]
大致过程:
[]是真值,所以![]得到false。- 宽松相等比较对象和布尔值时发生类型转换。
false转为0。[]转为原始值'',再转为0。- 最终比较
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 问题时,可以按下面顺序定位:
- 值是什么类型? 是否发生隐式转换?
- 变量来自哪个作用域? 是否被闭包捕获?
- 函数如何被调用?
this到底绑定到谁? - 对象属性来自自身还是原型?
- 代码是同步、微任务还是任务?
- 数据是否被意外修改? 是浅拷贝还是深层共享?
- 资源是否被清理? 定时器、事件、请求、缓存是否仍被引用?
- 问题属于语言还是宿主 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 怎么调用",而能理解框架为什么这样设计,以及问题到底发生在哪一层。
参考资料
- MDN JavaScript Guide
- ECMAScript Language Specification
- MDN Closures
- MDN Inheritance and the prototype chain
- MDN Microtask guide
- MDN Memory management JavaScript 入门很快:声明变量、写一个函数、操作 DOM,几天就能完成一个页面。但从"能写"到"写得可靠",中间隔着类型转换、作用域、闭包、
this、原型链、事件循环、Promise、模块化、内存管理和工程设计等一整套知识。
这篇文章尝试建立一张由浅入深的 JavaScript 知识地图。它不会只罗列 API,而是对每个重要主题回答四个问题:
- 它是什么?
- 它为什么这样运行?
- 工作中什么时候使用?
- 最容易踩什么坑?
JavaScript 的标准语言部分由 ECMAScript 规范定义;DOM、
fetch、setTimeout、localStorage等属于宿主环境提供的 Web API。理解两者的边界,是学习 JavaScript 的第一步。
一、先建立 JavaScript 的整体地图
可以把 JavaScript 能力分成五层:
text
语法基础
变量、类型、运算符、流程控制、函数
↓
核心机制
作用域、闭包、this、对象、原型、类
↓
数据与抽象
Array、Map、Set、Iterator、Generator、Proxy
↓
运行时
调用栈、事件循环、Promise、async/await、内存管理
↓
工程实践
模块化、网络请求、DOM、错误处理、性能、安全、测试
初学时不需要先读完规范,但要知道:语法只是入口,运行机制决定代码在真实项目里是否可靠。
阅读这篇文章时要先分清三个层次
很多 JavaScript 问题难以理解,是因为把语言、运行时和框架混在了一起:
- ECMAScript 语言层:变量、类型、函数、对象、Promise、模块等规则。
- 宿主环境层 :浏览器提供 DOM、
fetch、定时器;Node.js 提供文件、进程、网络等 API。 - 工程工具层:TypeScript、Babel、Vite、Webpack、ESLint 等负责类型检查、语法转换、打包和约束。
例如,setTimeout 不是 ECMAScript 语言的一部分,而是宿主提供的计时能力;Promise 是语言能力,但 Promise 回调何时得到执行,需要宿主的事件循环配合;TypeScript 能在编译期发现类型问题,却不会改变 JavaScript 最终的运行规则。
因此,遇到问题时先问一句:"这是语言规则、浏览器行为,还是构建工具行为?"定位会快很多。
二、变量、声明与执行前准备
0. Script、Module 与严格模式
JavaScript 源码可以作为传统 Script 或 ES Module 执行。Module 默认处于严格模式,并拥有独立模块作用域。
传统脚本可以显式开启严格模式:
js
'use strict'
function run() {
// 未声明变量、重复参数等不安全行为会更早暴露
}
严格模式的典型变化:
- 普通函数直接调用时
this是undefined,不再自动指向全局对象 - 给只读属性赋值会抛错
- 禁止意外创建全局变量
- 一些容易产生歧义的旧语法会报错
现代项目使用 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 不提升"。更准确的说法是:let 和 const 的绑定已经创建,但在执行声明语句前没有初始化,这段区域称为暂时性死区(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.1 和 0.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
可以把过程拆成四步:
- 外部变量
user保存一个引用值,记作R1。 - 调用
rename(user)时,把R1复制给参数user。 - 两个变量虽然彼此独立,但保存的都是
R1。 - 通过任一变量修改
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)) 会丢失 undefined、Symbol、函数,并把 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 作为少数有意使用宽松相等的场景,它同时匹配 null 和 undefined:
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,再根据场景尝试 valueOf 和 toString:
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
这些规则可以推导面试输出题,但业务代码默认使用 ===,真正需要转换时显式调用 Number、String 或 Boolean,能把意图直接写在代码里。
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 当作缺失;?? 只把 null 和 undefined 当作缺失。
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、释放锁和清理资源。不要在 finally 中 return,否则可能覆盖原来的返回值或异常。
七、函数: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
箭头函数没有自己的 this、arguments、super 和 new.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()
递归没有终止条件时会持续压栈,最终出现栈溢出。
一次函数调用可以概念化为两个阶段:
- 创建阶段 :建立参数、局部声明、词法环境、
this等运行所需信息。 - 执行阶段:按顺序执行函数体,读取和修改环境中的绑定。
变量查找不是扫描全部代码,而是从当前词法环境开始,沿外层环境引用逐级查找:
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') 可以近似理解为:
- 创建新对象。
- 把新对象的原型连接到
User.prototype。 - 以新对象作为
this调用User。 - 如果构造函数没有显式返回对象,就返回这个新对象。
构造函数显式返回值还有一个容易忽略的规则:返回对象会替代新实例,返回原始值则被忽略。
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
状态一旦确定就不会再次改变。
要分清 resolved 和 fulfilled。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 会立即同步执行 ,只有 then、catch、finally 注册的反应是异步微任务:
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 await 在 try/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。理解顺序时需要抓住三条规则:
- 当前调用栈中的同步代码必须先完整执行。
- 每个任务结束后,浏览器会执行微任务检查点,通常清空当前微任务队列,包括执行过程中新增的微任务。
- 微任务完成后浏览器才有机会更新渲染,然后再取下一个任务;"有机会"不代表每轮一定绘制。
一个点击事件处理器整体属于一个任务。处理器里安排的 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 queueMicrotaskMutationObserver
经典示例:
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.nextTick、setImmediate 等机制。不要把浏览器中的执行顺序结论不加区分地套到 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 使用 require 和 module.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 后,浏览器可能需要依次进行样式计算、布局、绘制与合成,但具体哪些阶段发生取决于改动内容:
- 改变元素尺寸或位置,通常可能触发布局和绘制。
- 只改变颜色,通常不需要重新布局,但可能需要绘制。
- 合理使用
transform和opacity的动画,往往更容易交给合成阶段处理。
这不是要求业务开发死记浏览器流水线,而是理解"每次赋值都可能产生后续渲染成本",批量更新和减少无意义 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。
一次稳健请求至少要区分四类失败:
- 网络层失败:断网、DNS、连接中断,
fetchPromise 会拒绝。 - HTTP 失败:服务端返回 400/500,Promise 通常仍成功,需要检查
response.ok。 - 解析失败:响应不是期望的 JSON,
response.json()会拒绝。 - 业务失败: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
}
支付、创建订单等非幂等操作不能盲目重试,需要服务端幂等键。
二十三、浏览器存储与 Cookie
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,通常绑定当前标签页会话,关闭标签页后清除。
3. Cookie
Cookie 会按规则随请求发送,常用于服务端会话。重要属性:
HttpOnly:JavaScript 不能读取,降低令牌被 XSS 窃取的风险Secure:只通过 HTTPS 发送SameSite:限制跨站发送,帮助缓解 CSRFExpires/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 类型
常见内置错误:
SyntaxErrorReferenceErrorTypeErrorRangeErrorURIErrorAggregateError
自定义业务错误:
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 面板观察长期增长
- 重复执行"进入页面 -> 操作 -> 离开页面",比较对象数量
不要只看一次内存高低,重点看相同操作后内存是否持续无法回落。
一次更可靠的排查流程是:
- 刷新页面并等待稳定,拍第一份 Heap Snapshot。
- 重复执行可疑操作多次,例如进入并离开详情页 10 次。
- 手动触发垃圾回收仅用于辅助实验,再拍第二份快照。
- 按 retained size、constructor 和对象增长数量比较。
- 从可疑对象查看 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'、0、null 更可靠。
这里还缺少一步:转换之前先校验外部值。TypeScript 类型只存在于编译阶段,接口响应、URL 参数、localStorage、postMessage 和用户输入在运行时都可能不符合声明。
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) {
// 纯业务计算
}
工作中可以用四个问题审查公共函数:
- 输入允许哪些类型、空值和边界值?
- 返回的是值、Promise,还是可取消任务?
- 是否修改参数、缓存、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
[] == ![]
大致过程:
[]是真值,所以![]得到false。- 宽松相等比较对象和布尔值时发生类型转换。
false转为0。[]转为原始值'',再转为0。- 最终比较
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 问题时,可以按下面顺序定位:
- 值是什么类型? 是否发生隐式转换?
- 变量来自哪个作用域? 是否被闭包捕获?
- 函数如何被调用?
this到底绑定到谁? - 对象属性来自自身还是原型?
- 代码是同步、微任务还是任务?
- 数据是否被意外修改? 是浅拷贝还是深层共享?
- 资源是否被清理? 定时器、事件、请求、缓存是否仍被引用?
- 问题属于语言还是宿主 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 怎么调用",而能理解框架为什么这样设计,以及问题到底发生在哪一层。