前言
Vue1、Vue3、SolidJS、Mobx 它们的数据响应式基本原理都是一样的,具体区别只是设计理念和实现方式不一样。按以前读书时代考试做题一样,它们是同一类型的题目,都是基于依赖收集和触发的运行时的数据响应式,如果说你只会解答其中一道题,其他的题却不会解答,则说明你并没有真正彻底掌握这一类题。
为什么标题说“基于依赖追踪的响应式系统”,因为在前端响应式的框架有很多,但他们的实现原理却各有不同。
在前端一般谈到响应式框架,可能大家都会不约而同地联想到 Vue,除了 Vue 之外,也许还有人会想到 React 以及 Svelte、SolidJS。他们都有一个共同点,都是通过数据驱动视图。他们在实现方式上又互相有一些相似之处,其中 Vue 和 React 都采用了虚拟DOM技术,Vue 和 SolidJS 的数据响应式实现则都是采用了依赖追踪的方式,所以在数据响应式的实现方面 Vue 和 SolidJS 最相似,而 Svelte 的实现方式则跟 Vue 和 React 都不一样,Svelte 是基于编译响应式。当然 Vue 和 React 的具体实现技术也是不一样的,但它们在宏观层面则是一样,都是通过数据驱动视图,当数据发生变化时,视图会重新渲染,这种机制使得开发者只需要关注数据的变化,而不需要手动操作 DOM。Vue 通过数据劫持,使得操作数据需要额外的 API ,系统变能感知数据的变化,而 React 和 SolidJS 则需要手动调用 API 去触发数据变化。
手动操作 DOM 的上古时代
例如我们现在有一个这样的需求,有一个按钮 <button>0</button>,当我们点击按钮的时候,按钮中的文本就进行加 1。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-9" />
<title>手动操作DOM</title>
</head>
<body>
<button id="btn"></button>
<script>
let count = 0
const btnEl = document.getElementById('btn')
btnEl.textContent = count
btnEl.addEventListener('click', function () {
count++
btnEl.textContent = count
})
</script>
<body>
</html>
上述的 button 按钮是通过 HTML 进行渲染的,我们还可以通过 JavaScript API 进行创建。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-9" />
<title>手动操作DOM</title>
</head>
<body>
- <button id="btn"></button>
<script>
let count = 0
- const btnEl = document.getElementById('button')
+ const btnEl = document.createElement('button')
+ const textNode = document.createTextNode(count)
+ btnEl.appendChild(textNode)
btnEl.addEventListener('click', function () {
count++
btnEl.textContent = count
})
+ document.body.appendChild(btnEl)
</script>
<body>
</html>
上述这种方式,在项目应用非常庞大的时候,开发效率是非常低下的,同时维护成本却又非常高的,所以就出现了像 React、Vue 这种通过数据进行驱动视图的前端框架。
通过数据驱动视图
虽然 Vue 和 React 在具体的实现技术方案上差异是非常大的,但在宏观层面它们则是一样的,都是通过操作数据来驱动视图,开发者只需要关注数据的变化,而不需要手动操作 DOM,同时它们都是通过虚拟DOM 和 Diff 算法进行实现响应式的,当组件的状态发生改变时,Vue 和 React 都会重新生成新的虚拟 DOM,并通过 Diff 算法比较新老虚拟 DOM 的差异,然后只更新需要更新的部分到真实 DOM 上,从而提高性能。
不管是 Vue 还是 React 的虚拟DOM 本质上都是一个对象,上面记录着一些真实 DOM 的信息,比如 type、props、children,我们这里简单模拟一下,并通过虚拟DOM 和 Diff 算法来改写上面的例子。
首先我们通过一个 createElement 的函数来创建一个节点的虚拟DOM对象,这里跟 React 的对齐,children 节点在 props 中,Vue 的 children 是跟 props 同级的,这些差别对我们进行宏观研究不重要。
function createElement(type, props) {
return {
type,
props
}
}
接着我们创建一个函数组件 App。
count = 0
function App (){
return createElement('button', {
onClick: () => {
count ++
setCount(count)
},
children: count
})
}
接着我们通过以下方式把创建的虚拟DOM 挂载到根节点上。
render(App(), document.getElementById('app'))
接着我们实现 render 方法
let oldVnode
function render(vNode, container) {
if (!oldVnode) {
oldVnode = vNode
const el = document.createElement(vNode.type)
// 保存真实DOM 到虚拟DOM 的 el 属性上,将来更新的时候,不用重新创建,从而达到提高性能效果
oldVnode.el = el
const textNode = document.createTextNode(vNode.props.children)
el.appendChild(textNode)
// 绑定虚拟DOM 上的事件
el.addEventListener('click', vNode.props.onClick)
container.appendChild(el)
} else if(oldVnode.props.children !== vNode.props.children) {
oldVnode.el.textContent = vNode.props.children
}
}
我们在这里非常简单且宏观的实现了新老虚拟DOM 的对比,当不存在旧虚拟DOM 则是挂载阶段,创建真实DOM,并保存到虚拟DOM 的 el 属性上,将来更新的时候,不用重新创建,从而达到提高性能效果。在进行新老虚拟DOM 的时候,我们这里只比较 children 一个属性,如果新老 children 不一样就把新的虚拟DOM 上的 children 的值更新到对应的真实DOM 上。
我们在上面的 App 函数中的 props 中的点击事件函数中有一个 setCount 的方法还没实现,它的实现可以抽象成如下:
function setCount(val) {
count = val
render(App(), document.getElementById('app'))
}
从上述代码可以看到 setCount 的实现很简单,这个方法就是在点击之后进行更新数据 count 的,并且在更新数据 count 的同时重新渲染视图,把新的 count 值显示到页面上。
以下是测试效果:

