iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐱

A Simple Explanation of Reactivity in Vue 2.x

に公開

Vue3 was released quite recently, but I still use Vue2 in my personal projects like my time management app and at work.

Since it's still widely used, I decided to investigate how reactivity is implemented under the hood.

In this article, I will focus mainly on tracking data.

Let's look at the sample code

We will follow the flow using the following code.
Initially, the string EXAMPLE is displayed on the screen, and after 1 second, it changes to the string CHANGED.

<!DOCTYPE html>
<html>
<body>
  <div id="target"></div>

  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script>
    new Vue({
      el: '#target',
      data() {
        return {
          message: 'EXAMPLE'
        }
      },
      mounted() {
        setTimeout(() => this.message = 'CHANGED', 1000)
      },
      render(h) {
        return h('div', this.message)
      },
    })
  </script>
</body>
</html>

Dep and Watch are working behind the scenes

When this.message is modified after 1 second by setTimeout, the display on the screen is automatically (reactively) updated. It's interesting to see how this works internally.

Actually, objects within Vue called Dep and Watch are doing the work.

Dep is the side that notifies when a change has occurred.
In this case, the message in data corresponds to this.

Watch is the side that receives the change notification and executes some process.
In this case, Vue's render corresponds to this.

This architecture is often called the Observer pattern.
Imagine that Dep notifies the change, and Watch updates the displayed content.

How does Dep know which Watcher to notify?

That's the general flow, but how on earth does Dep know which Watcher it needs to notify? To put it simply, the JavaScript getter syntax and a variable called Dep.target play a key role.

What is a Getter?

It's a convenient feature that allows you to execute an arbitrary function when you try to access an object's property.

Below is a simple example of a getter.
When obj.message is called, the message() function is processed.

const obj = {
  get message() {
    console.log("CALLED");
    return 'EXAMPLE';
  }
}

const text = obj.message; // => "CALLED" is displayed in the console
console.log(text);        // => "EXAMPLE"

data is a Getter

Let's look at the code mentioned at the beginning once more.

<!DOCTYPE html>
<html>
<body>
  <div id="target"></div>

  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script>
    new Vue({
      el: '#target',
      data() {
        return {
          message: 'EXAMPLE'
        }
      },
      mounted() {
        setTimeout(() => this.message = 'CHANGED', 1000)
      },
      render(h) {
        return h('div', this.message) // <--
      },
    })
  </script>
</body>
</html>

Inside the render(h) method, this.message is being called.
The return value is, of course, EXAMPLE, but this this.message property is actually a getter.

In the process of creating a Vue instance, data is converted into getters.

It's not simply returning EXAMPLE; some processing is running behind the scenes.
Let's check the Vue code to see what is actually happening inside the getter.

Below is where Vue turns data into a getter.

//github.com/vuejs/vue/blob/master/src/core/observer/index.js#L129-L186
/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()
  ...
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      // Note 1
      if (Dep.target) {  
        dep.depend()     
        ...
      }
      return value 
    },
    ...
  })
}

In Note 1, if Dep.target exists, dep.depend() is executed.
Looking at dep.depend()...

//github.com/vuejs/vue/blob/master/src/core/observer/dep.js#L8-L34
/**
 * A dep is an observable that can have multiple
 * directives subscribing to it.
 */
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  ...
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}

It calls addDep on Dep.target.

As I will explain later, Dep.target contains the calling Watcher.
Let's look at addDep in Watcher.

//github.com/vuejs/vue/blob/master/src/core/observer/watcher.js#L117-L129
/**
 * Add a dependency to this directive.
 */
Watcher.prototype.addDep = function addDep (dep) {
  var id = dep.id;
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id);
    this.newDeps.push(dep);
    if (!this.depIds.has(id)) {
      dep.addSub(this);  // <--
    }
  }
};

It calls dep.addSub.
Going back to Dep to look at addSub...

//github.com/vuejs/vue/blob/master/src/core/observer/dep.js#L8-L34
/**
 * A dep is an observable that can have multiple
 * directives subscribing to it.
 */
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub) // <--
  }
  ...
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}

It seems to be adding the Watcher to an array called subs in Dep.
This subs is the array of "Watchers to notify when a change occurs."

Summarizing the process so far in a diagram:

When the getter is called, it checks Dep.target and adds the Watcher to the notification list (subs).

Where is Dep.target set?

So, who sets Dep.target and where? To put it simply, the Watcher itself sets it.

Let's look at the code for the get method of the Watcher.

