跳至主要內容

Vue 2

俞文健大约 18 分钟

响应式原理

数据代理

  • Vue 实例中的 data 数据,默认在 _data 属性中,如果想要操作这个数据,需要通过 _data 访问。

  • 为了更方便地操作数据,可以将 _data 中的数据拷贝到实例上。访问实例上的数据,就是访问 _data 的数据。

  • 过程:

    遍历 _data 中的数据,使用 defineProperty 给实例扩展一个同名属性,并通过 get 和 set 监听这些属性。它们实际都是操作 _data 中的数据,所以我们访问实例上的数据就是访问 _data 中的数据。

数据劫持

  • 目的:监听数据(收集获取当前数据的模板信息,通知收集的所有模板进行更新数据)。

  • 原理:Object.defineProperty

  • 过程:

    遍历 _data 中的数据,然后调用 defineReactive 函数为每一个属性都创建一个 dep 对象,通过 defineProperty 对这些属性进行重写,并添加 getter 和 setter,此时 dep 对象会以闭包的形式保存在 getter 和 setter 中。

    当我们访问响应式数据时,就会触发 get 方法,它会返回数据的值,同时调用 dep 的 depend 方法,让 dep 和 watcher 相互收集依赖。dep 收集 watcher 是为了可以通过 dep 找到 watcher,然后触发视图更新;watcher 收集 dep 是为了防止重复收集。在 dep 的 depend 方法中,会调用 watcher 的 addDep 方法将 dep 收集到 newDeps 数组中,同时还会收集 dep 对象的 id;在 addDep 方法中,又会调用 dep 的 addSub 方法将 watcher 收集到 subs 数组中。这就是一个相互收集依赖的过程。

    当我们修改响应式数据时,就会触发 set 方法,它会更新数据,同时调用 dep 的 notify 方法,遍历 dep 的 subs 数组中的 watcher,并按照 id 从小到大排列,然后依次执行每个 watcher 的 update 方法。在 update 方法中,判断 watcher 的类型,如果是计算 watcher,则不执行回调,后续会在 evaluate 方法中计算求值;如果是渲染 watcher 或侦听 watcher,则把 watcher 对象添加到一个调度队列中,然后通过 nextTick 将一个调度任务的方法 flushSchedulerQueue 添加到异步队列,等待异步执行。当执行这个调度任务的方法时,会从调度队列中依次取出每一个 watcher 对象执行它的 run 方法触发视图更新并重新收集依赖。

观察者模式

  • 观察者模式:当一个对象中的数据被多个对象所依赖,并且当被依赖的对象发生改变时会通知所有依赖项。

  • 被依赖的对象称为目标对象,依赖项称为观察者。

发布与订阅者模式

  • 发布与订阅者模式其实是属于观察者模式中的一个细分;

  • 观察者模式中的观察者被称为 "订阅者",目标对象被称为 "发布者";

  • 发布者和订阅者由订阅中心来实现数据的通信,并且发布者和订阅者都不知道对方的存在;

  • 当订阅者订阅数据的时候,来到订阅中心,订阅中心收集所有的订阅者;

  • 当发布者发布信息的时候,先传到订阅中心,再由订阅中心统一传给订阅者。

响应式原理

  • 当响应式数据更新时,根据 render 函数返回的虚拟 DOM 树生成真实 DOM 元素,插入到页面,触发视图更新。

  • 主要由三个部分构成:

    • 数据代理

    • 数据劫持

    • 发布与订阅者模式

  • 详细过程:

    1. 使用 Object.defineProperty 完成数据代理,将 _data 中的数据拷贝到实例上,我们访问实例上的数据,其实就是访问 _data 中的数据。

    2. Observer 类(发布者)中,主要通过 Object.defineProperty_data 中的所有属性进行重写,并添加 getter 和 setter:1. 在 getter 中建立 dep(订阅中心的实例化对象)和 watcher(订阅者的实例化对象)的关系,让 dep 收集依赖(访问当前数据的 watcher);2. 在 setter 中让 dep 通知所有依赖(watcher)更新数据。

    3. 在 Dep 类(订阅中心)中,有收集所有 watcher 的方法,和通知所有 watcher 更新数据的方法。当 _data 中每一个属性被劫持的时候,都会创建一个 dep(Dep 的实例化对象),在 getter 中调用 dep 的 depend 方法收集依赖,在 setter 中调用 dep 的 notify 方法通知更新。

    4. 在 Watcher 类(订阅者)中,有获取数据的 get 方法和更新视图的 update 方法,每一个组件都对应一个 Wathcer。watcher 会在第一次获取数据时被 dep 收集,当收到更新要求的时候,dep 就会通知所有的 watcher(Wacther 的实例化对象)调用 update 方法重新获取数据并更新视图。