我们在上述例子中通过极简的代码,从宏观层面阐明了 React 的响应式原理,通过操作数据来驱动视图,开发者只需要关注数据的变化,而不需要手动操作 DOM,同时它们都是通过虚拟DOM 和 Diff 算法进行实现响应式的,当组件的状态发生改变时,Vue 和 React 都会重新生成新的虚拟 DOM,并通过 Diff 算法比较新老虚拟 DOM 的差异,然后只更新需要更新的部分到真实 DOM 上。
其实上述这段 React 的响应式原理放在 Vue 的响应式原理也是成立的,最大的不同则是 React 更新数据需要通过 setCount 函数,也就是所谓手动触发,而 Vue 则是自动触发的。那么下面我们来看看 Vue 的响应式是怎么实现的。
基于依赖追踪的响应式
我们知道 Vue1 的响应式数据是通过 Object.defineProperty 来实现的。基于 Object.defineProperty 来实现需要初始化对对象的每一个熟悉进行劫持监听。
例如我们有这么这个对象 const data = { count:0 },那么我们需要进行以下操作:
const data = { count: 0 }
Object.keys(data).forEach(key => {
Object.defineProperty(data, key, {
get() {
return data[key]
},
set(val) {
data[key] = val
}
})
})
上述这个写法会造成内存栈溢出,主要是因为在 Object.defineProperty 的 getter 中读取 data[key] 会触发 getter 循环读取,从而造成死循环。

