Vue 源码解读(数据响应式原理)

165 阅读8分钟

        作为一个典型的 MVVM 框架,相信对于 Vue 的基本使用,大家都能熟练使用。但是对于 Vue MVVM 的实现原理,如果不阅读源码,答出来则比较难了。虽然说不阅读源码也能完成日常的工作内容,但是如果去面试时,免不了被问起,如果不能答上来,则好的机会也会错失。除此之外,通过阅读源码我们可以了解到有哪些坑,从而避免跳入。

一、准备工作

        要想阅读源码,必不可少的需要进行调试,所以需要clone官方的源码。

git clone https://github.com/vuejs/vue.git

// 安装依赖
npm i

        vue 源码是用 ts 写的,而浏览器中并不能运行 ts,所以通常我们都是将 ts 编译为 js。对于 vue 同样如此,我们首先需要对 vue 源码进行编译,然后在项目引入 vue 的 js 文件,但是前面说了,要想阅读源码,必不可少的需要进行调试。必须得建立 js 和 ts 之间的映射,我们才可以找到对应的源码。

        在 package.json 中的 dev 命令后添加--sourcemap,然后执行npm run dev即可。

"dev": "rollup -w -c scripts/config.js --environment TARGET:full-dev --sourcemap"

        最后创建一个 html 文件并引入 dist 文件夹的 vue.js 便可以进行调试了。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue 响应式原理</title>
</head>
<body>
  <div id="app">{{ name }}</div>
  <script src="./vue.js"></script>
  <script>
    new Vue({
      el: '#app',
      data: {
        name: 'babur',
        name: 23,
      },
    })
  </script>
</body>
</html>

        src 目录为 vue 原本的代码,我们可以在浏览器中之间打断点。

image.png

二、响应式源码

         vue 响应式的源码为于 src/core/abserver 位置,我们之间从这里看即可,没必要去关注与本节内容不想干的内容。同时,为了避免过于关注与主流程无关的代码,vue 源码中的部分代码会进行移除。

observe

        在 index.ts 有这样一个函数 observe,可以看到这个函数做了这样一件事,如果 value 对象没有绑定 __ob__ 属性,则为当前 value 对象创建一个 Observer 对象,否则之间返回 __ob__ 属性。

export function observe(
  value: any,
): Observer | void {
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else {
    ob = new Observer(value)
  }
  return ob
}

        看了这段代码,可能有以下几个问题?

  1. value 对象是什么?
  2. __ob__ 又是什么?
  3. Observer对象的作用是什么?

         这里时候就需要我们进行调试了。在 ovserve 的第一行位置打一个断点:

image.png

image.png

        通过调试,可以解答上面的两个问题。

  1. value 对象就是组件中 data 属性的对象(因为要做数据的响应式,必须将对象绑定在 data 属性才可以)。
  2. __ob__ 就是我们新建的 Observer 对象。

        第三个问题,则需要我们去阅读 Observer 的源码才可以解答。

Observer

export class Observer {
  dep: Dep

  constructor(public value: any) {
    this.value = value
    this.dep = new Dep()
    value.__ob__ = this;
    const keys = Object.keys(value)
    for (let i = 0; i < keys.length; i++) {
      const key = keys[i]
      defineReactive(value, key, NO_INIITIAL_VALUE, undefined)
    }
  }
}

        通过阅读上述代码,可以知道 Observer 主要做了两件事。

  1. 将自己绑定到 value 对象的 __ob__ 属性。
  2. 遍历对象的所有属性,并且每个属性都会调用 defineReactive 方法。(根据我们对于 vue 的使用,当视图中依赖的数据发生变化时,页面就会重新渲染。这里所有的属性都调用了 defineReactive 方法,所以可以知道该方法就是数据响应式的原理)

defineReactive

export function defineReactive(
  obj: object,
  key: string,
  val?: any,
) {
  const dep = new Dep()

  let childOb = observe(val)

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    
    get: function reactiveGetter() {
      const value = val
      if (Dep.target) {
          dep.depend()
        if (childOb) {
          childOb.dep.depend()
        }
      }
      return value.value;
    },
    set: function reactiveSetter(newVal) {
      childOb = observe(newVal)
      dep.notify()
    }
  })

  return dep
}

         通过代码,可以看到该函数主要做了两个事

  1. 为属性设置 getter 和 setter
  2. 如果属性所对应的 value 是一个对象,则继续之前的流程,一直递归为当前对象创建 Observer 一直到执行到这里。这样做的目的是什么?深度监听

