JavaScript 运行时深度指南
前言
你已经学会了 JavaScript 的基本语法,但你是否想过:
- 代码到底在哪里运行?
- 为什么同样的代码在浏览器和 Node.js 中行为不一样?
- 为什么有时代码会"卡住",有时却能"并行"执行?
这篇文章会带你深入了解 JavaScript 的运行时环境,包括事件循环、调用栈、内存管理等。读完这篇,你就能理解代码为什么按某个顺序执行,快速定位异步相关的 bug,优化代码性能并避免内存泄漏。
这篇文章会带你学什么?
| 章节 | 内容 | 学完能干嘛 |
|---|---|---|
| 第 1 章 | 运行时概述 | 理解 JavaScript 代码在哪里运行 |
| 第 2 章 | 浏览器运行时 | 知道浏览器提供了哪些 Web API |
| 第 3 章 | Node.js 运行时 | 了解服务器端的 JavaScript 环境 |
| 第 4 章 | 事件循环深入 | 掌握宏任务和微任务的执行顺序 |
| 第 5 章 | 调用栈与内存 | 理解代码执行过程和内存管理 |
| 第 6 章 | 实战技巧 | 优化性能、调试内存泄漏 |
1. 运行时概述
🤔 核心问题
什么是"运行时"? JavaScript 只是一门语言,为什么同样的代码在不同环境中会有不同的行为?
1.1 运行时是什么
运行时 = JavaScript 引擎 + 环境提供的 API
如果把 JavaScript 比作"编程语言",那么运行时就是"操作系统"——它决定了你的代码能做什么、不能做什么。
┌─────────────────────────────────────┐
│ JavaScript 代码 │
├─────────────────────────────────────┤
│ JavaScript 引擎 (V8) │ ← 负责解析和执行代码
├─────────────────────────────────────┤
│ 运行时环境 (浏览器/Node.js) │ ← 提供额外能力
└─────────────────────────────────────┘一个比喻:JavaScript 是"普通话",运行时是"城市"
- JavaScript 语法(普通话)哪里都一样
- 但不同城市提供的设施不一样:
- 浏览器 = 有 DOM、window、fetch(就像城市有商场、图书馆)
- Node.js = 有 fs、http、path(就像城市有工厂、高速公路)
1.2 两大主流运行时
| 特性 | 浏览器 | Node.js |
|---|---|---|
| 主要用途 | 网页交互、用户界面 | 服务器端应用、命令行工具 |
| 全局对象 | window | global |
| DOM API | ✅ 支持 | ❌ 不支持 |
| 文件系统 | ❌ 受限 | ✅ 完整支持 |
| 模块系统 | ES Modules | CommonJS + ES Modules |
| 定时器 | setTimeout, setInterval | setTimeout, setInterval |
| 网络请求 | fetch, XMLHttpRequest | http, https 模块 |
👇 动手试试看:对比浏览器和 Node.js 的环境差异
运行时环境对比
浏览器环境
- ✅ 有 DOM 和 BOM API,可以操作网页
- ✅ 有 Web Storage (localStorage, sessionStorage)
- ✅ 有 fetch 和 XMLHttpRequest 进行网络请求
- ❌ 没有文件系统访问权限
- ❌ 不能直接创建 HTTP 服务器
代码演示:不同环境的差异
核心区别:
浏览器运行时专注于用户界面和网页交互,提供 DOM、BOM、fetch 等前端专用 API。
Node.js 运行时专注于服务器端开发,提供文件系统、HTTP 服务器、进程管理等后端专用 API。
同样的 JavaScript 语法,但能用的 API 完全不同——这就是"环境判断"的重要性。
💡 核心启示
运行时决定了你能用什么 API。在浏览器能用的 DOM API,在 Node.js 里用不了;在 Node.js 能用的文件 API,在浏览器里也用不了。这就是为什么有些代码需要"环境判断"。
2. 浏览器运行时
🤔 核心问题
浏览器提供了哪些能力让 JavaScript 操作网页?
2.1 浏览器运行时的组成
┌─────────────────────────────────────────────┐
│ JavaScript 引擎 │
│ (V8 / SpiderMonkey) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Web APIs │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ │
│ │ DOM │ │ BOM │ │ Network │ │
│ │ 操作网页 │ │ 操作浏览器 │ │ 网络请求 │ │
│ └─────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 事件循环 (Event Loop) │
│ 负责协调代码执行、事件处理、任务调度 │
└─────────────────────────────────────────────┘2.2 Web APIs 的三大类
1. DOM API - 操作网页内容
// 查找元素
const title = document.querySelector('h1')
// 修改内容
title.textContent = '新标题'
// 添加样式
title.style.color = 'red'2. BOM API - 操作浏览器
// 页面跳转
window.location.href = 'https://example.com'
// 浏览器存储
localStorage.setItem('key', 'value')
// 浏览器历史
history.back()3. Network API - 网络请求
// 发送 HTTP 请求
fetch('/api/data')
.then(response => response.json())
.then(data => console.log(data))2.3 浏览器特有的事件机制
浏览器运行时最强大的功能之一是"事件驱动"——代码不需要一直运行,而是等用户操作时才执行。
button.addEventListener('click', () => {
console.log('按钮被点击了')
})常见事件类型:
| 事件类型 | 触发时机 | 实际场景 |
|---|---|---|
click | 鼠标点击 | 按钮交互 |
input | 输入框内容变化 | 实时搜索 |
scroll | 页面滚动 | 懒加载 |
load | 资源加载完成 | 初始化数据 |
error | 发生错误 | 错误处理 |
3. Node.js 运行时
🤔 核心问题
JavaScript 能在服务器端运行,靠的是什么?
3.1 Node.js 的组成
┌─────────────────────────────────────────────┐
│ JavaScript 引擎 │
│ (V8) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Node.js 内置模块 │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ │
│ │ fs │ │ http │ │ path │ │
│ │ 文件操作 │ │ 网络服务器 │ │ 路径处理 │ │
│ └─────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ libuv 事件循环库 │
│ 跨平台的异步 I/O 支持 │
└─────────────────────────────────────────────┘3.2 Node.js 特有能力
1. 文件系统操作
const fs = require('fs')
// 读取文件
fs.readFile('./data.txt', 'utf8', (err, data) => {
if (err) throw err
console.log(data)
})
// 写入文件
fs.writeFile('./output.txt', 'Hello', (err) => {
if (err) throw err
console.log('写入成功')
})2. HTTP 服务器
const http = require('http')
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end('<h1>Hello World</h1>')
})
server.listen(3000)3. 模块系统
// CommonJS (Node.js 默认)
const fs = require('fs')
module.exports = { myFunction }
// ES Modules (现代方式)
import fs from 'fs'
export { myFunction }3.3 浏览器 vs Node.js 对比
| 特性 | 浏览器 | Node.js |
|---|---|---|
| 入口文件 | HTML 文件 | JavaScript 文件 |
| 全局对象 | window, document | global, process |
| 模块加载 | <script> 标签 | require() / import |
| 安全性 | 沙箱环境,受限 | 可以访问系统资源 |
| 用途 | 用户界面 | 后端服务、工具 |
4. 事件循环深入
🤔 核心问题
JavaScript 是单线程的,为什么能做到"不阻塞"?
4.1 事件循环是什么
事件循环 = JavaScript 的"任务调度中心"
JavaScript 是单线程的,一次只能做一件事。但事件循环让它看起来能"同时"做很多事。
核心机制:
- 执行同步代码 (调用栈)
- 处理异步任务 (任务队列)
- 等待新任务 (循环往复)
调用栈 任务队列
┌─────────┐ ┌──────────┐
│ 任务 1 │ │ 宏任务 1 │
│ 任务 2 │ ←──────────── │ 宏任务 2 │
│ 任务 3 │ 执行完一个 │ 宏任务 3 │
└─────────┘ 就取下一个 └──────────┘
↓ ↑
└────────────────────────┘
事件循环不断检查4.2 宏任务 vs 微任务
这是面试和实际开发中最容易搞混的概念!
宏任务 (Macrotask):
setTimeout,setInterval- I/O 操作
- UI 渲染
微任务 (Microtask):
Promise.thenMutationObserverqueueMicrotask
执行顺序:同步代码 → 微任务 → 宏任务
👇 动手试试看:观察宏任务和微任务的执行顺序
任务队列:宏任务 vs 微任务
代码示例
调用栈 (正在执行)
微任务队列 Microtask
宏任务队列 Macrotask
输出日志 (执行顺序)
执行顺序规则
核心要点: 微任务优先级高于宏任务。每次执行完一个宏任务后,都会检查并执行所有微任务,然后再执行下一个宏任务。
4.3 经典面试题
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
console.log('4')
// 输出: 1, 4, 3, 2为什么是这个顺序?
- 执行同步代码:
console.log('1'),console.log('4')→ 输出 1, 4 - 检查微任务队列:
Promise.then→ 输出 3 - 检查宏任务队列:
setTimeout→ 输出 2
💡 实战技巧
- 如果想让代码尽快执行,用微任务 (
Promise.then) - 如果想延迟执行,用宏任务 (
setTimeout) - 永远不要混用太多异步操作,否则会陷入"回调地狱"
5. 调用栈与内存
🤔 核心问题
代码是怎么被执行的?变量存在哪里?什么时候被回收?
5.1 调用栈:函数执行的"足迹"
调用栈 = 记录函数调用的"笔记本"
每次调用一个函数,就会在栈上新增一条记录;函数执行完,记录就被移除。
function a() {
b()
}
function b() {
c()
}
function c() {
console.log('执行完毕')
}
a()调用栈的变化:
步骤 1: 调用 a()
┌─────────┐
│ a │
└─────────┘
步骤 2: a() 调用 b()
┌─────────┐
│ b │
│ a │
└─────────┘
步骤 3: b() 调用 c()
┌─────────┐
│ c │
│ b │
│ a │
└─────────┘
步骤 4: c() 执行完,依次弹出
┌─────────┐
│ b │
│ a │
└─────────┘👇 动手试试看:观察调用栈的变化
调用栈:函数执行的足迹
代码
调用栈
当前状态:
调用 main()
输出
调用栈工作原理:
- 每次调用函数,就会在栈上"压入"一个新的"栈帧"
- 栈帧记录了函数的执行状态、局部变量等信息
- 函数执行完毕,栈帧就会从栈上"弹出"
- 栈是"后进先出"(LIFO)的数据结构
- 如果递归太深,会导致"栈溢出"错误
调用栈就像一摞盘子:最后放上去的盘子最先被取走。每个函数就是一个盘子,执行完就取走,然后继续执行下面的函数。
5.2 内存管理:垃圾去哪儿了
JavaScript 有"自动垃圾回收"机制——你不需要手动释放内存,引擎会帮你做。
垃圾回收的原理:标记-清除算法
- 标记阶段:从"根"开始,找到所有能访问的变量
- 清除阶段:没被标记的变量就是"垃圾",会被回收
// 垃圾回收示例
let obj1 = { name: '对象1' }
let obj2 = { name: '对象2' }
// obj1 被重新赋值,原来的对象失去了引用
obj1 = null // 原来的 { name: '对象1' } 会被回收
// obj2 还在使用中,不会被回收
console.log(obj2.name)👇 动手试试看:观察垃圾回收的过程
垃圾回收机制
对象引用关系
标记-清除算法 (Mark-and-Sweep)
从根对象(Root)开始,遍历所有可达对象,标记为"活动对象"
遍历整个堆内存,回收所有未被标记的对象
清除所有标记位,为下一次垃圾回收做准备
核心要点
- 根对象(Root): 全局变量、栈上的变量等,总是被认为是可达的
- 可达对象: 从根对象出发,通过引用链能访问到的对象
- 垃圾对象: 无法从根对象访问到的对象,会被回收
- 循环引用: 如果两个对象互相引用但都不可达,仍会被回收
实际应用技巧
对象不再使用时,将其设为 null
使用 const/let 代替 var
组件销毁时移除所有监听器
用 DevTools Memory 面板监控
5.3 内存泄漏:忘记清理的后果
内存泄漏 = 该释放的内存没释放,越积越多
常见原因:
1. 全局变量太多
// ❌ 错误:全局变量不会被回收
globalCache = []
function addItem(item) {
globalCache.push(item)
}2. 事件监听没移除
// ❌ 错误:监听器没移除
button.addEventListener('click', handleClick)
// ✅ 正确:不需要时移除监听
button.removeEventListener('click', handleClick)3. 闭包引用大对象
// ❌ 错误:闭包一直引用大对象,不会被回收
function createHandler() {
const bigData = new Array(1000000).fill('data')
return function() {
console.log('处理中')
}
}
const handler = createHandler() // bigData 一直存在于内存中👇 动手试试看:观察内存泄漏是如何发生的
内存泄漏演示
全局变量泄漏
问题:全局变量不会被垃圾回收,会一直占用内存
示例:不断往全局数组添加数据,从不清理
❌ 错误做法
// 全局变量不会被回收
globalCache = []
function addItem() {
globalCache.push(largeData)
}如何避免内存泄漏
- 避免全局变量: 使用 const/let 代替 var,尽量使用局部变量
- 及时清理监听器: 组件销毁时移除所有事件监听
- 释放闭包引用: 不需要时将闭包变量设为 null
- 使用 WeakMap/WeakSet: 自动清理不再被引用的对象
- 定期检查: 用 DevTools Memory 面板检查内存泄漏
💡 实战技巧
- 定期检查:打开浏览器 DevTools → Memory → Take Heap Snapshot,查看内存占用
- 避免全局变量:尽量用
const和let,不用var - 及时清理:事件监听、定时器用完要移除
- 弱引用:用
WeakMap和WeakSet存储对象引用
6. 实战技巧
🤔 核心问题
怎么写出高性能的 JavaScript 代码?遇到问题怎么调试?
6.1 性能优化技巧
1. 减少重排重绘
// ❌ 错误:每次循环都触发重排
for (let i = 0; i < 1000; i++) {
element.style.top = i + 'px'
}
// ✅ 正确:批量修改
element.style.transform = `translateY(${position}px)`2. 使用事件委托
// ❌ 错误:给每个按钮都添加监听
buttons.forEach(btn => {
btn.addEventListener('click', handleClick)
})
// ✅ 正确:只给父元素添加一个监听
container.addEventListener('click', (e) => {
if (e.target.matches('.button')) {
handleClick(e)
}
})3. 防抖和节流
// 防抖:用户停止输入后再执行
function debounce(fn, delay) {
let timer
return function(...args) {
clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), delay)
}
}
// 节流:限制执行频率
function throttle(fn, delay) {
let lastTime = 0
return function(...args) {
const now = Date.now()
if (now - lastTime >= delay) {
fn.apply(this, args)
lastTime = now
}
}
}6.2 调试技巧
1. 用 DevTools 查看调用栈
function a() {
b()
}
function b() {
c()
}
function c() {
debugger // 在这里暂停,查看调用栈
}
a()2. 用 console.trace() 追踪执行路径
function trackExecution() {
console.trace('执行路径')
// 会输出完整的调用栈
}3. 用 Performance 分析性能
performance.mark('start')
// 执行一些代码
for (let i = 0; i < 10000; i++) {
// ...
}
performance.mark('end')
performance.measure('循环性能', 'start', 'end')
const measure = performance.getEntriesByName('循环性能')[0]
console.log(`执行时间: ${measure.duration}ms`)6.3 常见问题速查
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 内存占用高 | 内存泄漏、缓存太多 | 检查全局变量、移除监听器 |
| 页面卡顿 | 长任务阻塞主线程 | 拆分任务、用 Web Workers |
| 事件不触发 | 监听器没绑定、元素不存在 | 检查 DOM 加载时机 |
| 异步顺序错乱 | 混用宏任务和微任务 | 统一用 Promise 或 async/await |
| 定时器不准 | 主线程阻塞 | 用 Web Workers 或 requestAnimationFrame |
总结
你现在应该能理解:
- 运行时 = 引擎 + 环境 API,不同运行时提供不同能力
- 事件循环负责协调同步代码、微任务、宏任务的执行顺序
- 调用栈记录函数执行过程,栈溢出是因为递归太深
- 垃圾回收自动清理不用的变量,但要注意内存泄漏
- 性能优化的关键是减少重排重绘、合理使用异步
💡 遇到问题时这样跟 AI 说
- "这个函数执行太慢,帮我看看怎么优化性能"
- "内存占用一直在涨,可能是内存泄漏,帮我检查一下"
- "异步操作顺序不对,应该是先 A 再 B,现在是 A 和 B 几乎同时开始"
- "事件监听器没有触发,检查一下元素是否已经加载到 DOM"