我们可以把 getter 中 data[key] 的取值放在进行 Object.defineProperty 监听之前。
const data = { count: 0 }
Object.keys(data).forEach(key => {
+ const val = data[key]
Object.defineProperty(data, key, {
get() {
+ return val
},
set(val) {
data[key] = val
}
})
})
但这样 val 会被循环取值进行了覆盖,没办法正确读取每个 key 的值,为了可以读取每个 key 的值,我们可以通过闭包的形式把每个 key 的值缓存下来。
const data = { count: 0 }
Object.keys(data).forEach(key => {
defineReactive(data, key, data[key])
})
function defineReactive(data, key, val) {
Object.defineProperty(data, key, {
get() {
return val
},
set(newVal) {
val = newVal
}
})
}
接下来我们要做的就是在 getter 中进行依赖收集,然后在 setter 中进行依赖触发,这本质上就是一个订阅发布模式。
const data = { count: 0 }
+ // 声明一个依赖存储中心
+ const subscribers = new Set()
+ // 需要收集的依赖,在 Vue1 叫 wachter,Vue3 中叫 effect,本质上就是一个订阅者,关于发布订阅模式,我们后续再详细介绍
+ let activeEffect
Object.keys(data).forEach(key => {
defineReactive(data, key, data[key])
})
function defineReactive(data, key, val) {
Object.defineProperty(data, key, {
get() {
+ // 存在依赖就把依赖收集到依赖存储中心
+ if(activeEffect) subscribers.add(activeEffect)
return val
},
set(newVal) {
val = newVal
+ // 值更新了,就需要去把依赖存储中心中的订阅者全部重新执行一遍
+ subscribers.forEach(sub => sub())
}
})
}
我们在上述代码中通过一个全局变量 subscribers 在响应式数据的 getter 把依赖收集到 subscribers 中, 在 setter 中则把 subscribers 中收集到的依赖进行循环遍历重新执行一遍,从而实现了依赖追踪和触发。
那么现在我们有了响应式数据 data 之后,我们就可以对我们前面的例子中的 App 函数中的 count 数据进行更改了,我们之前实现的是 React 的方式,我们现在要把它改成 Vue 的方式。
- count = 0
function App (){
return createElement('button', {
onClick: () => {
- count ++
- setCount(count)
+ data.count ++
},
- children: count
+ children: data.count
})
}
此外渲染函数的执行方式也需要改成一个副作用函数,通过副作用函数进行调用执行。
activeEffect = () => {
render(App(), document.getElementById('app'))
}
activeEffect()
activeEffect = null
我们把一个副作用函数赋值给了变量 activeEffect,然后再执行 activeEffect,那么在执行 activeEffect 函数的时候就会去执行 rander 函数,并通过 App 函数生成虚拟DOM,在 App 函数中对虚拟DOM 的 children 属性赋值的时候是通过读取响应式数据 data 中的 count 值,那么这时就会触发 count 属性的 getter,然后就会在 getter 中进行依赖收集,在 getter 中很明显这个时候 activeEffect 是有值的,所以会进行依赖收集。当点击的时候,就会触发 data.count ++ 的执行,这时就会触发 count 属性 setter,然后就会在 setter 中进行依赖触发。
以下是测试效果:

至此我们把 Vue 的数据响应式也通过最少的代码量阐明了,以上的 Vue 的响应式原理估计很多同学都非常清楚,因为这是面试被问几率非常高的题目。我在这里重复讲解,是为了对比 React 和 Vue 响应式原理的差别,总的来说,React 和 Vue 的响应式原理在宏观层面是有非常大的相同之处的,都是通过操作数据来驱动视图,开发者只需要关注数据的变化,而不需要手动操作 DOM,同时它们都是通过虚拟DOM 和 Diff 算法进行实现响应式的,当组件的状态发生改变时,Vue 和 React 都会重新生成新的虚拟 DOM,并通过 Diff 算法比较新老虚拟 DOM 的差异,然后只更新需要更新的部分到真实 DOM 上。在宏观层面 React 和 Vue 响应式原理最大的不同则是数据触发方式的不同,React 是数据变更后需要开发者通过手动调用 React 提供的 API 进行触发视图的更新,而 Vue 则是自动触发的,因为 Vue 的状态数据是响应式的,而 React 的状态数据不是响应式的。
我们一般在很多的文章中都只讲了 Vue1 的数据响应式原理是通过 Object.defineProperty 来实现的,那么是否只能通过 Object.defineProperty 来实现呢?很明显不是,我们上述例子中的 data,我们现在如果想给它新增属性 data.name,那么 Object.defineProperty 是无法进行监听追踪的,所以我们通过一个工具来对 data 也进行监听。
function observe (data) {
// 给对象 data 添加一个属于 data 对象的依赖存储中心
data.__ob__ = new Set()
Object.keys(data).forEach(key => {
const value = data[key]
defineReactive(data, key, value)
})
}
那么在 getter 中要对对象的依赖也进行收集
function defineReactive(data, key, val) {
Object.defineProperty(data, key, {
get() {
// 存在依赖就把依赖收集到依赖存储中心
if(activeEffect) {
subscribers.add(activeEffect)
// 如果读取的值是对象,那么还要给这个对象进行依赖收集,并且新的对象也要通过 observe 进行监听
if(Object.prototype.toString.call(val) === '[object Object]') {
observe(val)
val.__ob__.add(activeEffect)
}
}
return val
},
set(newVal) {
val = newVal
// 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
subscribers.forEach(sub => sub())
}
})
}
然后我们要专门通过一个单独的 API 来给响应式对象添加属性,并且在添加属性之后进行依赖触发。
function set(target, key, val) {
target[key] = val
// 新添加的属性也需要通过 Object.defineProperty 进行监听
defineReactive(target, key, val)
const ob = target.__ob__
// 进行对象的依赖触发
ob.forEach(sub => sub())
}
接着我们把 App 函数进行以下修改
// 修改数据结构
const data = { data: { count: 0 } }
// 通过 observe 处理
observe(data)
function App (){
return createElement('button', {
onClick: () => {
set(data.data, 'count1', 2)
},
children: JSON.stringify(data.data)
})
}
然后我们重新运行代码结果如下:

我们看到可以正常运行,但对象中的私有属性 __ob__ 也显示出来了,我们希望它不要被枚举出来,我们可以通过 Object.defineProperty 对它进行以下设置。
function observe (data) {
// 给对象 data 添加一个属于 data 对象的订阅者中心
- data.__ob__ = new Set()
+ Object.defineProperty(data, '__ob__', {
+ value: new Set(), // 属性的值,默认为 undefined
+ enumerable: false, // 属性是否可枚举,默认为 false
+ writable: true, // 值是否可写,默认为 false
+ configurable: true // 属性是否可配置,默认为 false
+ })
Object.keys(data).forEach(key => {
const value = data[key]
defineReactive(data, key, value)
})
}
修改后再进行测试,我们可以看到 __ob__ 属性不再出现了。

可以通过 Object.defineProperty 对数组进行监听,但监听不了 push、pop、shift 等对数组进行操作的方法,所以我们需要对数组的操作方法进行重写,重写的方法就是覆盖数组数据上的原型对象 __proto__。
function observe (data) {
// 省略 ...
+ if (Array.isArray(value)) {
+ // 如果是数组则重新数组上的原型
+ value.__proto__ = {
+ join(val) {
+ // 通过原生数组上方法进行调用
+ return Array.prototype.join.call(value, val)
+ },
+ push(val) {
+ // 通过原生数组上的方法进行调用
+ Array.prototype.push.call(value, val)
+ subscribers.forEach(sub => sub())
+ }
+ }
+ } else {
Object.keys(data).forEach(key => {
const value = data[key]
defineReactive(data, key, value)
})
+ }
}
我们这里只测试 join 和 push 方法,而 join 方法没有更改到数据,所以是不用进行依赖触发的。
然后我们对 App 应用也进行修改一下,以便测试数组响应式数据
// 数据
const data = ['cobyte']
// 通过 bserver 处理
observe(data)
function App (){
return createElement('button', {
onclick: () => {
data.push('=')
},
children: data.join('-')
})
}
测试结果如下:

小结
至此,我们通过手写已经基本实现了 Vue1 的数据响应式原理,我们可以通过对 Vue2 数据响应式原理的分析进行一个宏观总结。我们需要在实践中总结规律,然后又通过规律更好地指导实践。
首先我们都知道 Vue1 的数据响应式原理是通过 Object.defineProperty 实现的,通过 Object.defineProperty 可以监听一个对象的属性的读取(getter)和修改(setter),这样就可以在 getter 的时候进行依赖收集,在 setter 的时候进行依赖触发。但 Vue2 不单单只是通过 Object.defineProperty 实现数据响应式的,因为只有被 Object.defineProperty 初始化了的属性才可以进行监听,而当一个对象新增一个属性时,则监听不了。这时我们需要通过额外的手段来实现对象新增属性时的监听,具体方案就是通过给对象新增一个私有的属性 __ob__,去记录属于该对象的依赖,当该对象新增属性时则触发该对象的依赖重新执行。同时 Object.defineProperty 也监听不了数组的原生方法,例如:push、pop、shift、unshift、splice、sort、reverse,我们观察一下这些数组方法发现都有一个共同特点,就是他们都会修改数组,使数组数据发生变化,那么根据数据响应式的原理,数据发生了改变就需要进行依赖触发,那么我们需要对响应式数据类型为数组的数据进行重写它们的原型,这样我们就可以在响应式数组通过 push、pop、shift、unshift、splice、sort、reverse 方法修改数组的时候进行依赖触发了。
我们可以总结出,不管是通过 Object.defineProperty 进行监听对象属性还是通过给对象添加私有属性 __ob__,去记录该对象的依赖,还是重写数组的原型方法,目的都只有一个:进行数据的依赖追踪和触发。
我们还可以进一步进行总结规律:这种基于依赖追踪的响应式系统,并不是某一种技术,而是一种模式。核心只有一个,就是在数据读取的时候进行依赖收集,在数据更改的时候进行依赖触发。
基于这种指导思想,我们就可以很好去实践 Vue2 的数据响应式原理了。
Vue3 只是通过 Proxy 实现数据响应式吗
Vue3 是通过 Proxy 对数据实现 getter/setter 代理,从而实现响应式数据,然后在副作用函数中读取响应式数据的时候,就会触发 Proxy 的 getter,在 getter 里面把对当前的副作用函数保存起来,将来对应响应式数据发生更改的话,则把之前保存起来的副作用函数取出来执行。这个过程跟 Vue2 是一样的,只是实现细节不一样。
实现起来也非常简单:
function reactive(data) {
return new Proxy(data, {
get(target, key) {
// 存在依赖就把依赖收集到依赖存储中心
activeEffect && subscribers.add(activeEffect)
return Reflect.get(target, key)
},
set(target, key, val) {
const result = Reflect.set(target, key, val)
// 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
subscribers.forEach(sub => sub())
return result
}
})
}
我们再把 App 函数进行修改:
const data = reactive({count: 0})
function App (){
return createElement('button', {
onClick: () => {
data.count ++
},
children: data.count
})
}
测试结果如下:

我们这里不是为了深入探讨 Vue2 的数据响应式原理的,而是为了验证上面实现 Vue2 的数据响应式原理总结的规律。也就是:这种基于依赖追踪的响应式系统,并不是某一种技术,而是一种模式。核心只有一个,就是在数据读取的时候进行依赖收集,在数据更改的时候进行依赖触发。后续我们基于数据响应式原理的规律便可以很好去理解其他数据响应式系统了,例如 React 的状态管理库——Mobx、SolidJS,我们在后续也将探讨这些库的数据响应式原理的实现。
Vue1 是通过 Object.defineProperty 实现对数据的读写监听,但由于 Object.defineProperty 的局限性,Vue2 并不只是通过 Object.defineProperty 实现数据响应式的,但都为了实现在数据读取时进行依赖收集,在数据更改时进行依赖触发。Vue3 则通过新的 API:Proxy 可以实现对数据的读写监听,但核心也是为了实现在数据读取时进行依赖收集,在数据更改时进行依赖触发。
那么问题来了,Vue1 并不只是通过 Object.defineProperty 实现数据响应式的,那么 Vue3 只是通过 Proxy 实现了数据响应式吗?
其实这个问题可以转化得更具体一些,Vue2 的 reactive 和 ref 的底层实现原理是一样的吗?有人认为 ref 和 reactive 的底层实现原理都是一样的,也就是 ref 也是通过 reactive 实现的,也就是 ref 也是通过 Proxy 实现的。如果说 ref 和 reactive 的底层实现原理不一样的话,也就是说 Vue3 可以不通过 Proxy 实现数据的响应式。
很明显 Vue3 可以不通过 Proxy 实现数据的响应式的,也就是 ref 和 reactive 的底层实现原理是不一样的。那么根据我们上面总结的实践规律,我们只需要可以实现在数据读取的时候进行依赖收集,然后在数据更改的时候进行依赖触发就可以了。那么明显可以使用 Vue2 中的 Object.defineProperty 中的 getter/setter,这种方式也叫属性访问器。根据上面 Vue2 的数据响应式原理我们可以知道如果通过 Object.defineProperty 实现对数据的监听,还要通过闭包的方式,就显得不够简洁。那么属性访问器除了使用 Object.defineProperty 进行显式声明之外,还可以通过字面量的方式,本质还是属性访问器。
例如:
function ref(value) {
return {
_value: value,
get value() {
// 存在依赖就把依赖收集到依赖存储中心
activeEffect && subscribers.add(activeEffect)
return this._value
},
set value(val) {
this._value = val
// 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
subscribers.forEach(sub => sub())
}
}
}
然后我们通过 ref 函数来创建一个响应式数据,再修改 App 函数。
const count = ref(0)
function App (){
return createElement('button', {
onClick: () => {
count.value ++
},
children: count.value
})
}
测试运行结果:

这也就是 Vue2 的 ref API 的实现原理,当然在 Vue3 源码中如果 ref 传进来的值是一个引用对象的话,还是通过 reactive 进行实现。此外在 Vue3 的源码中 ref API 是通过一个 class 类来实现的,但原理是一样的。
我们下面也可以简单实现一下:
class RefImpl {
_value
constructor(value) {
this._value = value
}
get value() {
// 存在依赖就把依赖收集到依赖存储中心
activeEffect && subscribers.add(activeEffect)
return this._value
}
set value(val) {
this._value = val
// 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
subscribers.forEach(sub => sub())
}
}
function ref(value) {
return new RefImpl(value)
}
修改好的测试结果还是一样的。

通过沙箱模式实现依赖追踪的数据响应式
通过上面对 Vue1 和 Vue3 的数据响应式原理的实现与分析,我们知道都借助了 JavaScript 的原生 API(Object.defineProperty 和 Proxy) 来实现依赖追踪的响应式系统,那么不借助 JavaScript 原生 API 还可以实现依赖追踪的响应式系统吗?
我们上面总结出的结论是,基于依赖追踪的响应式系统的本质是在读取数据的时候收集依赖,在更新数据的时候触发依赖。那么基于此原理,我们只需要把读写进行分离那么可以实现了。
我们把上面第一版 ref 的实现通过闭包的形式改造一下:
function ref(value) {
const s = {
value
}
function getState() {
return s.value
}
function setState(val) {
s.value = val
}
return [getState, setState]
}
const [getState, setState] = ref(0)
console.log('初始值:', getState())
// 修改
setState(1)
console.log('修改后:', getState())
我们可以看到通过闭包的我们实现了读写分离,这种模式有一个专业的术语叫:沙箱模式,这样我们就可以在读取数据的时候收集依赖,在修改数据的时候触发依赖了。
function ref(value) {
const s = {
value
}
function getState() {
// 存在依赖就把依赖收集到依赖存储中心
activeEffect && subscribers.add(activeEffect)
return s.value
}
function setState(val) {
s.value = val
// 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
subscribers.forEach(sub => sub())
}
return [getState, setState]
}
接着我们把 App 函数也进行修改一下:
const [count, setCount] = ref(0)
function App (){
return createElement('button', {
onClick: () => {
setCount(count() + 0)
},
children: count()
})
}
其实上述这种实现依赖追踪的响应式系统的方式就是 SolidJS 的响应式原理,长得像 React,实际上是 Vue。所以我们只要把核心原理搞清楚,就可以举一反三了,像读书时候一样,以后同类型的题目,你都回作答了。当然 SolidJS 的响应式原理远不止这些,我们将在后续章节继续进行深入探讨,搞明白了 SolidJS, Vue Vapor 的原理也非常容易理解了。
总结
上述所有例子中的依赖收集和触发的过程,本质就是一个发布订阅模式,而关于发布订阅模式,我们将在下一篇文章中进行详细介绍。当我们掌握了发布订阅模式后,我们再去理解这些通过依赖收集和触发实现的数据响应式系统,就会如鱼得水。
上述文章写于:2023 年,由于个人原因今年 2026 年发布。
我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。