一个简单示例-刷新你对Vue2响应式原理的认知

6,797 阅读9分钟

1 序

为什么是不一样的Vue2响应式原理?

事情源于前两天给同事review代码,发现了一个超出自己认知的Vue2响应式现象,一个很有意思的现象,示例大家可以参考这个 沸点

Vue2对象的响应式,大家普遍的认知是这样的,来源于官网

  • 对于对象

    简单来说就是:对象的响应式只能针对已存在的属性,obj.newProdelete obj.oldProVue2的响应式是拦截不到的,需要使用 this.$set或者Vue.set方法才可以

  • 对于数组

    简单来说就是:数组的响应式一般是通过 7 个数组操作方法(被Object.defineProperty教育过的七个葫芦娃)来实现响应式,像是arr[0] = 1arr.length = 0这种是没办法实现响应式的

但是,不一样的Vue2响应式原理就会让你重新认识,发现上面的说法不是绝对的正确

接下来我们就通过一个简单的示例来从源码层次去找答案,去解析Vue从初始化到更新这两个过程都做了什么,当然,这篇文章主要是为了说明问题,源码做了一部分精简,全部拿出来的话太多了。

2 以下示例的现象和内部原理(执行过程)是什么?

大家可以先看代码,再看答案,当然也可以自己写个示例试一试,验证以下

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id = "app">
    <p>{{ form }}</p>
    <ul>
      <li v-for = "item in arr" :key = "item">{{ item }}</li>
    </ul>
  </div>
  <script src = "../../dist/vue.js"></script>
  <script>
  const ins = new Vue({
    data () {
      return {
        form: {
          name: 'lyn'
        },
        arr: [1]
      }
    },
    mounted () {
      setTimeout(() => {
        this.form.name = 'test'
        this.arr[0] = 11
      }, 2000)
    }
  })
  ins.$mount('#app')
  </script>
</body>
</html>

2.1 先上结论

相信很多同学的回答都是,页面初始完成,显示内容为:

然后两种以后执行定时函数,页面更新为入下:

如果你的回答是这个,那这篇文章你是值得一看的

当然你的回答所用的理论基础是没错的,但是答案为什么会错呢?理由很简单,对响应式整个执行过程的理解认知有那么一些瑕疵,至少前两天在看到这个现象时的我是这样的

2.1.1 现象

初始渲染结果为:

2s 钟后执行定时函数,页面更新为:

2.1.2 内部原理

示例代码被加载到浏览器以后,Vue开始初始化,执行各种init操作,其中最重要的就是实例化组件Watcher、初始化数据、收集依赖(dep)、关联depwatcher,当然中间穿插着生命周期方法的执行,比如beforeCreatecreatedbeforeMountmounted,如果有子组件的话,beforemount执行完了会去初始化子组件,直到自组件的mounted执行完成,然后回来执行mounted,唉,扯远了,不过不影响,问题点不在初始化,而是后面的更新。

页面渲染完 2s 后执行定时函数

首先执行 this.form.name = 'test',这里分了两步执行

  • 首先this.form触发getter得到value = { name: 'lyn' }

  • 然后执行value.name = 'test'触发setter,更新数据,现在this.form.name的值为test

setter中触发dep.notify(),通知watcher执行自己的update方法,update方法会将watcher自己 push 进一个队列(queue数组),然后调用nextTick 方法注册一个刷新队列(其实就是执行queue数组中的每个watcher的run方法)的函数,nextTick方法将刷新队列的函数用一个箭头函数包裹起来然后保存到一个名叫callbacks的数组中,接下来nextTick会执行timerFunc函数

timerFunc函数利用浏览器的异步机制,将刷新callbacks数组的函数注册为一个异步任务,当所有的同步任务执行完成以后就会去刷新刚才注册的队列,由于现在同步任务还没执行完,还差一个this.arr[0] = 11,所以异步任务暂时先挂起

接下来执行this.arr[0] = 11,这里也是分两步执行

  • 首先this.arr会触发getter得到value = [1]

  • 然后,就没了,因为this.arr[0] = 11这样的写法,Vue2的响应式核心Object.defineProperty无法拦截,但是

但是很重要的一点,this.arr[0] = 11这句代码确实是执行了,就意味着this.arr现在的值真的是[11]了,很重要,有疑问,带着疑问接着往下看

到这里所有的同步任务执行完成,开始执行刚才注册的异步任务

