28 Vue常考知识点

28.1 生命周期钩子函数

  • beforeCreate 钩子函数调用的时候,是获取不到 props 或者 data 中的数据的,因为这些数据的初始化都在 initState 中。
  • 然后会执行 created 钩子函数,在这一步的时候已经可以访问到之前不能访问到的数据,但是这时候组件还没被挂载,所以是看不到的。
  • 接下来会先执行 beforeMount 钩子函数,开始创建 VDOM,最后执行 mounted 钩子,并将 VDOM渲染为真实 DOM 并且渲染数据。组件中如果有子组件的话,会递归挂载子组件,只有当所有子组件全部挂载完毕,才会执行根组件的挂载钩子。
  • 接下来是数据更新时会调用的钩子函数 beforeUpdateupdated,这两个钩子函数没什么好说的,就是分别在数据更新前和更新后会调用。
  • 另外还有 keep-alive 独有的生命周期,分别为 activateddeactivated。用 keep-alive 包裹的组件在切换时不会进行销毁,而是缓存到内存中并执行 deactivated 钩子函数,命中缓存渲染后会执行 actived 钩子函数。
  • 最后就是销毁组件的钩子函数 beforeDestroydestroyed。前者适合移除事件、定时器等等,否则可能会引起内存泄露的问题。然后进行一系列的销毁操作,如果有子组件的话,也会递归销毁子组件,所有子组件都销毁完毕后才会执行根组件的 destroyed 钩子函数

28.2 组件通信

组件通信一般分为以下几种情况:

  • 父子组件通信
  • 兄弟组件通信
  • 跨多层级组件通信

对于以上每种情况都有多种方式去实现,接下来就来学习下如何实现。

1. 父子通信

  • 父组件通过 props 传递数据给子组件,子组件通过 emit 发送事件传递数据给父组件,这两种方式是最常用的父子通信实现办法。
  • 这种父子通信方式也就是典型的单向数据流,父组件通过 props 传递数据,子组件不能直接修改 props,而是必须通过发送事件的方式告知父组件修改数据。
  • 另外这两种方式还可以使用语法糖 v-model 来直接实现,因为 v-model 默认会解析成名为 valueprop 和名为 input 的事件。这种语法糖的方式是典型的双向绑定,常用于 UI 控件上,但是究其根本,还是通过事件的方法让父组件修改数据。
  • 当然我们还可以通过访问 $parent 或者 $children 对象来访问组件实例中的方法和数据。
  • 另外如果你使用 Vue 2.3 及以上版本的话还可以使用 $listeners.sync 这两个属性。
  • $listeners 属性会将父组件中的 (不含 .native 修饰器的) v-on 事件监听器传递给子组件,子组件可以通过访问 $listeners 来自定义监听器
  • .sync 属性是个语法糖,可以很简单的实现子组件与父组件通信
    <!--父组件中-->
    <input :value.sync="value" />
    <!--以上写法等同于-->
    <input :value="value" @update:value="v => value = v"></comp>
    <!--子组件中-->
    <script>
      this.$emit('update:value', 1)
    </script>

2. 兄弟组件通信

对于这种情况可以通过查找父组件中的子组件实现,也就是 this.$parent.$children,在 $children 中可以通过组件 name 查询到需要的组件实例,然后进行通信。

3. 跨多层次组件通信

对于这种情况可以使用 Vue 2.2 新增的 API provide / inject,虽然文档中不推荐直接使用在业务中,但是如果用得好的话还是很有用的。

假设有父组件 A,然后有一个跨多层级的子组件 B

    // 父组件 A
    export default {
      provide: {
        data: 1
      }
    }
    // 子组件 B
    export default {
      inject: ['data'],
      mounted() {
        // 无论跨几层都能获得父组件的 data 属性
        console.log(this.data) // => 1
      }
    }

终极办法解决一切通信问题

只要你不怕麻烦,可以使用 Vuex 或者 Event Bus 解决上述所有的通信情况。

28.3 extend 能做什么

这个 API 很少用到,作用是扩展组件生成一个构造器,通常会与 $mount 一起使用。

    // 创建组件构造器
    let Component = Vue.extend({
      template: '<div>test</div>'
    })
    // 挂载到 #app 上
    new Component().$mount('#app')
    // 除了上面的方式,还可以用来扩展已有的组件
    let SuperComponent = Vue.extend(Component)
    new SuperComponent({
        created() {
            console.log(1)
        }
    })
    new SuperComponent().$mount('#app')

