普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月7日首页

setTimeout设为0就马上执行?JS异步背后的秘密

作者 牛奶
2026年4月6日 20:52

你有没有遇到过这种情况:代码里写了 setTimeout(fn, 0),心想这下该马上执行了吧?结果发现,还是慢了一拍。还有,为什么 PromisesetTimeout 先执行?async/await 到底在等什么?

今天,用餐厅点餐的故事,来讲讲 JavaScript 事件循环。


原文地址

墨渊书肆/setTimeout设为0就马上执行?JS异步背后的秘密


为什么需要事件循环?

单线程的困境

JavaScript 是单线程的——同一时间只能做一件事。

就像只有一个厨师的小餐厅:如果厨师做完一道菜才接下一单,客人等得头发都白了。

所以 JavaScript 采用了异步回调的方式:点完单先去干别的,菜好了再叫你。

事件循环就是"传唤员"

事件循环就像餐厅里的传唤员

  • 厨房做好了菜,传唤员看看单子,喊"33号,你的菜好了"
  • 如果你正在吃饭(执行其他代码),传唤员就等着
  • 轮到你的时候,你放下筷子(执行完当前代码),去取菜(执行回调)

调用栈 — 厨师的工作台

代码是怎么"跑起来"的?

当你调用一个函数,这个函数就被放进调用栈里执行。

就像厨师在工作台上,一边做菜一边接新单,做完一单马上处理下一单:

function cooking() {
  console.log('开始炒菜');
  fry();
  console.log('炒好了');
}

function fry() {
  console.log('放油');
  console.log('放菜');
  console.log('翻炒');
}

cooking();

执行顺序:

调用栈:
1. cooking() 入栈
2. console.log('开始炒菜') 入栈,执行,出栈
3. fry() 入栈
4. fry() 内的 console.log 依次执行
5. fry() 出栈
6. console.log('炒好了') 入栈,执行,出栈
7. cooking() 出栈

调用栈的特点

  • 后进先出:就像叠盘子,最后放上去的先被用
  • 同步执行:每个函数必须执行完,下一个才能进来
  • 栈溢出:如果递归没终止,栈会无限增长直到崩溃
// 栈溢出示例
function recursive() {
  recursive();
}
recursive();
// RangeError: Maximum call stack size exceeded

任务队列 — 取餐口

异步代码放哪儿?

当遇到 setTimeoutPromise事件回调 这些异步任务时,它们不会马上执行,而是被放到任务队列里。

就像点完单,服务员把单子放到取餐口,等叫号再去取。

事件循环的运行机制

┌─────────────────────┐
       调用栈            正在执行
   (Call Stack)       
└─────────────────────┘
          
┌─────────────────────┐
      任务队列           等待执行
   (Task Queue)       
└─────────────────────┘
          
    事件循环 (Event Loop)
    "栈空了?好,取下一个"

事件循环的规则

  1. 首先执行调用栈里的所有同步代码
  2. 调用栈清空后,去任务队列取一个任务执行
  3. 完成后回到步骤1
console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

console.log('3');

// 输出:1 → 3 → 2
// 因为 setTimeout 的回调在任务队列,要等调用栈空才能执行

微任务 vs 宏任务 — VIP和普通号

两种不同的"队"

任务队列其实分两种:

类型 例子 优先级
宏任务(Macrotask) setTimeoutsetIntervalI/OUI渲染
微任务(Microtask) Promise.then()回调、MutationObserverqueueMicrotask

就像餐厅里:

  • 宏任务 = 普通取餐号,要排队
  • 微任务 = VIP会员卡,来了直接优先处理

注意:不是 Promise 本身是微任务,而是 Promise.then() 的回调函数是微任务。

执行顺序

console.log('1');

setTimeout(() => {
  console.log('2');  // 宏任务
}, 0);

Promise.resolve().then(() => {
  console.log('3');  // 微任务
});

console.log('4');

// 输出:1 → 4 → 3 → 2
// 同步代码 → 微任务 → 宏任务

完整执行流程

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

Promise.resolve()
  .then(() => console.log('Promise1'))
  .then(() => console.log('Promise2'));

Promise.resolve()
  .then(() => console.log('Promise3'));

console.log('同步代码');

// 输出顺序:
// 同步代码
// Promise1
// Promise3
// Promise2      ← Promise.then 链式调用在同一个微任务队列
// setTimeout     ← 所有微任务完成后,才执行宏任务

嵌套的 Promise

Promise.resolve().then(() => {
  console.log('第一个微任务');

  Promise.resolve().then(() => {
    console.log('嵌套的微任务');
  });
});

console.log('同步代码');

// 输出:
// 同步代码
// 第一个微任务
// 嵌套的微任务
// 微任务队列清空后,才会执行下一个宏任务

async/await — 语法糖的秘密

async/await 是什么?

async/await 是 Promise 的语法糖,让异步代码看起来像同步代码。

// Promise 写法
function getData() {
  return fetch('/api/user')
    .then(res => res.json())
    .then(data => console.log(data));
}

// async/await 写法
async function getData() {
  const res = await fetch('/api/user');
  const data = await res.json();
  console.log(data);
}

await 到底在等什么?

await暂停当前 async 函数的执行,等待 Promise 完成,然后继续执行后面的代码。

暂停期间,其他代码可以继续执行

async function example() {
  console.log('1');

  await fetch('/api/data');  // 这里"暂停"

  console.log('3');  // ← 这行去哪了?
}

console.log('2');
example();
console.log('4');

// 输出:2 → 1 → 4 → 3

await 后面那行代码去哪了?

await 后面的代码不会马上执行,而是被包成一个微任务。等 await 的 Promise resolve 后,这个微任务才会执行:

async function example() {
  console.log('1');

  await fetch('/api/data');  // Promise pending...
  // 下面的代码被包成微任务,要等 Promise 完成才执行

  console.log('3');  // ← 这行实际上是 await 的 resolve 后的回调
}

// 等价于:
function example() {
  console.log('1');
  return fetch('/api/data').then(() => {
    console.log('3');  // ← 这里
  });
}

async 函数返回值

async 函数总是返回一个 Promise

async function getNumber() {
  return 42;
}

getNumber().then(console.log);  // 42

// 等价于:
async function getNumber() {
  return Promise.resolve(42);
}

错误处理

// try-catch
async function fetchData() {
  try {
    const res = await fetch('/api/data');
    const data = await res.json();
  } catch (error) {
    console.log('出错了:', error);
  }
}

// Promise catch
async function fetchData() {
  const res = await fetch('/api/data').catch(err => console.log(err));
}

