avatar

Derek Zeng

A programmer

Vue 2.0 - 2 way data binding more in-depth analysis

by coderek

This is a review on https://github.com/DDFE/DDFE-blog/issues/7 based on vue 2.0.

(Courtesy of https://vuejs.org)

In the original article, the author did talk about different participants of 2 way binding, but what he has not explained clearly is how they interacted. Specifically, I have the following questions:

  1. what will happen when the observed object is changed.
  2. when and how the change propages along the dependcy chain.
  3. how does it collect dependency correctly.

We know that two way binding means that the data is changed by something (usually caused by user action through UI), and the change is observed by those UIs that use the data and the UIs are re-rendered to reflect that change. The data binding is between UI and data, data and UI.

In vue, before data binding is setup, the data object of vue is processed.

  1. proxied to Vue instance
  2. made reactive

The first one is easy to understand and explained well in the source article. Here I want to go through the second one in more details. Let's look at the code first.

/**
 * File: src/core/observer/index.js
 * commit: ab1203a same for all the code in this gist
 * NOTE: removed code that irrelevant to the analysis
 * NOTE: added comments
 * Define a reactive property on an Object.
 */
export function defineReactive (obj, key, val) {
  /* DZ: dependency instance in closure (only for obj[key])
         it's equivalent to an observable object */
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)

  /* DZ: data can define its own getter and setter, we are extending them here */
  const getter = property && property.get
  const setter = property && property.set

  /* DZ: observe child value recursively
         Note we are still in the "constructor code" in closure */
  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    get: function reactiveGetter () {
      /* DZ: this line is critical because it will recursively evaluate the val or trigger the getter 
             which means the code below will be executed for all its depended value first */
      const value = getter ? getter.call(obj) : val

      /* DZ: Dep.target is the watcher (observer) that triggers the getter */
      if (Dep.target) {
        /* DZ: this will subscribe Dep.target to the dep object */
        dep.depend()

        /* DZ: at meantime, child object has also become dependency, 
               since `{a: {b: 1}}` is considered changed when `b` is changed */
        if (childOb) {
          childOb.dep.depend()
        }
        /* DZ: similar to the above, but iterate array differently */
        if (Array.isArray(value)) {
          dependArray(value)
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      // DZ: no change then return (do nothing)
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }

      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // DZ: value is changed, so update the child observer
      childOb = observe(newVal)
      // DZ: notify dependent watchers (this is not recursive)
      dep.notify()
    }
  })
}

This is the first key function that makes the binding possible. Basically redefined getter and setter for all the values contained inside data object recursively. Despite their prescribed duties, getter is used to build dependency graph, and setter is used to notify the subscribers and update child observer.

This is the best use of closure I've ever seen in JavaScript world. Mind boggling. If it's not the closure, the Vue author would have to maintain several dictionaries of mappings of observer and obervables for each and every value, which would become a daunting task given that you have to constantly update them.

This function is called in the setup phase. Now let's see what happen when a value is set. Let's take an example:

const app = new Vue({
  data: {
    person: {
      name: "derek",
      age: 12
    }
  },
  watch: {
    person: {
      handler: ()=> console.log("person is changed"),
      deep: true, // deep watching
    }
  }
  el: '#app'
});

So now you can access person by caling app.person. Note that calling app.person is different from calling app.el because app.person is reactive but app.el is not.

Notice that I have register a watcher for person. Let's look at Watcher class:

/**
 * Simplified src/core/observer/watcher.js
 */
export default class Watcher {

  constructor (vm, expOrFn, cb) {
    this.vm = vm
    vm._watchers.push(this)
    ...
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
    /* DZ: this is important
           It runs the watcher getter once to collect all the depencies. 
           then when those dependencies change, the watcher will be triggered */
    this.value = this.get()
  }

  /**
   * Evaluate the getter, and re-collect dependencies.
   */
  get () {
    /* DZ: only one watcher is run at a time
           this is like a mutex
           it releases the lock by calling popTarget after it's done */
    pushTarget(this)

    /* DZ: getter is evaluated in the scope of vm */
    let value = this.getter.call(vm, vm)

    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value)
    }
    popTarget()

    return value
  }

  /**
   * Add a dependency to this directive.
   */
  addDep (dep: Dep) {
    dep.addSub(this)
  }

  /**
   * Subscriber interface.
   * Will be called when a dependency changes.
   */
  update () {
    this.run()
  }

  /**
   * Scheduler job interface.
   * Will be called by the scheduler.
   */
  run () {
    if (this.active) {
      const value = this.get()
      if (
        value !== this.value ||
        // Deep watchers and watchers on Object/Arrays should fire even
        // when the value is the same, because the value may
        // have mutated.
        isObject(value) ||
        this.deep
      ) {
        // set new value
        const oldValue = this.value
        this.value = value
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}

When the watcher is created, it has run the get method once to collect all the dependencies (see line 50). It subscribes itself to the Dep object of each referenced property. And literally, it starts watching those properties.

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)
  }

  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

For the watcher person, it will have dependency on name. When name is changed, person should be considered changed. When you call app.person.name = "zeng", this will trigger the setter for the name property on person object. The dep object of name then notify all the subscribers which include the person watcher. The update method is run then trigger the watch handler.

Essentially, the Computed property is implemented using watcher.

This is how data object -> watcher flow with correct dependency management.

Let's look at how watcher -> renderer works now.

In the source article where Vue 1.0 is analyzed, vue compilation process binds each directive with an watcher. The callback of the watcher is used to update the view. In Vue 2.0, this is changed.

const updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

// src/core/instance/lifecycle.js:190
vm._watcher = new Watcher(vm, updateComponent, noop)

Only one watcher is created in Vue 2.0 to watch data for rendering. We can see that, no callback is supplied here. This is because vm._render() has dependency on data object (only the portion of data that needed to render the template). Recall that when Watcher is initialized, the data dependency is built up. So when data is changed, the dep.notify() is triggered and hence the watcher run method, and hence the expOrFn is evaluated, hence the updateComponent function. A brilliant way to use watcher!

This is also considered as a huge speed boost as only one watcher is created comparing to many watchers in Vue 1.0.

The _render function is itself another topic.

(End of article)