28.4 mixin 和 mixins 区别

mixin 用于全局混入,会影响到每个组件实例,通常插件都是这样做初始化的

    Vue.mixin({
        beforeCreate() {
            // ...逻辑
            // 这种方式会影响到每个组件的 beforeCreate 钩子函数
        }
    })
  • 虽然文档不建议我们在应用中直接使用 mixin,但是如果不滥用的话也是很有帮助的,比如可以全局混入封装好的 ajax 或者一些工具函数等等。
  • mixins 应该是我们最常使用的扩展组件的方式了。如果多个组件中有相同的业务逻辑,就可以将这些逻辑剥离出来,通过 mixins 混入代码,比如上拉下拉加载数据这种逻辑等等。
  • 另外需要注意的是 mixins 混入的钩子函数会先于组件内的钩子函数执行,并且在遇到同名选项的时候也会有选择性的进行合并,具体可以阅读 文档。

28.5 computed 和 watch 区别

  • computed 是计算属性,依赖其他属性计算值,并且 computed 的值有缓存,只有当计算值变化才会返回内容。
  • watch 监听到值的变化就会执行回调,在回调中可以进行一些逻辑操作。
  • 所以一般来说需要依赖别的属性来动态获得值的时候可以使用 computed,对于监听到值的变化需要做一些复杂业务逻辑的情况可以使用 watch
  • 另外 computerwatch 还都支持对象的写法,这种方式知道的人并不多。
    vm.$watch('obj', {
        // 深度遍历
        deep: true,
        // 立即触发
        immediate: true,
        // 执行的函数
        handler: function(val, oldVal) {}
    })
    var vm = new Vue({
      data: { a: 1 },
      computed: {
        aPlus: {
          // this.aPlus 时触发
          get: function () {
            return this.a + 1
          },
          // this.aPlus = 1 时触发
          set: function (v) {
            this.a = v - 1
          }
        }
      }
    })

28.6 keep-alive 组件有什么作用

  • 如果你需要在组件切换的时候,保存一些组件的状态防止多次渲染,就可以使用 keep-alive 组件包裹需要保存的组件。
  • 对于 keep-alive 组件来说,它拥有两个独有的生命周期钩子函数,分别为 activateddeactivated 。用 keep-alive 包裹的组件在切换时不会进行销毁,而是缓存到内存中并执行 deactivated 钩子函数,命中缓存渲染后会执行 actived 钩子函数。

28.7 v-show 与 v-if 区别

  • v-show 只是在 display: nonedisplay: block 之间切换。无论初始条件是什么都会被渲染出来,后面只需要切换 CSSDOM 还是一直保留着的。所以总的来说 v-show 在初始渲染时有更高的开销,但是切换开销很小,更适合于频繁切换的场景。
  • v-if 的话就得说到 Vue 底层的编译了。当属性初始为 false 时,组件就不会被渲染,直到条件为 true,并且切换条件时会触发销毁/挂载组件,所以总的来说在切换时开销更高,更适合不经常切换的场景。
  • 并且基于 v-if 的这种惰性渲染机制,可以在必要的时候才去渲染组件,减少整个页面的初始渲染开销。

28.8 组件中 data 什么时候可以使用对象

这道题目其实更多考的是 JS 功底。

  • 组件复用时所有组件实例都会共享data,如果 data 是对象的话,就会造成一个组件修改 data 以后会影响到其他所有组件,所以需要将 data 写成函数,每次用到就调用一次函数获得新的数据。
  • 当我们使用 new Vue() 的方式的时候,无论我们将 data 设置为对象还是函数都是可以的,因为 new Vue() 的方式是生成一个根组件,该组件不会复用,也就不存在共享 data 的情况了

以下是进阶部分

28.9 响应式原理

Vue 内部使用了 Object.defineProperty() 来实现数据响应式,通过这个函数可以监听到 setget 的事件

    var data = { name: 'poetries' }
    observe(data)
    let name = data.name // -> get value
    data.name = 'yyy' // -> change value
    
    function observe(obj) {
      // 判断类型
      if (!obj || typeof obj !== 'object') {
        return
      }
      Object.keys(obj).forEach(key => {
        defineReactive(obj, key, obj[key])
      })
    }
    
    function defineReactive(obj, key, val) {
      // 递归子属性
      observe(val)
      Object.defineProperty(obj, key, {
        // 可枚举
        enumerable: true,
        // 可配置
        configurable: true,
        // 自定义函数
        get: function reactiveGetter() {
          console.log('get value')
          return val
        },
        set: function reactiveSetter(newVal) {
          console.log('change value')
          val = newVal
        }
      })
    }