requestAnimationFrame — 动画的正确姿势

为什么不用 setInterval?

setInterval 不保证什么时候执行,也不保证每次间隔精确:

setInterval(() => {
  moveBall();  // 可能丢帧、卡顿
}, 16);  // 约60fps,但不一定准

requestAnimationFrame 的特点

  • 浏览器优化:在下一次重绘之前执行,不丢帧
  • 页面不可见时:自动暂停,节省性能
  • 约60fps:和屏幕刷新率同步
function animate() {
  moveBall();
  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

// 取消动画
const id = requestAnimationFrame(animate);
cancelAnimationFrame(id);

执行顺序

用户点击
   
事件触发
   
微任务(全部清空)← 先清空所有微任务
   
宏任务
   
requestAnimationFrame   所有微任务清空后,渲染之前
   
浏览器渲染

深入了解事件循环 🔬

Node.js 的事件循环

Node.js 和浏览器的事件循环不一样

┌───────────────────────────────────────────────────────┐
│                    Node.js 事件循环                    │
├───────────────────────────────────────────────────────┤
│  ① Timers          →  setTimeout, setInterval 回调    │
│  ② Pending I/O     →  I/O callbacks(延迟到下一循环)   │
│  ③ Idle/Prepare    →  内部使用                         │
│  ④ Poll            →  获取新 I/O 事件                  │
│  ⑤ Check           →  setImmediate 回调               │
│  ⑥ Close           →  close 事件回调                   │
└────────────────────────────────────────── ────────────┘

浏览器和 Node.js 的区别

// 浏览器
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
// 输出:microtask → timeout

// Node.js(可能不同)
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
// 可能输出:microtask → timeout
// 但 setImmediate 可能更早

queueMicrotask vs Promise.then

queueMicrotask 显式创建一个微任务:

queueMicrotask(() => {
  console.log('我也是微任务');
});

Promise.resolve().then(() => {
  console.log('Promise微任务');
});

// 两者都是微任务,执行顺序相同

浏览器渲染时机

不是每次事件循环都会渲染,浏览器会批量处理

// 可能只触发一次重排/重绘
div.style.top = '100px';
div.style.left = '100px';
div.style.width = '200px';

// 而不是三次单独的重排

任务分解 — 避免卡顿

长时间任务可以分解,让页面保持响应:

function processItems(items) {
  let i = 0;

  function step() {
    // 处理一项
    process(items[i]);

    i++;
    if (i < items.length) {
      // 用 setTimeout 让出主线程
      setTimeout(step, 0);
    }
  }

  step();
}

// 现代浏览器可以用 scheduler.yield()
async function processItems(items) {
  for (const item of items) {
    process(item);
    await scheduler.yield();  // 让出主线程
  }
}

横向对比

API 类型 优先级 使用场景
setTimeout 宏任务 延迟执行、轮询
setInterval 宏任务 定时任务(慎用)
Promise.then 微任务 异步结果处理
async/await 微任务 异步代码写法
requestAnimationFrame 宏任务 动画、游戏循环
MutationObserver 微任务 DOM 变化监听

怎么选?

场景 推荐
延迟执行 setTimeout
等待 Promise await / Promise.then
动画/游戏 requestAnimationFrame
批量 DOM 操作 MutationObserver
分解长任务 setTimeout / scheduler.yield()

总结

概念 像什么 作用
调用栈 厨师灶台 同步代码执行
任务队列 取餐口 等待执行的异步任务
宏任务 普通取餐号 setTimeout、setInterval
微任务 VIP会员卡 Promise、queueMicrotask
事件循环 传唤员 协调调用栈和任务队列

同步代码 → 微任务 → 宏任务 → 渲染 → 下一轮


写在最后

现在你应该明白了:

  • setTimeout(fn, 0) 不是马上执行,要等调用栈空、微任务清空后才轮到你
  • PromisesetTimeout 先执行,因为微任务优先级更高
  • async/await 只是 Promise 的语法糖,本质还是异步
  • requestAnimationFrame 是做动画的正确方式,别用 setInterval

下次你的代码执行顺序不对,先看看是微任务还是宏任务——可能就是它插队了。

5MB vs 4KB vs 无限大:浏览器存储谁更强?

作者 牛奶
2026年4月6日 20:44

你有没有想过这个问题:为什么在网页上勾选了"记住我",下次打开还是登录状态?你改了个主题设置,关掉浏览器再打开,主题还在?浏览器是怎么记住这些数据的?

今天,用**"收纳房间"**的故事,来讲讲浏览器存储。


原文地址

墨渊书肆/5MB vs 4KB vs 无限大:浏览器存储谁更强?


浏览器是怎么"装东西"的?

想象一下你家要装修,需要各种收纳工具:

  • 贴身口袋:装点小东西,随时能用
  • 床头柜:装常用物品,随取随用
  • 衣柜:装换季衣服,大容量
  • 仓库:存大件物品,最大但找起来麻烦

浏览器存储也是这个道理。不同的数据,要用不同的"收纳工具"。


Cookie — 贴身口袋

像个口袋,随身带

Cookie 是最"古老"的浏览器存储方案。它最大的特点是——会自动跟着请求一起发出去

就像你出门带了个口袋,里面装着身份证、银行卡。进任何一家店,都要掏出身份证证明身份。

浏览器也是:每次请求网页,Cookie 都自动带上,服务器就知道"哦,这是张三的浏览器"。

Cookie 的特点

属性 像什么
容量 ~4KB 口袋里只能装这么多
发送 自动随请求发送 出门就带
生命周期 可设置过期时间 可以设有效期
访问 JS和服务器都能读 谁都能用

Cookie 的使用场景

  • 登录状态:"记住我"功能
  • 购物车:逛淘宝加购物车
  • 追踪分析:埋点上报

Cookie 的代码

// 设置Cookie
document.cookie = "username=张三; expires=Fri, 31 Dec 2026 23:59:59 GMT; path=/";

// 读取Cookie
console.log(document.cookie);  // "username=张三; theme=dark"

Cookie 的安全问题

Cookie 虽然方便,但有几个安全属性要注意:

属性 作用 什么意思
HttpOnly JS无法访问 口袋上锁了,店员碰不到
Secure 只在HTTPS发送 只能用加密通道
SameSite 防止CSRF攻击 别人拿不到你的卡

深入了解 Cookie 🔬

Cookie 是怎么工作的?

Cookie 由 HTTP 协议定义,通过 Set-Cookie 响应头设置:

HTTP/1.1 200 OK
Set-Cookie: sessionId=abc123; Path=/; HttpOnly; Secure; SameSite=Strict

HTTP/1.1 200 OK
Cookie: sessionId=abc123

浏览器怎么存 Cookie?

每个浏览器都有自己的存储方式:

浏览器 存储位置
Chrome/Edge SQLite 数据库 (%APPDATA%\Local\Google\Chrome\User Data\Default\Cookies)
Firefox JSON 文件 (cookies.sqlite)
Safari 二进制文件

Cookie 的发送规则?

浏览器根据 Domain + Path + SameSite 三个规则决定是否发送:

// 例如:Cookie 设置为 Domain=example.com, Path=/admin
// 会发送给:
// ✅ example.com/admin
// ✅ example.com/admin/users
// ❌ example.com/ (path不匹配)
// ❌ other.com/admin (domain不匹配)

Session Cookie vs 持久 Cookie?

# 会话Cookie(没有Expires/Max-Age)
Set-Cookie: sessionId=abc123
# 关掉浏览器就失效

# 持久Cookie
Set-Cookie: sessionId=abc123; Expires=Wed, 01 Jan 2027 00:00:00 GMT
# 有效期内都有效

LocalStorage — 床头柜

容量大,但不主动发

LocalStorage 是 HTML5 引入的存储方案。最大的特点:不会随请求发出去

就像床头柜——你把东西放里面,下次进门直接拿,不用每次出门都背着。

LocalStorage 的特点

属性 像什么
容量 ~5MB/域 床头柜大小
发送 不随请求发送 不随身带
生命周期 永久存储 除非搬家(手动删除)
API 同步操作 马上拿到

LocalStorage 的使用场景

  • 主题设置:深色/浅色模式
  • 用户偏好:字体大小、语言设置
  • 数据缓存:接口数据本地缓存

LocalStorage 的代码

// 设置
localStorage.setItem('username', '张三');
localStorage.setItem('theme', 'dark');

// 读取
const theme = localStorage.getItem('theme');  // 'dark'

// 删除
localStorage.removeItem('theme');

// 清空
localStorage.clear();

// 遍历
for (let i = 0; i < localStorage.length; i++) {
  const key = localStorage.key(i);
  console.log(`${key}: ${localStorage.getItem(key)}`);
}

LocalStorage 的缺点

  • 同步操作:大量数据会卡界面
  • 只能存字符串:对象要转成 JSON
  • 容量有限:5MB 对大数据不够

深入了解 LocalStorage 🔬

同源策略限制

LocalStorage 遵循同源策略:

✅ http://example.com 和 https://example.com 共享同一个Storage
✅ http://example.com:8080 和 http://example.com:3000 不共享(端口不同)
✅ http://www.example.com 和 http://example.com 不共享(子域名不同)

存储配额

实际容量取决于浏览器和磁盘空间,Chrome 默认是 5MB(可申请更多):

// 查询当前配额和使用量
navigator.storage.estimate().then(({ usage, quota }) => {
  console.log(`已使用: ${(usage / 1024 / 1024).toFixed(2)} MB`);
  console.log(`总配额: ${(quota / 1024 / 1024).toFixed(2)} MB`);
});

// 请求更大的存储空间(需要用户授权)
navigator.storage.persist().then((granted) => {
  console.log('永久存储权限:', granted);
});

为什么 LocalStorage 是同步的?

因为 LocalStorage 读取是直接读磁盘。如果数据量大,同步读取会阻塞主线程:

// ❌ 错误:大数据量时卡界面
localStorage.setItem('bigData', JSON.stringify(largeArray));

// ✅ 更好:拆分存储或用 IndexedDB

SessionStorage — 抽屉

只在当前标签页有效

SessionStorageLocalStorage 几乎一样,唯一的区别是——关闭标签页就没了

就像抽屉里的东西,只有在这个房间能用。换到另一个房间(另一个标签页),抽屉里的东西就不在了。

SessionStorage 的特点

属性 和LocalStorage的区别
容量 ~5MB/域 一样
作用域 仅当前标签页 ❌ 跨标签页不共享
生命周期 关闭标签页失效 ❌ 不能持久保存

SessionStorage 的使用场景

  • 表单草稿:填写到一半的表单
  • 临时状态:当前页面的操作状态

SessionStorage 的代码

// 用法和LocalStorage完全一样
sessionStorage.setItem('draft', JSON.stringify({ title: '我的文章', content: '...' }));

关键区别

// 标签页A中设置
sessionStorage.setItem('key', 'value');
localStorage.setItem('key', 'value');

// 在标签页B中读取
sessionStorage.getItem('key');  // null ❌
localStorage.getItem('key');    // 'value' ✅

深入了解 SessionStorage 🔬

iframe 共享问题

注意:同一个标签页中的 iframe 会共享 SessionStorage(因为是同一个浏览器标签页):

// 父页面
sessionStorage.setItem('shared', 'value');

// iframe 内可以读取到
console.log(sessionStorage.getItem('shared'));  // 'value'

sessionStorage 在隐私模式下

  • Chrome 无痕模式sessionStorage 仍然存在,但标签页关闭后失效
  • Firefox 隐私窗口:完全隔离,每个新窗口都是新的 sessionStorage

和 LocalStorage 的性能对比

两者都是同步 API,性能特性相同。但 SessionStorage 因为数据不持久,有时候比 LocalStorage 更适合存临时数据。


IndexedDB — 仓库

浏览器里的数据库

IndexedDB 是浏览器内置的数据库。容量巨大,能存文件、音频、视频这些大东西。

就像仓库——你家装修工具、电风扇、行李箱都放这儿。东西多,但找起来要翻半天。

IndexedDB 的特点

属性 像什么
容量 很大(取决于磁盘) 仓库,接近无限
数据类型 什么都能存 不挑东西
API 异步操作 异步,不卡界面
查询 支持索引 能分类查找

IndexedDB 的使用场景

  • 离线数据:PWA离线应用
  • 多媒体存储:图片、音频、视频缓存
  • 复杂数据:需要索引查询的数据

IndexedDB 的代码

// 打开数据库
const request = indexedDB.open('myDatabase', 1);

// 创建表(对象存储)
request.onupgradeneeded = (event) => {
  const db = event.target.result;
  const store = db.createObjectStore('users', { keyPath: 'id' });
  store.createIndex('name', 'name', { unique: false });
  store.createIndex('email', 'email', { unique: true });
};

// 添加数据
request.onsuccess = (event) => {
  const db = event.target.result;
  const tx = db.transaction(['users'], 'readwrite');
  const store = tx.objectStore('users');

  store.add({ id: 1, name: '张三', email: 'zhangsan@example.com' });
  store.add({ id: 2, name: '李四', email: 'lisi@example.com' });
};

// 查询数据
const getRequest = store.get(1);
getRequest.onsuccess = () => {
  console.log('查询结果:', getRequest.result);
};

// 使用索引查询
const index = store.index('name');
const indexRequest = index.get('张三');
indexRequest.onsuccess = () => {
  console.log('索引查询结果:', indexRequest.result);
};

IndexedDB 的缺点

  • API 复杂:需要写一堆回调
  • 学习成本高:概念多(数据库、表、事务、索引)

深入了解 IndexedDB 🔬

数据库版本和升级

const request = indexedDB.open('myDatabase', 2);  // 版本号从1升到2

request.onupgradeneeded = (event) => {
  const db = event.target.result;

  // 创建新存储
  if (!db.objectStoreNames.contains('products')) {
    db.createObjectStore('products', { keyPath: 'id' });
  }

  // 删除旧存储
  if (db.objectStoreNames.contains('oldData')) {
    db.deleteObjectStore('oldData');
  }
};

事务的原子性

const tx = db.transaction(['users', 'orders'], 'readwrite');

// 两个操作在一个事务里,要么全成功,要么全失败
tx.objectStore('users').add({ id: 1, name: '张三' });
tx.objectStore('orders').add({ id: 1, userId: 1, product: '电脑' });

tx.oncomplete = () => console.log('事务成功');
tx.onerror = () => console.log('事务失败,全部回滚');

游标遍历大量数据

const tx = db.transaction(['users'], 'readonly');
const store = tx.objectStore('users');
const cursor = store.openCursor();

cursor.onsuccess = (event) => {
  const cur = event.target.result;
  if (cur) {
    console.log('用户:', cur.value.name);
    cur.continue();  // 继续下一个
  }
};

Promise 封装(更简洁的写法)

function openDB(name, version) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(name, version);
    request.onupgradeneeded = (e) => resolve(e.target.result);
    request.onsuccess = (e) => resolve(e.target.result);
    request.onerror = (e) => reject(e.target.error);
  });
}