响应式原理-v3

  • Vue2 的响应式使用的是 defineProperty,它只能监听对象的现有属性,无法监听新增属性或者删除属性。

  • Vue3 使用的是 Proxy 进行数据监听,它可以监听整个对象,包括对属性的读取、新增、修改和删除等操作。

  • Vue3 的响应式主要用了四个方法:reactive、track、effect、trigger

    • reactive:

      它是用来将数据定义成响应式的。当我们定义 reactive 数据时,内部会通过 Proxy 对数据进行代理。但是 Proxy 只能进行代理对象的第一层属性,所以如果是引用类型,会递归调用 reactive,进行深度代理。

      当我们访问响应式数据的时候,就会触发 get 方法,会使用 Reflect.get 返回数据的值,然后通过 track 收集依赖,相对于 Vue2 中 dep 的 depend 方法。

      当我们修改响应式数据的时候,就会触发 set 方法,会使用 Reflect.set 更新数据,然后通过 trigger 触发视图更新,相对于 Vue2中 dep 的 notify 方法。

    • track:

      它是用来收集依赖的。建立响应式数据和 effect 实例之间的联系。

    • effect:

      它是用来触发视图更新的。

      初始化渲染时,effect 方法会执行一遍,此时会生成 effect 实例,将更新视图的方法保存在 effect 实例,并调用这个方法来完成页面的初始化渲染。此时会触发 Proxy 的 get 方法,通过 track 收集依赖:创建一个 WeakMap 容器,存储的 key 为响应式对象,value 是一个 Map 容器,Map 容器的 key 是响应式数据的某个属性,value 是一个 Set 容器,Set 容器会存储 effect 实例。这就是一个建立响应式数据和 effect 实例之间的联系的过程。

    • trigger:

      它是用来更新依赖的。找到响应式数据对应的 effect 实例,调用 run 方法更新视图。

      当响应式数据更新时,会触发 Proxy 的 set 方法,通过 trigger 更新依赖:通过 WeakMap 容器找到响应式数据对应的 effect 实例,调用 run 方法更新视图,完成响应式。这就是一个更新依赖的过程。

Diff 算法

什么是 Diff 算法?

diff 算法就是比较新旧 DOM 树,寻找差异的算法,在源码中通过 patch 函数实现,所以也称为 patch 算法。

Diff 算法比较思路

深度优先,同级比较。

Diff 算法执行过程

  • 当组件内部的响应式数据发生更新的时候,就会执行 Vue 内部的 updateComponent 函数,在函数内部先执行 _render 函数生成新的虚拟 DOM,将其作为参数传递给 _update 函数,并执行 _update 函数。

  • _update 函数中,先定义一个变量保存旧的虚拟 DOM (vm._vnode),然后将新的虚拟 DOM 赋值给 vm._vnode,此时 _update 函数中存在新旧虚拟 DOM,最后使用 patch 函数对新旧虚拟 DOM 进行比较。

Patch 比较过程

  • patch 函数首先使用 sameVnode 方法比较两个节点的标签类型key 以及表单元素的 type 是否相同;

  • 如果相同,则进入更新流程:

    • 把旧节点的真实 DOM 拿到新节点的位置复用;

    • 对比新旧节点的(标签)属性是否相同,如果不同则更新;

    • 比较子节点。

  • 如果不相同,直接根据新节点创建元素,删除旧元素。