上面说的异步任务,就那堆回调函数,它最终做的事情很简单(纯粹),就是执行watcher.run方法,watcher.run方法执行watcher.get方法,get方法负责执行updateComponent方法,这个方法是初始化组件时,实例化watcher时传递给watcher的,执行updateComponent方法时会先执行vm._render函数生成新的vdom,注意,生成新的vdom时需要去读取vue实例也就是this上的各个属性,当然只读取模版中用到的属性,我们的示例中就是this.formthis.arr,读到这里是不是已经有点明白了?

虽然this.arr[0] = 11没办法触发Vue2的响应式机制,但是它却可以更改this的属性值,所以,就在页面上看到了之前不理解的一幕

生成新的vdomupdateComponent执行vm._update方法,调用patch方法,对比新旧vdom,找出发生变化的dom节点,然后更新

看到这里是不是已经有点明白了,是不是也有点不一样的想法了?比如:

我就想通过this.arr[idx] = xxx更新数组元素,不想用this.splice之类的方法,这时只需要再带一个可以触发setter的有效操作即可,当然,在实际中还是不要这么写,就当是一个比较有意思的黑魔法吧,毕竟万一你写了,给别人带来不好体验就不太好了。

看到这里不知道是直接就明白了?还是有点懵,可以接着往下看,从源码中找答案,代码经过精简,有详细的注释,看完以后,自己回想一下过程,再回来对照着这个结论看,一定会有很大的收获的。

2.2 从源码中找答案

这一节会分析整个示例代码的执行过程,当内容被加载到浏览器以后,Vue源码的执行过程是这样的:

  • src/core/instance/index.js

    /**
     * Vue 构造函数,执行初始化操作
     */
    function Vue (options) {
      this._init(options)
    }
    
  • src/core/instance/init.js

    /**
     * 执行各种初始化操作,比如:
     * 最重要的给数据设置响应式(这部分内容就不展开了,否则太多了)然后执行实例的 $mount 方法
     */
    Vue.prototype._init = function (options?: Object) {
      initLifecycle(vm)
      initEvents(vm) 
      initRender(vm)
      callHook(vm, 'beforeCreate')
      initInjections(vm) // resolve injections before data/props
      initState(vm)
      initProvide(vm) // resolve provide after data/props
      callHook(vm, 'created')
      
      if (vm.$options.el) {
        vm.$mount(vm.$options.el)
      }
    }
    
  • src/platforms/web/runtime/index.js

    /**
     * $mount, 负责执行 mountComponent 方法
     */
    Vue.prototype.$mount = function (
      el?: string | Element,
      hydrating?: boolean
    ): Component {
      el = el && inBrowser ? query(el) : undefined
      return mountComponent(this, el, hydrating)
    }
    
  • src/platforms/web/entry-runtime-with-compiler.js

    /**
     * 不用管这个,和问题无关,这里其实重写了 $mount 执行了编译模版的动作,
     * 最后生成 render 函数,这部分内容被我删了
     */
    const mount = Vue.prototype.$mount
    Vue.prototype.$mount = function (
      el?: string | Element,
      hydrating?: boolean
    ): Component {
      return mount.call(this, el, hydrating)
    }
    
  • src/core/instance/lifecycle.js

    /**
     * mountComponent 方法
     * 很重要的几点
     * 1、定义组件的 updateComponent 方法
     * 2、实例化组件 watcher,并将 updateComponent 方法传递给 watcher
     *
     * watcher 后面会在自己的 run 方法中调用 get 方法,get 方法会负责执行这个 updateComponent 方法,重新生成新的 vdom,watcher 相关看下面的 watcher 部分
     */
    export function mountComponent (
      vm: Component,
      el: ?Element,
      hydrating?: boolean
    ): Component {
      callHook(vm, 'beforeMount')
    
      let updateComponent = () => {
        // vm._render 执行后会生成新的 vdom,vm._update 方法会调用 patch 方法,对比新旧 dom,更新视图
        vm._update(vm._render(), hydrating)
      }
    
      // we set this to vm._watcher inside the watcher's constructor
      // since the watcher's initial patch may call $forceUpdate (e.g. inside child
      // component's mounted hook), which relies on vm._watcher being already defined
      new Watcher(vm, updateComponent, noop, {
        before () {
          if (vm._isMounted && !vm._isDestroyed) {
            callHook(vm, 'beforeUpdate')
          }
        }
      }, true /* isRenderWatcher */)
      hydrating = false
    
      // 调用组件实例的 mounted 方法
      if (vm.$vnode == null) {
        vm._isMounted = true
        callHook(vm, 'mounted')
      }
      return vm
    }
    
    /**
     *负责执行各种各样的生命周期方法,比如 mounted
     */
    export function callHook (vm: Component, hook: string) {
      // handlers = vm.$options.mounted
      const handlers = vm.$options[hook]
      const info = `${hook} hook`
      if (handlers) {
        for (let i = 0, j = handlers.length; i < j; i++) {
          invokeWithErrorHandling(handlers[i], vm, null, vm, info)
        }
      }
    }
    
    /**
     * 负责执行 patch 方法,分为首次渲染和再次更新
     */
    Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
      const vm: Component = this
      const prevEl = vm.$el
      const prevVnode = vm._vnode
      const restoreActiveInstance = setActiveInstance(vm)
      vm._vnode = vnode
      // Vue.prototype.__patch__ is injected in entry points
      // based on the rendering backend used.
      if (!prevVnode) {
        // initial render
        vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
      } else {
        // updates
        vm.$el = vm.__patch__(prevVnode, vnode)
      }
    }
    
  • src/core/util/error.js

    // 执行 声明周期方法
    export function invokeWithErrorHandling (
      handler: Function,
      context: any,
      args: null | any[],
      vm: any,
      info: string
    ) {
      // 真正执行声明周期方法的地方
      return args ? handler.apply(context, args) : handler.call(context)
    }
    