// 使用
const db = await openDB('myDatabase', 1);
const tx = db.transaction('users', 'readwrite');
await tx.objectStore('users').add({ id: 1, name: '张三' });

Cache API — 集装箱

Service Worker 的专属工具

Cache API 是 Service Worker 的一部分,专门用来缓存网络请求。

就像集装箱——你坐飞机带不了大件行李,但可以用集装箱海运。东西多、个头大,但只能走特定渠道。

Cache API 的特点

属性 像什么
容量 很大 集装箱,装得多
存储内容 Request/Response 对 整套打包
生命周期 手动管理 不用就扔
API 异步操作 不卡界面

Cache API 的使用场景

  • 离线应用:把整个网站缓存下来
  • 性能优化:缓存静态资源
  • Service Worker:配合SW实现缓存策略

Cache API 的代码

// 在Service Worker中使用
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        return cachedResponse || fetch(event.request);
      })
  );
});

// 打开缓存
caches.open('my-cache').then((cache) => {
  cache.addAll([
    '/css/style.css',
    '/js/app.js',
    '/images/logo.png'
  ]);
});

// 缓存特定请求
cache.put(request, response);

// 删除缓存
caches.delete('my-cache');

深入了解 Cache API 🔬

缓存策略

// Cache First(缓存优先)
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => response || fetch(event.request))
  );
});

