Vue 源码 - 响应式分析

316 阅读6分钟

前言

本文主要记录 Vue 的响应式原理以及数据首次渲染流程,和数据更新后的渲染流程

本文依赖 Vue 版本 2.6.11, 为了方便理解,文章源码有删减

一、找出 Vue 构造函数

问题:import Vue from 'vue', 引入的是 vue dist 目录下哪个 js 文件?😏😏

1597636079577.jpg

vue 是通过 webpack 进行打包,可通过命令 vue inspect > config.js 将打包配置输出的 config.js 文件中 config.js 文件中可以看到,真正引入的是 vue.runtime.esm.js 文件, 我们接着看下 vue 源码中是如何打包出该文件的。 package.json 文件中可以看出 npm run build 命令执行的是 scripts/build.js 文件,在 build.js 文件中,主要代码是

let builds = require('./config').getAllBuilds()

build(builds)

function build (builds) {
  let built = 0
  const total = builds.length
  const next = () => {
    //...
  }

  next()
}

scripts/config.js 文件中, 可以找出打包成不同文件的配置信息 可以看出 vue.runtime.esm.js 文件的打包入口文件是 web/entry-runtime.js。在 vue 中声明了很多别名

// src/alias.js
module.exports = {
  vue: resolve('src/platforms/web/entry-runtime-with-compiler'),
  compiler: resolve('src/compiler'),
  core: resolve('src/core'),
  shared: resolve('src/shared'),
  web: resolve('src/platforms/web'),
  weex: resolve('src/platforms/weex'),
  server: resolve('src/server'),
  sfc: resolve('src/sfc')
}

所以解析出来的打包入口的完整路径是 src/platforms/web/entry-runtime.js 然后在该文件中,并没有找到 Vue 构造函数,而是从别处引入再导出

import Vue from './runtime/index'
export default Vue

通过在下面几个文件中不停跳转

  • src/platforms/web/runtime/index.js
  • src/core/index.js
  • src/core/instance/index.js

终于在 src/core/instance/index.js 找到了 Vue 构造函数

function Vue (options) {
  this._init(options)
}
// ...
export default Vue

我们从内向外,看看每个文件的具体工作

1、src/core/instance/index.js 截屏2020-08-17下午3.00.29.png 主要工作是:导入的每个 xxxMixin 都是为了扩展 Vue.prototype, 在原型上增加属性和方法

  • initMixin: 扩展 _init 方法
  • stateMixin:扩展 $data$props$set$delete$watch 方法
  • eventsMixin:扩展 $on$once$off$emit 方法
  • lifecycleMixin:扩展 _update$forceUpdate$destroy 方法
  • renderMixin:扩展 $nextTick_render 方法

2、src/core/index.js 截屏2020-08-17下午2.46.02.png 主要工作是: initGlobalAPI 初始化全局api, 以及扩展原型上的属性

3、src/platforms/web/runtime/index.js

截屏2020-08-17下午2.51.47.png

主要工作是:安装特定于平台的 utils、运行时指令和组件以及定义公用的 __patch__$mount 方法 。

二、new Vue 实例初始化

我们从一个简单的 vue 代码开始

<template>
  <div id="app">
     <div>{{name}}</div>
     <div>{{hobby}}</div>
  </div>
</template>
<script>

export default {
  name: 'App',
  data () {
    return {
      name: '张三',
      hobby: ['music', 'game', 'coding']
    }
  }
}
</script>

不出意外,页面会如下显示 截屏2020-08-17下午3.34.10.png

我们具体看下,数据首次渲染如何实现的吧

2.1 数据首次渲染

2.1.1 数据监测

从核心文件 src/core/instance/index.js 开始分析

  • **this._init(options)**调用方法 _init,前面分析得知,该方法定义在 initMixin 中
  • initState(vm) , 状态初始化
  • initData(vm)
  • observe(data, true)
export function observe (value, asRootData) {
  if (!isObject(value) || value instanceof VNode) { // 非对象和 VNode 不进行监测
    return
  }
  let ob
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__ // 已经监测过的直接返回,__ob__ 标识是否监测过
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value) // 进行监测
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}
  • new Observer(value) ,数据监测
