阅读视图

发现新文章,点击刷新页面。

Chrome偷藏了你的JS!V8引擎到底做了什么?

Chrome偷藏了你的JS!V8引擎到底做了什么?

你有没有想过:为什么 JavaScript 能"秒执行"?你写的 console.log('Hello') 到底经历了什么?从 Chrome 偷藏你的代码,到 V8 引擎对你的 JS 做了什么——今天全部揭秘!


原文地址

墨渊书肆/Chrome偷藏了你的JS!V8引擎到底做了什么?


V8 是什么?

JavaScript 引擎

浏览器能执行 JavaScript,全靠 JavaScript 引擎

常见的引擎有:

  • V8 — Chrome、Node.js、Deno 在用
  • SpiderMonkey — Firefox 在用
  • JavaScriptCore — Safari 在用
  • Chakra — 旧版 Edge 在用

V8 是 Google 开发的高性能引擎,用 C++ 编写,让 JS 执行速度可以媲美编译型语言。

V8 的工作流程

你写的 JS 代码,V8 要做的事情很简单:

JS代码 → 解析 → 编译 → 执行

但这中间,V8 做了大量偷跑优化

V8 架构演进

时代 架构 说明
早期 Full Codegen → Crankshaft 快速生成机器码,但维护困难
现在 Ignition → TurboFan 字节码+优化编译器,更高效
最新 Ignition + TurboFan + Sparkplug 新增无解释的 baseline JIT

代码是怎么跑起来的?

从 JS 到机器码

你写了一段代码:

function add(a, b) {
  return a + b;
}

console.log(add(1, 2));

V8 拿到这段代码后,经历了这些阶段:

1. 解析(Parser)
   
   把JS代码变成 AST(抽象语法树)
   
2. 解释(Ignition)
   
   编译成字节码,立即执行
   
3. 优化编译(TurboFan)
   
   热代码被编译成高效的机器码
   
4. 执行

Ignition — 解释器

字节码是什么?

V8 首先用 Ignition 解释器处理代码。

Ignition 会把你的 JS 代码编译成字节码——一种中间代码,比机器码容易生成,但比 JS 容易执行。

// 你写的 JS
function add(a, b) {
  return a + b;
}

对应的字节码(简化版):

# 字节码类似这样
LdaSmi [1]      # 加载小整数 1
StaA [0]        # 存到 [0] 位置(寄存器)
LdaSmi [2]      # 加载小整数 2
AddA [0]        # 加上 [0] 位置的数
Return           # 返回结果

为什么要转字节码?

直接执行 JS 转字节码再执行
每次都要重新解析 字节码更紧凑
无法优化 可以记录执行信息
启动慢 启动更快

Ignition 不只解释执行,还会记录信息——哪些函数被调用多次、参数类型是什么。这些信息给后续优化用。

Ignition 的执行反馈

function add(a, b) {
  return a + b;
}

add(1, 2);      // 第1次:记录类型
add(3, 4);      // 第2次:类型一致,继续记录
add("x", "y");  // 第3次:类型变了!记录下来

Ignition 维护一个 Feedback Vector(反馈向量),记录每段代码的类型信息。


TurboFan — 优化编译器

JIT 是什么?

JIT(Just-In-Time)= 即时编译。

不是提前编译好,而是一边执行一边编译。执行次数多的代码,会被更高效的机器码替代。

TurboFan 优化流程

TurboFan 不是直接生成最优机器码,而是层层优化:

字节码 + 执行反馈
   
Sea of Nodes(中间表示)
    优化 Pass 1: 类型推导
    优化 Pass 2: 内联
    优化 Pass 3: 环路优化
    优化 Pass 4: 寄存器分配
   
机器码

热代码检测

V8 有一套"热点检测"机制:

function add(a, b) {
  return a + b;
}

// 这个函数被调用了10000次
for (let i = 0; i < 10000; i++) {
  add(1, 2);
}
调用次数 < 1000