Patch 比较子节点

  • Vue 使用四个指针分别指向新旧子节点列表的首尾节点;

  • 首先比较新旧树中头指针指向的节点:

    • 如果相同则进入更新流程。头指针向后位移,继续比较。
  • 如果不同,则比较新旧树中尾指针指向的节点:

    • 如果相同则进入更新流程...
  • 如果不同,则交叉比较新旧树中头指针和尾指针指向的节点:

    • 如果相同则进入更新流程...
  • 如果以上比较都不相同,则以新树中头指针指向的节点为基础,循环旧虚拟 DOM 节点,查找是否存在相同节点:

    • 如果存在则复用,进入更新流程;

    • 如果不存在,说明该节点为新创建的,将该节点转为真实 DOM。

  • 当新树的头指针超过尾指针的时候,比较结束,删除旧树中的剩余节点。

Key 的作用

  • 在新旧虚拟 DOM 对比更新的时候,diff 算法默认采用 "就地更新" 原则;

  • 多个子节点比较的时候,如果没有 key 属性,默认都是 undefined,所以每个新旧虚拟 DOM 的 key 都相同,就会简单地按照节点的顺序依次比较。如果新旧节点是顺序的不同,那么 diff 算法将达不到最高效;

  • 使用 v-for 时,我们可以为每个元素提供唯一的 key,使它可以跟踪每个节点,重新排序时可以复用现有元素;

  • key 可以使 Vue 更高效地渲染虚拟 DOM;

  • key 必须满足稳定性和唯一性。

nextTick

nextTick 是什么?

nextTick 是用于异步执行任务的方法,会在下一次 DOM 更新完成之后执行。主要用于修改数据后,需要等待 DOM 更新再执行某些操作。例如打开弹窗需要等待表单元素渲染完成才能关闭表单校验,使用 Swiper 组件需要等待图片资源请求完成才能开启图片轮播。由于 Vue 在数据更新后不会立即进行 DOM 的重新渲染,而是在下一次事件循环中进行批量更新,因此直接在数据修改后获取 DOM 可能会得到旧的结果或者报错。此时可以使用 nextTick 确保在 DOM 更新后执行回调。

nextTick 原理

在 Vue2 和 Vue3 中,nextTick 的原理有所不同。在 Vue2 的实现中,先准备一个 callbacks 数组,用来存放回调函数。再定义一个 flushCallbacks 方法,用来遍历 callbacks 并执行回调函数。再定义一个 timerFunc 函数,用来将 flushCallbacks 添加到异步队列。考虑到兼容性问题,timerFunc 依次使用四种添加异步任务的方法,分别是 PromiseMutationObserversetImmediatesetTimeout,择优使用。Promise 通过 .then() 将回调函数添加到微队列;MutationObserver 会监听 DOM 元素的变化,并在变化时将回调函数添加到微队列;setImmediatesetTimeout 都是将回调函数添加到宏队列,但是 setImmediate 用于 nodejs 环境。

nextTick 有两种调用方式:回调函数形式和 Promise 形式。执行 nextTick 时,会将一个匿名函数添加到 callbacks 数组中。再执行 timerFunc,将 flushCallbacks 添加到异步队列。在这个匿名函数中,判断 nextTick 是否传入了回调函数。如果传入了回调函数,就会执行这个回调函数;如果没有,就会返回一个 Promise,并执行 resolve(),这样就会将 .then() 中的代码添加到微队列。等浏览器执行完同步代码,就会开始执行异步队列中的任务。执行到 flushCallbacks 时,会遍历 callbacks 中的回调函数并执行。

与 Vue3 的区别

Vue3 不再考虑兼容性问题,所以只会使用 Promise.then() 将回调函数添加到异步队列。

KeepAlive

在 created 阶段,创建一个 cache 对象,用来缓存组件;再创建一个 keys 数组,用来缓存组件的 key。

