无论是Vue、React还是现在前端各种状态管理库,核心都绕不开「响应式」——你改了数据,视图自动更新,不用你手动操作DOM。很多新手刚学的时候都会好奇:我就改了个JS变量,浏览器怎么知道要刷新视图?今天我们从最基础的原理开始,一步步讲透响应式的核心逻辑:数据变了,视图到底是怎么知道的?
一、响应式的核心本质:「依赖收集」+「触发更新」
不管是Vue2的Object.defineProperty还是Vue3的Proxy,甚至React的useState,响应式的核心逻辑其实就两步,非常好记:
依赖收集:哪个视图要用这个数据,就把这个视图的更新方法记下来
触发更新:数据一变,就把之前记下来的更新方法拿出来全部执行一遍,刷新视图
说白了就是「登记+通知」:你要用数据先登记好,我数据变了挨个叫你们来更新。我们从一个最简单的模拟实现入手,就能把这个逻辑讲清楚。
二、从零实现一个极简响应式:看懂核心逻辑
我们先不扯框架,就实现一个最基础的效果:修改数据,自动更新DOM,看看整个流程是怎么走通的。
第一步:定义一个存依赖的“笔记本”
首先我们需要一个地方存收集到的依赖,也就是谁要用我的数据,我记在小本本上:
javascript
// 全局依赖收集器:存每个数据对应的更新函数
class Dep {
constructor() {
// 存所有依赖的更新函数
this.subscribers = new Set();
}
// 把当前要执行的更新函数加进来(依赖收集)
depend() {
if (currentUpdateFn) {
this.subscribers.add(currentUpdateFn);
}
}
// 数据变了,挨个通知更新(触发更新)
notify() {
this.subscribers.forEach(fn => fn());
}
}
// 全局变量:当前正在要注册的更新函数,收集依赖的时候用
let currentUpdateFn = null;
这个Dep就是我们说的“小本本”,核心功能就是两个:收依赖、发通知。
第二步:把普通数据变成“可观测”的
我们要让数据变的时候能被检测到,就得给数据做个“包装”,当有人改数据的时候,我们能收到通知,这一步叫「数据劫持」。我们先拿最简单的Object.defineProperty来做演示:
javascript
// 把对象的属性变成响应式
function defineReactive(obj, key) {
// 每个属性都有一个自己的Dep,存这个属性对应依赖
const dep = new Dep();
// 存当前属性的值
let value = obj[key];
// 劫持属性的get和set
Object.defineProperty(obj, key, {
get() {
// 有人读这个属性的时候,收集当前的更新函数到依赖里
dep.depend();
return value;
},
set(newValue) {
// 改数据的时候,如果值变了,就更新值,然后通知所有依赖更新
if (newValue !== value) {
value = newValue;
dep.notify();
}
}
})
}
// 把整个对象变成响应式
function reactive(obj) {
for (let key in obj) {
defineReactive(obj, key);
}
return obj;
}
这里有个关键细节:每个响应式属性都有自己独立的Dep——只有用到这个属性的视图,才会在这个属性的Dep里登记,改这个属性的时候,只会通知用到它的视图更新,不会瞎通知所有视图,节省性能。
第三步:注册更新函数,完成依赖收集
现在数据已经变成可观测的了,我们还要把视图的更新函数注册进去:
javascript
// 注册一个副作用函数(更新视图的函数)
function effect(updateFn) {
// 先把当前更新函数存到全局,让get的时候能拿到
currentUpdateFn = updateFn;
// 执行一次更新函数,执行过程中会用到响应式数据,触发get,完成依赖收集
updateFn();
// 收集完了,清空全局,准备下一次收集
currentUpdateFn = null;
}
我们来测试一下,整个流程走通:
html
<!-- 我们要更新的DOM -->
<div id="app"></div>
<script>
// 定义一个响应式数据
const data = reactive({ count: 0 });
// 注册更新函数:数据变了就更新DOM
effect(() => {
document.getElementById('app').innerText = `当前计数:${data.count}`;
});
// 过两秒修改数据,看看会不会自动更新
setTimeout(() => {
data.count = 1;
console.log('数据改了,视图自动更新了!');
}, 2000);
</script>
打开页面你会看到,初始显示当前计数:0,两秒后自动变成当前计数:1——我们就只用改数据,视图自动更新了,一个最基础的响应式就跑通了!
整个流程我们捋一遍,你就懂了:
调用effect注册更新函数,把更新函数存到全局,然后执行更新函数
执行更新函数的时候,用到了data.count,触发get,把当前的更新函数加到data.count的Dep里,完成依赖收集
我们修改data.count,触发set,发现值变了,就调用Dep.notify,把之前收集的更新函数拿出来执行一遍
更新函数重新执行,把新的值渲染到DOM,视图更新完成
整个过程就是这么简单,核心就是「谁读数据我存谁,谁改数据我通知谁」。
三、Vue2和Vue3的响应式差异:为什么Vue3要换Proxy?
知道了核心原理,我们再看框架的实现,其实就是在这个基础上加了更多细节,Vue2和Vue3最核心的差异就是数据劫持的方式不一样:
Vue2:Object.defineProperty 的坑
Vue2用的就是我们上面写的Object.defineProperty,这个方案虽然能用,但是有两个天生的坑:
监听不到对象属性的新增和删除:Object.defineProperty只能劫持已经存在的属性,你给对象新增一个属性,这个属性一开始没被劫持,所以改了不会触发更新,Vue2才搞出个$setAPI来解决这个问题
监听不到数组的索引和长度修改:因为性能问题,Vue2一开始没有对数组的索引做劫持,所以你写arr = 123不能触发更新,也得用$set,而且push、pop这些原生数组方法也要做一层包装才能劫持
如果是深层嵌套对象,要递归遍历所有属性做劫持,初始化的时候性能开销比较大
所以Vue2里才有那么多“为什么我改了数据视图不更新”的问题,本质就是Object.defineProperty的天生限制。
Vue3:Proxy 解决了这些坑
Vue3换成了ES6的Proxy,直接代理整个对象,不需要遍历每个属性做劫持,天生解决了Vue2的问题:
Proxy代理的是整个对象,不管你新增属性还是删除属性,都能触发get/set,不需要额外的API
数组的索引修改、长度修改都能正常劫持,不用特殊处理
只有访问到深层属性的时候,才会对深层属性做代理,懒加载,初始化性能比递归劫持好很多
我们用Proxy重写一下defineReactive,就能看出区别:
javascript
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
const value = Reflect.get(target, key);
// 如果是对象,再做一层代理,实现懒劫持
if (typeof value === 'object' && value !== null) {
return reactive(value);
}
// 依赖收集和之前一样
dep.depend();
return value;
},
set(target, key, newValue) {
Reflect.set(target, key, newValue);
// 触发更新
dep.notify();
return true;
},
deleteProperty(target, key) {
delete target[key];
dep.notify();
return true;
}
})
}
可以看到,Proxy天生支持新增删除属性,懒加载深层对象,比Object.defineProperty好用太多,这就是为什么Vue3要换方案。
四、React的响应式:为什么和Vue不一样?
React没有用Vue这种“数据劫持+依赖收集”的响应式,而是用了另一种思路:状态改变 -> 重新渲染,核心原理其实是useState/useReducer触发的组件重渲染:
jsx
const [count, setCount] = useState(0);
// 你调用setCount修改数据,React就会自动让这个组件重新渲染,用新的count生成新的DOM,完成视图更新
React的思路其实更直接:我不做细粒度的依赖收集,你改了哪个状态,我就把这个状态对应的组件重新渲染一遍,虽然看起来比Vue的细粒度更新笨一点,但是逻辑简单,配合React的fiber调度,性能也不差。
现在React也有了useSyncExternalStore这些API,也能做细粒度的响应式更新,本质思路还是和我们之前说的一样:依赖收集+触发更新,只是把更新粒度从属性变成了组件。
五、常见的响应式问题解答
1. 为什么有时候改了数据,视图不更新?
无论是Vue还是React,本质都是没触发依赖更新:
Vue2:新增对象属性、直接改数组长度,没触发劫持,自然不会通知更新,用$set就好
Vue3:如果是响应式对象的解构,解构之后的变量变成普通值了,不是响应式的,改了不会触发更新
React:你直接改了state对象,没调用setState,React自然不知道你改了,不会重新渲染
2. 为什么不要在计算属性里修改数据?
计算属性本质是一个带缓存的副作用函数,计算属性里读数据会收集依赖,如果你在里面改数据,会触发当前的依赖更新,然后更新又会触发计算,循环递归,直接栈溢出。
3. 浅响应和深响应有什么区别?
Vue3里的shallowReactive就是浅响应,只有对象的第一层是响应式的,深层属性不会做代理,改深层属性不会触发更新,好处是性能快,适合大对象场景,reactive默认是深响应,所有层级都是响应式的。
六、总结
其实响应式说穿了就是一个非常简单的设计模式:观察者模式,核心逻辑就是三句话:
把数据包装成可观测的,能检测到什么时候被读、什么时候被改
读数据的时候,把用到这个数据的更新函数收集起来(依赖收集)
改数据的时候,把所有收集到的更新函数拿出来执行一遍(触发更新)
Vue2用Object.defineProperty做数据劫持,解决了响应式问题但是有新增属性、数组的坑;Vue3用Proxy解决了这些坑,性能更好;React换了个思路,用状态触发组件重渲染,本质都是实现「数据变了,视图自动更新」的效果。
现在你再改数据的时候,就知道视图到底是怎么知道数据变了——本质就是我们提前把依赖登记好,数据一变挨个发通知而已。