Vue2.xx 响应式原理及Observer、Dep、Watcher 介绍 computed、nextTick原理简述

1,899 阅读8分钟

总结下 Vue2 中部分常用的 api 的原理,方便开发与复习时备用

主要内容如下:

  • 一、Vue2 实现原理简述:简要介绍 Observer、Dep、Watcher的作用
  • 二、Vue2 响应式原理
  • 三、Vue2 双向绑定原理
  • 四、computed 的实现原理
  • 五、nextTick 实现原理

一、Vue2 实现原理简述:简要介绍 Observer、Dep、Watcher的作用

Observer 的作用:

Vue2 通过 Object.defineproperty(obj, key, handle) 将我们我们代码中data中的属性进行getter与setter的响应式转化 这样data中的数据获取,数据改变就会触发注册过的get、set事件,从而触发视图更新等其他操作,这个 Object.defineproperty() 的过程,就是有 Observer 实现的

Vue2 中 Observer 的作用

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: () => {
      console.log('数据获取时,被触发');
      return val;
    },
    set: newVal => {
      if (val === newVal) {
        return;
      }
      val = newVal;
      console.log("数据改变时,被触发");
    }
  })
}

let data = {
  test: '初始值',
};

// 对data上的test属性进行绑定
defineReactive(data, 'test', data.test);

console.log(data.test); // 数据获取时,被触发
data.test = 'hello Vue'; // 数据改变时,被触发

Dep 的作用:

data中有很多属性,但是我们可能只是用部分属性,Dep 就是用来收集获取data中属性对应的依赖,然后当触发 set 时, 通过发布订阅模式,通知执行收集的各个依赖,执行视图更新等操作

注:所谓的依赖就是Watcher

Dep 类似中间调度的一个功能中心,Dep 帮我们收集(究竟要通知到哪里的)。如下案例,我们知道,data 中有 test 和 msg 属性,但是只有 msg 被渲染到页面上, 至于 test 无论怎么变化都影响不到视图的展示,因此我们仅仅对 msg 变化影响到的更新操作进行收集即可

现在,对应属性 msg 的 Dep 就收集到了一个依赖,这个依赖就是用来管理 data 中 msg 变化的

Vue2 中 Dep 的作用

<div>
  <p>{{msg}}</p>
</div>

data: {
  test: '初始值',
  msg: 'hello vue',
}  

当使用 watch 监听 msg 属性的变化时,当 msg 变化时我们就要通知到watch这个钩子,让它去执行回调函数。 这个时候 msg 对应的Dep就收集到了两个依赖,第二个依赖就是用来管理 watch 中 msg 变化的

watch: {
  msg: function (newVal, oldVal) {
    console.log('newVal:',newVal, 'oldVal:',oldVal)
  },
}

自定义computed计算属性,如下 newMsg 属性,是依赖 msg 的变化的。所以 msg 变化时我们也要通知到computed,让它去执行回调函数。 这个时候 msg 的Dep就收集到第三个依赖,这个依赖就是用来对应管理computed中 msg 变化的

注:一个属性可能有多个依赖,每个响应式数据都有一个Dep来管理它的依赖

computed: {
  newMsg() {
    return this.msg + '新的';
  }
}

我们根据 Observer 中 Object.defineProperty() ,当读取某个属性就会触发get方法,进而进行依赖收集。代码如下:

function defineReactive (obj, key, val) {
  let Dep; // 依赖对象

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: () => {
      console.log('数据被获取');
      // 数据获取,将当前依赖进行收集
      Dep.depend(); // 本次收集依赖
      return val;
    },
    set: newVal => {
      if (val === newVal) {
        return;
      }
      val = newVal;
      // 数据改变,通知收集的依赖去执行更新操作
      Dep.notify(); // 给订阅者发消息,执行更新操作
      console.log("数据改变,执行更新操作");
    }
  })
}

Watcher 的作用:

Watcher 就是被收集的依赖,上例中 msg 就对应了三个 watcher 实例依赖,当 msg 变化,会通知这三个 watcher, 这三个 watcher 会执行各自的操作,watcher 能够控制自己属于 data 属性中,还是 watch 数据监听中的,或者 computed 中的, 因此,Watcher 中要有两个方法,一个通知变化,执行更新操作,另一个就是将自身实例添加到 Dep 的依赖收集中

class Watcher {
  addDep() {
    // 将 Watcher 实例添加到 Dep 依赖收集中
  },
  update() {
    // 当数据变更时,对应的执行渲染等更新操作
  }, 
}  

二、Vue2 响应式原理

  • 简单理解就是 vue 主要做了三件事:数据劫持、依赖追踪、派发更新
  • 1、第一步数据劫持:组件实例初始化的时候,先通过Object.defineproperty()给每一个Data属性都注册getter,setter
  • 2、第二步依赖追踪:当组件实例挂载mount时创建一个Watcher实例,组件挂载会执行render function,进而通过set获取到data中的属性,将依赖的属性进行收集跟踪
  • 3、第三步派发更新:当数据变化时,会触发相应的set,通过watcher实例通知订阅的属性进行视图更新

三、Vue2 双向绑定原理

  • Vue2 双向绑定采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调
  • 1、首先需要对数据进行劫持监听,实现一个监听器Observer,监听所有属性,如果有变动就通知订阅者
  • 2、实现一个订阅者Watcher,根据订阅的属性变化通知,执行相应的更新函数,从而更新view视图
  • 3、还需要一个解析器Compiler,可以记录和解析相应节点指令,根据初始化模版数据去初始化相应的订阅器

