跳到主要内容

前端专题

React 原理追问链

setState 为什么是异步的?—— 从批量更新到 Lane 模型的全景解释

第一层:批量更新

setState 异步的本质是批量更新(Batching)。React 不会在每次调用 setState 时立即触发重渲染,而是把所有更新收集起来,一次性计算新状态、执行 reconciliation。

const [count, setCount] = useState(0);

function handleClick() {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
// 只会触发一次渲染,最终 count = 3
}

第二层:为什么批量更新必须"异步"

如果 setState 同步执行,每次调用都立即触发 reconciliation,三次 setState 会触发三次渲染 → 三次虚拟 DOM diff → 三次 DOM 更新。这完全浪费了性能。

React 的策略是:在当前同步代码执行完毕后再统一处理更新。所以 setState 不会同步更新状态,而是把更新推入一个队列(update queue),等当前事件处理函数执行完后,再 flush 这个队列。

关键点:setState 异步的不是"延时执行",而是推迟到当前微任务/事件处理结束后的统一渲染周期

第三层:React 18 自动 Batching

React 17 及以前,只在事件处理函数(如 onClick、onChange)中批量更新。在 setTimeout、Promise、原生事件回调中调用 setState 是同步的:

// React 17
fetch('/api').then(() => {
setCount(c => c + 1); // 触发渲染
setCount(c => c + 1); // 又触发一次渲染
});

React 18 通过扩展批处理范围解决了这个问题。所有更新(无论在哪里调用)都会被自动批量处理:

// React 18
fetch('/api').then(() => {
setCount(c => c + 1); // 排队
setCount(c => c + 1); // 排队
// 一次渲染
});

实现方式:React 内部维护一个执行上下文标志(execution context),进入事件回调时设置 BatchedContext,退出时 flush。React 18 将这个范围扩展到了所有回调。

第四层:flushSync —— 冲出批处理

如果确实需要同步更新(比如需要读取更新后的 DOM 状态),可以用 flushSync

import { flushSync } from 'react-dom';

flushSync(() => {
setCount(c => c + 1);
});
// 这里 DOM 已经更新了
flushSync(() => {
setCount(c => c + 1);
});
// 再更新一次

这会退出批处理、强制执行 reconciliation。但会破坏批处理优化,应谨慎使用。

第五层:React 什么时候会同步更新?

实际上 setState 是同步还是异步取决于执行上下文。在 React 18 的自动批处理模式下,大部分情况是异步批量。但如果调用了 flushSync 或在某些 legacy 模式下,会同步执行。

关键认识:这不是一个"异步 vs 同步"的简单选择,而是React 调度器根据场景动态决策

第六层:用户输入优先级与 Lane 模型

React 18 引入了 Lane 模型来管理不同更新的优先级:

Lane 分类(简化):
├── SyncLane —— 最高优先级,flushSync
├── InputDiscreteLane —— 用户输入(click、keydown)
├── DefaultLane —— 普通更新
├── TransitionLane —— 过渡更新(startTransition)
└── IdleLane —— 空闲时执行

用户输入事件被标记为 discrete event(离散事件),具有高优先级。React 保证这类更新能被及时处理。

input 输入框需要"足够及时"是因为:用户打字输入时必须更新输入框的值,延时太长会让用户感到卡顿。React 通过对离散事件的特殊调度来保证低延迟。

高手视野

React 已经不是简单的 view library,而是一个 userspace scheduler

React 的调度系统(Scheduler)在用户空间管理了:

  • 优先级排队 —— Lane 决定谁先执行
  • 时间切片 —— 每帧 5ms 的工作单元,剩余时间让给浏览器
  • 可中断渲染 —— 高优先级任务可以打断低优先级
  • Cooperative Scheduling —— 任务主动让出控制权
Event → Scheduler → Lane assignment → Render Phase (可中断)

Commit Phase (不可中断)

这种设计让 React 在复杂的 UI 场景下仍然能保持交互流畅,本质上是把操作系统的调度思想搬到了浏览器中。


Fiber 为什么是链表而不是树或数组?—— 从可中断渲染到双缓存树

第一层:为了可中断遍历

React 16 之前,reconciliation 使用递归遍历虚拟 DOM 树。递归调用栈是深度优先、一气呵成的 —— 一旦开始就不能中断,直到整棵树 diff 完成。如果树很大(上千个组件),UI 线程会被阻塞超过 16ms,导致掉帧。

Fiber 用链表结构代替了隐式调用栈,使遍历变成可中断的循环

递归遍历(不可中断):
render() → render() → render() → render() → return → return → return

Fiber 链表遍历(可中断):
node → child → child → sibling → return → sibling → child → ...
↑ 随时可以暂停,下次从指针位置继续

第二层:Fiber 的三指针结构

每个 Fiber 节点维护三个指针:

interface Fiber {
child: Fiber | null; // 指向第一个子节点
sibling: Fiber | null; // 指向下一个兄弟节点
return: Fiber | null; // 指向父节点(处理完成后的归路径)
}

遍历采用深度优先的"先子后兄"策略:

A
/ \
B C
/ \
D E

遍历顺序:A → B → D → E → C
A.child → B
B.child → D, B.sibling → C
D.return → B
E.return → B
C.return → A

return 指针给了 Fiber "工作完成后回到哪里" 的能力 —— 这本质上是手动管理了一个遍历栈。

第三层:双缓存树(current / workInProgress)

React 在内存中维护两棵 Fiber 树:

  • current 树:对应当前屏幕上显示的 UI
  • workInProgress 树:正在构建的新 UI 版本
// ReactFiberRoot 中
root.current = currentFiberTree;
root.alternate = workInProgressFiberTree;

工作流程:

  1. 每次更新时,React 克隆 current 树创建 workInProgress 树(复用 fiber 节点)
  2. 在 workInProgress 树上执行 reconciliation(可中断)
  3. workInProgress 树构建完成后,一次切换将 current 树指向 workInProgress 树
render phase:
current → read only(用户看到的)
workInProgress → 构建中(可中断)

commit phase:
current = workInProgress(指针切换,原子操作)

这就是双缓冲(double buffering)—— 类似图形学中的交换缓冲区,保证用户永远不会看到"一半更新"的 UI。

第四层:Effect List 的变迁

React 17 及以前,需要执行副作用(useEffect、DOM 操作)的 Fiber 节点被串联成一个单向链表 —— effect list。commit 阶段只需要遍历 effect list,不必扫描整棵 Fiber 树,大幅提高性能。

Fiber A (has effect) ─→ Fiber B (has effect) ─→ Fiber E (has effect) ─→ null

React 18 用 effect flags(二进制位掩码) 替代了独立维护的 effect list。每个 Fiber 节点的 flags 属性记录了需要执行的副作用类型:

// ReactFiberFlags
const Update = 0b000000001;
const Placement = 0b000000010;
const Deletion = 0b000000100;
const Passive = 0b100000000; // useEffect

commit 阶段遍历整棵 Fiber 树,按位与 flags 判断要做什么。这看起来"退步"了(全量遍历 vs 链表),但 React 18 引入的并发特性(Concurrent Features) 使 effect list 的维护变得复杂且容易出错,flags 方案更简洁且与并发模型兼容。

第五层:为什么 commit 不能中断

commit 阶段执行的是真实的 DOM 变更

  • 插入节点(appendChild)
  • 更新属性(setAttribute)
  • 删除节点(removeChild)
  • 执行 useEffect/useLayoutEffect

DOM API 是同步且不可逆的。一旦 appendChild 执行了,就不能"undo"。如果 commit 可中断,中间状态会暴露给用户 —— 看到部分更新的 UI,导致视觉不一致。

所以 React 的设计是:render phase(构建 workInProgress 树)可中断,commit phase(DOM 操作)必须原子化完成

为什么是链表不是数组

  • 数组增删节点需要 O(n) 的 shift/splice 操作,链表只需要 O(1) 的指针修改
  • 数组无法表达"返回父节点"的关系(除非维护索引栈)
  • 链表天然支持中断恢复 —— 只需要记录当前工作指针的位置

useEffect 和 useLayoutEffect 真正区别是什么?—— 从 Commit 阶段到浏览器绘制

第一层:执行时机不同

useEffect:异步执行,在浏览器绘制之后
useLayoutEffect:同步执行,在浏览器绘制之前

第二层:"同步"是相对什么同步?

这个"同步"是指相对于浏览器 paint 的执行顺序

React 的 commit 阶段分为三步:

commit 阶段:
├── before mutation(DOM 变更前)
│ └── 调用 getSnapshotBeforeUpdate
├── mutation(DOM 变更)
│ └── 插入/更新/删除 DOM 节点
└── layout(DOM 变更后,浏览器 paint 前)
├── 同步调用 useLayoutEffect(上一次的 cleanup + 本次的 setup)
└── 更新 ref

关键时序:

useLayoutEffect cleanup → DOM 更新 → useLayoutEffect setup

浏览器 paint ← useEffect cleanup → useEffect setup

useLayoutEffect 在浏览器还没有 paint 之前执行。这意味着:

useLayoutEffect(() => {
// 此时 DOM 已经是最新的,但浏览器还没绘制
// 可以同步读取 DOM 布局(如测量元素尺寸)
const rect = ref.current.getBoundingClientRect();
// 可以同步修改 DOM,浏览器不会绘制中间状态
ref.current.style.top = `${rect.height}px`;
});

useEffect 在浏览器 paint 之后才执行。所以在 useEffect 中读取布局,会触发强制回流(forced layout)

第三层:useLayoutEffect 为什么能拿到 layout