在初始化渲染时触发 render 函数,判断组件是否符合缓存规则,也就是判断组件名是否在 include 数组中或者不在 exclude 数组中。如果符合缓存规则,再判断组件之前是否缓存过,如果缓存过,就使用之前缓存的组件,还需要移除原来缓存的 key,并将最新的 key 添加到缓存列表 keys 的末尾,然后返回缓存的组件;如果之前没有缓存过,就将新组件和 key 临时存储一下,等待 mounted 阶段再缓存,然后返回新组件。如果不符合缓存规则,就不缓存,直接返回组件。

在挂载完成后,也就是 mounted 阶段,使用 cacheVNode 方法将组件缓存起来,还要判断缓存列表长度是否超过设置的最大缓存数量,如果超过的话,就使用 LRU 算法,删除缓存列表的第一个组件。同时监听 include 和 exclude 数组,一旦它们发生变化,就删除不需要缓存的组件。

在组件渲染时,会触发 insert 方法,判断是不是缓存的组件,如果是,就会触发 activated 生命周期。

在组件卸载时,会触发 destroy 方法,判断是不是缓存的组件,如果是,就会触发 disactivated 生命周期。

生命周期

初始化流程

Vue 被实例化也就是 new Vue 之后,进入初始化阶段:

  • 初始化事件和生命周期;

  • beforeCreate 触发,此时还无法访问实例上的数据;

  • 初始化数据注入和数据劫持,同时初始化 data methods computed watch 等数据;

  • created 触发,此时可以通过实例访问数据。

编译流程

判断 Vue 是否配置 el 选项:

  • 没有 el 选项,则等待使用 $mount 提供 el 选项;

  • 存在 el 选项,再判断是否配置 template 选项:

    • 如果有 template 选项,则编译模板得到 render 函数,返回虚拟 DOM;

    • 如果没有 template 选项,则将 el 挂载容器的 outerHTML 作为模板进行编译。

挂载流程

  • 挂载之前,beforeMount 触发,此时视图呈现的是未被解析的模板。DOM 操作无效;

  • 将 Vue 实例挂载到 el 容器上,根据 render 函数返回的虚拟 DOM 生成真实 DOM,并替换 el 容器;

  • 挂载之后,mounted 触发,视图呈现真实 DOM。一般在这个阶段发送请求、监听自定义事件、开启定时器。

更新流程

当响应式数据发生改变的时候,进入更新阶段:

  • beforeUpdate 触发,此时数据已更新,视图还未更新;

  • 得到新的虚拟 DOM 并使用 patch 函数比较新旧虚拟 DOM 并更新真实 DOM;

  • updated 触发,此时数据和视图都完成更新。

销毁流程

$destroy 被调用,或条件渲染组件或路由时,进入销毁阶段:

  • 实例销毁之前,beforeDestroy 触发,一般在这个阶段移除自定义事件、关闭定时器,防止内存泄漏;

  • 实例销毁,取消所有 watcher 订阅,与所有子组件实例断开连接,移除所有事件监听器,解绑所有指令;

  • 实例销毁之后,destroyed 触发。

组件通信

双向绑定

v-model

当使用 :value + @input 模式时,可以替换为 v-model 模式。

子组件为 input。

<!-- Parent.vue -->
<MyComponent :value="message" @input="message = $event" />

<MyComponent v-model="message" />
<!-- MyComponent.vue -->
<input :value="value" @input="$emit('input', $event.target.value)" />

子组件为非 input。

<!-- Parent.vue -->
<MyComponent :value="count" @input="count = $event" />

<MyComponent v-model="count" />
<!-- MyComponent.vue -->
<button @click="$emit('input', value + 1)"></button>

v-bind.sync

当使用 :prop + @update:prop="prop = $event" 模式时,可以替换为 :prop.sync 模式。

<!-- Parent.vue -->
<MyComponent :count="count" @update:count="count = $event" />

<MyComponent :count.sync="count" />
<!-- MyComponent.vue -->
<button @click="$emit('update:count', count + 1)"></button>

事件总线