// Network First(网络优先)
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .catch(() => caches.match(event.request))
  );
});

// Stale-While-Revalidate(先返回缓存,同时更新缓存)
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('my-cache').then((cache) => {
      return cache.match(event.request).then((response) => {
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return response || fetchPromise;
      });
    })
  );
});

Cache API 和 cookies

Cache API 存储的是完整的 Request/Response 对,不只是 body:

// 缓存时包含了headers、status等所有信息
cache.match(request).then((response) => {
  console.log(response.status);      // 200
  console.log(response.headers.get('content-type'));  // 'text/html'
});

缓存清理策略

// 删除指定缓存
caches.delete('old-cache');

// 清理所有版本,只保留最新的
caches.keys().then((cacheNames) => {
  Promise.all(
    cacheNames
      .filter((name) => name.startsWith('app-') && name !== 'app-v2')
      .map((name) => caches.delete(name))
  );
});

Storage Event — 跨标签页喊话

标签页之间能"喊话"

当 LocalStorage 发生变化时,其他同源的标签页会收到通知。

就像你在客厅喊了一句"饭好了",厨房的人、卧室的人都能听到。

Storage Event 的代码

// 标签页A中监听
window.addEventListener('storage', (event) => {
  console.log('key:', event.key);      // 变化的键
  console.log('oldValue:', event.oldValue);  // 旧值
  console.log('newValue:', event.newValue);  // 新值
  console.log('url:', event.url);      // 触发变化的页面URL
  console.log('storageArea:', event.storageArea);  // localStorage 或 sessionStorage
});

// 标签页B中修改
localStorage.setItem('theme', 'dark');  // 标签页A会收到通知

使用场景

  • 多标签页同步:一个标签页登录,其他标签页同步登录状态
  • 状态广播:跨标签页的状态通知

深入了解 Storage Event 🔬

Storage Event 的触发条件

// ✅ 会触发 storage 事件
localStorage.setItem('key', 'value');
localStorage.removeItem('key');
localStorage.clear();

// ❌ 不会触发 storage 事件(同一个标签页)
// Storage Event 只在「其他标签页」变化时触发

SessionStorage 也会触发?

注意:SessionStorage 本身不跨标签页共享,但 Storage Event 只监听 localStorage 的变化。

// SessionStorage 变化不会触发 storage 事件
sessionStorage.setItem('key', 'value');  // 不会触发其他标签页

// localStorage 变化会触发
localStorage.setItem('key', 'value');  // 其他标签页会收到通知

隐私模式下不触发

在无痕/隐私模式下,Storage Event 不会触发,这是浏览器的隐私保护机制。


横向对比

特性 Cookie LocalStorage SessionStorage IndexedDB Cache API
容量 ~4KB ~5MB ~5MB 很大 很大
生命周期 可设置 永久 关闭失效 永久 手动
发送 自动发 不发 不发 不发 不发
API 简单 同步简单 同步简单 异步复杂 异步
数据类型 字符串 字符串 字符串 所有可序列化 Request/Response
跨标签页 共享 共享 不共享 共享 不共享

怎么选?