因为执行 useLayoutEffect 时,DOM 已经更新完毕(mutation 阶段已完成),但浏览器还没有进行样式计算和布局绘制

时间线:
1. Commit phase - mutation: DOM 已修改
2. Commit phase - layout: useLayoutEffect 执行 ← 此时 DOM 已更新可以读取
3. 浏览器 paint: 样式计算 → 布局 → 绘制 → 合成 ← 此时用户看到界面
4. useEffect 执行 ← 此时页面已绘制

所以在 useLayoutEffect 中可以读取 DOM 的精确布局信息(offsetHeight、getBoundingClientRect),因为 DOM 已经更新,只是还没绘制到屏幕上。

useEffect 在 paint 之后执行,此时读取布局信息会触发同步回流(浏览器被迫立即执行布局计算),有性能开销。

第四层:useEffect 在 commit 哪个阶段执行

useEffect 的 cleanup 和 setup 是在异步调度中执行的:

  1. 在 layout 阶段,React 收集需要执行 useEffect 的 Fiber 节点
  2. React 调度一个异步任务(使用 scheduler postTask 或 MessageChannel)
  3. 下一个事件循环的宏任务中执行 useEffect(浏览器 paint 之后)
// React 内部简化逻辑
function commitRoot() {
// 1. before mutation
// 2. mutation — DOM 更新
// 3. layout — useLayoutEffect 同步执行

// 4. 调度 useEffect(异步)
schedulePassiveEffects();
}

// 浏览器 paint 后执行
function flushPassiveEffects() {
// 执行 useEffect cleanup
// 执行 useEffect setup
}

第五层:insertion effect 又是什么

React 18 引入的第三种 effect:useInsertionEffect

useInsertionEffect(() => {
// 在 DOM 变更之前注入样式
const style = document.createElement('style');
style.textContent = '.dynamic { color: red }';
document.head.appendChild(style);
});

执行时机:在 before mutation 阶段(DOM 变更前),优先于 useLayoutEffect。

commit 阶段:
├── before mutation
│ └── useInsertionEffect ← 插入样式
├── mutation(DOM 变更)
│ └── 应用 DOM 更新
└── layout(浏览器 paint 前)
└── useLayoutEffect

CSS-in-JS 库(如 styled-components)需要 useInsertionEffect 来在 DOM 变更前注入 <style> 标签,确保样式在浏览器 paint 之前生效,避免闪烁(FOUC)。


为什么 React 不推荐直接操作 DOM?—— Virtual Ownership 与控制权博弈

第一层:声明式 vs 命令式

React 提倡的是声明式 UI —— 你描述"UI 应该长什么样",React 负责"怎么做到"。

// 声明式:描述目标状态
function App() {
const [items, setItems] = useState([]);
return <ul>{items.map(item => <li key={item.id}>{item.text}</li>)}</ul>;
}

// 命令式:直接操作 DOM
const ul = document.querySelector('ul');
items.forEach(item => {
const li = document.createElement('li');
li.textContent = item.text;
ul.appendChild(li);
});

直接操作 DOM 破坏了声明式模型 —— React 无法追踪你的手动修改,导致状态与 UI 不同步

第二层:真的是"不能操作"吗?

技术上完全可以操作 —— document.getElementByIdelement.appendChild 都是合法的 API。问题在于后果自负

function BadComponent() {
const ref = useRef(null);

useEffect(() => {
// 直接操作 DOM
ref.current.innerHTML = '<div>hacked</div>';
// React reconciliation 时发现 DOM 和虚拟 DOM 不匹配
// 但 React 会覆盖你的修改(重新渲染时)
});

return <div ref={ref}>Original</div>;
}

React 检测到 DOM 被"入侵"后,下一次 reconciliation 会用自己的虚拟 DOM 覆盖手动修改。所以你动的越快,React 覆盖的也越快,最终变成一场抢椅子游戏

第三层:React 如何检测 DOM mismatch

在 commit 阶段的 DOM 更新中,React 会比较 Fiber 节点的属性与真实 DOM 的实际属性。如果发现不一致(class 不同、属性缺失等),React 会覆盖真实 DOM。

对于 hydration(SSR 场景),React 在客户端激活时会逐节点比对:

// 服务端渲染的 HTML
<div id="root"><h1>Hello</h1></div>

// 客户端 hydration
const root = createRoot(document.getElementById('root'), { hydrate: true });
root.render(<h1>Hello</h1>); // 匹配:复用已有 DOM
root.render(<h1>Hi</h1>); // 不匹配:React 接管并更新

hydration mismatch 为什么危险?

如果服务端 HTML 和客户端首次渲染不一致,React 会丢弃服务端生成的 DOM 树,从头重新渲染。这会导致:

  1. 完全失去 SSR 的性能优势
  2. 页面闪现(服务端内容 → 客户端重新渲染)
  3. 在极端情况下可能破坏表单状态或滚动位置
<!-- 服务端 -->
<div class="server">content</div>

<!-- 客户端渲染 -->
<div class="client">content</div>
<!-- React 发现 class 不匹配 → 抛弃整个子树 → 重新创建 -->

第四层:uncontrolled component 为什么更快

受控组件(controlled):React 通过 state 控制 value,每次输入变化都要走 setState → render → diff → DOM update 的完整流程。

// 受控:每次按键都触发 React 渲染周期
<input value={value} onChange={e => setValue(e.target.value)} />

// 非受控:直接操作 DOM
<input defaultValue="initial" ref={ref} />

非受控组件直接依赖 DOM 自身的状态管理(input 的 value 属性由浏览器维护),完全绕过了 React 的 reconciliation 流程,所以性能更高。

React Hook Form 为什么快?

React Hook Form 的核心优化策略就是非受控组件 + ref

  1. 组件不经 React state 管理输入值,而是通过 ref 直接从 DOM 读取
  2. 只在提交或需要验证时从 DOM 取数
  3. 避免了每次按键触发的 React 渲染周期
// React Hook Form 内部简化
const { register } = useForm();
// register 返回的是 ref 回调,不是受控的 value/onChange
<input {...register('name')} /> // 非受控模式

这就使得包含大量输入的表单页面不会因为频繁的 setState 导致卡顿。

高手视野:Virtual Ownership

React 的核心契约是 virtual ownership —— React 声明了对虚拟 DOM 树的所有权。当你直接操作 DOM 时,本质上是侵犯了这个所有权。

React 的结论是:你 update state,React update DOM。如果你 update DOM,双方就会打架,最终 React 会赢(用虚拟 DOM 覆盖),但浪费了性能。

所以"不推荐直接操作 DOM"不是技术限制,而是一个架构契约:你管数据,我管渲染。


浏览器底层深水题

Chrome 多进程架构是什么?—— Site Isolation 如何改变浏览器安全模型

第一层:一个 Tab 一个进程

Chrome 的多进程架构是现代浏览器的标杆。最基本的划分:

Chrome 进程架构:
├── Browser Process(浏览器主进程)
│ ├── UI 界面(地址栏、书签)
│ ├── 网络请求(Network Service)
│ ├── 文件访问
│ └── 跨进程通信(IPC)

├── Renderer Process(渲染进程) ← 每个 Tab 对应一个
│ ├── Blink 渲染引擎
│ ├── V8 JavaScript 引擎
│ └── 处理 HTML/CSS/JS

├── GPU Process(GPU 进程)
│ ├── 页面合成(Compositing)
│ └── 硬件加速渲染

├── Network Service(网络服务进程)
│ └── 所有网络请求的统一处理

├── Storage Service(存储服务进程)
│ └── IndexedDB、LocalStorage 等

└── Utility Processes(工具进程)
├── 扩展进程(每个扩展)
├── 插件进程(Flash,已废弃)
└── 开发者工具进程

第二层:为什么用多进程而不是多线程

核心原因:安全与稳定性隔离

  • 稳定性:一个 Tab 崩溃不会影响其他 Tab,也不会拖垮整个浏览器
  • 安全性:渲染进程在沙箱(Sandbox)中运行,不能直接访问文件系统
  • 性能:多进程可以利用多核 CPU

第三层:Site Isolation —— Spectre 如何改变浏览器架构

2018 年 Spectre 漏洞暴露了一个根本问题:同一个进程中的不同站点可以通过侧信道攻击读取彼此的内存

传统架构下,同一个 Tab 中的所有 iframe 共享同一个渲染进程。恶意站点可以:

  1. 在 iframe 中加载受害者站点
  2. 利用 Spectre 风格的侧信道攻击(利用 CPU 分支预测的缓存时间差异)
  3. 读取受害者站点的数据

Chrome 的应对是 Site Isolation

无 Site Isolation:
Tab A: [example.com | malicious.com] ← 同一个渲染进程
↑ 恶意站点可以读取 example.com 的数据

有 Site Isolation:
Tab A: [example.com] ← 渲染进程 1
[malicious.com] ← 渲染进程 2(独立进程,独立内存空间)

跨站 iframe 会独立进程 —— 这是 Site Isolation 的直接结果。每个不同的站点(eTLG+1)都被分配到独立的渲染进程,进程之间无法互相读取内存。

第四层:V8 Isolate

V8 Isolate 是 V8 引擎的独立虚拟机实例。每个 Isolate 有:

  • 独立的堆内存(Heap)
  • 独立的垃圾回收器
  • 独立的编译缓存
  • 独享的 JS 执行环境
Renderer Process A Renderer Process B
├── V8 Isolate 1 ├── V8 Isolate 2
│ ├── Heap (10MB) │ ├── Heap (15MB)
│ ├── GC Thread │ ├── GC Thread
│ └── Compiler Cache │ └── Compiler Cache
├── Blink Render Tree ├── Blink Render Tree
└── Site: example.com └── Site: malicious.com