Ignition 解释器执行(字节码)

调用次数 > 1000

TurboFan 优化编译(机器码)

优化与反优化

TurboFan 很聪明,但也有"翻车"的时候:

function add(a, b) {
  return a + b;
}

// 前1000次调用,参数都是整数
for (let i = 0; i < 1000; i++) {
  add(1, 2);  // TurboFan 优化:整数加法
}

// 第1001次,参数变成字符串
add("hello", "world");  // 反优化!退回字节码

TurboFan 发现类型变了,会反优化(Deoptimization),退回字节码。

常见的优化场景

// ✅ 好优化:类型稳定
function length(arr) {
  return arr.length;  // 数组 length 是稳定的
}
length([1, 2, 3]);
length([4, 5]);

// ❌ 难优化:类型不稳定
function getX(obj) {
  return obj.x;  // obj 可能是任意类型
}
getX({ x: 1 });
getX("string");  // 字符串没有 x 属性!

隐藏类 — 快速属性访问

对象属性查找

JS 里访问对象属性很快,这要归功于隐藏类(Hidden Class),也叫 ShapesMaps

const person = { name: 'Tom', age: 18 };

V8 内部会为这个对象创建一个隐藏类:

隐藏类 HC0
├── name: offset 0
└── age: offset 1

属性访问加速原理

当你访问 person.name 时:

// 幕后发生的事情
person.name
  → 通过隐藏类 HC0
  → 直接定位到 offset 0
  → 拿到值 "Tom"

就像图书馆的书有固定编号(隐藏类),管理员知道每本书在哪个书架第几格。

隐藏类转换

对象属性改变时,会产生新的隐藏类:

const obj = { x: 1 };
//   ↓ 添加 y
obj.y = 2;
//   ↓ 修改 x
obj.x = 10;
HC0: { x: 1 }
   添加 y 属性
HC1: { x: 1, y: 2 }
   修改 x 属性(值变化不改变结构)
HC1(不变)

属性顺序很重要!

// 好:属性顺序一致 → 共享同一个隐藏类
const p1 = { x: 1, y: 2 };
const p2 = { x: 3, y: 4 };

// 差:属性顺序不一致 → 产生多个隐藏类
const p3 = { y: 1, x: 2 };  // 新建 HC1!

多态与全态

// 单态(Monomorphic):一种隐藏类,最快
function getX(obj) { return obj.x; }
getX({ x: 1 });      // HC0
getX({ x: 2 });      // 还是 HC0,命中缓存

// 多态(Polymorphic):2-4种隐藏类,较慢
function getX(obj) { return obj.x; }
getX({ x: 1, a: 0 });    // HC0
getX({ x: 2, b: 0 });    // HC1

// 全态(Megamorphic):5+种隐藏类,最慢
function getX(obj) { return obj.x; }
getX({ ... });  // 每次都是新结构

内联缓存 — 加速函数调用

函数调用有多慢?

函数调用看起来简单:

function getName(user) {
  return user.name;
}

const user = { name: 'Tom' };
getName(user);

但每次调用,V8 都要查找 user.name 在哪里。

内联缓存的原理

V8 第一次执行 getName(user) 时:

第1次调用:
1. 查找 user 的隐藏类  HC0
2. 查找 name 属性在 HC0 的位置  offset 0
3. 返回结果
4. 记录:HC0 的对象调用这个函数,返回 offset 0

之后调用同样的函数,直接跳过查找

第2次调用:
1. 检查隐藏类是 HC0 
2. 直接用记录的 offset 0
3. 返回结果

这就是内联缓存(Inline Cache)——把查找结果"缓存"起来。

IC 的类型状态

Uncached  Monomorphic  Polymorphic(2-4)  Megamorphic(5+)
                                           
 每次查     命中缓存       部分命中         全局查表

垃圾回收 — 内存管理