以上代码简单的实现了如何监听数据的 setget 的事件,但是仅仅如此是不够的,因为自定义的函数一开始是不会执行的。只有先执行了依赖收集,从能在属性更新的时候派发更新,所以接下来我们需要先触发依赖收集

    <div>
        {{name}}
    </div>
  • 在解析如上模板代码时,遇到 {name} 就会进行依赖收集。
  • 接下来我们先来实现一个 Dep 类,用于解耦属性的依赖收集和派发更新操作
    // 通过 Dep 解耦属性的依赖和更新操作
    class Dep {
      constructor() {
        this.subs = []
      }
      // 添加依赖
      addSub(sub) {
        this.subs.push(sub)
      }
      // 更新
      notify() {
        this.subs.forEach(sub => {
          sub.update()
        })
      }
    }
    // 全局属性,通过该属性配置 Watcher
    Dep.target = null

以上的代码实现很简单,当需要依赖收集的时候调用 addSub,当需要派发更新的时候调用 notify

接下来我们先来简单的了解下 Vue组件挂载时添加响应式的过程。在组件挂载时,会先对所有需要的属性调用 Object.defineProperty(),然后实例化 Watcher,传入组件更新的回调。在实例化过程中,会对模板中的属性进行求值,触发依赖收集。

因为这一小节主要目的是学习响应式原理的细节,所以接下来的代码会简略的表达触发依赖收集时的操作。

    class Watcher {
      constructor(obj, key, cb) {
        // 将 Dep.target 指向自己
        // 然后触发属性的 getter 添加监听
        // 最后将 Dep.target 置空
        Dep.target = this
        this.cb = cb
        this.obj = obj
        this.key = key
        this.value = obj[key]
        Dep.target = null
      }
      update() {
        // 获得新值
        this.value = this.obj[this.key]
        // 调用 update 方法更新 Dom
        this.cb(this.value)
      }
    }

以上就是 Watcher的简单实现,在执行构造函数的时候将 Dep.target指向自身,从而使得收集到了对应的 Watcher,在派发更新的时候取出对应的 Watcher 然后执行 update 函数。

接下来,需要对defineReactive 函数进行改造,在自定义函数中添加依赖收集和派发更新相关的代码

    function defineReactive(obj, key, val) {
      // 递归子属性
      observe(val)
      let dp = new Dep()
      Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
          console.log('get value')
          // 将 Watcher 添加到订阅
          if (Dep.target) {
            dp.addSub(Dep.target)
          }
          return val
        },
        set: function reactiveSetter(newVal) {
          console.log('change value')
          val = newVal
          // 执行 watcher 的 update 方法
          dp.notify()
        }
      })
    }

以上所有代码实现了一个简易的数据响应式,核心思路就是手动触发一次属性的 getter 来实现依赖收集。

现在我们就来测试下代码的效果,只需要把所有的代码复制到浏览器中执行,就会发现页面的内容全部被替换了

    var data = { name: 'poetries' }
    observe(data)
    function update(value) {
      document.querySelector('div').innerText = value
    }
    // 模拟解析到 `{{name}}` 触发的操作
    new Watcher(data, 'name', update)
    // update Dom innerText
    data.name = 'yyy'

28.9.1 Object.defineProperty 的缺陷

  • 以上已经分析完了 Vue 的响应式原理,接下来说一点 Object.defineProperty 中的缺陷。
  • 如果通过下标方式修改数组数据或者给对象新增属性并不会触发组件的重新渲染,因为 Object.defineProperty 不能拦截到这些操作,更精确的来说,对于数组而言,大部分操作都是拦截不到的,只是 Vue 内部通过重写函数的方式解决了这个问题。
  • 对于第一个问题,Vue 提供了一个 API 解决
    export function set (target: Array<any> | Object, key: any, val: any): any {
      // 判断是否为数组且下标是否有效
      if (Array.isArray(target) && isValidArrayIndex(key)) {
        // 调用 splice 函数触发派发更新
        // 该函数已被重写
        target.length = Math.max(target.length, key)
        target.splice(key, 1, val)
        return val
      }
      // 判断 key 是否已经存在
      if (key in target && !(key in Object.prototype)) {
        target[key] = val
        return val
      }
      const ob = (target: any).__ob__
      // 如果对象不是响应式对象,就赋值返回
      if (!ob) {
        target[key] = val
        return val
      }
      // 进行双向绑定
      defineReactive(ob.value, key, val)
      // 手动派发更新
      ob.dep.notify()
      return val
    }