在多进程架构下,每个渲染进程至少有一个 V8 Isolate,Isolate 之间的内存完全隔离。这也是 Site Isolation 能够生效的底层基础。


V8 为什么快?—— Hidden Class、Inline Cache 到 TurboFan 的完整 JIT 管道

第一层:JIT(Just-In-Time Compilation)

V8 不是简单的解释器,也不是简单的编译器,而是多层编译管道

源码

Ignition(解释器) → 生成 Bytecode → 快速执行
↓ (热点代码)
Sparkplug(快速编译器) → 简单编译 → 提速 3-5x
↓ (非常热)
TurboFan(优化编译器) → 激进优化 → 提速 10-100x
↓ (假设失效)
Deoptimization(去优化) → 回退到 Bytecode

第二层:Hidden Class 与 Shape Transition

JavaScript 是动态类型语言,对象的属性可以在运行时任意增删。这给属性访问带来了巨大挑战 —— 每次 obj.prop 都需要动态查找。

V8 的解决方案是 Hidden Class(隐藏类),也叫 Map

function Point(x, y) {
this.x = x; // → 创建 Hidden Class C0
this.y = y; // → 创建 Hidden Class C1(继承 C0)
}

const p1 = new Point(1, 2);
const p2 = new Point(3, 4);
// p1 和 p2 共享相同的 Hidden Class 链:C0 → C1
p1 (Point)
└── map → C1
├── descriptor: [x: 0, y: 4]
└── parent → C0
├── descriptor: [x: 0]
└── parent → null

所有以相同顺序添加相同属性的对象共享同一个 Hidden Class,属性访问变成 O(1) 的固定偏移读取,类似 C 语言 struct 的字段访问。

第三层:Inline Cache(IC)

当 V8 执行 obj.x 这样的属性访问时,会缓存这次的查找结果

function getX(obj) {
return obj.x; // 第一次执行:查找 → 缓存结果
}

每个属性访问点在代码中都有一个 IC 槽位(IC Slot),记录:

IC Slot for getX:
state: MONOMORPHIC ← 只有一种类型
map: C1 (Point class)
offset: 0 ← x 在对象中的偏移量

下次执行 getX(p1) 时,V8 直接检查 p1 的 Hidden Class 是否是 C1 → 如果是,直接从偏移 0 读取 → 跳过查找过程

这就是为什么多态(多种类型调用同一函数)会影响性能:

MONOMORPHIC(单态) → 最快,IC 直接命中
POLYMORPHIC(多态) → 4 种以内,IC 保存查找结果数组,慢一些
MEGAMORPHIC(巨态) → > 4 种,IC 失效,回退到完整查找

第四层:为什么 delete obj.a 会导致性能暴跌

const obj = { x: 1, y: 2 };
// obj 的 Hidden Class 是:C_x_y,x: offset 0, y: offset 4

delete obj.x;
// obj 结构变了!V8 无法在原有 Hidden Class 上删除属性
// → 对象退化为 Dictionary Mode

delete 操作符会破坏对象的 Hidden Class 结构。V8 没办法优雅地处理"删除中间属性"这种场景 —— 因为所有属性偏移已经固定了。

V8 的处理方式是:把对象切换到 dictionary mode。这时候对象不再使用 Hidden Class + 固定偏移,而是使用类似哈希表的键值对存储。所有属性访问都退化为哈希查找,性能暴跌(10-100x)。

正常模式(Fast Mode):
obj.x → map check → offset 0 → 直接内存读取

Dictionary Mode(慢速模式):
obj.x → hash("x") → 哈希表查找 → 内存读取

第五层:TurboFan 的激进优化

TurboFan 是 V8 的最高优化层级。它会基于运行时收集的类型反馈做激进假设

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

TurboFan 观察到第一次调用 add(1, 2),两个参数都是整数 → 假设"这个函数永远做整数加法" → 编译成:

; 伪汇编
mov eax, [a] ; 直接加载整数
add eax, [b] ; CPU 整数加法指令
ret

但如果后续调用 add('hello', ' world'),假设被打破 → Deoptimization(去优化)

TurboFan 优化 add():
[假设: a, b 都是 Smi(小整数)]

调用 add('hello', 'world')

检查失败(不是 Smi)

Deopt → 回退到 Ignition Bytecode

重新执行(这次用字符串拼接逻辑)

Deopt 是性能陡降点,但保证了正确性。这就是 JIT 编译器的本质:基于假设的性能优化

补充:分代垃圾回收(Generational GC)

V8 的堆内存分为两代:

New Space(新生代) ← 新创建的对象
├── From Space
└── To Space
│ Scavenge 算法:频繁回收,只复制存活对象
↓ 存活超过 2 次 GC
Old Space(老生代) ← 存活较久的对象
│ Mark-Sweep/Mark-Compact:低频但昂贵

大多数对象"朝生夕死"(函数局部变量等),所以在新生代就回收了,代价很低。只有少数对象晋升到老生代,触发完整 GC。


Promise 为什么比 setTimeout 快?—— 从 Event Loop 到 Microtask Starvation

第一层:宏任务 vs 微任务

Event Loop 优先级:
┌──────────────────────────┐
│ 宏任务(MacroTask) │
│ setTimeout, setInterval, │
│ setImmediate, I/O, │
│ UI rendering │
└────────────┬─────────────┘
│ 每执行一个宏任务

┌──────────────────────────┐
│ 微任务(MicroTask) │
│ Promise.then/catch/finally│
│ MutationObserver, │
│ queueMicrotask │
│ process.nextTick(Node) │
└────────────┬─────────────┘
│ 清空整个微任务队列

┌──────────────────────────┐
│ UI Rendering │
│ (浏览器每帧) │
└──────────────────────────┘

Promise.then 加入的是微任务(microtask)队列,setTimeout 加入的是宏任务(macrotask)队列。

微任务队列在当前宏任务执行完毕后、下一个宏任务开始前被全部清空。所以 Promise.then 的回调比 setTimeout 的回调更早执行。

第二层:微任务在哪执行

具体来说,每轮事件循环的检查点是:

执行一个宏任务
→ 检查微任务队列 → 如果有,逐个执行直到队列清空
→ 如果需要,执行 UI Render(浏览器渲染)
→ 开始下一个宏任务

注意:微任务队列的清空是"直到空为止",不是说只执行一个。

console.log(1);
Promise.resolve().then(() => console.log(2));
Promise.resolve().then(() => console.log(3));
setTimeout(() => console.log(4), 0);

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

执行流程:

  1. 当前宏任务输出 1
  2. 检查微任务队列:执行 console.log(2), console.log(3) → 队列清空
  3. 调度 setTimeout(4) 作为下一个宏任务
  4. 输出 4

第三层:浏览器什么时候真正 render

浏览器不需要每帧都渲染。渲染时机由以下因素决定:

  1. VSync 信号:通常 60Hz 显示器每 16.6ms 一个垂直同步信号
  2. 渲染管道触发:在宏任务事件循环的末尾、下一帧开始前,浏览器决定是否渲染
Event Loop with Rendering(典型 60fps 场景):
Frame 1 (~16.6ms):
├── 宏任务: click handler
├── 微任务: Promise callbacks
├── requestAnimationFrame callbacks
└── Render: 样式计算 → 布局 → 绘制 → 合成

Frame 2 (~33.3ms):
├── 宏任务: setTimeout callback
├── 微任务: Promise callbacks
├── requestAnimationFrame callbacks
└── Render: ...

如果微任务执行时间太长,会推迟渲染 —— 这就是为什么 while(await Promise.resolve()) 会导致页面卡死的原因。

第四层:Node 的 process.nextTick 为什么优先级更高

Node.js 的事件循环比浏览器多了一个队列层:

Node.js Event Loop 阶段(libuv):
┌─────────┐
│ timers │ ← setTimeout/setInterval 回调
├─────────┤
│ I/O │ ← 文件/网络 I/O 回调
├─────────┤
│ idle │ ← 内部使用
├─────────┤
│ poll │ ← 等待 I/O 事件
├─────────┤
│ check │ ← setImmediate 回调
├─────────┤
│ close │ ← 关闭事件回调
└─────────┘
│ 每个阶段切换时

microtasks


nextTickQueue ← process.nextTick 在这里

Node 里 process.nextTick 有比 Promise 微任务更早的执行时机。在每个事件循环阶段的切换点,先执行 process.nextTick 队列的全部回调,再执行其他微任务。

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

// Node 输出:nextTick → promise

但实际上 Node 已经开始将两者统一调度了。nextTick 的更高优先级是一个历史遗留设计,官方建议尽量用 queueMicrotaskPromise 而不是 nextTick

第五层:为什么 while(Promise.resolve().then(...)) 会导致页面"假死"

while (true) {
Promise.resolve().then(() => {});
}

这不只是一个"死循环"问题。关键在于:

  1. Promise.resolve().then(fn)fn 加入微任务队列
  2. 当前宏任务结束后,浏览器开始执行微任务队列
  3. 执行 fn(空函数)→ 很快完成 → while 判断继续 → 又一个 .then()
  4. 微任务队列**永远不会清空** → 浏览器永远无法进入下一个渲染帧
  5. 页面从用户视角看 → 假死(不响应点击、滚动、重绘)

这与 while(true){} 不同。while(true){}单个宏任务长时间占用,而微任务 starving 是在宏任务间无限插入微任务

这个问题在 Node 中更严重:没有渲染需求的 Node 会一直处理微任务,导致其他 I/O 永远得不到处理。这就是 microtask starvation(微任务饥饿)

