前端专题
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;
工作流程:
- 每次更新时,React 克隆 current 树创建 workInProgress 树(复用 fiber 节点)
- 在 workInProgress 树上执行 reconciliation(可中断)
- 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 是在异步调度中执行的:
- 在 layout 阶段,React 收集需要执行 useEffect 的 Fiber 节点
- React 调度一个异步任务(使用 scheduler postTask 或 MessageChannel)
- 在下一个事件循环的宏任务中执行 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.getElementById、element.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 树,从头重新渲染。这会导致:
- 完全失去 SSR 的性能优势
- 页面闪现(服务端内容 → 客户端重新渲染)
- 在极端情况下可能破坏表单状态或滚动位置
<!-- 服务端 -->
<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:
- 组件不经 React state 管理输入值,而是通过 ref 直接从 DOM 读取
- 只在提交或需要验证时从 DOM 取数
- 避免了每次按键触发的 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 共享同一个渲染进程。恶意站点可以:
- 在 iframe 中加载受害者站点
- 利用 Spectre 风格的侧信道攻击(利用 CPU 分支预测的缓存时间差异)
- 读取受害者站点的数据
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 - 检查微任务队列:执行
console.log(2),console.log(3)→ 队列清空 - 调度
setTimeout(4)作为下一个宏任务 - 输出
4
第三层:浏览器什么时候真正 render
浏览器不需要每帧都渲染。渲染时机由以下因素决定:
- VSync 信号:通常 60Hz 显示器每 16.6ms 一个垂直同步信号
- 渲染管道触发:在宏任务事件循环的末尾、下一帧开始前,浏览器决定是否渲染
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 的更高优先级是一个历史遗留设计,官方建议尽量用 queueMicrotask 或 Promise 而不是 nextTick。
第五层:为什么 while(Promise.resolve().then(...)) 会导致页面"假死"
while (true) {
Promise.resolve().then(() => {});
}
这不只是一个"死循环"问题。关键在于:
Promise.resolve().then(fn)将fn加入微任务队列- 当前宏任务结束后,浏览器开始执行微任务队列
- 执行
fn(空函数)→ 很快完成 →while判断继续 → 又一个.then() - 微任务队列**永远不会清空** → 浏览器永远无法进入下一个渲染帧
- 页面从用户视角看 → 假死(不响应点击、滚动、重绘)
这与 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') 中:
- 第一个参数
1→number - 第二个参数
'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
核心机制:
- T 被匹配到
(...args: any[]) => infer R模式 - TypeScript 计算出 R 应该是什么
- 返回 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
- 跨浏览器兼容:不同浏览器的事件 API 有差异(IE 的
event.srcElementvs 标准的event.target),SyntheticEvent 抹平了这些差异 - 性能优化:通过事件委托减少事件监听器的注册数量
- 与 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 改到了 rootNode(createRoot 的挂载节点):
React 16:
document → 事件委托在这里
└── root
└── app content
React 17:
document
└── root → 事件委托在这里
└── app content
原因:
- 嵌套 React 应用:多个 React 应用嵌套时,document 级别的事件监听会互相干扰
- 与微前端兼容:微前端架构中不同子应用可能有独立的 React 实例,各管各的根节点更安全
- 避免全局冲突:不污染 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 移除了事件池,原因:
- 不符合直觉:开发者经常踩
e.persist()的坑 - 性能收益有限:现代浏览器已经很快,事件对象创建的开销可以忽略
- 简化代码:移除 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
为什么需要预构建:
- CommonJS → ESM 转换:很多依赖是 CJS 格式(
module.exports),浏览器不能直接识别,需要转换成 ES Module - 依赖合并:lodash 有上百个文件,预构建将它们合并成一个文件,避免浏览器发上百个 HTTP 请求
- 预编译优化: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,因为:
- Tree Shaking 更彻底:Rollup 基于 ES Module 静态分析,能准确移除未使用的代码
- 代码分割(Code Splitting)更精细:支持动态 import、manual chunks
- 插件生态:Rollup 有成熟的插件生态(特别是 CSS 处理、资源处理)
- 输出格式:支持多种输出格式(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 还没有被浏览器原生支持的时代:
- 打包逻辑:需要把一切模块包裹进 webpack runtime(
__webpack_require__等) - 需要 loader 处理一切:CSS、图片、字体都需要 loader → 没有 loader 就报错
- 配置驱动:高度可配置但学习曲线陡峭
- HMR 维护复杂:需要维护模块依赖图、模块代理等
第八层:Module Federation 的真正难点
Module Federation(模块联邦)是 webpack 5 引入的运行时跨应用加载模块方案。它的难点:
- 共享依赖的版本冲突:两个应用都用了 React,版本不同怎么办?—— 需要通过
shared配置解决 - 异步加载时序:远程模块必须在运行时加载完成,路由切换时要等待
- TypeScript 类型共享:远程模块的 TS 类型需要跨项目同步
- 开发调试体验:远程模块在另一个项目,断点调试困难
- 安全策略:远程模块的加载需要 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,但 count 被 increment 修改,而 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 的优势被放大:
- 全局上下文:AI 能看到整个项目的代码组织、共享类型、公共工具函数
- 约束统一:统一的 ESLint、TypeScript 配置在仓库级别生效
- 跨项目重构:AI 可以同时修改多个包(配合代码库级别的 refactoring)
- 依赖锁定:减少"不同项目版本不同"导致的幻觉概率
第七: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 代码生成:
每次修改 → 熵增 + 熵减操作 → 维持代码健康
熵减操作包括:
- Code Review 聚焦架构一致性 —— 不是看对错,而是看是否与现有模式一致
- Constantly Refactor —— 用 AI 发现重复,用 AI 合并重复
- 建立显式 Contract —— 明确的接口契约,限制 AI 的行为空间
- 自动化验证 —— 类型检查、Lint、测试、架构检测(如 dependency-cruiser)
- 上下文分层 —— 不让一个 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/L3 | CDN → 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 找出变化),这带来了:
- 无谓的 diff:大部分情况下子组件没有变化,但仍然需要运行组件函数和 diff
- 需要手动优化:React.memo、useMemo、useCallback 都是补丁,不是原生能力
- 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。