什么是垃圾?

程序里不再使用的对象就是"垃圾":

function createUser() {
  const user = { name: 'Tom' };
  return user.name;  // user 对象还在用
}  // 但 user 变量没了

createUser();
// 之后再也访问不到这个 { name: 'Tom' } 对象了
// 它就成了"垃圾"

V8 的内存布局

┌─────────────────────────────┐
          新生代                新对象
    (New Space / Semi-Space) 
├─────────────────────────────┤
          老生代                存活久的对象
    (Old Space)              
├─────────────────────────────┤
        大对象区                 无法放入其他区的对象
    (Large Object Space)    
├─────────────────────────────┤
        代码区                   JIT 编译后的机器码
    (Code Space)            
├─────────────────────────────┤
        Cell / Map              特殊对象
    (Cell / Map Space)       
└─────────────────────────────┘

V8内存布局图

V8 的垃圾回收策略

V8 采用分代回收

代际 对象来源 回收频率 算法
新生代 新创建的对象 频繁 Scavenge(复制)
老生代 经历一次 GC 仍存活 较少 Mark-Sweep-Compact

新生代:Scavenge 算法

新生代内存分两半:FromTo

┌─────────────────┬─────────────────┐
│      FromTo        │
│   (使用中)     │   (空闲)       │
└─────────────────┴─────────────────┘

1. From 满了,存活对象复制到 To
2. From 清空
3. FromTo 交换

晋升:经历两次 Scavenge 仍存活的对象,会进入老生代。

老生代:Mark-Sweep-Compact

步骤1:标记(Mark)

遍历所有根对象(全局变量、栈上变量)
    
标记能访问到的对象为"存活"
    
没被标记的就是垃圾

步骤2:清除(Sweep)

回收没有标记的对象的内存

步骤3:压缩(Compact)

存活对象移动到一起

解决内存碎片问题

增量 GC

为了避免长时间停顿(Stop-The-World),V8 使用增量标记:

传统 GC:
████████████████████████████  100% 停顿
     执行时间 ←────────────────→

增量 GC:
███    ████    ███    ██
                    
执行  执行  执行  执行

Orinoco — 并行与并发 GC

现代 V8 使用更先进的 GC 算法:

技术 说明 效果
并行 GC GC 多线程并行执行 充分利用多核 CPU
增量 GC GC 分多次小步执行 减少停顿时间
并发 GC GC 与 JS 执行同时进行 几乎无停顿

深入了解 V8 🔬

V8 执行流程全图

JS代码
    Parser
AST(抽象语法树)
    Ignition
字节码 + Feedback Vector(反馈向量)
    (热代码触发)
TurboFan
   
优化机器码
    (类型不稳定)
反优化  退回字节码

V8执行流程详图

为什么 V8 这么快?

优化手段 作用
JIT 即时编译 热代码用机器码执行
隐藏类 对象属性快速访问
内联缓存 函数调用加速
分代回收 高效内存管理
懒解析 延迟解析,只解析用到的
并行 GC 多核加速垃圾回收

Sparkplug — 无解释的 Baseline JIT

V8 最近引入了 Sparkplug,一个超快的 baseline JIT:

之前:JS  Ignition 字节码  TurboFan 机器码
现在:JS  Ignition 字节码  Sparkplug 机器码  TurboFan 优化机器码

Sparkplug 不做任何优化,直接把字节码转成机器码,比 Ignition 快 2-5 倍。

TurboFan 优化的代码例子

// 优化前:字节码执行
function sum(arr) {
  let total = 0;
  for (let i = 0; i < arr.length; i++) {
    total += arr[i];
  }
  return total;
}

// 优化后:TurboFan 可能生成的机器码
// 1. 使用寄存器代替变量
// 2. 循环展开(Loop Unrolling)
// 3. 预取数据到 CPU 缓存

编写高性能 JS

