一、为什么需要脏标记
你正在做一个游戏引擎,场景图有 1000 个节点,每个节点有局部坐标和世界坐标。当父节点移动时,所有子孙节点的世界坐标都需要重算。最朴素的做法是:每次 setPosition() 都递归更新整棵子树的世界坐标。
问题来了:一帧里父节点可能被移动 5 次(动画系统改一次、物理引擎改一次、脚本改一次……),每次都触发子树重算。如果子树有 500 个节点,一帧就做了 2500 次矩阵乘法,而实际渲染时只需要最终位置。更糟糕的是,如果本帧不渲染(窗口被最小化),这些计算全白做了。
脏标记的核心思路:变更时只设一个布尔标记,重算推迟到值被实际读取时。三次 setPosition() 只设三次标记,一次 getWorldPosition() 才真正计算一次。写入频繁、读取稀疏的场景下,这个策略能把计算量降到原来的几十分之一。
二、现实类比
酒店房间门上的「需要打扫」标牌。客房服务只进标记为脏的房间。如果客人没用过房间,标牌保持干净,客房服务直接跳过——零浪费。客人退房时标记脏了,但打扫不是立刻发生,而是等到客房服务下次巡楼时才执行。
三、核心思想
脏标记模式通过追踪派生状态是否过期来避免冗余计算。当源值改变时,不立即重新计算所有依赖值,而只是设置一个「脏」标记。昂贵的重计算仅在派生值被实际请求时才发生,重算后清除标记。这以每次读取时的布尔检查代价,换取可能永远不需要的昂贵计算。
时间线上看,set(x) set(y) set(z) 连续三次变更只标记脏一次,get() 时才重算一次。如果没有脏标记,每次 set() 都要重算,三次变更就是三次重算。
| 属性 | 值 |
|---|---|
| 变更代价 | O(1) 仅设置布尔标记 |
| 读取代价(干净) | O(1) 返回缓存值 |
| 读取代价(脏) | O(recompute) 计算 + 缓存 + 清除标记 |
| 空间 | 每个追踪值 O(1) 一个布尔标记 |
四、变体与对比
| 模式 | 关系 | 区别 |
|---|---|---|
| 观察者(Observer) | 观察者在状态变更时立即通知 | 脏标记将反应延迟到需要时 |
| 依赖图(Dependency Graph) | 脏传播沿依赖图边标记下游节点 | 依赖图管全局顺序,脏标记管局部延迟 |
| 位掩码(Bitmask) | 脏标记作为位掩码中的位高效存储 | 位掩码是存储优化,脏标记是延迟策略 |
| 双缓冲(Double Buffering) | 脏标记追踪哪个缓冲区已更改 | 双缓冲管显示切换,脏标记管计算时机 |
一个重要的组合模式:React 的 Fiber 架构用位掩码实现多种脏标记。一个 Fiber 节点可能需要 Placement(新 DOM 节点)、Update(props 变更)、Deletion 等多种操作,每个位是针对特定关注点的独立脏标记。单个布尔值只能说「有东西变了」,位掩码能编码「什么变了」,让 commit 阶段可以分别处理每种工作。
五、多语言实现
5.1 Go 实现
package dirtyflag
// DirtyFlag 延迟计算包装器:只在脏时重算type DirtyFlag[T any] struct { dirty bool cached T compute func() T}
// New 创建一个脏标记包装器,初始为脏func New[T any](compute func() T) *DirtyFlag[T] { return &DirtyFlag[T]{dirty: true, compute: compute}}
// MarkDirty 标记为脏,下次 Get() 时重算func (d *DirtyFlag[T]) MarkDirty() { d.dirty = true}
// Get 获取值,脏时重算并缓存func (d *DirtyFlag[T]) Get() T { if d.dirty { d.cached = d.compute() d.dirty = false } return d.cached}
// IsDirty 返回当前是否为脏func (d *DirtyFlag[T]) IsDirty() bool { return d.dirty}变换层级中的脏传播示例:
// TransformNode 带脏标记世界坐标的场景节点type TransformNode struct { localX, localY int worldX, worldY int worldDirty bool children []*TransformNode parent *TransformNode}
func (n *TransformNode) SetPosition(x, y int) { n.localX, n.localY = x, y n.markWorldDirty() // 级联标记子孙节点}
func (n *TransformNode) GetWorldPosition() (int, int) { if n.worldDirty { if n.parent != nil { px, py := n.parent.GetWorldPosition() n.worldX, n.worldY = px+n.localX, py+n.localY } else { n.worldX, n.worldY = n.localX, n.localY } n.worldDirty = false } return n.worldX, n.worldY}
func (n *TransformNode) markWorldDirty() { n.worldDirty = true for _, child := range n.children { child.markWorldDirty() }}5.2 TypeScript 实现
// 脏标记延迟计算包装器class DirtyFlag<T> { private dirty = true; private cached: T | undefined;
constructor(private compute: () => T) {}
// 标记为脏,下次 get() 时重算 markDirty(): void { this.dirty = true; }
// 获取值,脏时重算并缓存 get(): T { if (this.dirty) { this.cached = this.compute(); this.dirty = false; } return this.cached!; }
get isDirty(): boolean { return this.dirty; }}场景图 1000 个节点,根节点移动,所有子孙变脏。但本帧只渲染 3 个节点——只会发生 3 次重算(加上祖先节点的缓存命中)。标记 1000 个节点为脏只是翻转 1000 个布尔值,代价极低。关键洞察:脏标记的开销与被读取的节点数成正比,而非与被标脏的节点数成正比。
六、生产验证
- Chromium/Blink — layout_object.h#L1425-L1430 中
NeedsLayout()返回布局对象的几何是否脏。CSS 属性变更时SetNeedsLayout()将节点及祖先标记为脏,布局计算仅在下一个布局阶段执行——将数百次 DOM 变更批处理为单次布局计算。 - React — ReactFiberFlags.js#L18-L22 中 Fiber 标志如
Placement、Update、Deletion是 fiber 节点上的脏标记。状态变更时 fiber 被标记,commit 阶段仅处理具有非零标志的 fiber,完全跳过未变化的子树。 - Unity Engine —
Transform.hasChanged标记延迟世界矩阵重计算,是游戏引擎中最广泛使用的脏标记实现。
七、小结
何时使用:
- UI 布局引擎——样式变更时标记节点为脏,批量执行布局计算
- 游戏场景图——脏世界变换从父节点级联到子节点,仅在渲染时重计算
- 电子表格单元格——输入变化时标记依赖单元格为脏,显示时重计算
- 构建系统——源文件变化时标记目标为脏,仅重建需要的部分
何时不用:
- 重计算成本低——如果计算只需纳秒级,标记检查反而增加了无益的开销
- 每次变更都需要结果——每次写入后都要读取时,只是在每个操作上增加了标记检查
- 无同步的并发访问——脏标记本质上是可变共享状态,并发读写需要锁或原子操作
八、参考资料
- Chromium LayoutObject - 浏览器布局引擎的脏标记实现
- React Fiber Flags - React 协调器的位掩码脏标记
- Unity Transform.hasChanged - 游戏引擎世界矩阵延迟计算
- Qt QWidget::update() - Qt 框架的脏区域标记与延迟绘制
- Game Programming Patterns - Dirty Flag - Robert Nystrom 对脏标记模式的深入讲解
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