深度监听

data: {
    name: 'babur',
    age: 23,
    info: {
      m: {
        n: 1
      }
    }
}

        倘若我们的 data 为如上的数据,按照上面的流程,则 data.__ob__ = Observer1data.info.__ob__ = Observer2data.info.m.__ob__ = Observer3 这样便做到了深度监听的效果,对象不管有多深,每一个属性(data.info.m.n)都设置了属性描述符.

setter

    set: function reactiveSetter(newVal) {
      childOb = observe(newVal)
      dep.notify()
    }

        当我们对 data 对象的每一个已有属性做修改时,都会执行 setter。

        这个方法同样是做了两件事:

  1. 如果新设置的值是一个对象时,则继续为新值创建 Observer
this.name = {
    firstName: 'xxxx',
    lastName: 'xxx',
    info: {
        a: {
            // 不管有多深,依然可以监听到每个属性
            b: ''
        }
    }
}
  1. 调用 notify(我们可以猜猜这个函数要做什么?1、数据发生变化时,让页面更新。2、如果有监听器或计算属性,则调用对应的函数)

getter

        不管是对页面更新还是执行监听器或者计算属性所绑定的函数,我们必须得确定是哪个组件、哪个监听器才可以。

        怎么才能知道呢?我们缺钱时,肯定是要取银行取现金,这样银行才知道你缺钱了。

        vue 同样采用这样的理念,就是在组件需要这个数据时,我们才知道是哪个组件对这个数据有依赖,这个时候将这个组件收集便可以了。

    get: function reactiveGetter() {
      const value = val
      if (Dep.target) {
          dep.depend()
        if (childOb) {
          childOb.dep.depend()
        }
      }
      return value.value;
    },

        什么时候会调用 getter ?

  1. 数据渲染到页面的情况下
  2. 组件中依赖当前数据进行计算

Dep

        在 getter 和 setter 中,我们都用到了 Dep 这个类及其对象,我们可以看看代码。

export default class Dep {
  subs: Array<DepTarget>
  constructor() {
    this.id = uid++
    this.subs = []
  }

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

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

export function pushTarget(target?: DepTarget | null) {
  targetStack.push(target)
  Dep.target = target
}

        可以看出, subs 用来存储依赖,通过 depend 方法将依赖添加到 subs 中,当属性发生变化时,则通过 notify 遍历依赖数组,进行更新。

        Dep.target 是一个全局变量,用来记录当前的依赖,当依赖添加到 subs 后,则置空。供下一个依赖所引用。

        说了这么多,我们还是没有找到谁才是依赖对象。搜索 pushTarget方法所引用的地方,在 watcher.ts 中调用了该方法,并且 wather 实例作为参数被传入。 所以所有的依赖对象都是 Watcher 实例

Watcher

        数据发生变化时,需要告知所有的依赖即 wather 实例。接下来我们看看 watcher 是被什么时候被推入队列,以及数据发生变化时,wather 做了什么?

  constructor(
    vm: Component | null,
    expOrFn: string | (() => any),
    cb: Function,
    options?: WatcherOptions | null,
    isRenderWatcher?: boolean
  ) {
    // 一系列的属性赋值
    this.xxxx = xxx;
    this.vm = vm;
    // 数据发生变化时的回调函数
    this.cb = cb;
    // 用于获取对象属性的值的表达式或者函数
    this.getter = parsePath(expOrFn);
    this.value = this.get();
}

        经过一系列的赋值,最后在构造器中调用了 get 方法。

        get 源码如下:

get() {
    Dep.target = this;
    value = this.getter.call(vm, vm)
    Dep.target = null;
}

        get 函数中将 wather 实例绑定到对象的全局变量 Dep.target 中,然后获取 value 对象的属性

        获取 value 对象的属性会做什么? 会进入到属性描述符的 geeter 中,而我们在 getter 中添加了依赖,这样我们就成功将依赖对象的整个加入过程给理清楚了

        接着我们看一看 update 方法做了什么。

update() {
    this.run();
}

run() {
    this.cb.call(this.vm, value, oldValue);
}

        前面已经说过了,cb 是数据发生变化时,所要执行的方法。这样我们就搞清了整个数据响应式的过程。

        接下来当然是验证的流程了。

组件

