Vue Reactivity Source Code Review
Note: This article was translated from Chinese to English by Claude AI (Anthropic).
Vue 2 Reactivity Principles
In Vue 2, when a JavaScript object is passed into a Vue instance as a data option, Vue will traverse all properties of this object and use Object.defineProperty() to convert these properties into getters/setters. Dependencies are collected in the getter and updates are triggered in the setter: each component instance corresponds to a watcher instance, which records data properties accessed during component rendering as dependencies. When a dependency’s setter is triggered, it notifies the watcher, causing its associated component to re-render. This is the approach behind Vue 2’s reactivity implementation. As for how it is specifically implemented, we’ll analyze this in detail below.

Object.defineProperty()
Object.defineProperty() is the foundation of Vue’s reactivity implementation. It allows precise addition or modification of properties on an object. The syntax is as follows:
Object.defineProperty(obj, prop, descriptor)Parameters:
obj: The object on which to define the property
prop: The name of the property to be defined or modified
descriptor: The descriptor for the property being defined or modified
Common properties of descriptor:
enumerable: Whether the property is enumerable, defaults to falseconfigurable: Whether the property can be modified or deleted, defaults to falseget: Method for getting the propertyset: Method for setting the property
Reference: MDN-Object.defineProperty()
Let me continue with the implementation part.
Implementing Reactivity
In Vue’s source code, the defineReactive function wraps Object.defineProperty to implement reactivity. get triggers reactiveGetter to collect dependencies, while set triggers reactiveSetter to notify dependencies to update. Below is a simplified version of this function’s source code, just focus on the non-commented parts:
Parameters:
obj(object to bind)key(object property name)val(specific value)
function defineReactive(obj, key, val) {
// Create dependency collector
// const dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
// Collect dependencies
// if (Dep.target) {
// dep.depend()
// }
return val
},
set: function reactiveSetter(newVal) {
if (newVal === val) return
val = newVal
// Notify all dependencies
// dep.notify()
}
})
}As mentioned at the beginning of this article, Vue traverses all properties of an object, so we need to wrap another layer called Observer to traverse the object and convert all properties of a regular JavaScript object into reactive ones. Here’s a simplified version:
class Observer {
constructor(value) {
// Add __ob__ property to the observed object, pointing to this Observer instance
def(value, '__ob__', this)
if(Array.isArray(value)) {
// Intercept array methods
// ...
// Process array
this.observeArray()
} else {
// Process object
const keys = Object.keys(value)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
defineReactive(value, key, NO_INITIAL_VALUE, undefined, shallow, mock)
}
}
}
observeArray(value) {
for (let i = 0, l = value.length; i < l; i++) {
observe(value[i], false, this.mock)
}
}
}Here, def() is a utility function used to add properties to an object:
export function def(obj, key, val) {
Object.defineProperty(obj, key, {
value: val,
})
}observe() is the entry function that returns an Observer instance:
function observe(value) {
if (typeof value !== 'object') return
let ob
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else {
ob = new Observer(value)
}
return ob
}Dependency Collection
The previously commented out lines in defineReactive, highlighted below, are for dependency collection in get and notification of all dependencies in set - this is another important part of implementing the reactivity system - dependency collection.
|
|
Why Dependency Collection is Needed
Let’s look at an example. Here’s a Vue object:
new Vue({
template:`
<div>
<span>{{ text1 }}</span>
<span>{{ text2 }}</span>
</div>
`,
data: {
text1: 'text1',
text2: 'text2',
text3: 'text3',
}
})If we modify text3:
data.text3 = 'text3 modify'Although we modified the value of text3 in data, since text3 isn’t used in the template rendering, it won’t trigger dep.notify() to notify dependencies and update the view.
Here’s another example. Suppose we have a global object that we use in two Vue objects.
let globalObj = {
text1: 'text1'
}
let o1 = new Vue({
template:`
<div><span>{{ globalObj.text1 }}</span></div>
`,
data: {
globalObj
}
})
let o2 = new Vue({
template:`
<div><span>{{ globalObj.text1 }}</span></div>
`,
data: {
globalObj
}
})When we modify the value of globalObj.text1:
globalObj.text1 = 'text1 modify'In the reactivity system, when globalObj.text1 changes, it should notify both o1 and o2 vm instances to update their views. Dependency collection implements this: it lets this data (globalObj.text1) know that two places (o1 and o2) depend on it, so when this data (globalObj.text1) changes, it will notify the o1 and o2 that depend on it.
Core of Dependency Collection - Dep and Watcher
Let’s return to the code to see how dependency collection is implemented.
First, we need a Dep. Each Dep instance has an array to store watchers, which will notify watchers to update when data changes. Similarly, each Watcher instance also has an array to store its dependent deps. In other words, dependency collection is bidirectional - Dep records Watcher, and Watcher also records Dep, this is very important.
Let’s look at Dep first:
class Dep {
static target // Static property, globally unique currently executing Watcher
id // Instance property, unique identifier for each dep
subs // Instance property, stores all subscribers
constructor {
this.id = uid++
this.subs = []
}
// Add subscriber
addSub(sub) {
this.subs.push(sub)
}
// Remove subscriber
removeSub(sub) {
remove(this.subs, sub)
}
// Establish dependency relationship
depend() {
if(Dep.target) { // If there is a currently executing Watcher, use it as dependency
Dep.target.addDep(this)
}
}
// Notify all subscribers
notify() {
for(let i = 0, l = this.subs.length; i < l; i++) {
this.subs[i].update()
}
}
}
Dep.target = null;As mentioned above, class Dep’s target is a static property used to record the currently unique executing Watcher, id is used to give Dep instances unique identifiers, and subs is used to store all subscribers. addSub() and removeSub() are used to add and remove subscribers, depend() is used to establish dependency relationships, and notify() is used to notify all subscribers.
Watcher is a bit more complex, let’s take a look:
class Watcher {
constructor (vm, expOrFn, cb, options={}) {
if(options) {
this.lazy = !!this.lazy
this.sync = !!this.sync
}
this.vm = vm // Vue instance
this.cb = cb // Callback function
this.deps = [] // Store all deps this watcher depends on
this.newDeps = [] // New deps collected in this round
this.depIds = new Set() // Set of dependency IDs for deduplication
this.newDepIds = new Set() // Set of new dependency IDs
this.expression = expOrFn.toString() // For debugging
this.dirty = this.lazy
this.id = ++uid // Unique identifier
// Define getter here
if(typeof expOrFn === 'function') {
this.getter = expOrFn // If expOrFn is a function, use it directly as getter
} else {
this.getter = parsePath(expOrFn) // If it's a string, convert to function that gets object property
}
// Execute getter immediately once for initialization and dependency collection
this.value = this.lazy ? undefined : this.get()
}
get() {
pushTarget(this) // Set current watcher as globally active watcher
let value
try {
// Execute getter, triggering observed object's getter, thus collecting dependencies
value = this.getter.call(this.vm, this.vm)
} catch (e) {
throw e
} finally {
popTarget() // Restore previous active watcher
this.cleanupDeps() // Clean up dependencies
}
return value
}
addDep(dep) {
const id = dep.id
// Check if already in new dependency collection
if(!this.newDepIds.has(id)) {
// Add to new dependency ID set
this.newDepIds.add(id)
// Add to new dependency array
this.newDeps.push(dep)
// Check if in old dependencies
if(!this.depIds.has(id)) {
// If not in old dependencies, let dep collect current watcher
dep.addSub(this)
}
}
}
// Clean up dependencies
cleanupDeps() {
let i = this.deps.length
// Traverse old dependency array
while (i--) {
const dep = this.deps[i]
// If new dependency array doesn't include this old dependency
if (!this.newDepIds.has(dep.id)) {
// Remove this watcher from dep
dep.removeSub(this)
}
}
// Update depIds, clear newDepIds
let tmp: any = this.depIds
this.depIds = this.newDepIds
this.newDepIds = tmp
this.newDepIds.clear()
// Update deps, clear newDeps
tmp = this.deps
this.deps = this.newDeps
this.newDeps = tmp
this.newDeps.length = 0
}
update() {
// Lazy mode, lazy computation, won't recompute immediately, just mark dirty as true
if (this.lazy) {
this.dirty = true
} else if (this.sync) { // Sync mode, synchronous update mode, update immediately
this.run()
} else { // Default async update mode, put watcher into update queue
queueWatcher(this)
}
}
run() {
const value = this.get() // Get new value
if(value !== this.value || isObject(value) || this.deep) { // If new value != old value | new value is object | deep watching
const oldValue = this.value // Save old value
this.value = value // Set new value
this.cb.call(this.vm, value, oldValue) // Execute callback function, passing new and old values
}
}
}In Watcher’s constructor, it first processes the passed expOrFn. When it’s a function, it’s directly assigned to getter. If it’s a string, it needs parsePath to convert it to an access function. For example, parsePath('user.name') would return a function: (obj) => obj['user']['name']. Note that obj here is the vm called in getter.call, which is the current Vue instance. Additionally, it will immediately execute getter() once for initialization and dependency collection.
Looking at the code below, to handle nested Watcher scenarios, at the start of getter(), the current Watcher (target) is pushed onto the targetStack stack, and Dep.target is pointed to the current Watcher. When getter() finishes, this Watcher is popped from the stack, and Dep.target’s pointer is modified to the previous Watcher.
const targetStack = []
export function pushTarget(target) {
targetStack.push(target)
Dep.target = target
}
export function popTarget() {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}The addDep method of Watcher and the addSub method in Dep call each other for bidirectional dependency collection. Don’t worry about infinite loops, as Watcher also has a cleanupDeps to clean up dependencies in a timely manner.
update() and run() are responsible for update handling when data changes. Let’s first look at update(), which has three ways of handling when data changes:
-
lazymodeif(this.lazy) { this.dirty = true }Searching for
dirtyin the source code, we can find theevaluate()method:/** * Evaluate the value of the watcher. * This only gets called for lazy watchers. */ evaluate() { this.value = this.get() this.dirty = false }When
evaluate()is called, it calls theget()method and setsdirtytofalse. So where isevaluate()used? After a global search, we can see it being called in thestate.tsfile:function createComputedGetter(key) { return function computedGetter() { const watcher = this._computedWatchers && this._computedWatchers[key] if (watcher) { if (watcher.dirty) { watcher.evaluate() } if (Dep.target) { // ... watcher.depend() } return watcher.value } } }
In other words, when the computed property’s getter() is called, if this watcher is dirty data (dirty is true), then evaluate() will be executed. Using lazy mode won’t immediately recalculate values, it just marks dirty as true, waiting until the next access to this property to actually calculate.
The computed property uses this mode.
-
syncmodeelse if(this.sync) { this.run() }If in
syncmode, it will callrun():run() { const value = this.get() // Get new value if(value !== this.value || isObject(value) || this.deep) { // If new value != old value | new value is object | deep watching const oldValue = this.value // Save old value this.value = value // Set new value this.cb.call(this.vm, value, oldValue) // Execute callback function, passing new and old values } }We can see that the
runmethod immediately executesget(), and when the new and old values are not equal or the new value is an object or deep watching is enabled, it executes the callback function, passing both new and old values. Sosyncmode is mainly used for scenarios that need immediate response to data changes, butsyncisfalseby default, and most cases useasyncasynchronous updates. -
queueasynchronous updateselse { queueWatcher(this) }Let’s see what
queueWatcher()does:let has = {} /** * Push a watcher into the watcher queue. * Jobs with duplicate IDs will be skipped unless it's * pushed when the queue is being flushed. */ export function queueWatcher(watcher: Watcher) { // Duplicate check to prevent the same watcher from being added to the queue multiple times const id = watcher.id if (has[id] != null) { return } // Prevent watcher from triggering recursive updates during its own update process if (watcher === Dep.target && watcher.noRecurse) { return } has[id] = true if (!flushing) { // If queue hasn't started flushing, push into queue queue.push(watcher) } else { // If queue is flushing, need to insert according to id order // Ensure watchers execute in order of creation (id size) because: 1.parent component watchers need to update before child components 2.computed needs to update before regular watchers let i = queue.length - 1 while (i > index && queue[i].id > watcher.id) { i-- } queue.splice(i + 1, 0, watcher) } // queue the flush if (!waiting) { waiting = true // Ensure queue will be flushed in next tick nextTick(flushSchedulerQueue) } }- When
flushSchedulerQueueis executed,flushingwill be set totrue - By calling
flushSchedulerQueue()throughnextTick(), it executesflushSchedulerQueue()in the next microtask, so multiple data modifications will only trigger one update.
- When
Vue Reactivity System
Through the above sections, we know that Vue’s reactivity system implementation relies on data reactivity and dependency collection. The core/principle of data reactivity is Object.defineProperty, which registers get and set for dependency collection. Specifically, it creates a new Dep instance in the reactive object to handle dependency collection, adding the current Watcher to the Dep instance’s subscriber list (subs). At this point, the process of building the entire reactivity system is still somewhat unclear, especially when new Watcher() happens, so when we create a new Vue instance, when are all these processes performed?
First, we need to know what Watchers exist in a Vue instance. As Vue users, we commonly encounter three types: Render Watcher, Computed Watcher, and User Watcher:
// Template rendering - Render Watcher
<div>{{ message }}</div>
// computed option - Computed Watcher
computed: {
total() {
return this.price * this.quantity
}
}
// watch option - User Watcher
watch: {
price(newVal, oldVal) {
console.log('price change:', newVal)
}
}Now, let’s not rush to focus on when new Watcher happens, let’s first understand the overall flow of new Vue():
|
|
We mainly focus on initState and $mount.
initState
initState initializes props -> methods -> data -> computed -> watch in sequence:
|
|
The process of generating reactive objects, which we mentioned earlier using Object.defineProperty, is implemented in initData:
function initData() {
observe(data) // Entry function for generating reactive objects mentioned earlier
}initComputed and initWatch create Computed Watcher and User Watcher respectively:
function initComputed() {
// Create computed watcher for each computed property
new Watcher(vm, getter, null, options)
}
function initWatch() {
// Create user watcher for each watch
new Watcher(vm, key, cb, options)
}$mount
Render Watcher is created during component mounting ($mount).
// Create render watcher (during mount phase)
Vue.prototype.$mount = new Watcher(vm, updateComponent, null, options)Vue2 Reactivity System Limitations
Vue2’s reactivity isn’t perfect. The implementation using Object.defineProperty comes with inherent limitations, so when using Vue2, we need to be aware of these issues.
Objects
Vue2 cannot detect object property additions and deletions.
For example:
var vm = new Vue({
data: {
a: 1
}
})
// vm.a is reactive
vm.b = 2
// vm.b is non-reactive
This is because Vue converts properties to reactive ones during instance initialization, so properties must exist on the data object for Vue to make them reactive.
function initData(vm) {
let data = vm.$options.data
observe(data) // Call observe here to make object reactive
}Solution 1: Vue.set
For already created instances, while Vue doesn’t allow adding reactive properties at the root level, you can use Vue.set(object, propertyName, value) to add reactive properties to nested objects.
For example, if you have an object in data:
var vm = new Vue({
data: {
someObject: {
a: 1,
}
}
})Use Vue.set to add a new reactive property b to someObject:
Vue.set(vm.someObject, 'b', 2)or
this.$set(this.someObject, 'b', 2)Solution 2: Object.assign
When you need to assign multiple new properties to an existing object, you can use Object.assign.
this.someObject = Object.assign({}, this.someObject, {a: 1, b: 2})However, note that new properties added like this aren’t reactive:
Object.assign(this.someObject, {a: 1, b: 2})The reason is simple - Object.assign(this.someObject,{a:1,b:2}) is equivalent to directly modifying/adding properties:
// Equivalent to
this.someObject.a = 1 // Triggers a's setter
this.someObject.b = 2 // b remains non-reactive
While Object.assign({},this.someObject,{a:1,b:2}) triggers someObject’s setter, recursively traversing all properties on someObject for reactive conversion.
Arrays
Vue2 cannot detect changes to array indices and length.
- Array indices:
vm.items[indexOfItem] = newValue - Array length:
vm.items.length = newLength
These two types of changes won’t trigger reactive updates because array indices are essentially object properties. While theoretically Vue could handle array indices like object properties, for arrays with large lengths, like an array of 10000 elements, setting getter/setter for each index would cause serious performance issues. Similarly, the length property of arrays isn’t made reactive because it could trigger chain reactions (modifying length could affect many elements).
To solve this problem, Vue2 rewrote seven array methods that can trigger reactive updates:
push()
pop()
shift()
unshift()
splice()
sort()
reverse()So to trigger array reactivity, you should do this:
// Modify array index
vm.items.splice(indexOfItem, 1, newValue)
// Modify array length
vm.items.splice(2)Performance
Vue2’s reactive implementation requires recursive traversal of all object properties, which itself has significant performance overhead. This is also why Vue3 switched to using proxy to implement reactivity.
Vue3’s Improvements to Reactivity
Implementing Reactivity with Proxy
Vue3’s reactivity system is based on ES6’s proxy, which is a major upgrade from Vue2’s Object.defineProperty. The advantages include:
- Can detect object property additions and deletions
- Can monitor array changes without additional handling
- No need for deep recursive traversal, better performance
- Supports data structures like Map, Set
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
track(target, key) // Dependency collection
return res
},
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver)
trigger(target, key) // Trigger updates
return res
}
})
}Here Reflect is a built-in object that provides methods for intercepting JavaScript operations.
Reflect.get(target, key) // Get property
Reflect.set(target, key, value) // Set property
Reflect.has(target, key) // Check property
Reflect.deleteProperty(target, key) // Delete property
Reflect provides unified APIs for object operations, making it convenient for proxy:
|
|
New Reactive APIs
ref and reactive
ref and reactive are both new reactive APIs in Vue3 for handling reactive data, with some differences in usage:
| Feature | ref | reactive |
|---|---|---|
| Access method | Access via .value |
Direct access |
| Auto-unwrapping | Auto-unwraps in <template> and reactive |
No unwrapping needed |
| Data type support | Supports all data types | Only supports object types |
| Destructuring behavior | Loses reactivity after destructuring, need toRef/toRefs |
Loses reactivity after destructuring, need toRef/toRefs |
| Assignment characteristic | Can directly replace entire value ref.value = newValue |
Can’t directly replace entire object, can only modify properties |
| Nested data | Internally uses reactive to handle objects |
Deep reactive conversion |
| Use cases | Basic data types / Single data source / Composition function return values / Data needing reassignment | Related data collections / Reference data types / Data not needing reassignment |
- Access Method
ref objects are accessed via .value, reactive accessed directly.
const count = ref(0)
console.log(count.value) // needs .value to access
const obj = reactive({
count: 0
})
console.log(obj.count) // direct access, no .value needed
- Auto-unwrapping
ref objects auto-unwrap in template, reactive
<template>
<div>
<!-- Already auto-unwrapped, direct access, no .value needed --->
{{ count }}
</div>
</template>
<script setup>
const count = ref(0)
// ref auto-unwraps in reactive objects
const state = reactive({
count, // auto-unwrapped
double: computed(() => state.count*2),
})
</script>- Data Type Support
// ref supports all data types
const num = ref(0)
const str = ref('XD')
const boo = ref(true)
const obj = ref({a:1, b:2})
const arr = ref([1,2])
// reactive only supports reference data types (objects/arrays)
const obj2 = reactive({a:1, b:2})
const arr2 = reactive([1,2])- Using
toRef/toRefsfor Destructuring
const obj = reactive({name: 'River', age: 18})
const { age } = obj // Direct destructuring will lose reactivity
// Using toRef
const age = toRef(obj, 'age')
// Or toRefs
const { name, age } = toRefs(obj)
// The benefit is maintaining object reactivity
// Modifying ref will update source object
age.value++
console.log(obj.age) // 19
// Modifying source object will update ref
obj.age++
console.log(age.value) // 20
- Assignment Characteristics
ref objects can be directly replaced, reactive cannot be directly replaced, only properties can be modified
const foo = ref([1,2])
foo.value = [3,4] // allowed
const foo = reactive([1,2])
foo = [3,4] // not allowed
- Nested Data
ref handling nested data
const user = ref({
name: 'Zhang',
profile: {
age: 25,
address: {
city: 'Beijing'
}
}
})
// ref internally uses reactive for deep object conversion
user.value.profile.age = 26 // triggers reactive update
user.value.profile.address.city = 'Shanghai' // triggers reactive update
reactive handling nested data
const user = reactive({
name: 'Zhang',
profile: {
age: 25,
address: {
city: 'Beijing'
}
}
})
// reactive deeply converts all nested objects
user.profile.age = 26 // triggers reactive update
user.profile.address.city = 'Shanghai' // triggers reactive update
- Use Cases
For composition function return values, using ref is better. If reactive is needed, maintain data reactivity through toRefs
function useCount() {
const count = ref(0)
return count
}
function useUser() {
const state = reactive({name: 'River', age: 18})
return toRefs(state)
}watchEffect
watchEffect automatically tracks reactive dependencies and reruns the effect function when reactive dependencies update. Simply put, it does these things:
- Immediately executes the callback function once
- Automatically tracks reactive dependencies used in the callback function
- Reruns the callback function when dependencies change
Basic Usage
import { ref, watchEffect } from 'vue'
const count = ref(0)
const message = ref('Hello')
watchEffect(() => {
console.log(`Count: ${count.value}, Message: ${message.value}`)
})
// Modifying any dependency will trigger callback
count.value++ // Output: Count: 1, Message: Hello
message.value = 'Hi' // Output: Count: 1, Message: Hi
Pause/Resume/Stop Watching
watchEffect also returns a stop function, executing it will stop watching
const stop = watchEffect(() => {})
// When watching is no longer needed
stop()When pausing/resuming is needed
const { stop, pause, resume } = watchEffect(() => {})
// Pause
pause()
// Resume
resume()
// Stop
stop()Cleanup Effects
Why cleanup effects are needed:
- Prevent memory leaks (like timers)
- Avoid duplicate event listeners
- Cancel unnecessary network requests
- Clean up potentially conflicting old states
The onCleanup parameter in watchEffect’s callback function is used to clean up side effects. It executes at these times:
- Just before
watchEffectis about to re-execute - When
watchEffectis stopped
Let’s take a network request as an example, creating a network request controller controller and calling the cancel network request method in the onCleanup function.
const userId = ref('1')
const userData = ref(null)
watchEffect((onCleanup) => {
// Create a cancel controller
const controller = new AbortController()
// Network request
fetch(`/api/user/${userId.value}`, {
signal: controller.signal,
}).then(data => userData.value = JSON.parse(data))
// Cleanup function: cancel previous request if userId changes
onCleanup(() => {
controller.abort()
})
})
setTimeout(() => {
userId.value = '2'
}, 100)The execution sequence is like this:
Initial execution:
1. Start request, userId: 1
100 ms later when userId changes:
2. Execute cleanup, cancel previous network request (cleanup previous side effect `onCleanup`)
3. Request canceled (previous request `abort`)
4. Start request, userId: 2 (re-execute `watchEffect`)Cleanup after 3.5+
import { onWatcherCleanup } from 'vue'
// ...
watchEffect(() => {
// ...
onWatcherCleanup(() => {
controller.abort()
})
// ...
})
// ...
Execution Timing
watchEffect also provides a second parameter that can control when the effect function executes.
|
|
watchEffect vs watch
watchEffectautomatically tracks dependencies,watchneeds explicitly specified source to monitor
watchEffect(() => console.log(count.value)) // automatically tracks dependencies
watch(count, newVal => console.log(count.value)) // explicitly specifies dependency
watchEffectexecutes immediately by default,watchneedsimmediate: truesetting
watchEffect(() => {}) // executes immediately by default
watch(source, () => {}, { immediate: true }) // won't execute immediately by default, needs immediate set to true