vue源码-深入响应式原理

2019-12-16  本文已影响0人  cd2001cjm

前言

随着前后端分离成为Web开发的常态,Mvvm框架越来越普及。让前端开发从关注Dom,变为关注数据,提高了开发效率,降低了学习成本。同时也能有效避免低级的Dom操作错误。

在享受Mvvm框架带来的便利的同时,我们也会对它的具体实现产生兴趣。笔者认为Mvvm框架重要的有两个部分

  1. 数据变化的捕获,通知与响应

  2. vDom对通知产生响应,并对Dom进行相应的操作

今天我们先来看一下变化的捕获,通知与响应,分为下面四个部分

  1. 数据变化的捕获

  2. 监听器的创建

  3. 数据变化与监听器的关联

  4. 变化的响应

一,数据变化的捕获

日常项目中,我们常用的与数据变化相关的,有以下三个:

image.png

除了上述三个,其实vue框架本身,还有一个组件层面的变化,比如路由变化会重新渲染组件。虽然都是变化,但这四者既有联系也有区别,关系如下图

image

数据变化会触发监听器,会触发组件渲染,而组件渲染的时候,会重新计算属性。那么该如何监听数据变化呢?

监听数据变化
JavaScript中监听数据变化API:Getter和Setter,先看一个简单的示例:

var user = {}
var name;
Object.defineProperty(user, 'current', {
  get: function(){
      console.log('获取名称')
      return name
  },
  set:function(val){
      console.log('设置名称')
      name = val
  }
})

user.current = '张三';
console.log(user.current);

控制台输出:

设置名称

获取名称

张三

API getter和setter就是数据劫持的基础,通过这个例子我们看到,在设置数据或获取数据的时候,我们都可以加入自己的处理逻辑。从而达到我们监听数据变化的目的。接下来我们再对比一下vue的代码实现。

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
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })

同样的,Vue也是通过这种方式劫持数据,然后拦截到变化后,去通知订阅者。Vue捕获变化并发送通知的流程图如下(放大查看):

image.png

二,监听器的创建

Watcher的分类

image

Watcher什么时候创建的呢?先看下面这段熟悉的代码:

new Vue({
 el: '#app',
 router, 
 components: { App }, 
 template: '<App/>’
})

上面这块代码简单来说就是创建了一个Vue的实例。声明了一个组件App,渲染绑定的节点是#app,还有路由。

Vue实例化的处理流程(本文无关的部分略过)。

image

和变化相关的有三个方法:

我们分别看一下三种监听器的创建

监听器Watcher

Vue.prototype.$watch = function (
  expOrFn: string | Function,
  cb: any,
  options?: Object
): Function {
  const vm: Component = this
  if (isPlainObject(cb)) {
    return createWatcher(vm, expOrFn, cb, options)
  }
  options = options || {}
  options.user = true
  const watcher = new Watcher(vm, expOrFn, cb, options)
  if (options.immediate) {
    try {
      cb.call(vm, watcher.value)
    } catch (error) {
      handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
    }
  }
  return function unwatchFn () {
    watcher.teardown()
  }
}

注意这一行:const watcher = new Watcher(vm, expOrFn, cb, options)

$watch方法也是Vue对外提供的API

var vm = new Vue({
  el: '#demo',
  data: {
    firstName: 'Foo',
    lastName: 'Bar',
    fullName: 'Foo Bar'
  },
  watch: {
    firstName: function (val) {
      this.fullName = val + ' ' + this.lastName
    },
    lastName: function (val) {
      this.fullName = this.firstName + ' ' + val
    }
  }
})

我们结合Vue官网watch例子来看new Wacher的参数:

vm:Vue实例本身

expOrFn:firstName

cb:对应的函数

option:额外的参数,从上面我们也看到,有个immediate属性,如果为true就先调用一次

计算属性Watcher

计算属性也是和上面类似,但有个重要的参数,lazy为true。这就代表着,在创建的时候,并不会立即执行。

