Touch and take you to understand Vue's responsive principle

preface

As the core of Vue, responsive principle uses data hijacking to realize data-driven view. In the interview is often examined knowledge points, but also interview bonus items.

This paper will analyze the workflow of response principle step by step, mainly in the following structure:

  1. Analyze key members and understand them to help understand the process
  2. Split the process and understand its role
  3. Combined with the above points, understand the overall process

The article is a little long, but most of it is code implementation, please watch patiently. In order to facilitate the understanding of the principle, the code in this article will be simplified. If you can, please refer to the source code for learning.

leading member

In the responsive principle, the three classes of Observe, Watcher and Dep are the main members of the complete principle.

  • Observe, the entrance of the responsive principle, processes the observation logic according to the data type
  • Watcher, which is used to perform update rendering. The component will have a rendering watcher. We often say that collection dependency is to collect watcher
  • Dep, dependency collector and attribute all have a Dep, which is convenient to find the corresponding dependency trigger update in case of change

Let's take a look at the implementation of these classes, including the main properties and methods.

Observe: I will observe the data

Warm tip: the serial number in the code corresponds to the explanation of the serial number below the code block

// Source location / src/core/observer/index.js
class Observe {
  constructor(data) {
    this.dep = new Dep()
    // 1
    def(data, '__ob__', this)
    if (Array.isArray(data)) {
      // 2
      protoAugment(data, arrayMethods)
      // 3
      this.observeArray(data)
    } else {
      // 4
      this.walk(data)
    }
  }
  walk(data) {
    Object.keys(data).forEach(key => {
      defineReactive(data, key, data[key])
    })
  }
  observeArray(data) {
    data.forEach(item => {
      observe(item)
    })
  }
}
  1. Add for observed properties__ ob__ Property, whose value is equal to this, that is, the current Observe instance
  2. Add overridden array methods to the array, such as push, unshift, splice and so on. The purpose of overriding is to update and render when these methods are called
  3. Observe the data in the array. new Observe will be called inside the observe to form a recursive observation
  4. For observation object data, defineReactive defines get and set for data, i.e. data hijacking

Dep: I will rely on for data collection

// Source location / src/core/observer/dep.js
let id = 0
class Dep{
  constructor() {
    this.id = ++id // dep unique identification
    this.subs = [] // Store Watcher
  }
  // 1
  depend() {
    Dep.target.addDep(this)
  }
  // 2
  addSub(watcher) {
    this.subs.push(watcher)
  }
  // 3
  notify() {
    this.subs.forEach(watcher => watcher.update())
  }
}

// 4
Dep.target = null

export function pushTarget(watcher) {
  Dep.target = watcher
} 

export function popTarget(){
  Dep.target = null
}

export default Dep
  1. The main methods relied on for data collection, Dep.target Is a watcher instance
  2. Add watcher to array, that is, add dependency
  3. When the property changes, the notify method will be called to notify each dependency to update
  4. Dep.target It is used to record the watcher instance, which is globally unique. Its main purpose is to find the corresponding Watcher in the process of collecting dependencies

pushTarget and popTarget are two obvious methods for setting Dep.target Of. Dep.target It is also a key point. It may be difficult to understand this concept when viewing the source code for the first time. In the later process, we will explain its function in detail. We need to pay attention to this part of the content.

Watcher: I'll trigger the view update

// Source location / src/core/observer/watcher.js
let id = 0
export class Watcher {
  constructor(vm, exprOrFn, cb, options){
    this.id = ++id  // watcher unique ID
    this.vm = vm
    this.cb = cb
    this.options = options
    // 1
    this.getter = exprOrFn
    this.deps = []
    this.depIds = new Set()

    this.get()
  }
  run() {
    this.get()
  }
  get() {
    pushTarget(this)
    this.getter()
    popTarget(this)
  }
  // 2
  addDep(dep) {
    // Prevent adding dep repeatedly
    if (!this.depIds.has(dep.id)) {
      this.depIds.add(dep.id)
      this.deps.push(dep)
      dep.addSub(this)
    }
  }
  // 3
  update() {
    queueWatcher(this)
  }
}
  1. this.getter Stored are functions that update the view
  2. The watcher stores the DEP, and the dep also stores the watcher for bidirectional recording
  3. Trigger the update. queueWatcher is for asynchronous update. Asynchronous update will call run method to update the page