export default {
  beforeCreate() {
    Vue.prototype.$bus = this // 在 Vue 的原型上安装事件总线,所有组件都能访问
  }
}
// A.vue
export default {
  mounted() {
    this.$bus.$on("my-event", value => { /* 监听事件 */ })
  },
  beforeDestroy() {
    this.$bus.$off("my-event") // 移除事件
  }
}
// B.vue
this.$bus.$emit("my-event", [...this.args]) // 触发事件

发布订阅

// A.vue
import Pubsub from "pubsub-js"

export default {
  mounted() {
    this.pubsubId = Pubsub.subscribe("my-message", (_ /* message-name */, value) => {}) // 订阅
  },
  beforeDestroy() {
    Pubsub.unsubscribe(this.pubsubId) // 取消订阅
  }
}
// B.vue
import Pubsub from "pubsub-js"

Pubsub.publish("my-message", [...this.args]) // 发布消息

透传

$attrs 包含了父组件传递的数据(不包含被 props 接受的数据)。可以通过 v-bind 批量传递给内部组件。

<MyComponent v-bind="$attrs" />

$listeners 包含了父组件传递的事件。可以通过 v-on 批量传递给内部组件。

<MyComponent v-on="$listeners" />

依赖注入

provide 可以给后代组件提供数据和方法。

注意:

  • provide 如果是一个对象,将无法访问 this,就不能将实例上的数据提供给后代,所以推荐写成函数。

  • 提供的数据需要写成函数返回值形式,否则不具备响应式。

export default {
  provide() {
    return {
      count: () => this.count,
      increment: this.increment
    }
  }
}

在任何后代组件里,我们都可以使用 inject 来接收 provide 提供的数据和方法。

export default {
  inject: ["count", "increment"]
}

Router

基本配置

注意:以 "/" 开头的嵌套路径会被当作根路径,所以子路由的路径不加 "/"。

import VueRouter from "vue-router"

const router = new VueRouter({
  mode: "history", // 默认为 hash 模式
  routes: [
    {
      path: "/",
      redirect: "/home"
    },
    {
      path: "/home",
      name: "Home",
      component: () => import("@/views/Home.vue")
    },
    {
      path: "*",
      name: "404",
      component: () => import("@/views/NotFound.vue")
    }
  ]
})

解决重复跳转的报错

重写 push 方法,replace 同理。

const originalReplace = VueRouter.prototype.push

VueRouter.prototype.push = function (
  location,
  onComplete = () => {},
  onAbort = () => {}
) {
  originalReplace.call(this, location, onComplete, onAbort)
}

缓存路由

<keep-alive> 内部的路由组件会在初始创建的时候被缓存。

缓存组件的生命周期函数 activateddeactivated 也适用于缓存路由组件。

<!-- 缓存一个路由组件 -->
<keep-alive include="Home">
  <router-view />
</keep-alive>

<!-- 缓存多个路由组件 -->
<keep-alive :include="['Home', 'User']">
  <router-view />
</keep-alive>

Vuex

State

获取状态

在组件中,我们可以通过 $store.state 访问全局状态。

如果在插值语法中大量使用 $store.state,这样会使代码的可阅读性和可维护性变得较差。

所以我们一般使用计算属性获取 store 中的数据。

export default {
  computed: {
    count() {
      return this.$store.state.count
    },
    message() {
      return this.$store.state.message
    }
  }
}

mapState

Vuex 为我们提供了一种简便方法,我们可以使用 mapState 辅助函数生成对应的计算属性。

import { mapState } from "vuex"

export default {
  computed: {
    ...mapState({
      count: "count" // this.$store.state.count => "count"
    })
  }
}

当计算属性与状态同名时,可以给 mapState 传入一个字符串数组。

export default {
  computed: {
    ...mapState(["count"])
  }
}

Mutations

提交载荷

commit(type, payload),推荐提交的载荷(payload)为一个对象。

export default {
  methods: {
    increment() {
      // 以载荷形式提交
      this.$store.commit("increment", { n: 2 })
      
      // 以对象形式提交
      this.$store.commit({ type: "increment", n: 2 })
    }
  }
}

处理事件