解决方案:偶尔插入宏任务让出控制权:

function processWithYield(items) {
return new Promise(resolve => {
let i = 0;
function batch() {
while (i < items.length && i % 100 !== 0) {
process(items[i]);
i++;
}
if (i < items.length) {
setTimeout(batch, 0); // 让出控制权,让浏览器渲染
} else {
resolve();
}
}
batch();
});
}

TypeScript 深水题

TS 泛型为什么很多时候"不智能"?—— Best Common Type 到 Variance 的解释

核心问题

function foo<T>(a: T, b: T) {}

foo(1, 'a');
// T 推导为 string | number
// 而不是报错说"类型不匹配"

很多开发者期望 TypeScript 在这里报错——"参数类型不一致"。但 TypeScript 的行为是把 T 推断为两个类型的联合

第一层:Best Common Type

TypeScript 的类型推断遵循 Best Common Type 算法。当多个表达式共享一个类型参数时,TypeScript 会找出所有类型的父类型

const arr = [1, 'a', true];
// → 推导为 (string | number | boolean)[]
// 而不是报错

foo(1, 'a') 中:

  • 第一个参数 1number
  • 第二个参数 'a'string
  • T 必须同时兼容两者 → string | number

这个行为是设计选择,不是 bug。TypeScript 的选择是宽容推断而非严格约束。

第二层:Widening(拓宽)

TypeScript 在推断字面量类型时会自动 widen(拓宽为更通用的类型):

const x = 'hello'; // 类型: 'hello'(字面量类型)
let y = 'hello'; // 类型: string(拓宽)

type Hello = 'hello';
const z: Hello = 'hello'; // 类型: 'hello'(显式类型阻止拓宽)

foo(1, 'a') 中:

  • 1 可以 widen 为 number
  • 'a' 可以 widen 为 string
  • 两者的最佳共同父类型是 string | number

第三层:如何让 TS "更严格"

如果需要强制两个参数类型一致,有几种方式:

// 方案 1:用 extends 约束
function foo<T extends string | number>(a: T, b: T) {}
// 仍然无法阻止 foo(1, 'a')

// 方案 2:用元组泛型约束
function foo<T extends [unknown, unknown]>([a, b]: T) {}
// foo(1, 'a') → 同样会推断为 [string | number]

// 方案 3:真正严格的方案 —— 重载或分开参数
function foo<T, U>(a: T, b: U, ...args: T extends U ? [] : [never]) {}
// 但 TypeScript 目前不支持这样约束

// 方案 4:最实用的方案 —— 使用 satisfies 或 as const
foo(1 as const, 'a' as const);
// → T = 1 | 'a'(还是联合,但更精确了)

实际上没有办法让泛型参数强制"两个参数完全同类型"。这是 TypeScript 类型系统的设计局限——联合类型推断优先于严格匹配。

第四层:Distributive Conditional Types(分布式条件类型)

当条件类型作用于泛型且参数是联合类型时,TypeScript 会自动展开联合类型中的每个成员:

type ToArray<T> = T extends unknown ? T[] : never;

type Result = ToArray<string | number>;
// = string[] | number[]
// 不是 (string | number)[]

这就是 distributive 行为。如果想禁用展开,用方括号包裹:

type ToArray<T> = [T] extends [unknown] ? T[] : never;

type Result = ToArray<string | number>;
// = (string | number)[]

第五层:infer(类型推断的"解构"工具)

infer 允许在条件类型中声明待推断的类型变量:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type Fn = (x: number) => string;
type R = ReturnType<Fn>; // string

核心机制:

  1. T 被匹配到 (...args: any[]) => infer R 模式
  2. TypeScript 计算出 R 应该是什么
  3. 返回 R 类型

infer 的限制在于它只能出现在条件类型 extends 的 true 分支中

第六层:Variance(型变)

Variance 决定了泛型参数在子类型关系中的行为:

interface Animal { eat(): void; }
interface Dog extends Animal { bark(): void; }

type VarianceCheck<T> = T;

// Covariance(协变)—— 大多数情况
// Dog[] extends Animal[] ? ✅ (数组是协变的)
// Promise<Dog> extends Promise<Animal> ? ✅

// Contravariance(逆变)—— 函数参数
// (animal: Animal) => void 可以赋值给 (dog: Dog) => void ? ✅
// 协变示例
const dogs: Dog[] = [];
const animals: Animal[] = dogs; // ✅ OK,数组元素类型协变

// 函数参数逆变
type Handler<T> = (x: T) => void;
const handleAnimal: Handler<Animal> = (a: Animal) => {};
const handleDog: Handler<Dog> = handleAnimal; // ✅ 参数逆变
// 因为 Handler<Dog> 需要一个能接收 Dog 的参数
// handleAnimal 也能接收 Dog(因为 Dog extends Animal)

// 不变的 Counterexample
type Container<T> = { value: T };
// Container<Dog> 不能赋值给 Container<Animal>
// 因为 value 可以读写——写操作时需要赋值具体的 Animal

Variance 决定了 TypeScript 对复杂泛型参数的检查严格程度。不理解 Variance 就会觉得"TS 有时候太松,有时候太严,很分裂"。


为什么 React 的事件类型不是原生 DOM Event?—— SyntheticEvent 到事件委托的演进

核心答案:SyntheticEvent

React 的事件系统包装了原生 DOM 事件,创建了一个跨浏览器的 SyntheticEvent(合成事件)

function Button() {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// e 是 SyntheticEvent,不是原生 MouseEvent
e.preventDefault(); // 跨浏览器一致
console.log(e.nativeEvent); // 通过 nativeEvent 访问原生事件
};
return <button onClick={handleClick}>Click</button>;
}

为什么需要 SyntheticEvent

  1. 跨浏览器兼容:不同浏览器的事件 API 有差异(IE 的 event.srcElement vs 标准的 event.target),SyntheticEvent 抹平了这些差异
  2. 性能优化:通过事件委托减少事件监听器的注册数量
  3. 与 React 的调度系统集成:合成事件能更好地与 React 的批量更新和优先级机制配合

事件委托机制

React 并不是把事件监听器直接绑定到 DOM 元素上,而是利用事件委托

React 16 及以前:
document.addEventListener('click', React 事件处理)
↑ 所有事件都冒泡到 document,由 React 统一分发

React 17+:
rootNode.addEventListener('click', React 事件处理)
↑ 事件委托从 document 改为 React 根节点
// 实际上 React 会这样处理:
<button onClick={handleClick} />

// 内部实现(简化)
container.addEventListener('click', (e) => {
const fiber = findFiberFromDOM(e.target);
// 沿着 Fiber 树收集 onClick 回调
// 按事件冒泡/捕获顺序执行
});

React 17 为什么修改事件挂载点?

React 17 将事件委托从 document 改到了 rootNodecreateRoot 的挂载节点):

React 16:
document → 事件委托在这里
└── root
└── app content

React 17:
document
└── root → 事件委托在这里
└── app content

原因:

  1. 嵌套 React 应用:多个 React 应用嵌套时,document 级别的事件监听会互相干扰
  2. 与微前端兼容:微前端架构中不同子应用可能有独立的 React 实例,各管各的根节点更安全
  3. 避免全局冲突:不污染 document,和其他库的冲突减少

Pooling(事件池)为什么被移除

在 React 16 及以前,SyntheticEvent 使用了事件池(event pooling)

function handleClick(e) {
setTimeout(() => {
console.log(e.type); // ❌ 报错!事件对象已被回收
}, 100);
}

因为事件对象被复用了——e 的回放在事件池中,后续事件会覆盖它的属性。

事件池机制:
1. 事件触发 → 从池中取出 SyntheticEvent 实例
2. 填充属性(type, target 等)
3. 执行回调
4. 回调执行完毕后 → 清空属性 → 放回池中(准备下次复用)

React 17 移除了事件池,原因:

  1. 不符合直觉:开发者经常踩 e.persist() 的坑
  2. 性能收益有限:现代浏览器已经很快,事件对象创建的开销可以忽略
  3. 简化代码:移除 pooling 让 React 内部逻辑更简单

工程化深水题

Vite 为什么快?—— 从开发到构建的完整解释

第一层:ESModule 原生

Vite 开发服务利用浏览器原生 ESModule

<!-- 开发环境:直接加载原生 ES Module -->
<script type="module">
import { createApp } from '/node_modules/.vite/deps/vue.js'
import App from '/src/App.vue'
</script>

浏览器直接加载模块,不需要像 webpack 那样先打包整个应用。

Webpack Dev:
[app.js] → webpack 打包所有模块 → bundle.js → 浏览器加载

Vite Dev:
[main.ts] → 浏览器发现 import → 请求依赖 → Vite 实时转换

第二层:为什么 dev 快 build 不一定快

Vite 的快主要体现在开发环境。因为:

开发环境:

  • 无需打包,浏览器按需加载
  • 只编译修改的文件(利用浏览器缓存)
  • esbuild 预构建依赖(用 Go 写的 esbuild 比 JS bundler 快 10-100x)

生产构建(build):

  • 必须打包(为了兼容性、代码分割、tree shaking)
  • Vite 使用 Rollup 做生产打包(Rollup 是 JS 写的,没有 esbuild 快)
  • 需要完整的 tree shaking、代码分割、chunk 优化

这就是为什么有时 Vite 的 build 时间不比 webpack 快太多。

第三层:Pre-bundling(预构建)在干什么

Vite 的 pre-bundling 是在启动时对 node_modules 中的依赖做的预处理:

node_modules/
├── lodash/ ──→ 预构建 ──→ .vite/deps/lodash.js (单个 ES Module 文件)
├── vue/ ──→ 预构建 ──→ .vite/deps/vue.js
└── axios/ ──→ 预构建 ──→ .vite/deps/axios.js

为什么需要预构建:

  1. CommonJS → ESM 转换:很多依赖是 CJS 格式(module.exports),浏览器不能直接识别,需要转换成 ES Module
  2. 依赖合并:lodash 有上百个文件,预构建将它们合并成一个文件,避免浏览器发上百个 HTTP 请求
  3. 预编译优化:esbuild 将 TS/JSX 等一次性编译为普通 JS

第四层:esbuild 为什么快

esbuild 的"快"不是倍数级的,而是数量级的。原因:

因素esbuild传统工具
语言Go(编译成原生机器码)JavaScript
并行利用多核 CPU 并行解析单线程 + 异步
内存共享内存,零开销通信JSON 序列化,IPC
解析自定义解析器(一次扫描)多阶段处理(AST → transform → generate)

对比实际数据(官方 benchmark):

esbuild: 0.03s
webpack 5: 0.40s (13x slower)
Rollup: 0.34s (11x slower)
Parcel 2: 0.49s (16x slower)

第五层:Rollup 为什么适合 production

Vite 用 Rollup 做 build 而不是 esbuild,因为:

  1. Tree Shaking 更彻底:Rollup 基于 ES Module 静态分析,能准确移除未使用的代码
  2. 代码分割(Code Splitting)更精细:支持动态 import、manual chunks
  3. 插件生态:Rollup 有成熟的插件生态(特别是 CSS 处理、资源处理)
  4. 输出格式:支持多种输出格式(ESM、CJS、UMD、IIFE)

esbuild 在 tree shaking 方面虽然也有能力,但精度不如 Rollup。Evan Wallace(esbuild 作者)自己也承认 esbuild 的 tree shaking 是简化版。

第六层:HMR 为什么能做到模块级更新

Vite 的 HMR 基于原生 ESM 的热更新协议

修改 src/Button.vue

Vite 服务器检测到文件变化

通知浏览器:"Button.vue 变了"

浏览器:
1. 请求新的 Button.vue 模块
2. 只替换这一个模块的代码
3. 保留 App 和其他组件的状态

对比 webpack 的 HMR:

Webpack HMR:
修改 1 个文件 → 重新打包整个 chunk → 通知更新

Vite HMR:
修改 1 个文件 → 只发这一个文件的变化 → 浏览器直接重新加载

对于大型应用,webpack 的 HMR 会随着项目增大而变慢(需要重新打包的 chunk 越来越大),Vite 的 HMR 则始终是 O(1) 的——只影响修改的文件。

第七层:webpack 的历史包袱

webpack 的问题不是设计本身,而是它诞生在一个 ES Module 还没有被浏览器原生支持的时代:

  1. 打包逻辑:需要把一切模块包裹进 webpack runtime(__webpack_require__ 等)
  2. 需要 loader 处理一切:CSS、图片、字体都需要 loader → 没有 loader 就报错
  3. 配置驱动:高度可配置但学习曲线陡峭
  4. HMR 维护复杂:需要维护模块依赖图、模块代理等

第八层:Module Federation 的真正难点

Module Federation(模块联邦)是 webpack 5 引入的运行时跨应用加载模块方案。它的难点:

  1. 共享依赖的版本冲突:两个应用都用了 React,版本不同怎么办?—— 需要通过 shared 配置解决
  2. 异步加载时序:远程模块必须在运行时加载完成,路由切换时要等待
  3. TypeScript 类型共享:远程模块的 TS 类型需要跨项目同步
  4. 开发调试体验:远程模块在另一个项目,断点调试困难
  5. 安全策略:远程模块的加载需要 CORS 支持,企业内网环境需要额外配置

Vite 的 Module Federation 方案(如 @originjs/vite-plugin-federation)实现方式不同,但核心挑战是一样的。


Tree Shaking 为什么有时失效?—— SideEffects、CommonJS、Live Binding 全解析

第一层:什么是 Tree Shaking

Tree Shaking 是通过静态分析移除未使用代码的优化技术。前提是使用 ES Module(import/export):

// utils.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

// app.js
import { add } from './utils';
console.log(add(1, 2));

// tree shaking 后:subtract 被移除

第二层:为什么有时候失效

失效的主要原因:

1. sideEffects(副作用标记)

// 直接导入一个模块但不使用它的 export
import './polyfill'; // 有副作用,不能 tree shake

// 一个模块的 import 可能有隐式副作用
import { Button } from 'antd';
// → 可能实际引入了 antd 的整个 CSS 和所有组件

package.json 中的 sideEffects 字段告诉打包工具哪些文件有副作用:

{
"sideEffects": [
"*.css",
"./polyfill.js"
]
}

打包工具看到 sideEffects: false 就知道这个包的所有模块都"安全",可以放心移除未使用的导出。如果这个字段缺失或错误,tree shaking 会保守地保留所有代码

2. CommonJS 为什么难 shake

// CommonJS 模块(lodash 等)
const _ = require('lodash');

// 或者:
const { debounce } = require('lodash');

CommonJS 的 require动态的,可以在运行时决定加载什么:

const moduleName = Math.random() > 0.5 ? 'fs' : 'path';
const mod = require(moduleName);

打包工具在编译时无法确定实际加载了哪些导出,所以只能保留整个模块。这就是为什么 lodash 的 tree shaking 效果很差的原因(lodash 是 CJS)。

解决方案:使用 lodash-es(ESM 版本)或按路径引用:

import debounce from 'lodash-es/debounce'; // ✅ 只加载 debounce

3. Live Binding(ESM 的实时绑定)

ES Module 的 export 是实时绑定的——模块内部的值变化会反映到导入方:

// counter.js
export let count = 0;
export function increment() { count++; }

// app.js
import { count, increment } from './counter';
increment();
console.log(count); // 1 —— 实时反映内部状态

这意味着打包工具必须保留 count 和函数之间的绑定关系。即使你只 import 了 count,但 countincrement 修改,而 increment 可能又有其他依赖…… 打包工具需要保守地保留整个依赖链。

4. Babel 为什么可能破坏 tree shaking

Babel 的 @babel/preset-env 在转译 ESM 时,可能将 ES Module 转换为 CommonJS:

// Babel 配置
{
"presets": [
["@babel/preset-env", {
"modules": "commonjs" // ❌ 这会把 import/export 转成 require
}]
]
}

如果 Babel 把 import 转成 require,后面接的打包工具(如 webpack)就失去了静态分析的能力:

// 原始代码
import { add } from './utils';

// Babel 转译后
const _utils = require('./utils');
// → tree shaking 失效!

解决方案:设置 modules: false,让 Babel 保留 ES Module 语法:

{
"presets": [
["@babel/preset-env", { "modules": false }]
]
}

第三层:为什么 export * 可能影响 shaking

// components/index.js
export * from './Button';
export * from './Input';
export * from './Select';
// ... 几十个组件

当你在入口文件使用 export * 时,打包工具遇到的问题是:

问题 1:无法确认哪些导出被使用
import { Button } from './components'
→ 打包工具需要检查 ./components 的所有 re-export → 最终找到 Button
→ 但它不能确定其他 re-export 是否被使用,因为 export * 是动态的

问题 2:副作用模糊
如果 ./Input 有副作用(如修改全局原型),
即使只 import 了 Button,打包工具也必须执行 ./Input

问题 3:循环依赖的可能性
export * 可能产生循环 re-export,打包工具需要处理

优化方案:使用具名 re-export 替代 export *

// 不好的做法
export * from './Button'; // 全部导出

// 好的做法
export { Button } from './Button'; // 只导出需要的

这样打包工具能精确追踪依赖,tree shaking 效率大幅提升。


AI Native 前端新题

AI 生成代码时代,前端架构最重要的能力是什么?—— 约束、可验证性与 Harness 工程

核心答案

AI 生成代码时代,前端架构最重要的能力是:约束(Constraint)可验证性(Verifiability)

在 AI 写代码的场景中,最危险的不是"写错代码",而是写得看起来都对,但架构在缓慢腐烂

第一:约束

AI 模型本质上是一个概率生成器。给它越多的上下文、越少的约束,它生成的代码就越不可预测。

架构层面的约束手段:

约束金字塔:
├── 类型系统(TypeScript)—— 最底层、最强的约束
│ └── 禁止 any、严格模式、类型推导
├── 代码规范(ESLint + Prettier)
│ └── 规则集、自动修复
├── 架构规则(Architecture Linter)
│ └── 依赖方向(禁止 UI 直接操作 store)、分层规则
├── Component Contract
│ └── 明确的 Props 接口、不可变数据要求
└── 流程约束(CI/CD)
└── 类型检查 → Lint → 测试 → 构建
// 好的约束示例:Component Contract 明确
type Props = {
/** 用户数据,必须由父组件提供 */
user: User;
/** 操作回调,组件不关心实现 */
onUpdate: (data: UpdatePayload) => Promise<void>;
/** 可选:变更后的回调 */
onSuccess?: () => void;
};

function UserProfile({ user, onUpdate, onSuccess }: Props) {
// 组件不关心数据从哪来,不关心 API 怎么调用
// 只负责渲染和调用回调
}

第二:可验证性

AI 生成的代码需要能被快速验证。验证能力越强,越信任 AI 的输出。