场景 推荐
需要服务器读取 Cookie
存用户偏好、主题 LocalStorage
临时状态、标签页隔离 SessionStorage
大数据、离线存储 IndexedDB
Service Worker缓存 Cache API

注意事项

1. 不要存敏感信息

LocalStorage 可以被 JS 访问,XSS 攻击能偷走数据。敏感信息用 HttpOnly Cookie。

2. 存储配额

浏览器对存储有限制,可以用 API 查询:

navigator.storage.estimate().then(({ usage, quota }) => {
  console.log('已用:', (usage / 1024 / 1024).toFixed(2), 'MB');
  console.log('总配额:', (quota / 1024 / 1024).toFixed(2), 'MB');
});

3. 序列化问题

LocalStorage 和 SessionStorage 只能存字符串,对象要转 JSON:

// 存
localStorage.setItem('data', JSON.stringify({ name: '张三' }));

// 取
const data = JSON.parse(localStorage.getItem('data'));

4. 同步 API 的性能问题

LocalStorage/SessionStorage 是同步操作,大量数据会阻塞主线程:

// ❌ 不好:大量数据卡界面
for (let i = 0; i < 10000; i++) {
  localStorage.setItem(`key${i}`, `value${i}`);
}

// ✅ 更好:用 IndexedDB 存储大量数据

总结

存储方式 像什么 特点
Cookie 口袋 小、随请求发、安全属性多
LocalStorage 床头柜 5MB、不发送、永久
SessionStorage 抽屉 5MB、不发送、仅标签页
IndexedDB 仓库 巨大、异步、复杂
Cache API 集装箱 Service Worker专用

选对"收纳工具",数据管理更轻松。


写在最后

现在你应该明白了:

  • Cookie = 口袋,随身带、自动发送、容量小
  • LocalStorage = 床头柜,大容量、不发送、永久保存
  • SessionStorage = 抽屉,只在当前标签页有效
  • IndexedDB = 仓库,最大但操作复杂
  • Cache API = 集装箱,Service Worker专用

下次你在网页上勾选"记住我",或者调整了主题设置——你就知道浏览器是用哪种"收纳工具"帮你存的了。

昨天 — 2026年4月6日首页

浏览器是怎么把代码变成页面的?

作者 牛奶
2026年4月6日 01:04

你在地址栏输入一个URL,敲下回车,页面就出现了。但浏览器内部到底经历了什么?HTML、CSS、JS是如何变成你看到的页面的?

今天用**"装修房子"**的故事,聊聊浏览器的渲染原理。


原文地址

墨渊书肆/浏览器是怎么把代码变成页面的?


从URL到页面:渲染总览

当你在浏览器输入URL并回车,浏览器内部经历了:

浏览器地址栏
├── URL输入
├── DNS解析
   └── 域名  IP地址
├── TCP连接
   └── 三次握手
├── HTTP响应
   └── 服务器返回HTML/CSS/JS
└── 渲染进程处理
    ├── 构建阶段:HTML解析 + CSS解析
    └── 绘制阶段:布局  分层  绘制  合成

渲染流水线可以分为构建阶段绘制阶段

构建阶段(并行):
┌─────────────┐     ┌─────────────┐
  HTML解析          CSS解析    
   生成DOM           生成CSSOM  
└──────┬──────┘     └──────┬──────┘
                           
       └────────┬───────────┘
                
          渲染树构建
                