事件处理函数可以接受两个参数:

  • state:全局状态

  • payload:载荷(可选)

new Vuex.Store({
  mutations: {
    increment(state, { n }) {
      state.count += n
    }
  }
})

mapMutations

使用 mapMutations 辅助函数可以将组件中的 methods 映射为 store.commit 调用。

import { mapMutations } from "vuex"

export default {
  methods: {
    ...mapMutations(["increment"]) // this.$store.commit("increment") => "increment"
  }
}

Getters

计算属性

计算属性函数可以接受两个参数:

  • state:全局状态

  • getters:其他 getter(可选)

new Vuex.Store({
  getters: {
    total: state => state.count * 100
  }
})

我们可以在组件中使用它。

export default {
  computed: {
    total() {
      return this.$store.getters.total
    }
  }
}

mapGetters

我们可以使用 mapGetters 辅助函数将 getters 映射到计算属性。

import { mapGetters } from "vuex"

export default {
  computed: {
    ...mapGetters(["total"]) // this.$store.getters.total => "total"
  }
}

Actions

分发异步操作

dispatch(type, payload)

export default {
  methods: {
    increment() {
      this.$store.dispatch("increment", { n: 1 })
    }
  }
}

异步处理函数

异步处理函数可以接受两个参数:

  • context:上下文对象,具有与 store 相同的方法

  • payload:载荷(可选)

new Vuex.Store({
  actions: {
    increment(context, payload) {
      setTimeout(() => {
        context.commit("incrementCount", payload)
      }, 1000)
    }
  },
  mutations: {
    incrementCount(state, { n }) {
      state.count += n
    }
  }
})

mapActions

使用 mapActions 辅助函数将组件中的 methods 映射为 store.dispatch 调用。

import { mapActions } from "vuex"

export default {
  methods: {
    ...mapActions(["increment"]) // this.$store.dispatch("increment") => "increment"
  }
}

Modules

当应用变得非常复杂时,store 对象就会变得臃肿,我们可以将 store 分割成模块,便于代码的维护。

// countModule.js

const state = {
  count: 1
}

const actions = {
  increment(context, payload) {
    setTimeout(() => {
      context.commit("increment", payload)
    }, 2000)
  }
}

const mutations = {
  increment(state, { n }) {
    state.count += n
  }
}

export default {
  namespaced: true,
  state,
  actions,
  mutations
}
// store.js

import countModule from "./modules/countModule"

const movieModule = {
  // ...
}

new Vuex.Store({
  modules: {
    countModule,
    movieModule
  }
})

访问 state

// 直接访问
this.$store.state.countModule.count
this.$store.state.movieModule.movie

export default {
// mapState
  computed: {
    ...mapState("countModule", ["count"]),
    ...mapState("movieModule", ["movie"])
  }
}

访问 getters

// 直接访问
this.$store.state.countModule.countPlus
this.$store.state.movieModule.movieList

export default {
  // mapGetters
  computed: {
    ...mapGetters("countModule", ["countPlus"]),
    ...mapGetters("movieModule", ["movieList"])
  }
}

调用 dispatch

// 直接调用
this.$store.dispatch("countModule/increment", payload)
this.$store.dispatch("movieModule/getmovies", payload)

export default {
  // mapActions
  methods: {
    // 对象写法
    ...mapActions({ increment: "countModule/increment" }),
    ...mapActions({ getmovies: "movieModule/getmovies" }),
    
    // 数组写法
    ...mapActions("countModule", ["increment"]),
    ...mapActions("movieModule", ["getmovies"])
  }
}

调用 commit

// 直接调用
this.$store.commit("countModule/increment", payload)
this.$store.commit("movieModule/getmovies", payload)

export default {
  // mapMutations
  methods: {
    // 对象写法
    ...mapMutations({ increment: "countModule/increment" }),
    ...mapMutations({ getmovies: "movieModule/getmovies" }),
    
    // 数组写法
    ...mapMutations("countModule", ["increment"]),
    ...mapMutations("movieModule", ["getmovies"])
  }
}