验证金字塔:
├── 类型检查 —— AI 生成的代码类型对不对
├── 单元测试 —— 逻辑是否正确
├── 快照测试 —— UI 是否意外变化
├── E2E 测试 —— 用户流程是否完整
└── 合规检查 —— 是否违反架构规则

Schema-first 是可验证性的关键:

// 先定义 schema,再生成代码
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

// AI 只要遵循这个 schema,就不会产生类型错误

第三:Deterministic Workflow

AI 需要有确定性的工作流。不是说每步输出都相同,而是工作流本身是可预测的:

AI 生成代码的推荐工作流:
1. HLD(高层设计) → 人工审批
2. Component Tree → 人工审批
3. 接口定义 → 人工审批
4. 代码生成 → AI 独立完成
5. Code Review → 人工 + 自动化

第四:Harness Engineering

"Harness"是指约束AI行为的运行环境。包括:

  • Skill 系统:把任务步骤打包成可复用的规范(如 CLAUDE.md、AGENTS.md)
  • 沙箱执行:AI 生成的代码在沙箱中先跑一次
  • 反馈闭环:AI 犯错后系统能捕获并纠正

第五:为什么 AI 更怕 Implicit Convention

人类可以通过上下文理解隐式约定("这个项目用 PascalCase 命名组件")。AI 如果没被明确告知,会在每次生成时随机猜测:

没有显式约定:
AI 生成 10 次代码:
- 3 次用 PascalCase(MyComponent.tsx)
- 4 次用 kebab-case(my-component.tsx)
- 2 次用 camelCase(myComponent.tsx)
- 1 次混合

有显式约定:
AGENTS.md:
"所有组件文件使用 PascalCase 命名:MyComponent.tsx"
→ AI 100% 遵守

这就是为什么 AI 需要更显式的约定,而不是依赖人类容易理解的"潜规则"。

第六:为什么 Monorepo 更重要

在 AI 开发时代,monorepo 的优势被放大:

  1. 全局上下文:AI 能看到整个项目的代码组织、共享类型、公共工具函数
  2. 约束统一:统一的 ESLint、TypeScript 配置在仓库级别生效
  3. 跨项目重构:AI 可以同时修改多个包(配合代码库级别的 refactoring)
  4. 依赖锁定:减少"不同项目版本不同"导致的幻觉概率

第七:MCP 对 IDE 的影响

MCP(Model Context Protocol)让 AI 通过标准化接口与 IDE 和工具链交互。这意味着:

  • AI 可以直接读取 LSP diagnostics 来修正自己的代码
  • AI 可以运行测试并查看结果来验证逻辑
  • AI 可以通过 git diff 检查变更范围是否合理

这在架构层面的影响是:AI 不再是"黑箱生代码",而是通过结构化协议与工程工具有机融合


AI Agent 为什么特别容易写出"看起来能跑但逐渐腐烂"的前端代码?—— Entropy Control 才是工程师的终极价值

核心问题

AI 生成的代码在单个文件、单个组件层面通常质量不错。但问题出在跨文件、跨时间、跨迭代的维度。

原因一:Context Window 污染

AI 的上下文窗口有限。在一个长时间的项目中:

第一轮迭代:
AI 创建了 Button 组件 → 正确

第二轮迭代(10 个文件之后):
AI 忘了之前 Button 组件的接口 → 创建了新的 Button2

第三轮迭代(50 个文件之后):
AI 不再记得项目里有 select 组件 → 重新造了一个

后果:架构熵增 —— 代码量增加,但有效信息密度下降。

原因二:Architecture Drift(架构漂移)

AI 没有"全局架构意识"。它每次修改都是在局部上下文中做最优决策:

第 1 次:AI 在 PageA 用 fetch 直接调用 API
第 2 次:AI 在 PageB 创建了 apiClient 封装
第 3 次:AI 在 PageC 又用 fetch 直接调用(不知道 apiClient 的存在)
第 4 次:AI 发现 apiClient 缺少某个功能,直接修改 apiClient 加参数

每次局部最优 → 全局熵增。

原因三:Duplicated Abstraction(重复抽象)

AI 倾向于按需创建而不是复用

AI 需要解析日期:创建 formatDate()
AI 需要格式化日期:创建 formatDateTime()
AI 需要展示日期:创建 displayDate()
AI 需要展示日期:又创建 DateDisplay()

每个函数单独看都没问题,但放在一起是一堆功能重叠、接口不一致的工具函数。

原因四:Hallucinated API(幻觉 API)

AI 有时会使用不存在的 API:

// AI 生成的代码——这个 API 可能不存在
import { useDebouncedEffect } from 'react-use';

// 或者
const data = await api.fetchUsers();
// api.fetchUsers 在真实代码中可能是 api.getUsers()

编译器能捕获的类型错误还好,但逻辑层面的幻觉更危险

// AI 以为这个回调会被调用
onSaveSuccess();
// 但实际上 onSaveSuccess 从未在组件中被传入

原因五:Local Optimization(局部优化陷阱)

AI 倾向于在局部做"最优优化",但局部最优 ≠ 全局最优

// AI 在 ComponentA 做了一个"巧妙"的性能优化
const ComponentA = React.memo(({ data }) => {
// 用了 useMemo、useCallback、自定义比较器
});

// 但这个优化和另一个组件的架构冲突了
// → 需要花大量时间定位"为什么性能没提升"

高手视角:Entropy Control

人类工程师在前端 AI 时代的终极价值是 entropy control(熵控制)

没有控制的 AI 代码生成:
每次修改 → 熵增 → 代码逐渐腐烂 → 不可维护

有人类控制的 AI 代码生成:
每次修改 → 熵增 + 熵减操作 → 维持代码健康

熵减操作包括:

  1. Code Review 聚焦架构一致性 —— 不是看对错,而是看是否与现有模式一致
  2. Constantly Refactor —— 用 AI 发现重复,用 AI 合并重复
  3. 建立显式 Contract —— 明确的接口契约,限制 AI 的行为空间
  4. 自动化验证 —— 类型检查、Lint、测试、架构检测(如 dependency-cruiser)
  5. 上下文分层 —— 不让一个 AI 对话覆盖整个项目,而是拆成独立、边界清晰的工作单元

如何让 AI 写出不腐烂的代码?

最佳实践清单:
□ TypeScript strict mode(类型约束)
□ 明确的 Component Contract(Props 接口)
□ 架构规则(依赖方向、分层)
□ Monorepo(全局上下文可见)
□ 自动化代码审查(类型、Lint、测试、架构)
□ 定期的 AI Refactoring 会话(专门做熵减)
□ 显式的 AGENTS.md/CLAUDE.md(约束 AI 行为)

开放式高手题

为什么 React 最终没有选择 Signals?—— Pull vs Push 到 Ownership Graph 的架构博弈

背景

Signals(信号)是一种细粒度的响应式方案,典型代表有 SolidJS、Vue 3、Preact Signals、Angular Signals。它通过直接追踪数据依赖关系,实现精确更新——改变一个值,只有依赖这个值的 DOM 节点会更新。

// SolidJS:Selective DOM Update
const [count, setCount] = createSignal(0);

// 只有依赖 count 的 DOM 会被更新
// 不是整个组件重渲染
<p>Count: {count()}</p>

React 不选择 Signals 的原因

1. Pull vs Push 的根本分歧

Signals(Push 模式):
数据变化 → 通知所有依赖 → 逐级传播更新
优点:精确,只更新依赖者
缺点:更新范围难以静态分析,容易产生级联更新

React(Pull 模式):
数据变化 → 标记需要更新 → 从根部重新 render →
Diff 找出实际变化 → 应用最小更新
优点:确定性高,从上而下的一致性
缺点:可能做多余的工作(需要 diff)

React 选择 pull 模式的原因:从上而下的渲染保证了一致性(consistency)

2. Tearing 问题

在 Concurrent Mode 中,高优先级更新可以打断低优先级渲染。如果使用信号(push 模式),会出现 tearing(撕裂)——同一个渲染批次中,读取同一个信号两次得到不同的值:

// Tearing 场景
function Component() {
// 第一次读取 signalA: 1
const a = signalA.value;

// 高优先级更新插入,修改了 signalA → 2

// 第二次读取 signalA: 2
const b = signalA.value;

// a !== b —— 同一个 render 过程中 data 不一致!
}

React 的 pull 模型天然解决 tearing:所有状态在本次渲染开始时就 snapshot 固定了,不会在渲染过程中变化。

// React 通过快照保证一致性
function Component() {
const [state, setState] = useState(0);
// state 在整个 render 过程中是常量
// 无论高优先级任务怎么抢,state 不会变
}

3. Scheduler 的兼容性

React 18 的 scheduler 依赖渲染是纯函数的假设——给定 state 树,输出确定的 UI。Signals 引入了"值会变化"的动态性,与这个假设冲突。

React Fiber 的可中断渲染 + Signals 的 push 式响应式,在架构上是一对不可调和的矛盾

4. Ownership Graph(所有权图)

React 的数据流是单向 + 树形的:父组件 → 子组件。这个结构简单、可预测。

Signals 数据流是形的:一个 signal 可以被任何组件读取,也可以在任何地方被修改。这带来了几个问题:

  • 数据流向难以追踪("这个 signal 是谁改的?")
  • 组件复用时 signal 的归属不明确
  • 调试困难(没有清晰的单向数据流)
// Signals 可能导致混乱的数据流
// 任何组件都可以读取和修改 signal
const globalCount = signal(0);

function ComponentA() {
return <div onClick={() => globalCount.value++}>+</div>;
}

function ComponentB() {
return <div>{globalCount.value}</div>;
// 谁在修改?从代码结构看不出
}

React Forget 替代方案