绘制阶段:
```yaml
绘制阶段
├── 布局计算
   └── 计算每个元素的位置、大小、边距
├── 分层
   └── 哪些元素需要独立图层(fixed/动画/视频)
├── 绘制
   └── 生成绘制指令(矩形、文字、线条)
└── 合成输出
    └── GPU合并图层  显示到屏幕

解读

  • 构建阶段:HTML和CSS解析同时进行(并行),完成后合并成渲染树
  • 绘制阶段:按顺序执行布局、分层、绘制、合成,最终输出画面
阶段 输入 输出
HTML解析 HTML字符串 DOM树
CSS解析 CSS字符串 CSSOM树
渲染树构建 DOM + CSSOM 渲染树
布局 渲染树 盒模型信息
分层 布局信息 图层树
绘制 图层 绘制指令
合成 图层+指令 画面

第一步:HTML解析 → DOM树

浏览器收到HTML响应后,首先要解析HTML,构建DOM树

DOM是什么?

DOM(Document Object Model,文档对象模型)是HTML/XML文档的编程接口。浏览器把HTML文档解析成一棵树状结构,每个HTML标签都是树上的一个节点

<html>
  <head>
    <title>标题</title>
  </head>
  <body>
    <h1>欢迎</h1>
    <p>这是段落</p>
  </body>
</html>

DOM树结构:

html
├── head
   └── title  "标题"
└── body
    ├── h1  "欢迎"
    └── p  "这是段落"

HTML解析过程

解析器从上到下读取HTML,遇到<head>标签创建head节点,遇到<body>标签创建body节点,遇到嵌套标签创建子节点...

HTML解析器:逐行读取  创建节点  构建DOM树
<html>  html节点
<head>  head节点  title节点  文本节点  关闭title  关闭head
<body>  body节点  h1节点  文本节点  关闭h1  p节点  文本节点  关闭p  关闭body  关闭html
 DOM树构建完成

遇到JS会怎样?

HTML解析器遇到<script>标签时会暂停解析,先执行JS:

解析HTML  构建DOM  完成
    
遇到<script>:暂停  执行JS  继续

因为JS可能document.write()修改DOM,所以HTML解析器必须等JS执行完成才能继续。

这就是为什么把JS放在body底部可以加快首屏渲染——让HTML先解析完,显示内容,JS最后再执行。


第二步:CSS解析 → CSSOM树

HTML解析的同时,浏览器也在解析CSS,构建CSSOM树(CSS Object Model)。

CSSOM是什么?

CSSOM是CSS样式表的树状结构,描述了每个元素的样式信息。

body { font-size: 16px; }
h1 { color: red; font-size: 24px; }
p { color: blue; }

CSSOM树结构:

body
├── font-size: 16px
├── color: (inherited)
└── children
    ├── h1
       ├── color: red
       └── font-size: 24px
    └── p
        └── color: blue

CSS解析特性

与HTML不同,CSS解析是上下文相关的

标签选择器:p { color: blue; }      所有<p>生效
类选择器:.title { ... }          class="title"生效
ID选择器:#header { ... }        id="header"生效

CSS解析器需要考虑选择器优先级(ID > 类 > 标签)、层叠规则、继承规则等。


第三步:渲染树(Render Tree)

DOM树 + CSSOM = 渲染树(Render Tree)

渲染树只包含可见节点——display: none的元素不会出现在渲染树中。

DOM + CSSOM = 渲染树

DOM节点 CSSOM样式 渲染树
display:none ✗ 不显示
容器样式 body
├─h1 color:red h1(red)
├─p display:none ✗ 不显示
└─span color:green span(green)

注意<p style="display: none">不会生成渲染树节点,但<p style="visibility: hidden">会生成(只是不可见)。


第四步:布局(Layout)

渲染树构建完成后,浏览器计算每个元素的几何信息:位置、大小、边距、边框等。

布局计算

渲染树  布局计算  盒模型信息
元素1:x=0, y=0, width=200, height=50
元素2:x=0, y=50, width=200, height=30
元素3:x=0, y=80, width=100, height=80
 每个元素都有精确的位置和大小

盒模型(Box Model)

CSS中的盒模型定义了元素的空间占用:

┌─margin─────────────────────────────┐
  ┌─border───────────────────────┐  
    ┌─padding──────────────────┐   
      ┌─content─────────────┐    
         width × height       
      └─────────────────────┘    
    └──────────────────────────┘   
  └───────────────────────────────┘  
└─────────────────────────────────────┘
属性 说明
content 内容区域(width × height)
padding 内边距,内容与边框之间的空间
border 边框,围绕内边距的线条
margin 外边距,边框与其他元素之间的空间

回流(Reflow)

当元素的几何信息发生变化时,浏览器需要重新计算布局,这称为回流(Reflow)

触发回流的操作:

  • 添加/删除可见DOM元素
  • 元素位置/尺寸变化
  • 浏览器窗口大小变化
  • 获取元素的offsetWidth/Height(强制触发计算)
回流过程:
修改DOM  重新计算布局  重绘(耗时操作)

回流比重绘更昂贵,因为它需要重新计算整棵布局树。


第五步:分层(Layer)

布局完成后,浏览器根据一定规则把页面分成多个图层(Layer)

为什么要分层?

分层可以让页面的不同部分独立绘制和合成,避免互相影响。

分层示意:
Layer 3: 固定定位的导航栏(最顶层)
Layer 2: 主体内容
Layer 1: 背景图片
Layer 0: 页面根元素(最底层)

哪些元素会生成独立图层?

生成独立图层的触发条件:

  • position: fixed(固定定位)
  • will-change: transform(transform动画)
  • <video><canvas>元素
  • 3D变换:transform: translate3d()
  • CSS动画:@keyframes + transform
  • 加速属性:opacitytransform

浏览器会为这些元素创建独立的合成层(Compositing Layer),让它们的渲染不影响其他图层。

CSS Containment

contain属性可以告诉浏览器元素内容独立于页面其他部分,帮助浏览器优化:

.container {
  contain: content;  /* 布局、样式、绘制都独立 */
}

第六步:绘制(Paint)

分层后,每个图层内部需要绘制,生成绘制指令。

绘制顺序

浏览器按从后到前的顺序绘制各图层:

绘制顺序:
1. 背景色(最底层)
2. 背景图片
3. 边框
4. 内容(从左上到右下)
5. 伪元素
6. 轮廓(最顶层)

绘制指令

绘制不是直接画像素,而是生成绘制指令列表(Paint Records):

绘制指令示例:
1. drawRect(x=0, y=0, w=100, h=50)  矩形
2. drawText("Hello", x=10, y=30)   文字
3. drawRect(x=0, y=50, w=200, h=1)  分割线

这些指令会交给**光栅线程(Raster)**执行,将指令转换为实际像素。

重绘(Repaint)

当元素的外观改变但不影响布局时,触发重绘:

触发重绘(不改布局):改变颜色、改变可见性、改变边框样式
改变样式  重绘  完成(比回流快)

重绘比回流快,因为它不需要重新计算布局。


第七步:合成(Composite)

绘制完成后,所有图层提交给GPU,GPU将各图层合成成最终画面。

合成过程

Layer 0(背景层)
Layer 1(内容层)
Layer 2(浮动层)
    
GPU合成  输出到屏幕

为什么需要合成层?

  1. 滚动流畅:合成层有自己的GPU加速,滚动不经过主线程
  2. 动画流畅:transform/opacity动画在合成线程执行,不被JS阻塞
  3. 分离更新:只有一个图层内容变化,只需重绘该图层
传统渲染(无合成层)
└── JS修改  重排  重绘  合成  输出
    └── 主线程执行(可能被JS阻塞)

现代渲染(有合成层)
├── JS修改  重排  重绘  合成  输出
└── 合成线程独立执行(不受JS阻塞)

关键渲染路径(Critical Rendering Path)

关键渲染路径是浏览器从接收HTML到首次绘制页面的最短路径

优化关键渲染路径

想让页面更快显示?优化关键渲染路径:

优化目标 说明
减少关键资源数量 合并文件,减少请求
减少关键资源大小 压缩文件,删除注释空格
缩短关键路径长度 内联CSS、JS放底部、懒加载

回流与重绘:性能杀手

浏览器渲染过程中最怕什么?频繁的回流和重绘

强制回流/重绘

某些CSS属性和方法会强制触发回流或重绘:

// 读取以下属性会强制触发回流
element.offsetWidth;     // 布局信息
element.offsetHeight;
element.scrollTop;
element.clientWidth;
getComputedStyle(element).width;

// 修改DOM结构
element.appendChild(child);
element.removeChild(child);

批量读写原则

读写分离,避免交叉触发回流:

// 错误:每次读取触发一次回流
element.width = element.offsetWidth * 2;
element.height = element.offsetHeight * 2;
element.marginTop = element.offsetTop * 2;

// 正确:先读后写,写只触发一次回流
const width = element.offsetWidth;
const height = element.offsetHeight;
const marginTop = element.offsetTop;
element.style.width = width * 2;
element.style.height = height * 2;
element.style.marginTop = marginTop * 2;

requestAnimationFrame

对于需要连续动画的场景,使用requestAnimationFrame代替setTimeout/setInterval

// 不推荐:可能在帧之间执行
setTimeout(() => {
  element.style.transform = 'translateX(100px)';
}, 16);

// 推荐:在下一帧开始前执行
requestAnimationFrame(() => {
  element.style.transform = 'translateX(100px)';
});

总结:渲染流水线

阶段 输入 输出 耗时
HTML解析 HTML字符串 DOM树
CSS解析 CSS字符串 CSSOM树
渲染树构建 DOM + CSSOM 渲染树
布局 渲染树 盒模型信息
分层 布局信息 图层树
绘制 图层 绘制指令
合成 图层+指令 画面

核心思想:浏览器渲染页面如同装修房子——先搭骨架(DOM),再刷墙(CSS),然后布局家具位置(Layout),最后上色绘制(Paint),不同房间(Layer)可以同时施工,最后统一验收(Composite)。

理解渲染原理,才能写出性能更好的页面。


扩展阅读

概念 说明
虚拟DOM React等框架用JS对象模拟DOM,减少真实DOM操作
增量更新 只更新变化的部分,不全量重渲染
Content-visibility CSS新属性,跳过屏幕外内容的渲染
渲染性能指标 LCP(最大内容绘制)、CLS(布局偏移)、FID(首次输入延迟)
昨天以前首页

开100个标签页,为什么浏览器没崩?

作者 牛奶
2026年4月3日 13:50

你开了一个视频,又开了10个网页,再开了20个标签页...Chrome 居然没崩?而其他软件早就卡死了。Chrome是怎么做到的?

今天用**"酒店"**的故事,聊聊 Chrome 的多进程架构。


原文地址

墨渊书肆/开100个标签页,为什么浏览器没崩?


进程与线程:有什么区别?

想象一下:

进程如同一个独立的厨房,有自己的灶台、冰箱、厨师。

线程如同厨房里的厨师,多个厨师共享同一个厨房的资源——灶台是共用的,冰箱是共用的,但每个厨师可以同时干活。

进程A(独立厨房)              进程B(独立厨房)
┌─────────────────┐            ┌─────────────────┐
   厨师A1                      厨师B1       
   厨师A2                      厨师B2       
   厨师A3                      厨师B3       
                                        
 一个厨师中毒                其他厨师正常   
 其他厨师没事                继续做饭       
└─────────────────┘            └─────────────────┘

关键区别

  • 进程是"隔离的":进程A崩溃了,进程B完全不受影响
  • 线程共享资源:线程A1崩溃,可能影响整个进程A,其他线程都完蛋

Chrome多进程架构

Chrome 不像某些浏览器把所有功能塞进一个进程,而是把不同任务交给不同进程

Chrome 多进程架构:

┌─────────────────────────────────────────────────────┐
                    浏览器主进程(Browser)              
            (负责UI、地址栏、书签、下载、标签页管理)      
└─────────────────────────────────────────────────────┘
                            
            ┌───────────────┼───────────────┐
                                          
                                          
        ┌─────────┐    ┌─────────┐    ┌─────────┐
        │渲染进程1     │渲染进程2   ... │渲染进程N 
        │(Tab 1)      │(Tab 2)        │(Tab N)  
        └─────────┘    └─────────┘      └─────────┘
                                          
                                          
         GPU进程        网络进程        插件进程
进程 职责 崩溃影响
浏览器主进程(Browser) 标签页管理、地址栏、书签、下载、UI渲染 整个浏览器崩溃
渲染进程(Renderer) 运行网页内容(HTML/CSS/JS) 只影响当前标签页
GPU进程 图形渲染、视频解码、GPU加速 不影响网页渲染
网络进程(Network) 网络请求、DNS缓存、SSL验证 所有标签页断网
插件进程(Plugin) 运行浏览器插件(如Flash、PDF插件) 只影响使用该插件的页面
实用工具进程(Utility) 处理PDF阅读、扩展安装、打印等 不影响主功能

渲染进程:每个标签页一个

最重要的进程是渲染进程——每个标签页都有自己的渲染进程:

标签页1  渲染进程A(独立内存空间)
标签页2  渲染进程B(独立内存空间)
标签页3  渲染进程C(独立内存空间)
   ...
标签页100  渲染进程100(独立内存空间)

这就是为什么一个标签页崩溃不会影响其他标签页——每个渲染进程都有自己独立的内存空间,互不干扰。

为什么Chrome选择多进程?

早期浏览器(如IE、Firefox早期版本)都是单进程架构

单进程浏览器:
┌─────────────────────────────┐
  所有标签页 + UI + 插件 + JS     全在一个进程
          一个崩,全部崩         
└─────────────────────────────┘

单进程的问题:

  1. 一个标签页死循环,UI就卡死
  2. 一个标签页内存泄漏,慢慢拖垮整个浏览器
  3. 插件崩溃,浏览器跟着崩溃
  4. JS可以访问浏览器内部任意资源,安全隐患大

Chrome设计者认为:稳定性和安全性比内存占用更重要


进程间通信:IPC

不同进程之间怎么"对话"?

Chrome 使用**IPC(Inter-Process Communication,进程间通信)**机制。就像酒店房间之间不能直接串门,得通过对讲机沟通。

渲染进程(标签页1)              浏览器主进程
┌──────────────────┐         ┌──────────────────┐
  JS执行引擎                 标签页管理器    
  HTML解析器       ←───────→│  UI渲染引擎      
  CSS解析器         IPC      地址栏管理      
  DOM操作          消息通道    书签管理        
└──────────────────┘         └──────────────────┘

IPC消息类型

Chrome中主要的消息类型:

消息类型 说明 示例
ViewMsg 渲染进程→主进程 "用户点击了链接"
HandleViewMsg 主进程→渲染进程 "创建新标签页"
Route 路由消息 跨进程路由分发

IPC工作流程

点击链接时,Chrome 内部经历了:

┌───────────────────────────────────┐
 步骤1:渲染进程检测点击             
 JS事件监听器捕获 <a> 点击          
└───────────────────────────────────┘
                
                 ViewMsg_LinkOpened
                
┌───────────────────────────────────┐
 步骤2:主进程接收消息              
 决定打开新标签页                   
└───────────────────────────────────┘
                
                 HandleViewMsg_CreateWidget
                
┌───────────────────────────────────┐
 步骤3:创建新渲染进程              
 分配新内存空间,初始化V8引擎       
└───────────────────────────────────┘
                
                 Channel_LoadURL
                
┌───────────────────────────────────┐
 步骤4:新渲染进程加载URL           
 网络请求、HTML解析、渲染           
└───────────────────────────────────┘

整个过程仅需几十毫秒。


渲染进程内部:线程

每个渲染进程内部也不是单线程,而是多线程协作

渲染进程内部:

┌───────────────────────────────────────┐
            主线程(Main Thread)        
  V8 JS引擎执行                       
  HTML/CSS解析                        
  DOM树构建·布局计算·事件处理         
  requestAnimationFrame               
└───────────────────────────────────────┘
                    
        ┌───────────┴───────────┐
                               
┌──────────────┐         ┌──────────────┐
   合成线程                光栅线程     
│(Compositor)│            (Raster)   
├──────────────┤         ├──────────────┤
│• 图层合成             │• 绘制指令执行 
│• 滚动·动画           │• 像素填充     
│• 接收输入事件│         │• 纹理上传GPU 
└──────────────┘         └──────────────┘
线程 职责 为什么需要独立
主线程 JS执行、DOM、Layout、事件处理 JS必须单线程执行
合成线程 图层合成、滚动、动画 滚动必须60fps,不能等JS
光栅线程 绘制指令执行、像素填充 耗时操作,不能阻塞主线程

为什么主线程这么忙?

主线程要干太多事情:

  • JS引擎执行
  • HTML解析成DOM树
  • CSS解析成CSSOM
  • DOM + CSSOM = 渲染树
  • 布局计算每个元素位置
  • 绘制指令生成
  • 事件处理
  • 定时器回调
  • 网络回调
  • ...

这就是为什么长任务(Long Task)会卡页面——主线程太忙,用户的点击、滚动都没人处理。

合成线程的秘密

Chrome把滚动交给了合成线程处理,不经过主线程

传统方式(经过主线程):
滚动事件  主线程处理  重新布局  重绘  合成
         
       可能被JS阻塞

Chrome方式(合成线程直接处理):
滚动事件  合成线程  直接合成  输出
         
       完全不经过主线程

所以即使JS卡住了,页面滚动和动画依然流畅。


安全机制:沙箱

渲染进程为什么能"安全"地运行任意网页?

因为 Chrome 给渲染进程加了沙箱(Sandbox)——如同酒店房间:你可以用自己的东西,但不能动酒店的基础设施,也不能进别人房间。

沙箱限制:

渲染进程能做的事:
├──  执行JS(V8引擎隔离)
├──  操作DOM(沙箱内DOM树)
├──  计算样式
└──  发送网络请求(通过IPC代理)

渲染进程不能做的事:
├──  直接读写文件系统
├──  直接访问摄像头/麦克风(需用户授权)
├──  直接访问系统剪贴板(全权)
├──  直接读取本机Cookie/密码
├──  直接创建网络连接(必须经过网络进程)
└──  直接调用系统API

沙箱的技术原理

沙箱主要依赖操作系统提供的隔离机制

机制 说明
进程隔离 每个渲染进程有独立虚拟地址空间
用户权限限制 渲染进程以低权限用户运行
系统调用过滤 禁止某些危险系统调用
文件访问限制 无法访问用户文件

即使网页中的恶意代码能执行,它也被"关在笼子里",无法直接伤害你的电脑。


Site Isolation:更严格的安全

2018 年 Chrome 引入Site Isolation(站点隔离),把安全提升到新级别。

以前的规则

每个标签页一个渲染进程

标签页1  渲染进程A  可以访问标签页1的内存
标签页2  渲染进程A  可以访问标签页2的内存
                        
                   同一个进程
                   理论上可以访问彼此

现在的规则

每个跨站点的iframe也可能是独立进程

example.com 页面:
┌─────────────────────────────────────────┐
  主页面(主框架)      渲染进程A         
    ├── iframe(ads.example.com)   渲染进程B 
    ├── iframe(analytics.com)    渲染进程C 
    └── iframe(cdn.example.com)   渲染进程D 
└─────────────────────────────────────────┘
         
    进程级别完全隔离

为什么需要这么严格?

防止Spectre/Meltdown等侧信道攻击

攻击场景:
1. evil.com 运行在 渲染进程A
2. victim.com 也在 渲染进程A(作为iframe)
3. 恶意JS利用Spectre漏洞
4. 通过侧信道 timing攻击 读取渲染进程A的内存
5. 理论上可以读到 victim.com 的数据!

有了 Site Isolation,即使 evil.com 被攻破,它的渲染进程也无法访问 victim.com 的数据——因为它们根本不在同一个进程里。

Site Isolation的代价

更严格的隔离带来更高的内存占用:

情况 进程数
10个同源标签页 10个渲染进程
10个跨源标签页 可能10+个渲染进程
一个页面有5个跨站iframe 6个渲染进程

Chrome为了安全,愿意付出更多内存代价


为什么Chrome占用内存高?

很多人抱怨Chrome"吃内存"。

确实,多进程架构比单进程消耗更多内存,但这是故意的设计权衡

对比 单进程浏览器 Chrome多进程
内存占用 高(每个进程有独立内存空间)
稳定性 一个标签页崩,全部崩 一个崩,不影响其他
安全性 低(JS可以访问更多资源) 高(沙箱保护,进程隔离)
流畅度 JS卡住就卡顿 滚动动画由合成线程处理,更流畅
溃恢复 全部丢失 崩溃的标签页可以单独恢复

Chrome的内存管理优化

虽然多进程更耗内存,但Chrome也做了很多优化:

  1. 渲染进程合并:同源的多个标签页可能共享一个渲染进程
  2. 内存共享:使用**共享内存(Shared Memory)**减少复制
  3. 进程休眠:长时间未激活的标签页进程可以休眠
  4. 垃圾回收优化:V8 的垃圾回收已经高度优化

什么时候会内存爆炸?

内存爆炸场景:
├── 开100个淘宝/京东商品页(每个都有大量JS)
├── 开50个在线文档(Google Docs、Notion)
├── 开20个视频网站(爱奇艺、优酷、B站)
└── 结果:内存占用轻松上10GB

这是Chrome的"有钱任性"设计哲学——用内存换稳定性和用户体验


总结:Chrome核心知识点

概念 说明 类比
多进程架构 不同任务交给不同进程 酒店各部门分工
渲染进程 每个标签页一个,隔离运行 每人一间房
IPC通信 进程间通过消息传递协作 对讲机沟通
主线程 JS执行、DOM、Layout、事件处理 客房服务员(单线程)
合成线程 滚动、动画(不经主线程) 专属电梯(直达)
沙箱 限制渲染进程权限 房间门禁
Site Isolation 跨站iframe也隔离 同一房间的不同访客也分开
内存换稳定 多进程占用更多内存,但更安全稳定 酒店房间多,但互不干扰

核心思想:Chrome用"酒店"架构——每个房间(进程)独立,隔音好,一个房间出问题不影响其他;房间内有限制,不能动基础设施;甚至同一页面的不同访客也要隔开。

技术不复杂,但正是这套架构,让"100个网页同时运行"成为可能。

下次 Chrome 占用几百MB甚至几GB内存时,别急着骂它——那是它"有钱任性"的设计,是为了让你的浏览器更稳定、更安全、更流畅。

❌
❌