//github.com/vuejs/vue/blob/master/src/core/observer/watcher.js#L90-L115
get () {
  pushTarget(this)  // Note 1
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)  // Note 2
  } catch (e) {
    ...
  } finally {
    ...
    popTarget()  // Note 3
    this.cleanupDeps()
  }
  return value
}

This get method is called when a change notification is received from Dep, or when the Watcher instance is created, basically whenever an update process is required.

Let's focus on Note 1, Note 2, and Note 3.
pushTarget is called, then this.getter is called, and finally popTarget is called.

pushTarget and popTarget are where Dep.target is overwritten.

//github.com/vuejs/vue/blob/master/src/core/observer/dep.js#L45-L58
// the current target watcher being evaluated.
// this is globally unique because there could be only one
// watcher being evaluated at any time.
Dep.target = null
const targetStack = []

export function pushTarget (_target: Watcher) {
  if (Dep.target) targetStack.push(Dep.target)
  Dep.target = _target  // <==
}

export function popTarget () {
  Dep.target = targetStack.pop() // <==
}

In other words, before performing the update process, the Watcher declares to Dep using the Dep.target variable that "I am the one calling now."

Also, this.getter is the method that actually performs the update process.
In this case, it eventually calls the render(h) method mentioned earlier.

render(h) {
  return h('div', this.message)
},

Then, since this.message is a getter, Dep looks at Dep.target and adds the calling Watcher to the notification list... that's the flow.

The summary of the above content is as follows:

How is the Watcher notified when data changes?

We now understand how it knows who to notify.

So, how is the Watcher notified when data is overwritten? Let's look back at the image I posted earlier. This corresponds to "2. Notify change."

The notification mechanism uses setters this time.

What is a Setter?

It is a convenient feature that allows you to execute an arbitrary function when you try to set a value to an object's property.

Below is a simple example of a setter.
When you try to assign a value to obj.message, the message() function is executed.

const obj = {
  set message(newMessage) {
    console.log(`CALLED:${newMessage}`);
  }
}

obj.message = 'CHANGED' // => "CALLED:CHANGED" is displayed in the console

data is also a Setter

In addition to a getter, a setter is also configured for data.

To put it simply, Dep uses the setter to tell the Watcher that the value has changed.

Let's look at the original code again.

<!DOCTYPE html>
<html>
<body>
  <div id="target"></div>

  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script
    new Vue({
      el: '#target',
      data() {
        return {
          message: 'EXAMPLE'
        }
      },
      mounted() {
        setTimeout(() => this.message = 'CHANGED', 1000)
      },
      render(h) {
        return h('div', this.message)
      },
    })
  </script>
</body>
</html>

In the setTimeout, this.message = 'CHANGED' is executed after 1 second, and the setter process runs at this timing.

Let's see what the setter is doing...

//github.com/vuejs/vue/blob/master/src/core/observer/index.js#L129-L186
/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()
  ...
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    ...
    set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val;
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if ("development" !== 'production' && customSetter) {
        customSetter();
      }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      childOb = !shallow && observe(newVal);
      dep.notify();  // <--
    }
  })
}

In the last line, it calls dep.notify().
In dep.notify():

//github.com/vuejs/vue/blob/master/src/core/observer/dep.js#L22-L24
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;
  ...
  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update() // <--
    }
  }
}

It calls update() for all Watchers in the subs array. By calling the update method, it communicates to the Watcher that a change has occurred.

Now, let's look at what the Watcher does when it receives the notification:

//github.com/vuejs/vue/blob/master/src/core/observer/watcher.js#L156-L165
export default class Watcher {
  ...
  update () {
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this) // <--
    }
  }

The behavior after receiving the notification changes based on flags like lazy or sync in the Watcher.

Watchers are used not only for component updates like in this example, but also for the implementation of computed and props.

Therefore, depending on the flags, the timing of the update process can vary—such as executing the update immediately upon notification, or just setting a dirty flag and executing the update later when it's actually needed.

In the case of this component update, queueWatcher(this) is executed. As described in the Vue documentation, it is temporarily stored in a queue, and the component update is performed in the next event loop.

Regardless of the timing, when the update process is executed, the Watcher's get method is called. This is the code we looked at a bit earlier.

//github.com/vuejs/vue/blob/master/src/core/observer/watcher.js#L90-L115
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    ...
  } finally {
    ...
    popTarget()  // Note 3
    this.cleanupDeps()
  }
  return value
}

It starts the flow of declaring itself with pushTarget and updating the component with this.getter. From here on, it follows the same flow as explained in the section "Where is Dep.target set?".

Conclusion

In this article, we tracked the flow of data and component updates. For computed and props, although there are minor differences, the general flow is the same.

I hope this article is helpful to someone!

Discussion