React Forget(React Compiler)选择了另一条路——在编译时分析组件的依赖关系,自动 memoize,避免不必要的重渲染

// Compiler 自动优化
function Component({ items, filter }) {
// Compiler 自动分析依赖
// 自动添加 useMemo/useCallback
// 开发者不需要手动优化
const filtered = items.filter(i => i.type === filter);
return <List items={filtered} />;
}

React 的思路是:不改变运行时模型(pull),而是在编译期做优化。这保持了:

  • 一致性(snapshot 模型)
  • 可预测的调度(render 是纯函数)
  • 单向数据流

Signals 不是"失败了",而是 React 在一致性 vs 性能之间的架构选择中,决定押注一致性。


如果让你重新设计浏览器,你会改什么?—— 从 DOM 到 GPU-First Rendering 的系统设计

没有标准答案,但高水平的回答会覆盖以下几个维度的权衡和取舍。

1. DOM 太动态了

当前 DOM 的问题:属性修改后立即生效,每次修改都触发潜在的样式重算和布局重排。

// 当前 DOM —— 每次修改有代价
el.style.width = '100px'; // 可能触发重排
el.style.height = '200px'; // 又可能触发重排
el.style.top = '50px'; // 再触发一次

改进方向:微事务(Micro-transaction)DOM

// 理想的设计:批量 DOM 变更 + 自动合并
el.batch(() => {
el.style.width = '100px';
el.style.height = '200px';
el.style.top = '50px';
// 只触发一次重排
});

React 实际上在应用层解决了这个问题(虚拟 DOM + 批量提交),但浏览器层面没有这个抽象。

2. JS 与 Layout 解耦不够

当前:JS 执行 → 可能触发样式读取(强制回流)→ Layout 重新计算 → 绘制

el.style.width = '100px'; // 标记 dirty
console.log(el.offsetHeight); // ❌ 强制回流(读取上一次的布局结果)

解决办法:Layout 和 JS 在不同线程执行,Layout 输出不可变快照,JS 读取快照而非实时布

局。

类似 Servo 引擎的 Layout 与 Script 并行设计。

3. 主线程瓶颈

当前浏览器主线程任务:
├── JS 执行
├── 样式计算
├── 布局(Layout)
├── 绘制(Paint)
├── 合成(Composite)
├── 事件处理
└── requestAnimationFrame

一个 50ms 的 JS 任务可以导致整帧延迟。

改进方向:默认 off-main-thread

  • 布局线程独立(已经部分实现:LayoutNG)
  • 事件处理大部分走 compositor thread(已经实现)
  • JS 本身默认 off-main-thread(Worker 默认而不是可选)

4. Retained Mode UI 的取舍

当前浏览器是 retained mode(保留模式):创建 DOM 元素后浏览器保留其状态,修改时更新。

替代方案 immediate mode(立即模式):每帧重新绘制所有 UI。

Retained Mode:
优点:声明式、状态持久化(不需要每帧重建)
缺点:状态同步复杂、内存开销、事件系统耦合

Immediate Mode:
优点:简单(每帧全量绘制)、适合游戏
缺点:状态管理麻烦、不适合复杂交互 UI

理想的设计:混合模式——大部分 UI 用 retained(声明式),高动态区域(动画、游戏)用 immediate。

5. Incremental Layout(增量布局)

当前浏览器的布局计算虽然是增量的(只重新计算 dirty 的子树),但 布局信息是全局共享的 —— 修改一个元素可能影响整个文档的布局。

现状:
change #1 → 全局布局可能全部失效 → 全量重算

理想:
每个组件有独立的布局上下文,类似 CSS containment
change #1 → 只影响该组件的布局子树

CSS contain: layout 已经部分实现了这个,但需要开发者手动声明。理想情况下浏览器应该默认隔离布局上下文

6. Display List(显示列表)

游戏引擎的常用技术,浏览器也可以借鉴:

当前的绘图过程:
解析 CSS → 创建绘制指令 → 执行绘制

Display List 方案:
每个元素产生一个"绘制指令"(draw call)
将所有指令组合成显示列表
GPU 统一执行显示列表

这意味着真正的 GPU-first rendering。目前浏览器已经在 compositing 阶段做了类似的工作,但范围有限。

7. Capability Security(能力安全)

当前安全模型依赖于:

  • Same-origin policy
  • CSP(Content Security Policy)
  • Sandbox(iframe 沙箱)

但这些都是粗粒度的。理想的设计是 capability-based security

// 当前:页面要么能访问 localStorage,要么不能
// 改进:细粒度的能力授权
capability.request('localStorage', { scope: 'my-app' });
capability.request('geolocation', { once: true });
capability.request('network', { origins: ['api.example.com'] });

// 每个能力可以被撤销
capability.revoke('geolocation');

这种方式比 CSP 更精确,也比权限弹窗更友好。


为什么"前端越来越像操作系统"?—— Scheduler、Resource Management 到 Sandbox 的系统级思维

这已经是 Staff/Principal 级别的问题了。不是在问你"前端技术",而是在考察你是否具备系统设计思维

1. Scheduler(调度器)

操作系统 前端框架
────────────────────────────────────────
进程调度器 → React Scheduler(Lane Model)
时间片轮转 → 时间切片(Time Slicing)
抢占式调度 → 高优任务打断低优(Concurrent Mode)
上下文切换 → Fiber 中断恢复
优先权反转 → 在 React 中也有类似问题

React Scheduler 在用户空间实现了类似操作系统的调度:

React 的调度级别:
├── Sync(同步,立即执行)
├── UserBlocking(用户交互,256ms 内响应)
├── Normal(普通更新,5s 内响应)
├── Low(低优先级)
└── Idle(闲置时执行)

类似操作系统的进程优先级队列。

2. Resource Management(资源管理)

操作系统 浏览器/前端
────────────────────────────────────────
虚拟内存 → 虚拟 DOM(内存中的 UI 快照)
内存分页 → React Fiber 的链表结构(分片可交换)
垃圾回收 → V8 的分代 GC
内存池 → DOM 节点池(如 React 的 key 复用)
地址空间隔离 → Site Isolation 的进程隔离
概念操作系统前端
虚拟化进程虚拟地址空间Virtual DOM(UI 的虚拟表示)
Lazy loading虚拟内存的按需加载Code Splitting + Lazy
分页内存页Chunk(代码块)
缓存CPU Cache → L1/L2/L3CDN → Service Worker → Memory Cache

3. Async Orchestration(异步编排)

操作系统 前端
────────────────────────────────────────
中断处理 → 事件循环(Event Loop)
信号量 → Promise/Semaphore
锁 → Mutex(如 shared memory 场景)
文件描述符 → WebSocket/SSE/Fetch 连接管理
管道通信 → MessageChannel / BroadcastChannel

4. Worker(工作线程)

操作系统 前端
────────────────────────────────────────
进程 → Web Worker(独立线程)
线程池 → Worker Pool 模式
进程间通信(IPC) → postMessage
轻量级线程 → Audio Worklet / OffscreenCanvas

5. Memory Pressure(内存压力处理)

操作系统在内存压力下会触发 OOM Killer。前端类似:

内存压力信号 → window.onmemorywarning(Chrome 实验 API)
主动释放 → CanvasRenderer.dispose()
降级策略 → 关闭动画 → 降低图片质量 → 卸载 Tab

6. Cache(缓存体系)

┌──────────────────────────────────────────────┐
│ 缓存体系(类似 CPU Cache) │
├──────────────────────────────────────────────┤
│ L0: Inline 数据(组件内部状态) │
│ L1: React Context / Zustand(应用状态) │
│ L2: Service Worker(离线缓存) │
│ L3: Memory Cache(浏览器内存缓存) │
│ L4: Disk Cache(磁盘缓存) │
│ L5: CDN(远端缓存,类比分布式文件系统) │
└──────────────────────────────────────────────┘

7. Streaming(流式处理)