Responsive principle process

We have a general understanding of the functions of the above members. Let's combine them to see how these functions work in a responsive principle process.

Data observation

The data will be initialized to create the observe class through the observe method

// Source location / src/core/observer/index.js
export function observe(data) {
  // 1
  if (!isObject(data)) {
    return
  }
  let ob;
  // 2
  if (data.hasOwnProperty('__ob__') && data.__ob__ instanceof Observe) {
    ob = data.__ob__
  } else {
    // 3
    ob = new Observe(data)
  }
  return ob
}

During initialization, the data obtained by observe is the object returned in the data function.

  1. observe function only observes data of object type
  2. The observed data will be added__ ob__ Attribute. It can prevent repeated observation by judging whether the attribute exists
  3. Create the Observe class and start processing the observation logic

Object observation

Enter the Observe internal, because the initialized data is an object, the walk method will be called:

walk(data) {
  Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key])
  })
}

Internal use of defineReactive method Object.defineProperty Hijacking data is the core of the responsive principle.

function defineReactive(obj, key, value) {
  // 1
  let childOb = observe(value)
  // 2
  const dep = new Dep()
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) {
        // 3
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
        }
      }
      return value
    },
    set(newVal) {
      if (newVal === value) {
        return
      }
      value = newVal
      // 4
      childOb = observe(newVal)
      // 5
      dep.notify()
      return value
    }
  })
}
  1. Because the value may be of object type, observe needs to be called for recursive observation
  2. The dep here is that every attribute mentioned above will have a DEP, which exists as a closure and is responsible for collecting dependency and notification updates
  3. At initialization, Dep.target Is the component's render watcher, here dep.depend This watcher is the dependence of collection, childOb.dep.depend Mainly collect dependencies for arrays
  4. The new value set may be of the object type and needs to be observed
  5. Value changes, dep.notify Notify the watcher to update, which is the trigger point for us to update the page in real time after changing the data

Through Object.defineProperty After the property is defined, the get callback is triggered when the property is acquired, and the set callback is triggered when the property is set, so as to realize the responsive update.

Through the above logic, we can also find out why Vue3.0 uses proxy instead of Object.defineProperty Yes. Object.defineProperty You can only define a single attribute. If the attribute is an object type, you need to recursively observe it, which will consume performance. Proxy is the proxy for the whole object, and the callback will be triggered whenever the property changes.

Array observation

For array type observations, the observeArray method is called:

observeArray(data) {
  data.forEach(item => {
    observe(item)
  })
}

Unlike objects, it performs observe to observe the types of objects in the array, not every item in the array Object.defineProperty In other words, there is no dep for the items in the array.

Therefore, when we modify an item through an array index, the update will not be triggered. But you can modify the trigger update through this.$set. So the question is, why does Vue design this way?

Combined with the actual scenario, the array usually holds multiple data, such as list data. This observation will consume performance. Another reason is that generally, modifying array elements rarely replaces the whole element directly through index. For example:

export default {
    data() {
        return {
            list: [
                {id: 1, name: 'Jack'},
                {id: 2, name: 'Mike'}
            ]
        }
    },
    cretaed() {
        // If you want to change the value of name, you usually use
        this.list[0].name = 'JOJO'
        // Not the following
        // this.list[0] = {id:1, name: 'JOJO'}
        // Of course you can update it like this
        // this.$set(this.list, '0', {id:1, name: 'JOJO'})
    }
}

Array method override

