02-响应式系统全景图
本章目标:建立响应式系统的整体认知,理解核心设计哲学,为后续深入各模块打好基础
1.1 数据驱动视图的本质追问
在日常Vue开发中,我们习惯了这样的代码模式:
const count = ref(0)
// 修改数据
count.value++
// 视图自动更新,无需手动操作DOM
这段代码看起来理所当然,但如果停下来想一想:Vue是如何"知道"count变了?它又是如何精确地只更新依赖count的那部分视图?
要回答这些问题,我们需要先理解"数据驱动视图"这个概念的演进历程。
从手动DOM操作到声明式编程
在jQuery时代,更新视图需要手动操作DOM:
// jQuery时代:命令式编程
let count = 0
$('#increment').click(function() {
count++
$('#count').text(count) // 手动更新DOM
})
这种方式的问题很明显:数据和视图的同步完全依赖开发者手动维护。当应用变得复杂,数据之间存在依赖关系时,很容易遗漏某个更新,导致视图与数据不一致。
现代前端框架的核心理念是声明式编程——开发者只需要描述"数据是什么"和"视图应该是什么样子",框架负责保持两者同步:
// Vue:声明式编程
const count = ref(0)
// 模板声明了视图与数据的关系
// <div>{{ count }}</div>
// 只需要修改数据,视图自动同步
count.value++
这种"数据变化→视图自动更新"的能力,正是响应式系统要解决的核心问题。
不同框架的技术选择
实现"数据驱动视图"有多种技术方案,不同框架做出了不同的选择:
| 框架 | 技术方案 | 特点 |
|---|---|---|
| React | 显式setState + Virtual DOM diff | 需要手动触发更新,通过diff找出变化 |
| Vue | Proxy自动追踪 + 精确更新 | 自动检测变化,精确知道哪些依赖需要更新 |
| Solid | 细粒度信号(Signals) | 编译时优化,无Virtual DOM |
| Svelte | 编译时响应式 | 编译为命令式代码,运行时开销小 |
Vue选择了基于Proxy的自动依赖追踪方案。这意味着开发者可以像操作普通JavaScript对象一样操作响应式数据,框架会自动追踪哪些地方使用了这个数据,并在数据变化时精确通知这些地方更新。
这种"透明"的开发体验是Vue的设计哲学之一:渐进式、低侵入。你不需要学习特殊的API来触发更新,只需要修改数据本身。
1.2 Vue响应式系统架构总览
在深入细节之前,我们需要先建立一张"全局地图"。Vue的响应式系统可以分为三个层次:
graph TB
subgraph "数据层"
reactive["reactive()"]
ref["ref()"]
computed["computed()"]
end
subgraph "追踪层"
targetMap["targetMap<br/>WeakMap<target, Map<key, Dep>>"]
Dep["Dep 依赖容器"]
Link["Link 双向链表节点"]
end
subgraph "执行层"
Effect["ReactiveEffect"]
Scope["EffectScope"]
watch["watch()"]
end
reactive --> |"Proxy get/set"| targetMap
ref --> |"getter/setter"| Dep
computed --> |"既是Dep也是Subscriber"| Dep
targetMap --> Dep
Dep --> |"track()"| Link
Link --> |"subscribe"| Effect
Effect --> |"trigger()"| Dep
Scope --> |"批量管理"| Effect
watch --> |"基于"| Effect
让我们逐层理解:
数据层负责创建响应式数据:
reactive()将普通对象转换为响应式对象,通过Proxy拦截读写操作ref()将任意值(包括原始类型)包装为响应式引用computed()创建派生数据,依赖其他响应式数据自动计算
追踪层负责管理依赖关系:
targetMap是一个全局的WeakMap,存储"哪个对象的哪个属性被哪些副作用依赖"Dep是依赖容器,每个响应式属性对应一个DepLink是连接Dep和订阅者的节点,采用双向链表结构
执行层负责副作用的调度与执行:
ReactiveEffect封装副作用函数,管理依赖收集和触发EffectScope批量管理多个effect的生命周期watch()基于ReactiveEffect实现数据监听
这三层协作的核心流程是:
- 副作用函数执行时,访问响应式数据
- 响应式数据通过track()收集这个副作用作为依赖
- 数据变化时,通过trigger()通知所有依赖的副作用重新执行
这个"收集依赖→触发更新"的循环,就是响应式系统的心跳。
1.3 核心概念四元组
理解响应式系统,需要先掌握四个核心概念。它们就像四个齿轮,相互咬合,驱动整个系统运转:
| 概念 | 定义 | 对应实现 |
|---|---|---|
| 响应式数据 | 被代理的数据源,访问和修改都会被拦截 | reactive()创建的Proxy,ref()创建的RefImpl |
| 副作用函数 | 依赖响应式数据的函数,数据变化时需要重新执行 | ReactiveEffect包装的函数 |
| 依赖收集 | 建立数据与副作用的关联,记录"谁依赖了什么" | track()函数,通过Link建立关联 |
| 触发更新 | 数据变化时通知所有依赖方重新执行 | trigger()函数,遍历Dep的订阅者 |
这四个概念形成了一个闭环:
graph LR
A["副作用函数执行"] --> B["访问响应式数据"]
B --> C["track() 收集依赖"]
C --> D["建立 Effect ↔ Dep 关联"]
D --> E["数据变化"]
E --> F["trigger() 触发更新"]
F --> A
让我们用一个具体例子来理解这个循环:
const state = reactive({ count: 0 })
// 这个函数就是"副作用函数"
watchEffect(() => {
console.log(state.count) // 访问响应式数据,触发依赖收集
})
// 修改数据,触发更新,副作用函数重新执行
state.count++
当watchEffect的回调执行时,访问state.count会触发Proxy的get trap,进而调用track()将当前副作用记录为count属性的依赖。当state.count++执行时,触发set trap,调用trigger()通知所有依赖count的副作用重新执行。
这个过程中有几个关键的数据结构:
- targetMap:一个全局的WeakMap,结构为
WeakMap<target, Map<key, Dep>>,存储所有响应式对象的依赖关系 - Dep:依赖容器,每个响应式属性对应一个Dep,内部维护订阅者链表
- Link:连接Dep和订阅者的节点,采用双向链表结构,支持高效的添加和删除
// 文件: packages/reactivity/src/dep.ts
// targetMap的结构示意
targetMap: WeakMap<target, KeyToDepMap>
└── KeyToDepMap: Map<key, Dep>
└── Dep: { subs: Link, version: number, ... }
为什么使用WeakMap作为最外层?因为它的键是弱引用——当target对象不再被使用时,可以被垃圾回收,相关的依赖信息也会自动清理,避免内存泄漏。
1.4 从Vue 2到Vue 3的技术演进
Vue 2使用Object.defineProperty实现响应式,这是当时浏览器兼容性最好的方案。但它有几个众所周知的限制:
- 无法检测对象属性的添加和删除:必须使用
Vue.set()和Vue.delete() - 无法直接监听数组索引变化:需要重写数组方法
- 初始化时递归遍历:对象越大,初始化越慢
Vue 3选择了ES6的Proxy,彻底解决了这些问题:
// Vue 2: Object.defineProperty
// 需要递归遍历对象的每个属性
function defineReactive(obj, key) {
let value = obj[key]
Object.defineProperty(obj, key, {
get() {
track(obj, key) // 收集依赖
return value
},
set(newVal) {
value = newVal
trigger(obj, key) // 触发更新
}
})
}
// Vue 3: Proxy
// 一次代理整个对象,按需递归
new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
const res = Reflect.get(target, key, receiver)
// 访问时才递归代理嵌套对象(懒代理)
if (isObject(res)) return reactive(res)
return res
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
trigger(target, key)
return result
}
})
Proxy的优势不仅在于功能更完整,还在于性能更好——它是"懒代理"的,只有访问到的属性才会被代理,而不是初始化时递归遍历整个对象。
Vue 3还引入了一套标记系统来识别响应式对象的状态:
// 文件: packages/reactivity/src/constants.ts
export enum ReactiveFlags {
SKIP = '__v_skip', // 标记对象跳过响应式转换
IS_REACTIVE = '__v_isReactive', // 是否是reactive代理
IS_READONLY = '__v_isReadonly', // 是否是readonly代理
IS_SHALLOW = '__v_isShallow', // 是否是浅层代理
RAW = '__v_raw', // 获取原始对象
IS_REF = '__v_isRef', // 是否是ref
}
这些标记通过Proxy的get trap返回,不会真正存储在对象上,既节省内存又保持了对象的纯净。
本章涉及源码:
packages/reactivity/src/index.ts- API导出入口packages/reactivity/src/constants.ts- ReactiveFlags、TrackOpTypes、TriggerOpTypes常量定义packages/reactivity/src/dep.ts- targetMap、Dep、Link的定义
至此,我们已经建立了响应式系统的全局视角——从设计哲学到核心概念,从数据结构到技术演进。但这些都还停留在"是什么"的层面,真正有趣的问题是"怎么做"。当你写下reactive(obj)的那一刻,Vue内部究竟发生了什么?Proxy是如何一步步"劫持"对象的读写操作的?下一章,我们将深入reactive的实现细节,揭开数据代理的神秘面纱。
🤔 深度思考
-
设计权衡:Vue选择"自动依赖追踪"而非React的"显式setState",这种设计带来了哪些优势?在什么场景下可能成为性能瓶颈?
-
架构解耦:为什么Vue 3的响应式系统被设计为独立的
@vue/reactivity包?这种解耦设计对于非Vue项目有什么意义? -
技术选型:如果让你设计一个响应式系统,除了Proxy和defineProperty,还有哪些可能的技术方案?它们各自的权衡是什么?
-
内存管理:targetMap使用WeakMap而不是普通Map,这对内存管理有什么影响?如果使用Map会导致什么问题?