// ✅ 好:保持属性类型一致
const p1 = { x: 1, y: 2 };
const p2 = { x: 3, y: 4 };

// ✅ 好:避免类型变化
function add(a, b) {
  return a + b;
}
add(1, 2);       // 都是整数
add(3.14, 2.86); // 都是浮点数

// ❌ 差:属性顺序不一致
const a = { x: 1, y: 2 };
const b = { y: 1, x: 2 };  // 新建隐藏类!

// ❌ 差:类型乱变
function example(x) {
  return x.value;  // x 可能是对象,可能是 undefined
}

// ✅ 好:使用固定形状的对象
const cache = {};
for (let i = 0; i < 1000; i++) {
  cache.key = i;  // 每次都用相同的 key
}

V8 性能陷阱

陷阱 说明 解决方案
隐藏类爆炸 对象结构不一致 保持属性顺序一致
类型不稳定 参数类型经常变化 使用多态函数时要小心
内存泄漏 闭包引用大量对象 及时解除引用
大对象 大数组、大对象放新生代 手动管理或拆分

总结

概念 作用 比喻
Ignition 解释器,生成字节码 + 记录反馈 同声传译先听懂意思
TurboFan 优化编译器,生成高效机器码 翻译稿润色升级
JIT 即时编译,热代码加速 多次练习后越说越溜
隐藏类 快速属性访问 图书馆编号系统
内联缓存 函数调用加速 记住常走的路
分代回收 高效内存管理 新书放前台,旧书放仓库
Sparkplug 超快 baseline JIT 不用练习,直接上岗

写在最后

现在你知道了:

  • V8 不是直接执行 JS,而是经过 Parser → Ignition → TurboFan
  • JIT 让热代码越来越快,但类型变化会导致反优化
  • 隐藏类和内联缓存,是 JS 快的秘密
  • 写代码时保持类型一致,能帮助 V8 优化
  • 新生代用复制算法,老生代用标记清除

下次有人说"JS 慢",你可以理直气壮地说:你了解 V8 吗?

为什么禁止我请求别的网站的接口?——跨域与CORS

你有没有遇到过这种情况:在自己的网页上想请求别人的API,结果浏览器直接报错:Access-Control-Allow-Origin' header is missing。为什么浏览器要阻止你?服务器不响应不就完了吗?

今天,用**"小区门禁"**的故事,来讲讲 跨域CORS


原文地址

墨渊书肆/为什么禁止我请求别的网站的接口?——跨域与CORS


什么是"跨域"?

同源策略 — 浏览器的安全基石

浏览器有个同源策略Same-Origin Policy):只有来自同一个"家"的资源才能随便用。

什么叫"同一个家"?看三个条件:协议(http/https)、域名(example.com)、端口(:8080)。三个都一样,才是同源;有一个不一样,就是跨域。

跨域的例子

http://example.com 和 http://example.com/profile     // 协议+域名+端口都相同 → 同源
✅ https://example.com 和 https://example.com           // 协议+域名+端口都相同 → 同源
❌ http://example.com 和 https://example.com           // 协议不同 → 跨域
❌ http://example.com 和 http://api.example.com        // 域名不同(子域名)→ 跨域
❌ http://example.com:8080 和 http://example.com:3000  // 端口不同 → 跨域

跨域限制了什么?

浏览器的同源策略主要限制了三件事:

  • DOM 访问:无法读取不同源的 iframe 内容、无法修改不同源的 iframe DOM
  • AJAX 请求:无法请求不同源的 API
  • Cookie/LocalStorage:无法访问不同源的数据

为什么要限制跨域?

模拟一个攻击场景

想象一下:你登录了银行网站 bank.com,浏览器保存了你的登录 Cookie。

然后你手滑点进了一个恶意网站 evil.com,这个网站里有一段代码:

<form action="http://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="hacker">
  <input type="hidden" name="amount" value="1000000">