export class Observer {
  constructor (value) {
    this.value = value
    this.dep = new Dep() // 1、为对象、数组增加一个 dep 实例
    this.vmCount = 0
    def(value, '__ob__', this) // 2、增加 __ob__ 标识
    if (Array.isArray(value)) {
      if (hasProto) { // 3、是否支持使用 __proto__
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value) // 4、对数组(eg: 对象数组)中的每一项进行 observe
    } else {
      this.walk(value) 
    }
  }
  walk (obj: Object) { // 5、遍历 Object
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

需要特别注意的是:在 constructor 中,执行 this.dep = new Dep(),为对象、数组创建一个 dep 实例,而在👇 介绍的 defineReactive 中,是给对象中的每个属性创建一个 dep 实例。 (ps: 关于 Dep 对象,稍后介绍)

那为什么要给对象、数组创建一个 dep 实例?

当我们执行 arr.push 或者使用 $set 给对象增加新属性时,就是通过自身的 dep 实例来实现页面从新渲染,后续介绍

  • defineReactive(obj, keys[i]),对象进行观测
export function defineReactive (obj,key,val,) {
  const dep = new Dep() // 1、为每个属性创建一个 dep 实例
  // ...
  let childOb = observe(val) // 2、val 可能是数组、对象,也需要监测
  Object.defineProperty(obj, key, { // 3、使用 Object.defineProperty 数据劫持
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = val
      /* if (Dep.target) {   // 3.1、依赖收集,后续介绍
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      } */
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = val
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      val = newVal
      childOb = observe(newVal) // 3.2、新设置的 newVal 可能是数组、对象,也需要监测
      // dep.notify() // 数据更新,触发更新,后续介绍
    }
  })
}
  • arrayMethods ,数组进行观测,通过重写数组方法
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [ // 需要重新的数组方法,共 7 个
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args) // 1、通过调用默认的方法获取返回值
    const ob = this.__ob__  // 2、this.__ob__ 可以访问的 Observer 实例
    let inserted
    switch (method) { // 3、push、unshift、splice 三个方法会往数组中增加新值,可能是对象,需要监测
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // ob.dep.notify() // 数据更新,触发更新,后续介绍
    return result 
  })
})

2.1.2 模版编译及渲染

继续回到 _init 方法中,initState(vm) 之后,会执行 $mount ,我们看着整个流程

  • vm.mount(vm.mount(vm.options.el),$mount 方法定义在 src/platforms/web/runtime/index.js 文件中

  • mountComponent(this, el), mountComponent 方法定义在 src/core/instance/lifecycle.js 文件中

export function mountComponent (vm, el) {
  vm.$el = el

  let updateComponent = () => { //
    vm._update(vm._render())
  }
  
  // 1、初始化,创建一个 Watcher 实例,(ps: 可以记着渲染 watcher)
  new Watcher(vm, updateComponent, noop, {}, true /* isRenderWatcher */)

  return vm
}
  • new Watcher()
export default class Watcher {
  constructor (vm, expOrFn, cb, options,isRenderWatcher) {
    this.vm = vm
    this.cb = cb
    this.id = ++uid // 1、给Watcher 实例设置 id

    if (typeof expOrFn === 'function') { // 2、定义 getter
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
    this.value = this.get() // 3、get() => 执行 getter()
  }

  get () {
    // 4、Dep.target = this, 将 Dep.target 设置成当前 Watcher 实例, 也就是渲染watcher
    pushTarget(this)
    let value = this.getter.call(vm, vm) // 5、执行 getter -> expOrFn
    return value
  }
}

从👆分析得出,new Watcher 初始化时,会默认执行 get() -> getter() -> expOrFn -> updateComponent -> vm._update(vm._render()) , 我们继续看下 vm._update(vm._render()) 的执行过程吧

  • vm._update(vm._render()),_render 定义在 renderMixin中,_update 定义在 lifecycleMixin
  • vm._render()
Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render } = vm.$options
    let vnode = render.call(vm)
    return vnode
}

_render() 方法内部执行 $options.render 方法,生成 vnode, 当 render 中使用了实例中的属性时,在 render 执行过程中,会进行取值,触发这些属性的 getter, 从何实现依赖收集(ps: 这部分后续介绍

我们需要先搞清楚的是 render 方法没定义,从何而来?

根据 Vue 文档 中定义:

el 提供一个在页面上已存在的 DOM 元素作为 Vue 实例的挂载目标, Vue 生成的 DOM 会替换掉挂载元素 如果 render 函数和 template property 都不存在,挂载 DOM 元素的 HTML 会被提取出来用作模板,此时,必须使用 Runtime + Compiler 构建的 Vue 库

前面分析知道,Vue 默认使用的是 vue.runtime.esm.js 文件,并不支持 Compiler ,而 Compiler 则提供了将 template 转换成 render 的能力

import Vue from 'vue'
new Vue({
  data: {
    msg: '233'
  },
  template: '<div>{{msg}}</div>'
}).$mount('#app')

此时控制台会报错

当使用 Runtime + Compiler 构建的 Vue 库时,会重写 $mount 方法(参考 src/platforms/web/entry-runtime-with-compiler.js 文件)

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el, hydrating): Component {
  el = el && query(el)

  const options = this.$options
  if (!options.render) { // 1、判断 render 是否存在
    let template = options.template // 2、判断 template 是否存在
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        return this
      }
    } else if (el) {
      // 3、当 template 不存在,将 el 对应的挂载 DOM 元素的 OuterHTML 提取出来用作 template
      template = getOuterHTML(el) 
    }
    if (template) {
      // 4、compileToFunctions 将 template 转换成 render, => 模版编译
      const { render } = compileToFunctions(template, {}, this) 
      options.render = render
    }
  }
  return mount.call(this, el, hydrating) // 5、调用之前定义的 render 方法
}

默认 .vue 文件中的 template 处理是通过 vue-loader 来进行处理,靠的是 vue-template-compiler 模块生成 render