对于数组而言,Vue内部重写了以下函数实现派发更新

    // 获得数组原型
    const arrayProto = Array.prototype
    export const arrayMethods = Object.create(arrayProto)
    // 重写以下函数
    const methodsToPatch = [
      'push',
      'pop',
      'shift',
      'unshift',
      'splice',
      'sort',
      'reverse'
    ]
    methodsToPatch.forEach(function (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
      })
    })

补充(现代做法):以上是 Vue 2 基于 Object.defineProperty 的实现。Vue 3 改用 Proxy 重写了响应式系统,从根本上消除了上述缺陷——能直接拦截属性新增/删除和数组下标修改,不再需要 Vue.set / Vue.delete 这类补丁 API,对 Map/Set 也有内建支持。配合 Composition API,可用 reactive()(深层代理对象)和 ref()(包裹原始值,.value 访问)独立声明响应式状态。

28.9.2 编译过程

想必大家在使用 Vue 开发的过程中,基本都是使用模板的方式。那么你有过「模板是怎么在浏览器中运行的」这种疑虑嘛?

  • 首先直接把模板丢到浏览器中肯定是不能运行的,模板只是为了方便开发者进行开发。Vue 会通过编译器将模板通过几个阶段最终编译为 render 函数,然后通过执行 render 函数生成 Virtual DOM 最终映射为真实 DOM
  • 接下来我们就来学习这个编译的过程,了解这个过程中大概发生了什么事情。这个过程其中又分为三个阶段 ,分别为:
  • 将模板解析为 AST
  • 优化 AST
  • AST转换为 render函数

在第一个阶段中,最主要的事情还是通过各种各样的正则表达式去匹配模板中的内容,然后将内容提取出来做各种逻辑操作,接下来会生成一个最基本的 AST对象

    {
        // 类型
        type: 1,
        // 标签
        tag,
        // 属性列表
        attrsList: attrs,
        // 属性映射
        attrsMap: makeAttrsMap(attrs),
        // 父节点
        parent,
        // 子节点
        children: []
    }
  • 然后会根据这个最基本的 AST 对象中的属性,进一步扩展 AST
  • 当然在这一阶段中,还会进行其他的一些判断逻辑。比如说对比前后开闭标签是否一致,判断根组件是否只存在一个,判断是否符合 HTML5 Content Model规范等等问题。
  • 接下来就是优化 AST 的阶段。在当前版本下,Vue 进行的优化内容其实还是不多的。只是对节点进行了静态内容提取,也就是将永远不会变动的节点提取了出来,实现复用 Virtual DOM,跳过对比算法的功能。在下一个大版本中,Vue 会在优化 AST 的阶段继续发力,实现更多的优化功能,尽可能的在编译阶段压榨更多的性能,比如说提取静态的属性等等优化行为。
  • 最后一个阶段就是通过 AST 生成 render 函数了。其实这一阶段虽然分支有很多,但是最主要的目的就是遍历整个 AST,根据不同的条件生成不同的代码罢了。

28.9.3 NextTick 原理分析

nextTick 可以让我们在下次 DOM 更新循环结束之后执行延迟回调,用于获得更新后的 DOM

  • Vue 2.4 之前都是使用的 microtasks,但是microtasks 的优先级过高,在某些情况下可能会出现比事件冒泡更快的情况,但如果都使用 macrotasks 又可能会出现渲染的性能问题。所以在新版本中,会默认使用 microtasks,但在特殊情况下会使用 macrotasks,比如 v-on
  • 对于实现 macrotasks ,会先判断是否能使用 setImmediate ,不能的话降级为 MessageChannel ,以上都不行的话就使用 setTimeout
    if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
      macroTimerFunc = () => {
        setImmediate(flushCallbacks)
      }
    } else if (
      typeof MessageChannel !== 'undefined' &&
      (isNative(MessageChannel) ||
        // PhantomJS
        MessageChannel.toString() === '[object MessageChannelConstructor]')
    ) {
      const channel = new MessageChannel()
      const port = channel.port2
      channel.port1.onmessage = flushCallbacks
      macroTimerFunc = () => {
        port.postMessage(1)
      }
    } else {
      macroTimerFunc = () => {
        setTimeout(flushCallbacks, 0)
      }
    }

以上代码很简单,就是判断能不能使用相应的API

Last Updated:
Contributors: leeguooooo