</form>
<script>document.forms[0].submit();</script>

如果没有同源策略,这个表单请求会自动带上 bank.com 的 Cookie,银行服务器以为是你本人操作的——钱就没了。

同源策略就是浏览器的"门禁":只有同一家人才能进,陌生人要查证件。

💡 注意:<img> 标签的 GET 请求虽然也会带 Cookie,但现代浏览器有 SameSite Cookie 保护。上面表单 POST 场景更典型。


CORS — 跨域的"通行证"

CORS 是什么?

CORS(Cross-Origin Resource Sharing)= 跨域资源共享。

它的工作原理很简单:让服务器告诉浏览器,"我允许来自这些源的请求"

简单请求 vs 预检请求

简单请求

满足以下条件的请求是"简单请求":

条件 要求
请求方法 GETPOSTHEAD
请求头部 只有几种常见类型
Content-Type 只能是 application/x-www-form-urlencodedmultipart/form-datatext/plain

简单请求的流程:

1. 浏览器发送请求(自动带上 Origin 头)
   
2. 服务器检查 Origin,决定是否允许
   
3. 服务器返回响应头 Access-Control-Allow-Origin
   
4. 浏览器检查响应头,允许就完事

服务器端示例(Node.js):

app.get('/api/data', (req, res) => {
  const origin = req.headers.origin;

  if (origin === 'https://example.com') {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }

  res.json({ data: '这是返回的数据' });
});

响应头

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://example.com
Content-Type: application/json

{"data": "这是返回的数据"}

预检请求(Preflight)

不满足"简单请求"条件的,浏览器会先发一个 OPTIONS 请求"探路":

1. 浏览器发送 OPTIONS 预检请求
   
2. 服务器检查方法/头部/Origin
   
3. 服务器返回允许的头 Access-Control-*
   
4. 浏览器发送实际请求

预检请求检查什么?

预检请求(OPTIONS)就像登机前的安检——先检查你带没带危险品。

浏览器会问服务器三件事:

  • 我从哪来?(Origin)
  • 我想用什么方法?(Access-Control-Request-Method)
  • 我想带什么头?(Access-Control-Request-Headers)

服务器回答"可以",浏览器才放行实际请求。

# 请求(浏览器发给服务器)
OPTIONS /api/data HTTP/1.1
Origin: https://example.com              # 我从哪来
Access-Control-Request-Method: PUT        # 我想用 PUT 方法
Access-Control-Request-Headers: Content-Type, Authorization  # 我想带这些头

---

# 响应(服务器告诉浏览器)
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://example.com  # 允许这个源
Access-Control-Allow-Methods: GET, POST, PUT, DELETE  # 允许这些方法
Access-Control-Allow-Headers: Content-Type, Authorization  # 允许这些头
Access-Control-Max-Age: 86400          # 预检结果缓存24小时

服务器端处理

app.options('/api/data', (req, res) => {
  const origin = req.headers.origin;

  if (origin === 'https://example.com') {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '86400');
  }

  res.status(204).send();
});

CORS 响应头详解

常用响应头

响应头 作用 例子
Access-Control-Allow-Origin 允许的源 *https://example.com
Access-Control-Allow-Methods 允许的方法 GET, POST, PUT
Access-Control-Allow-Headers 允许的头部 Content-Type, Authorization
Access-Control-Max-Age 预检缓存时间 86400(秒)
Access-Control-Allow-Credentials 是否允许带 Cookie true

credentials 模式

默认情况下,CORS 不带 Cookie。如果需要携带 Cookie:

前端

fetch('/api/data', {
  credentials: 'include'
});

服务端

res.setHeader('Access-Control-Allow-Origin', 'https://example.com');
res.setHeader('Access-Control-Allow-Credentials', 'true');

注意:Access-Control-Allow-Origin 不能用 *,必须是具体域名。


跨域的解决方案

1. JSONP(已不推荐)