When an array element is added or deleted, the view is updated. This is not a matter of course, but Vue internally rewrites the array methods. When these methods are called, the array will update and detect, triggering the view update. These methods include:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

Back in the Observe class, when the observed data type is an array, the protoaugust method is called.

if (Array.isArray(data)) {
  protoAugment(data, arrayMethods)
  // Watch array
  this.observeArray(data)
} else {
  // Observation object
  this.walk(data)
}

In this method, the array prototype is replaced by arrayMethods. When calling the array changing method, the overridden method is preferred.

function protoAugment(data, arrayMethods) {
  data.__proto__ = arrayMethods
}

Next let's see how arrayMethods are implemented:

// Source location / src/core/observer/array.js
// 1
let arrayProto = Array.prototype
// 2
export let arrayMethods = Object.create(arrayProto)

let methods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'reverse',
  'sort',
  'splice'
]

methods.forEach(method => {
  arrayMethods[method] = function(...args) {
    // 3
    let res = arrayProto[method].apply(this, args)
    let ob = this.__ob__
    let inserted = ''
    switch(method){
      case 'push':
      case 'unshift':
        inserted = args
        break;
      case 'splice':
        inserted = args.slice(2)
        break;
    }
    // 4
    inserted && ob.observeArray(inserted)
    // 5
    ob.dep.notify()
    return res
  }
})
  1. Save the prototype of the array, because in the rewritten array method, you still need to call the native array method
  2. arrayMethods is an object for saving overridden methods, which are used here Object.create(arrayProto) objects are created so that users can inherit and use native methods when calling non overriding methods
  3. Call the native method to store the return value, which is used to set the return value of the rewriting function
  4. Inserted stores the new value. If inserted exists, observe the new value
  5. ob.dep.notify Trigger view update

Dependency collection

Dependency collection is not only the premise of view updating, but also the crucial part of responsive principle.

Pseudo code flow

To facilitate understanding, write a piece of pseudo code here, about the process of relying on Collection:

// Data data
let data = {
    name: 'joe'
}

// Render watcher
let watcher = {
    run() {
        dep.tagret = watcher
        document.write(data.name)
    }
}

// dep
let dep = [] // Storage dependency 
dep.tagret = null // Record watcher

// Data hijacking
Object.defineProperty(data, 'name', {
   get(){
       // Collect dependencies
       dep.push(dep.tagret)
   },
   set(newVal){
       data.name = newVal
       dep.forEach(watcher => {
           watcher.run()
       })
   }
})

initialization:

  1. First define get and set for the name property
  2. Then initialization is performed once watcher.run Render Page
  3. At this time, get data.name , trigger the get function to collect dependencies.

to update:

Modification data.name , trigger the set function and call run to update the view.

Real process

Let's see how the real dependency collection process works.

function defineReactive(obj, key, value) {
  let childOb = observe(value)
  const dep = new Dep()
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) {
        dep.depend() // Collect dependencies
        if (childOb) {
          childOb.dep.depend()
        }
      }
      return value
    },
    set(newVal) {
      if (newVal === value) {
        return
      }
      value = newVal
      childOb = observe(newVal)
      dep.notify()
      return value
    }
  })
}

First, initialize the data and call the defineReactive function to hijack the data.

export class Watcher {
  constructor(vm, exprOrFn, cb, options){
    this.getter = exprOrFn
    this.get()
  }
  get() {
    pushTarget(this)
    this.getter()
    popTarget(this)
  }
}

Initialize to mount the watcher to Dep.target , this.getter Start rendering the page. When rendering a page, you need to take a value for the data and trigger the get callback, dep.depend Collect dependencies.

class Dep{
  constructor() {
    this.id = id++
    this.subs = []
  }
  depend() {
    Dep.target.addDep(this)
  }
}

Dep.target For the watcher, call the addDep method and pass in the dep instance.