const VueTemplateCompiler = require('vue-template-compiler');
const {render} = VueTemplateCompiler.compile("<div id="hello">{{msg}}</div>");
console.log(render.toString())

小总结:render 方法如何生成?

1、非 Compiler 构建版本, 提供 render 函数

import Vue from 'vue'
import App from './App.vue'

new Vue({
  render: h => h(App),
}).$mount('#app')

2、Compiler 构建版本,按照 render > template > OuterHTML 优先级生成 render 函数

  • vm._update(vnode)
Vue.prototype._update = function (vnode) {
  const vm: Component = this
  const prevEl = vm.$el 
  const prevVnode = vm._vnode
  vm._vnode = vnode 
  if (!prevVnode) {
    // 1、首次渲染
    vm.$el = vm.__patch__(vm.$el, vnode)
  } else {
    // 2、更新
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
}
  • vm.patch(vm.$el, vnode), 首次渲染,该方法定义在 src/platforms/web/runtime/index.js, 通过引用路径,最终的 patch 方法定义在 src/core/vdom/patch.js文件中
  • patch
export function createPatchFunction (backend) {
	return function patch (oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // 1、更新数据,patch existing root node
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      // 2、首次渲染,replacing existing element
      const oldElm = oldVnode.elm
      const parentElm = nodeOps.parentNode(oldElm)

      // create new node
      createElm(
        vnode,
        insertedVnodeQueue,
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      )

      // update parent placeholder node element, recursively
      if (isDef(vnode.parent)) {
        //...
      }

      // destroy old node
      if (isDef(parentElm)) {
        removeVnodes([oldVnode], 0, 0)
      } else if (isDef(oldVnode.tag)) {
        invokeDestroyHook(oldVnode)
      }
    }
    return vnode.elm
  }
}

当首次渲染时,会使用传入的 VNode 生成新的 DOM, 去替换掉旧的 DOM 至此,页面就成功渲染了

2.1.3 依赖收集

render 中使用了实例中的属性时,在 render 执行过程中,会进行取值,触发这些属性的 getter, 从何实现依赖收集

依赖收集是通过 Dep 对象来实现的,在数据劫持阶段,给每个属性创建一个 dep 实例,在属性取值时,会触发 dep.depend()

export function defineReactive (obj,key,val,) {
  const dep = new Dep() // 1、为每个属性创建一个 dep 实例

  // ...

  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = val
      if (Dep.target) {   
        dep.depend() // 2、进行依赖收集
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      // ...
    }
  })
}

dep.depend() 执行的整个流程图如下 截屏2020-08-18上午9.21.02.png

在挂载阶段 Dep.target 存储这渲染watcher, 执行 dep.depend() 后,属性的 dep 中就会存储这个渲染 watcher, 同时渲染watcher 中也会存储该 dep ,二者双向绑定

2.2 数据更新渲染

还是从一个简单的 vue 代码开始

<template>
  <div id="app">
     <div>{{name}}</div>
     <div>{{hobby}}</div>
  </div>
</template>
<script>

export default {
  name: 'App',
  data () {
    return {
      name: '张三',
      hobby: ['music', 'game', 'coding']
    }
  },
  mounted (){
    setTimeout(()=> {
      this.name = 'zhangsan',
      this.hobby.push('running')
    }, 2000)
  }
}
</script>

2s 之后。页面如下展示 截屏2020-08-18上午10.47.09.png

我们来看下当数据更新到页面渲染的整个流程。👆数据更新可分成对象属性更新 和 数组更新

2.2.1 对象属性更新

执行 this.name = 'zhangsan', 会触发属性 namesetter 方法

export function defineReactive (obj,key,val,) {
  const dep = new Dep()

  // ...

  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
    	//...
    },
    set: function reactiveSetter (newVal) {
      const value = val
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      val = newVal
      childOb = observe(newVal)
      dep.notify() // 数据更新
    }
  })
}

dep.notify() 执行的整个流程图如下 截屏2020-08-18下午12.36.37.png

当执行 dep.notify() 时,会遍历 dep 中存储的所有 watcher, 执行其 update 方法,该方法会将当前 watcher 加入队列,并开启异步执行队列实现更新,也就是执行 getter,最终会执行 vm._update(vm._render()) ,再到 patch 方法,进行 VNode Diff 实现页面更新

2.2.2 数组更新

执行 this.hobby.push('running') , 会执行我们重写的 push 方法

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__  // 2、this.__ob__ 可以访问的 Observer 实例
    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 
  })
})

push 方法中,会执行 ob.dep.notify() , 但需要声明的是,此时的 dep 是在数组 ['music', 'game', 'coding'] 创建的 dep 实例,后续的更新操作和对象属性的更新相同,不再啰嗦

三、总结

本文从源码的角度,介绍了 Vue 响应式原理以及数据更新流程,大致的流程图如下 Vue 源码- 初始化.png

如有错误之处,欢迎指出

参考链接

Vue.js 文档

孟思行 - 图解 Vue 响应式原理