走到这里,mounted 方法已经执行完成,页面也已经渲染完成,接下来就是执行 2s 后的定时函数,更新数据,通过响应式拦截触发视图更新

```javascript
/**
 * mounted 方法中的定时函数
 */
setTimeout(() => {
  this.form.name = 'test'
  this.arr[0] = 11
}, 2000)
```

接下来分析定时器注册的回调函数执行过程是什么

  • src/core/observer/index.js

    执行定时函数时会触发下面的gettersetter,比如:this.form.name = 'test'会先执行this.form触发getter得到value = { name: 'lyn' },再执行value.name = 'test'触发setter更新name属性,然后执行dep.notify()

    /**
     * 这个其实就是数据响应式的核心了,拦截了示例对象上的各个属性,数据读取时执行 get,设置数据时执行 set
     */
    export function defineReactive (
      obj: Object,
      key: string,
      val: any,
      customSetter?: ?Function,
      shallow?: boolean
    ) {
      const dep = new Dep()
    
      let childOb = !shallow && observe(val)
      Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter () {
          const value = getter ? getter.call(obj) : val
          if (Dep.target) {
            dep.depend()
            if (childOb) {
              childOb.dep.depend()
              if (Array.isArray(value)) {
                dependArray(value)
              }
            }
          }
          return value
        },
        set: function reactiveSetter (newVal) {
          const value = getter ? getter.call(obj) : val
          /* eslint-disable no-self-compare */
          if (newVal === value || (newVal !== newVal && value !== value)) {
            return
          }
          // #7981: for accessor properties without setter
          if (getter && !setter) return
          if (setter) {
            setter.call(obj, newVal)
          } else {
            val = newVal
          }
          childOb = !shallow && observe(newVal)
          // 通知 watcher 去执行 update 方法
          dep.notify()
        }
      })
    }
    
  • src/core/observer/dep.js

    /**
     * A dep is an observable that can have multiple
     * directives subscribing to it.
     *
     * dep 负责收集依赖,通知 watcher 更新
     * 这里只保留了 notify(通知watcher更新) 和 构造函数
     */
    export default class Dep {
      static target: ?Watcher;
      id: number;
      subs: Array<Watcher>;
    
      constructor () {
        this.id = uid++
        this.subs = []
      }
      // 通知通知执行 update 方法
      notify () {
        // stabilize the subscriber list first
        const subs = this.subs.slice()
        for (let i = 0, l = subs.length; i < l; i++) {
          // 这个其实就是 watcher 的 update 方法
          subs[i].update()
        }
      }
    }
    
  • src/core/observer/watcher.js

    /**
     * A watcher parses an expression, collects dependencies,
     * and fires callback when the expression value changes.
     * This is used for both the $watch() api and directives.
     * 
     * 一个组件对应一个 watcher 实例(渲染watcher),实例化过程是在 mountComponent 方法中做的,也就是执行 vm.$mount 之后
     * 在同步执行过程中最重要的就是将当前 watcher 实例 push 到一个 watcher 执行队列中,
     * 待将来执行,通过一个 Promise.resolve().then() 来执行 run 方法,从而执行 updateComponent 方法
     */
    export default class Watcher {
      constructor (
        vm: Component,
        // updateComponent
        expOrFn: string | Function,
        // noop
        cb: Function,
        options?: ?Object,
        isRenderWatcher?: boolean
      ) {
        this.vm = vm
        // 很重要,就是租价更新方法,updateComponent
        this.getter = expOrFn
      }
       
      /**
       * Evaluate the getter, and re-collect dependencies.
       * 
       * 由 this.run 执行,执行 updateComponent 方法,生成新的 vdom,然后执行 patch,更新视图
       */
      get () {
        // Dep.target = watcher实例,这里让 dep 和 watcher 关联
        pushTarget(this)
        let value
        // 组件实例
        const vm = this.vm
        try {
          // 这里其实执行的是这个 updateComponent 方法:
          // let updateComponent = () => { vm._update(vm._render(), hydrating) }
          value = this.getter.call(vm, vm)
        } catch (e) {
        } finally {
          // "touch" every property so they are all tracked as
          // dependencies for deep watching
          if (this.deep) {
            traverse(value)
          }
          popTarget()
        }
        return value
      }
    
      /**
       * 将 watcher 实例加入 watcher 队列
       */
      update () { 
        queueWatcher(this)
      }
    
      /**
       * Scheduler job interface.
       * Will be called by the scheduler.
       *
       *
       * 这里其实通过 timerFunc 来调用,借用了浏览器的异步机制(Promise)
       * 执行 this.get 方法,让 get 执行 updateComponent
       */
      run () {
        const value = this.get()
      }
    }
    
  • src/core/observer/scheduler.js

    /**
     * 将 watcher push 进 queue 数组,然后注册一个回到函数,在将来[Promise.resolve().then()]来执行这些 watcher 的 run 方法
     */
    export function queueWatcher (watcher: Watcher) {
      queue.push(watcher)
      // 这里其实就是注册回调函数 flushSchedulerQueue 
      nextTick(flushSchedulerQueue)
    }
    
    /**
     * 负责让队列中所有的 watcher 执行自己的 run 方法.
     */
    function flushSchedulerQueue () {
      for (index = 0; index < queue.length; index++) {
        watcher = queue[index]
        watcher.run()
      }
    }
    
  • src/core/util/next-tick.js

    如果宏任务、微任务不太理解,可以看 这篇文章

    /**
     * 很重要的几个点
     *
     * 定义 nextTick 方法,将回调函数全部放到一个 callbacks 数组,然后执行 timerFunc
     * 定义 timerFunc,其实就是就是利用了浏览器的异步任务机制,这里选了 Promise 微任务,Vue首选就是Promise
     * Promise.resolve().then() 注册的回调函数就是刷新刚才存储的 queue 队列(数组),
     * 执行 watcher.run(),触发 updateComponent,这里很关键的一点是理解宏任务、微任务,
     * 当宏任务都执行结束后,比如示例中的整个setTimeout 回调,就会执行这里注册的微任务,Promise.resolve().then()
     */ 
    const callbacks =[]
    let pending = false
    
    // nextTick 就是用一个箭头函数将 flushSchedulerQueue 函数包裹然后放到 callbacks 数组
    export function nextTick (cb?: Function, ctx?: Object) {
      let _resolve
      callbacks.push(() => {
        cb.call(ctx)
      })
      if (!pending) {
        pending = true
    		// 就是执行一个 异步 方法,首选 Promise
        timerFunc()
      }
    }
    
    
    // 执行一个立即就绪 Promise,Promise 回调负责执行 flushCallbacks 函数
    let timerFunc = () => {
      Promise.resolve().then(flushCallbacks)
    }
    
    /**
     * 执行 callbacks 数组中的 () => flushSchedulerQueue.call(ctx),而最终会放 watcher 去执行自己的 run 方法,
     * run 方法执行 get 方法,get 方法中最终会调用组件的 updateComponent 方法,然后执行 render 重新生成 vnode,然后执行
     * patch 过程,最终更新 dom
     */
    function flushCallbacks () {
      pending = false
      const copies = callbacks.slice(0)
      callbacks.length = 0
      for (let i = 0; i < copies.length; i++) {
        copies[i]()
      }
    }
    
  • vm._render 函数执行后生成的内容是什么?

    /**
     * 可以看到,生成 vdom 的时候,会去组件实例对象上读取响应的属性值,比如我们这里的,this.form,this.arr
     * 理解这里很重要,为什么我们的视图会被有效更新?是因为 vm.arr 确实被更新成了 [11]
     */
    function anonymous() {
    	with(this) {
        return _c(
          'div',
          {attrs:{"id":"app"}},
          [
            // this.form
            _c('p',[_v(_s(form))]),_v(" "),
            _c(
              'ul',
              _l(
                // this.arr
                (arr),
                function(item) {
              	  return _c('li',{key:item},[_v(_s(item))])
            	  }
              ),
              0
            )
          ]
        )
      }
    }
    

3 升华 ?

读到这里,自己在梳理一遍思路,再回头看一遍前面的结论,是不是就有种豁然开朗的感觉??