        对应 vue 组件来说,肯定是其 data 属性的依赖对象。当数据发生变化时,肯定要更新视图。

image.png

        接着我们在 update 位置打一个断点,并修改 data 对象的属性的值。

image.png

        可以看到立即执行到了 update 方法

image.png

image.png

        可以看到在run方法执行完后,视图发生了变化。

侦听器

        在组件中定义如下两个侦听器。

    new Vue({
      el: '#app',
      data: {
        name: 'babur',
        age: 23,
      },
      watch: {
        name: {
          handler(val) {
            console.log('name updated, new value is: ', val);
          }
        },
        age(val) {
          console.log('age updated, new value is: ', val);
        }
      }
    })

image.png

image.png

        可以看到每个侦听器都会创建一个 watcher 实例。并且在 cb 属性绑定了回调函数。

image.png

计算属性

        我们知道,当计算属性所依赖的数据发生变化时,对应的计算属性函数则会再次执行。

      computed: {
        info() {
          const info = this.name + this.age;
          return info;
        }
      }

        这是因为 vue 同样会为计算属性创建一个 watcher。

image.png

        通过以上篇章,想必对于数据响应式原理有了深刻的认识。

三、数组响应式源码

        对于数组的响应式,根据我们的实践以及官方文档中的说明,让我们别直接通过修改索引去修改数组或者调用指定的7个方法才可以实现响应式。

        同样的,为什么这样做还是得通过源码才可以理解。

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator(...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 告知依赖数组发生变化,进行视图更新或其他操作
    ob.dep.notify()
    return result
  })
})

        上面的代码做了这样几件事:

  1. 根据 Array 的原型创建一个新的对象
  2. 重写了 methodsToPatch 中的7个方法
  3. 如果数组中新添加的项是对象,则为创建观察者,流程同上
  4. 告知数组的依赖项,诗句发生变化了。

        重写了原型对象,那么是在什么时候把这个原型对象赋值给数组的呢?

image.png

四、问题

        以上便是数组响应式的原理了,接下来我们看看我们哪些错误的使用方法导致响应式无效。

        可以先根据上面所讲解,想一想问题答案,在文章的后面公布答案。

问题一

    const vm = new Vue({
      el: '#app',
      data: {
        user: {
          name: 'babur',
          age: 23,
        },
      }
    })

image.png

问题二

    const vm = new Vue({
      el: '#app',
      data: {
        user: {
          name: 'babur',
          age: 23,
        },
      }
    })

image.png

问题三

    const vm = new Vue({
      el: '#app',
      data: {
        stus: [
          {
            name: 'a',
            age: 10,
          },
          {
            name: 'b',
            age: 20
          }
        ]
      }
    })

image.png

问题四

    const vm = new Vue({
      el: '#app',
      data: {
        stus: [
          {
            name: 'a',
            age: 10,
          },
          {
            name: 'b',
            age: 20
          }
        ]
      }
    })

image.png

答案

问题一:一开始遍历 user 对象的所有属性时,不能为没有的属性定义属性描述符,从而没有办法设置依赖以及进行响应式。

问题二:删除对象的属性没有办法被监听到,从而没有办法进行响应式。

问题三:直接通过索引修改同样没有办法被监听到,从而没有办法进行响应式。

问题四:同二。

五、扩展

        在 observer 包中,还有这样的一个文件,就是 scheduler.ts

        这个文件主要做了这样一件事,将 watcher 加入到队列中,然后在下一次 nextTick 的时候调度队列中的所有 watcher。

export function queueWatcher(watcher: Watcher) {
  if (!flushing) {
    queue.push(watcher)
  } else {
    // if already flushing, splice the watcher based on its id
    // if already past its id, it will be run next immediately.
    let i = queue.length - 1
    while (i > index && queue[i].id > watcher.id) {
      i--
    }
    queue.splice(i + 1, 0, watcher)
  }
  // queue the flush
  if (!waiting) {
    waiting = true
    nextTick(flushSchedulerQueue)
  }
}

        为什么要在下一次 nextTick 的时候在执行。

        想象一下,如果数据发生了1000次、10000次,每次都去刷新视图或者执行监听器是不是会带来性能消耗呢?并且也没必要每一次都执行,只需要在视图刷新前执行最后一次即可。

        如上便是我对vue响应式的理解,如有错误,欢迎指正,讨论。