const computedWatcherOptions = { lazy: true }
function initComputed (vm: Component, computed: Object) {
  ……略
  for (const key in computed) {
     ……略

    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(vm, getter || noop, noop, computedWatcherOptions)
    }
     ……略
  }
 ……略
}

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

而调用的时机是在渲染组件的时候触发,然后watcher.evaluate()

组件Watcher


export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  ………略
  let updateComponent
  if (process.env.NODE_ENV !== ‘production’ && config.performance && mark) {
    updateComponent = () => {
      ………略
    }
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  ………略
  return vm
}

当组件发生变化的时候就会触发vm._update(vm._render(), hydrating),在创建的时候也会执行一次,进行第一次渲染。具体见下图:

image.png
  1. Init方法中,会进行各种Watcher的创建

  2. $mount中会创建组件Watcher并执行

  3. 组件Watcher触发渲染

  4. 渲染过程发现有子组件,对子组件再走一遍上面的流程

注意:上面我们说了三种Watcher的创建,计算属性的Watcher不会立即执行,而其他两个都会立即执行一次。

三,数据变化与监听器的关联

到目前为止,我们解决了变化的监听,以及观察者的创建,那么两者又是如何联系起来的呢?
再来看一下数据劫持的getter方法,我们发现只有在Dep.target(Watcher)存在的时候才建立关联

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

再看一下Watcher类的get方法(这个就是一个普通的方法名称,不要和getter混淆)

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) {
      handleError(e, vm, `getter for watcher "${this.expression}"`)
    } else {
      throw e
    }
  } finally {
    if (this.deep) {
      traverse(value)
    }
    popTarget()
    this.cleanupDeps()
  }
  return value
}

pushTarget(this):将当前watcher压入栈,同时将this赋值给Dep.target

popTarget():出栈

哪这个栈又是个什么东东呢?

Watcher Stack
组件是按树形的结构递归解析。如果不考虑出栈的情况,那么整个栈的情况如图所示:

image

而在实际过程中当关联设置结束后,会进行出栈操作。整个解析过程按照从根节点到子节点,也就是监听先压入栈,然后解析的时候发现栈里有监听,就会绑定。

是不是有点乱?没关系,我们再捋一遍。

  1. new Watcher的时候调用其内部get方法,在这个方法中会将当前监听压入栈,并赋值给target。

  2. 继续向下执行,解析组件时第一次必然获取数据,这个时候就会触发数据劫持的getter,在getter里判断当前target是否有值,有值就把当前数据和Watcher进行关联,没有就忽略继续向下

  3. 出栈并清空target

结合上面的文字,再具体看一下这三个Watcher关联的流程

组件Watcher关联

image.png

监听器Watcher关联

image.png

组件Watcher和监听器Watcher的区别,是组件Watcher要进行渲染。这当然也比较好理解,监听的目的,归根结底是要渲染到页面用户才能看到变化。比如vue-router,就是利用组件Watcher进行的触发。

计算属性Watcher关联

计算属性和前面两个不同,它在创建watcher的时候,并不会触发get。

在初始化的时候创建好Watcher,渲染的时候才会触发,同时把组件Watcher也追加进订阅

image

四,变化的响应

变化劫持,通知Watcher,Watcher响应具体的动作。这部分内容相对就比较简单了。唯一需要注意的是,计算属性因为没有入栈,所以它的响应会被丢弃。

  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true //不执行具体动作
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }

通过代码可以看到,当是lazy的时候,设置dirty=true,但并没有进行具体的操作。

我们最后再整体回顾一下开始的关系图:

image

结语

数据响应式可以说是Mvvm框架的精髓,希望通过本文的描述,可以让大家更好的理解它的实现原理,只是通过文章,依然不能完全的描述透彻,细节部分还是需要去阅读源码,对照分析和研究。前端水越来越深,一起共勉。本文都是作者自己的理解,有不当之处欢迎批评指正。关于vDom的渲染部分,会在下篇文章中分享。


image.png
上一篇下一篇

猜你喜欢

热点阅读