类似`管道"ls | grep | sort":

SSR Streaming:
React 组件 → Suspense 边界 →
可以"边渲染边发送"而不是等全部完成

数据流:
fetch → ReadableStream → 逐块处理

8. Sandbox(沙箱)

操作系统 前端
────────────────────────────────────────
进程隔离 → iframe + Site Isolation
权限控制 → Permissions API
安全边界 → CSP + CORP + COEP
系统调用 → Web API(浏览器提供的能力)

V8 的沙箱就是最好的例子——JS 代码不能直接访问文件系统,必须通过浏览器的 Web API。

9. Capability Isolation(能力隔离)

之前的"能力安全"问题也对应操作系统的 capability-based security —— 每个进程只拥有完成任务所需的最小权限。

Web 中的能力隔离:
├── Storage Partition(存储分区,不同站点隔离数据)
├── Network Partition(网络分区,不同源隔离请求)
├── Permission Prompt(权限提示,用户控制敏感能力)
└── Fenced Frame(完全隔离的嵌入框架)

总结

前端进化路径:
jQuery(DOM 操作库)
→ React(UI 框架)
→ React + Scheduler(有调度能力的视图层)
→ React 18(用户空间调度器)
→ ??? → Browser-as-OS

核心趋势:
随着应用越来越复杂,前端必须在浏览器这个受限环境中
实现操作系统级别的资源管理、调度、隔离和保护机制。

这不是比喻,而是工程现实的映射


大厂风格题

字节/阿里/蚂蚁分别偏爱考察什么?

字节跳动:极致性能

字节的风格偏向 运行时性能、大数据量、渲染调度

典型题:"10 万节点如何秒开?"

回答框架:

1. 数据层优化
- 虚拟化(react-window / virtual-scroll):只渲染可见区域
- 数据分页 + 按需加载
- 不可变数据 + 共享结构

2. 渲染层优化
- 时间切片(Time Slicing):将渲染工作分散到多帧
- Web Worker 处理数据计算
- OffscreenCanvas 绘制大量图形

3. 架构层
- 编辑器场景(字节的核心场景)
- 协同 CRDT 算法
- 本地优先(Local-first)

4. 调度策略
- requestIdleCallback 做低级任务
- requestAnimationFrame 做渲染任务
- Concurrent Mode 拆分优先级

字节看重的是:面对极限场景时的解构能力和性能敏感度

阿里巴巴:工程化

阿里的风格偏向 中后台、微前端、SSR、稳定性

典型题:"如何保证大型前端系统可治理?"

回答框架:

1. 分层架构
- 应用层(业务代码)
- 框架层(统一的技术选型)
- 基建层(CI/CD、监控、部署)

2. 微前端治理
- 主应用 + 子应用的边界划分
- 公共依赖的去重与版本锁定(importmap / shared)
- 运行时沙箱隔离(JS sandbox + CSS scoped)

3. 可观测性
- 性能监控(FP、FCP、LCP、TTI)
- 错误监控(SourceMap + 聚合)
- 用户行为追踪

4. 灰度发布
- 流量灰度
- 功能开关(Feature Flag)
- 回滚策略

5. 代码治理
- Monorepo
- 依赖治理(depcheck、统一升级)
- 架构守护(dependency-cruiser 控制依赖方向)

阿里看重的是:大规模团队协作下的工程化能力和稳定性思维

蚂蚁集团:金融级稳定性

蚂蚁的风格偏向 沙箱、权限、动态化、低代码

典型题:"如何设计一个安全可扩展的动态页面引擎?"

回答框架:

1. 安全沙箱
- iframe + postMessage 通信(完全隔离)
- Proxy 劫持 + 白名单 API
- 资源消耗限制(CPU 时间、内存上限)

2. 动态渲染
- 组件注册中心(运行时加载组件)
- Schema-driven UI(JSON 驱动渲染)
- 动态表单、动态表格

3. 权限体系
- 组件级权限控制
- 数据级权限控制(行级、字段级)
- 操作审计(谁改了什么)

4. 扩展性设计
- 插件化架构(生命周期钩子)
- 自定义组件接入协议
- 事件总线(跨组件通信)

5. 回滚与容错
- 动态模块的版本管理
- 灰度加载机制
- 兜底降级 UI

蚂蚁看重的是:在安全约束下做动态化的设计能力和对金融场景的理解


P8/P9 常见开放题

"你觉得 React 最大的问题是什么?"—— 不是喷框架,而是 Tradeoff 意识

这道题不是在考察你能否列举 React 的缺点,而是考察:

  • 抽象理解:你能不能在更高维度理解框架的设计取舍
  • Tradeoff 意识:你知道每个设计选择都有代价,而不是"XXX 就是好"
  • 系统设计能力:你能不能设计出一个更好的方案

高水平的回答方向

核心问题:React 的运行时开销来自 pull 模型

React 选择了 pull 模型(从根开始渲染,diff 找出变化),这带来了:

  1. 无谓的 diff:大部分情况下子组件没有变化,但仍然需要运行组件函数和 diff
  2. 需要手动优化:React.memo、useMemo、useCallback 都是补丁,不是原生能力
  3. React Forget 的承诺:编译器自动 memo 可以解决,但落地难度大

与 SolidJS 的 push 模型对比:

// React(pull):父组件更新 → 子组件全部重新执行 → diff
<Parent>
<ChildA /> // 每次 Parent 更新,ChildA 都重新执行
<ChildB /> // 即使 ChildB 没有变化
</Parent>

// SolidJS(push):数据变化 → 精确更新依赖者
<Parent>
<ChildA /> // 只有 ChildA 依赖的数据变化时才更新
<ChildB /> // 完全不受影响
</Parent>

第二个问题:JSX 在组件化和灵活性之间的张力

JSX 太大的灵活度让构建工具很难做静态优化

// 动态 JSX — 编译器无法优化
const Comp = condition ? ComponentA : ComponentB;
return <Comp />;

// 对比 Svelte 的模板 — 编译时已知结构
{#if condition}
<ComponentA />
{:else}
<ComponentB />
{/if}

第三个问题:状态管理没有标准答案

useState → useReducer → useContext → 第三方状态库 → 每一层都有复杂度。

应该避免的回答

  • ❌ "React 太大了"(SPA 框架都需要这个规模)
  • ❌ "JSX 很丑"(审美偏好,不是技术问题)
  • ❌ "Hooks 学习曲线陡"(这是个人感受,不是架构问题)

回答示例

React 最大的问题不在于它有什么缺点,而在于 pull 模型带来的"精确性"与"性能"之间的根本矛盾。

为了保持一致性(没有 tearing、可中断渲染),React 必须从根开始渲染。这意味着即使只有一个状态变化,整个组件树都可能被重新执行。React.memo 等优化手段本质上是开发者手动告诉 React"这里可以优化",这其实是运行时不够聪明的体现。

React Forget 试图在编译时解决这个问题,但如果编译器的路径分析不能覆盖所有动态场景,那么运行时的手动优化仍然不可避免。

从架构权衡的角度看,React 选择了可预测性 > 极致性能。这个选择让 React 在大规模应用中的行为更可预测,但也导致了性能优化的心智负担。

"你会如何设计下一代前端框架?"—— Compiler-First、Signal、Resumability 到 AI-Friendly AST

这个问题的回答框架要体现:理解当前框架的瓶颈 + 提出系统性解决方案 + 前瞻性思考

1. Compiler-First(编译器优先)

问题:当前框架在运行时做了太多工作(Virtual DOM diff、hook 调度、effect 管理等)

方案:尽可能把工作提前到编译时

编译时做的事:
├── 依赖分析 → 自动 memo,不需要 useMemo
├── 模板优化 → 静态部分预编译,只留动态 hole
├── 代码分割 → 自动按路由/组件分割
└── 类型推导 → 从 JSX 自动生成类型

灵感来源:Svelte、SolidJS Compiler、React Forget

2. Signal/Reactivity(信号/响应式)

问题:pull 模型需要全量 diff

方案:使用 Signal 做细粒度的依赖追踪,但通过快照机制保持一致性:

// 设计思路:Signal + Snapshot
const [count, setCount] = createSignal(0);

// 在 render 时固定快照
effect(() => {
const snapshot = count.snapshot(); // 冻结当前值
// 每次 count 变化,这个 effect 重新运行
// 但运行期间 count 不会变(防 tearing)
});

3. Resumability(可恢复性)

问题:当前 SSR + Hydration 的代价是客户端激活时必须重跑组件代码来"接手"服务端渲染的 DOM。

方案可恢复 UI(灵感:Qwik)

传统 Hydration:
服务端:渲染 HTML + JS
客户端:下载 JS → 执行所有组件的 hydration → 接管 DOM
❌ 即使没有交互,也要重跑所有逻辑

可恢复 UI:
服务端:渲染 HTML + 序列化事件处理器的位置
客户端:不需要 hydration,事件绑定即可
第一次交互时:按需加载交互组件的 JS
✅ 零 JS 开销直到真的需要交互

4. Partial Hydration(部分激活)

问题:整个页面要么全激活(SPA),要么全不激活(纯 SSR)

方案:选择性地激活页面中的交互部分(灵感:Astro、Islands)

┌──────────────────────────────┐
│ 静态 HTML(不需要 JS) │
│ ┌──────────────────────┐ │
│ │ 交互组件(Island) │ ← 只给这部分加 JS
│ │ 只在浏览器中激活 │ │
│ └──────────────────────┘ │
│ 静态 HTML(不需要 JS) │
└──────────────────────────────┘

5. Server-Centric(以服务端为中心的架构)

问题:前端框架经历了"服务端渲染一切 → 客户端渲染一切 → 又回到服务端"的摇摆。

方案默认服务端,客户端只做增强(灵感:React Server Components、HTMX)

数据获取 → 服务端组件负责
交互逻辑 → 客户端组件负责
路由导航 → 服务端主导(RSC 模式)
表单提交 → 服务端处理

6. Edge-Native(边缘原生)

问题:渲染位置固定(要么服务端,要么客户端)

方案:渲染位置可编程

渲染策略:
├── 静态页面 → CDN(构建时生成)
├── 个性化页面 → Edge(如 Vercel Edge Functions)
├── 动态内容 → 服务端
└── 交互 UI → 客户端

在 Edge 上做流式渲染,减少 TTFB 和 FCP。

7. AI-Friendly AST

问题:当前框架的模板/JSX 语法对 AI 代码生成不友好。AI 容易生成不一致的代码。

方案:设计对 AI 友好的抽象语法树

AI-Friendly 特性:
├── Schema-driven——UI 由 JSON Schema 定义
├── 确定性结构——组件接口明确且稳定
├── 类型全推导——AI 生成的代码类型自检
├── 可验证——生成后立即验证(TypeScript + Runtime Check)
└── 可安全合并——AI 生成代码可被精确 merge 到现有 codebase

总结:一个框架设计的核心取舍

下一代框架 =
Compiler-First(性能)+
Signal(细粒度)+
Resumable(零 JS 开销)+
Server-Centric(架构合理)+
Edge-Native(部署灵活)+
AI-Friendly(时代前瞻)

但最重要的是:知道在 Consistency(一致性)和 Performance(性能)之间选择什么。
因为所有框架问题归根结底都是这个 tradeoff。