四、computed 的实现原理

computed 是一个惰性求值的观察者,comupted 内部实现了一个惰性求值的 computed watcher, computed watcher 同样不会立即求值,同时也有一个 Dep 实例,内部实现中通过 this.dirty 属性标记计算属性是否重新求值, 当 computed 依赖的状态改变时,就会通知 computed watcher,computed watcher 通过 this.dep.subs.length(就是上文中的中间调度收集的依赖订阅者), 判断是否有订阅者,如果有订阅者,computed 会重新求值,并且比对新值与旧值是否相同,如果不同,通知订阅者 watcher 进行重新渲染等操作

注:Vue 目的是为了确认最终计算的值发生变化才会触发 watcher 重新渲染,不仅仅是依赖的属性发生变化,以此提高性能, 只有之后其他地方需要获取当前计算属性时,computed 才会真正计算,实现惰性执行的目的

五、nextTick 实现原理

注:理解 nextTick 之前要先理解 javascript 事件循环

通常我们会碰到这种需求:当某一个状态改变后, Dom 重新渲染完成,获取对应组件元素高度, 如果属性改变后我们直接获取元素高度,获取的数值是错误的, 这个时候就需要使用 nextTick,通过在 nextTick 的回调函数内进行 Dom 的操作

状态改变直接获取数值为什么会错误?

<template
 <div class="test">
  <el-button type="primary" @click="showContent">点击{{ !isShow ? '显示':'隐藏'}}</el-button>
  <div class="content" ref="content" v-if="isShow">内容</div>
 </div>
</template>
  <script>
  export default {
    name: "Test",
    data() {
      return {
        isShow: false
      }
    },
    methods: {
      showContent() {
        this.isShow = !this.isShow
        const content = this.$refs.content
        console.log('同步获取',content) // 同步获取 undefined

        this.$nextTick(() => {
          const content1 = this.$refs.content
          console.log('异步获取',content1) // 异步获取 <div class=​"content">​内容​</div>​
          console.log('高度:', content1.clientHeight) // 
        })
      }
    }
  };
  </script>

  <style>
  .content {
    width: 100%;
    height: 60px;
    line-height: 60px;
    background: gray;
  }
  </style>

Vue 官方描述: 可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。 如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。 然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate, 如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替

直白的说,就是数据更新是同步的,视图更新是异步的,当我们数据更新完毕,视图的更新还处在任务队列中, 如果我们频繁更新数据,Vue 不会马上更新视图,通过将更新操作放入队列,同时进行去重处理,提高性能, 最后,我们需要通过 nextTick 的回调函数,告诉 Vue 底层,当视图更新完毕帮我们执行 nextTick 的回调函数, 完成我们的需求

在设置 this.isShow = !this.isShow 的时候,Vue并没有马上去更新DOM数据,而是将这个操作放进一个队列中;如果我们重复执行的话,队列还会进行去重操作; 等待同一事件循环中的所有数据变化完成之后,会将队列中的事件拿出来处理,提升整体性能

源码分析:nextTick 函数实现,pending 控制 timerFunc 同一时间只能执行一次

  const callbacks = []
  let pending = false
  let timerFunc

  export function nextTick(cb?: Function, ctx?: Object) {
    let _resolve
    callbacks.push(() => {
      if (cb) { // 执行回调
        try {
          cb.call(ctx)
        } catch (e) {
          handlerError(e, ctx, 'nextTick')
        }
      } else if (_resolve) {
        _resolve(ctx)
      }
    })

    if (!pending) {
      pending = true // 控制 timerFunc 同一时间只能执行一次
      timerFunc() // 兼容异步函数的函数,用来执行callbacks队列
    }

    if (!cb && typeof Promise !== 'undefined') {
      return new Promise(resolve => {
        _resolve = resolve
      })
    }
  }

timerFunc 函数实现目的: 对当前环境进行不断的降级处理,尝试使用原生的Promise.then、MutationObserver和setImmediate, 上述三个都不支持最后使用setTimeout;降级处理的目的都是将flushCallbacks函数放入微任务(判断1和判断2)或者宏任务(判断3和判断4), 等待下一次事件循环时来执行

export let isUsingMicroTask = false
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
    // 判断原生是否支持 Promise
    const p = Promise.resolve()
    timerFunc = () => {
      // flushCallbacks 用来执行callbacks中的回调函数
      p.then(flushCallbacks)
      if (isIOS) setTimeout(noop)
    }
    isUsingMicroTask = true
  } else if (!isIE && typeof MutationObserver !== 'undefined' && isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]') {
    // 判断原生是否支持 MutationObserver
    let counter = 1
    const observer = new MutationObserver(flushCallbacks)
    const textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
      characterData: true
    })
    timerFunc = () => {
      counter = (counter + 1) % 2
      textNode.data = String(counter)
    }
    isUsingMicroTask = true
  } else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    // 判断原生是否支持 setImmediate
    timerFunc = () => {
      setImmediate(flushCallbacks)
    }
  } else {
    // 以上都不行,最终兼容 setTimeout
    timerFunc = () => {
      setTimeout(flushCallbacks, 0)
    }
  }

timerFunc 函数实现目的: 用来执行callbacks中的回调函数

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