export class Watcher {
  constructor(vm, exprOrFn, cb, options){
    this.deps = []
    this.depIds = new Set()
  }
  addDep(dep) {
    if (!this.depIds.has(dep.id)) {
      this.depIds.add(dep.id)
      this.deps.push(dep)
      dep.addSub(this)
    }
  }
}

After adding DEP in addDep, call dep.addSub And pass in the current watcher instance.

class Dep{
  constructor() {
    this.id = id++
    this.subs = []
  }
  addSub(watcher) {
    this.subs.push(watcher)
  }
}

Collect the incoming watcher, and the dependency collection process is completed.

To add, usually there are many attribute variables bound to the page, and the rendering will take values for the attributes. At this time, each attribute collection depends on the same watcher, that is, the component's rendering watcher.

Dependency collection of arrays

methods.forEach(method => {
  arrayMethods[method] = function(...args) {
    let res = arrayProto[method].apply(this, args)
    let ob = this.__ob__
    let inserted = ''
    switch(method){
      case 'push':
      case 'unshift':
        inserted = args
        break;
      case 'splice':
        inserted = args.slice(2)
        break;
    }
    // Observation of new values
    inserted && ob.observeArray(inserted)
    // update the view
    ob.dep.notify()
    return res
  }
})

Remember that in the overridden method, the ob.dep.notify Update view__ ob__ It is the identification defined for observation data in Observe. The value is the Observe instance. So ob.dep Where are the dependencies collected?

function defineReactive(obj, key, value) {
  // 1
  let childOb = observe(value)
  const dep = new Dep()
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) {
        dep.depend()
        // 2
        if (childOb) {
          childOb.dep.depend()
        }
      }
      return value
    },
    set(newVal) {
      if (newVal === value) {
        return
      }
      value = newVal
      childOb = observe(newVal)
      dep.notify()
      return value
    }
  })
}
  1. The return value of the Observe function is the Observe instance
  2. childOb.dep.depend Execute, add dependency for dep of Observe instance

So when the array is updated, ob.dep Dependency has been collected in.

Overall process

Next, go through the initialization process and update process. If you are looking at the source code for the first time, and don't know where to start, you can also refer to the following order. Because there are many source code implementations, the source code shown below will be slightly reduced

Initialization process

Entry file:

// Source location / src/core/instance/index.js
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'

function Vue (options) {
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

_init:

// Source location / src/core/instance/init.js
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++

    // merge options
    if (options && options._isComponent) {
      // optimize internal component instantiation
      // since dynamic options merging is pretty slow, and none of the
      // internal component options needs special treatment.
      initInternalComponent(vm, options)
    } else {
      // mergeOptions merges the mixin options and the options passed in
      // The $options here can be understood as the object passed in when new Vue
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }

    // expose real self
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    // Initialization data
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

    if (vm.$options.el) {
      // Initialize the render page mount component
      vm.$mount(vm.$options.el)
    }
  }
}

The above focuses on two functions: initState initialization data, VM. $mount (VM$ options.el )Initializes the rendered page.

Enter initState first:

// Source location / src/core/instance/state.js 
export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    // data initialization
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

function initData (vm: Component) {
  let data = vm.$options.data
  // When data is a function, execute the data function and take out the return value
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  // observe data
  // Here we go to the logic of observation data
  observe(data, true /* asRootData */)
}

The internal process of observe has been mentioned above, and it's simple here:

  1. new Observe data
  2. defineReactive hijacking data

After the initState logic is executed, go back to the beginning, and then execute VM. $mount (VM$ options.el )Render page:

$mount:

// Source location / src/platforms/web/runtime/index.js 
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

mountComponent:

// Source location / src/core/instance/lifecycle.js
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  callHook(vm, 'beforeMount')

  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    updateComponent = () => {
      const name = vm._name
      const id = vm._uid
      const startTag = `vue-perf-start:${id}`
      const endTag = `vue-perf-end:${id}`

      mark(startTag)
      const vnode = vm._render()
      mark(endTag)
      measure(`vue ${name} render`, startTag, endTag)

      mark(startTag)
      vm._update(vnode, hydrating)
      mark(endTag)
      measure(`vue ${name} patch`, startTag, endTag)
    }
  } else {
    // This method is called when the data changes
    updateComponent = () => {
      // vm._render() returns vnode, which will take the value of data data
      // vm._update turns vnode into real dom and renders it to the page
      vm._update(vm._render(), hydrating)
    }
  }
  
  // Execute Watcher, which is the rendering wacther mentioned above 
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

Watcher:

// Source location / src/core/observer/watcher.js 
let uid = 0

export default class Watcher {
  constructor(vm, exprOrFn, cb, options){
    this.id = ++id
    this.vm = vm
    this.cb = cb
    this.options = options
    // exprOrFn is the updateComponent passed in above
    this.getter = exprOrFn

    this.deps = []
    this.depIds = new Set()

    this.get()
  }
  get() {
    // 1. pushTarget records the current watcher to Dep.target , Dep.target  It's unique
    pushTarget(this)
    let value
    const vm = this.vm
    try {
    // 2. Call this.getter  It is equivalent to executing VM_ Render function, which takes the value of the attribute on the instance,
    //This triggers Object.defineProperty  In the get method of the( dep.depend ), which depends on collection Dep.target
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      // 3. popTarget will Dep.target  Empty
      popTarget()
      this.cleanupDeps()
    }
    return value
  }
}

At this point, the initialization process is completed. The main tasks of the initialization process are data hijacking, page rendering and collection dependency.

Update process

Data changes, trigger set, execute dep.notify

// Source location / src/core/observer/dep.js 
let uid = 0

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

  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()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      // Execute the update method of the watcher
      subs[i].update()
    }
  }
}

wathcer.update:

// Source location / src/core/observer/watcher.js 
/**
 * Subscriber interface.
 * Will be called when a dependency changes.
 */
update () {
  /* istanbul ignore else */
  if (this.lazy) {  // Calculation property update
    this.dirty = true
  } else if (this.sync) {  // Synchronize updates
    this.run()
  } else {
    // General data will be updated asynchronously
    queueWatcher(this)
  }
}

queueWatcher:

// Source location / src/core/observer/scheduler.js

// Used to store watcher
const queue: Array<Watcher> = []
// For watcher de duplication
let has: { [key: number]: ?true } = {}
/**
 * Flush both queues and run the watchers.
 */
function flushSchedulerQueue () {
  let watcher, id

  // Sort watcher s
  queue.sort((a, b) => a.id - b.id)

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    id = watcher.id
    has[id] = null
    // run method update view
    watcher.run()
  }
}
/**
 * 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) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    // watcher adding array
    queue.push(watcher)
    // Asynchronous update
    nextTick(flushSchedulerQueue)
  }
}

nextTick:

// Source location / src/core/util/next-tick.js

const callbacks = []
let pending = false

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  // Traversal callback function execution
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

let timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
  }
}

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // Add callback function to array
  callbacks.push(() => {
    if (cb) {
      cb.call(ctx)
    }
  })
  if (!pending) {
    pending = true
    // Traversal callback function execution
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

This step is to use the micro task to execute the callback function asynchronously, which is p.then above. Finally, the watcher.run Update the page.

This completes the update process.

Write at the end

If I haven't contacted the source code students, I believe I will be a little confused after reading it, which is very normal. It is recommended that you read the source code several times to know the process. For the students who have a foundation, it's like reviewing.

Want to become strong, learn to see the source code is the only way. In this process, we can not only learn the design idea of the framework, but also cultivate our own logical thinking. It is difficult to start everything. Sooner or later, we should take this step. It is better to start from today.

I have put the simplified code in github , you can have a look if you need.

Keywords: Javascript Vue Attribute github

Added by chanchelkumar on Mon, 22 Jun 2020 06:07:29 +0300