利用 <script> 标签不受同源策略限制的特性:

<script>
  function handleData(data) {
    console.log(data);
  }
</script>
<script src="http://api.example.com/data?callback=handleData"></script>
缺点 说明
只支持 GET 无法处理 POST 等请求
有安全风险 可能被注入恶意代码
无法捕获错误 错误处理困难

2. 代理服务器

在自己的服务器上转发请求,"伪装"成同源:

浏览器 ──> 我的服务器(同一源) ──> 目标服务器

Nginx 代理

location /api/ {
  proxy_pass http://target-server.com/;
}

Node.js 代理

app.get('/api/data', async (req, res) => {
  const response = await fetch('http://target-server.com/data');
  const data = await response.json();
  res.json(data);
});

3. Webpack/Vite 开发代理

开发环境配置代理:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://target-server.com',
        changeOrigin: true
      }
    }
  }
};

4. postMessage

不同窗口/iframe 之间的通信:

window.addEventListener('message', (event) => {
  if (event.origin === 'https://example.com') {
    console.log('收到消息:', event.data);
  }
});

iframe.contentWindow.postMessage('hello', 'https://example.com');

深入了解 CORS 🔬

第三方 Cookie 的限制

现代浏览器正在逐步限制第三方 Cookie:

浏览器 政策
Chrome 计划逐步淘汰第三方 Cookie
Safari 默认阻止第三方 Cookie
Firefox 提供第三方 Cookie 阻止选项

CORS 和 CSRF 的区别

CORS CSRF
是什么 跨域资源共享机制 跨站请求伪造攻击
作用 服务端允许/禁止跨域请求 利用用户已登录状态发起攻击
防御 服务端配置 Access-Control-* Token、SameSite Cookie、验证码

为什么 OPTIONS 叫"预检"?

"预检"就像登机前的安检——先检查你带没带危险品(方法、头部),没问题了才让你登机(发送实际请求)。


常见错误排查

错误 1:No 'Access-Control-Allow-Origin' header

原因 解决
服务端没配置 CORS 添加 Access-Control-Allow-Origin
Origin 不匹配 检查配置的域名是否正确
credentials 时用了 * 必须指定具体域名

错误 2:Method not allowed

原因 解决
请求方法(如 PUT)不在允许列表 检查 Access-Control-Allow-Methods

错误 3:Header not allowed

原因 解决
请求头部(如 Authorization)不在允许列表 检查 Access-Control-Allow-Headers

错误 4:预检请求 404

原因 解决
服务端没有处理 OPTIONS 请求 中间件或网关要放行 OPTIONS

总结

概念 像什么 作用
同源策略 小区门禁 限制不同源的访问,保护安全
CORS 通行证 告诉浏览器哪些跨域请求是允许的
简单请求 普通访客 不需要预检,直接请求
预检请求 安检验票 先检查再放行,更安全的请求
JSONP 走后门 已不推荐,有安全风险
代理 同一个家门 绕过跨域,最推荐的开发方案

写在最后

现在你应该明白了:

  • 跨域是浏览器的安全机制,不是为了刁难你
  • CORS 是服务器授权机制,服务器说可以,浏览器才放行
  • 预检请求 = 安检,OPTIONS 通过了才能发送实际请求
  • 生产环境推荐用代理,开发环境用 webpack/vite 代理

下次遇到跨域错误,先看浏览器控制台的报错信息——是"缺通行证"(header 缺失)还是"通行证不对"(origin 不匹配),处理方式不一样的。

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

你有没有遇到过这种情况:代码里写了 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 无限大:浏览器存储谁更强?

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

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


原文地址

墨渊书肆/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专用

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

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

你在地址栏输入一个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个标签页,为什么浏览器没崩?

你开了一个视频,又开了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内存时,别急着骂它——那是它"有钱任性"的设计,是为了让你的浏览器更稳定、更安全、更流畅。

❌