Vue 的这些技巧你真的都掌握了吗?

前端瓶子君

共 28574字,需浏览 58分钟

 ·

2021-11-08 07:15

点击上方 前端瓶子君,关注公众号

回复算法,加入前端编程面试算法每日一题群


前言

文章目的昭然若揭🐱‍🐉,整理汇总 Vue 框架中重要的特性、框架的原理。

那 "前车之鉴" 从何而来?

是的,我又要讲小故事了,但这次是故事的续集。

故事第 1 集:CSS预处理器,你还是只会嵌套么 ?[2]
故事第 2 集:【自适应】px 转 rem,你还在手算么?[3]

为什么说是续集,因为这些都是同一大佬问的,在此感谢大佬,天降素材🤣。

故事续集

大佬:有看过 Vue 源码么?

我:嗯嗯,看过。

大佬:那大概讲一讲 nextTick 的底层实现 ?

我:停顿了大概10s,说了句忘了。(理不直气还壮)

大佬:噢噢,没事。(内心大概已经放弃对我知识面的挖掘)

因为是视频面试,强装自信的尴尬从屏幕中溢出,这大概就是普通且自信🤦‍♂️?装X失败案例引以为戒,能写出续集的面试结果不提也罢。

这次面试打击还是蛮大的,考察内容全面且细节。面试后一直在整理 Vue 相关的知识点,所以不会将nextTick实现单独成文,只是收录在下方试题中。前车之鉴可以为鉴,大家可以把本篇文章当测验,考察自己是否对这些知识点熟练于心。

万字长文,持续更新,若有遗漏知识点,后续会补充。

题目

Vue 的优缺点

优点

  1. 创建单页面应用的轻量级Web应用框架
  2. 简单易用
  3. 双向数据绑定
  4. 组件化的思想
  5. 虚拟DOM
  6. 数据驱动视图

缺点

不支持IE8(现阶段只能勉强凑出这么半点😂)

SPA 的理解

SPA是Single-Page-Application的缩写,翻译过来就是单页应用。在WEB页面初始化时一同加载Html、Javascript、Css。一旦页面加载完成,SPA不会因为用户操作而进行页面重新加载或跳转,取而代之的是利用路由机制实现Html内容的变换。

优点

  1. 良好的用户体验,内容更改无需重载页面。
  2. 基于上面一点,SPA相对服务端压力更小。
  3. 前后端职责分离,架构清晰。

缺点

  1. 由于单页WEB应用,需在加载渲染页面时请求JavaScript、Css文件,所以耗时更多。
  2. 由于前端渲染,搜索引擎不会解析JS,只能抓取首页未渲染的模板,不利于SEO。
  3. 由于单页应用需在一个页面显示所有的内容,默认不支持浏览器的前进后退。

缺点3,想必有人和我有同样的疑问。

通过资料查阅,其实是前端路由机制解决了单页应用无法前进后退的问题。Hash模式中Hash变化会被浏览器记录(onhashchange事件),History模式利用 H5 新增的pushStatereplaceState方法可改变浏览器历史记录栈。

new Vue(options) 都做了些什么

如下 Vue 构造函数所示,主要执行了 this._init(options)方法,该方法在initMixin函数中注册。

import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'

function Vue (options{
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  // Vue.prototype._init 方法
  this._init(options)
}

// _init 方法在 initMixin 注册
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue
复制代码

查看initMixin方法的实现,其他函数具体实现可自行查看,这里就不贴出了。

let uid = 0
export function initMixin() {
  Vue.prototype._init = function(options{
    const vm = this
    vm._uid = uid++
    vm._isVue = true
   
    // 处理组件配置项
    if (options && options._isComponent) {
       /**
       * 如果是子组件,走当前 if 分支
       * 函数作用是性能优化:将原型链上的方法都放到vm.$options中,减少原型链上的访问
       */
   
      initInternalComponent(vm, options)
    } else {
      /**
       * 如果是根组件,走当前 else 分支
       * 合并 Vue 的全局配置到根组件中,如 Vue.component 注册的全局组件合并到根组件的 components 的选项中
       * 子组件的选项合并发生在两个地方
       * 1. Vue.component 方法注册的全局组件在注册时做了选项合并
       * 2. { component: {xx} } 方法注册的局部组件在执行编译器生成的 render 函数时做了选项合并
       */
  
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
  
    if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }

    vm._self = vm
    /**
    * 初始化组件实例关系属性,如:$parent $root $children $refs
    */

    initLifecycle(vm)
    /**
    * 初始化自定义事件
    * @click
="handleClick">
    * 组件上注册的事件,监听者不是父组件,而是子组件本身
    */
    initEvents(vm)
    /**
    * 解析组件插槽信息,得到vm.$slot,处理渲染函数,得到 vm.$createElement 方法,即 h 函数。
    */

    initRender(vm)
    /**
    * 执行 beforeCreate 生命周期函数
    */

    callHook(vm, 'beforeCreate')
    /**
    * 解析 inject 配置项,得到 result[key] = val 的配置对象,做响应式处理且代理到 vm 实力上
    */

    initInjections(vm) 
    /**
    * 响应式处理核心,处理 props、methods、data、computed、watch
    */

    initState(vm)
    /**
    * 解析 provide 对象,并挂载到 vm 实例上
    */

    initProvide(vm) 
    /**
    * 执行 created 生命周期函数
    */

    callHook(vm, 'created')

    // 如果 el 选项,自动执行$mount
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}
复制代码

MVVM 的理解

MVVM是Model-View-ViewModel的缩写。Model 代表数据层,可定义修改数据、编写业务逻辑。View 代表视图层,负责将数据渲染成页面。ViewModel 负责监听数据层数据变化,控制视图层行为交互,简单讲,就是同步数据层和视图层的对象。ViewModel 通过双向绑定把 View 和 Model 层连接起来,且同步工作无需人为干涉,使开发人员只关注业务逻辑,无需频繁操作DOM,不需关注数据状态的同步问题。

mvvm.png

如何实现 v-model

v-model指令用于实现inputselect等表单元素的双向绑定,是个语法糖。

原生 input 元素若是text/textarea类型,使用 value 属性和 input 事件。
原生 input 元素若是radio/checkbox类型,使用 checked属性和 change 事件。
原生 select 元素,使用 value 属性和 change 事件。

input 元素上使用 v-model 等价于

<input :value="message" @input="message = $event.target.value" />
复制代码

实现自定义组件的 v-model

自定义组件的v-model使用prop值为valueinput事件。若是radio/checkbox类型,需要使用model来解决原生 DOM 使用的是 checked 属性 和 change 事件,如下所示。

// 父组件
<template>
  <base-checkbox v-model="baseCheck" />
template>
复制代码
// 子组件
<template>
  <input type="checkbox" :checked="checked" @change="$emit('change', $event.target.checked)" />
template>
<script>
export default {
  model: {
    prop'checked',
    event'change'
  },
  prop: {
    checkedBoolean
  }
}
script
>
复制代码

如何理解 Vue 单向数据流

Vue 官方文档 Prop 菜单下的有个名为单项数据流的子菜单。

image.png

我们经常说 Vue 的双向绑定,其实是在单向绑定的基础上给元素添加 input/change 事件,来动态修改视图。Vue 组件间传递数据仍然是单项的,即父组件传递到子组件。子组件内部可以定义依赖 props 中的值,但无权修改父组件传递的数据,这样做防止子组件意外变更父组件的状态,导致应用数据流向难以理解。

如果在子组件内部直接更改prop,会遇到警告处理。

2 种定义依赖 props 中的值

  1. 通过 data 定义属性并将 prop 作为初始值。
<script>
export default {
  props: ['initialNumber'],
  data() {
    return {
      numberthis.initailNumber
    }
  }
}
script
>
复制代码
  1. 用 computed 计算属性去定义依赖 prop 的值。若页面会更改当前值,得分 get 和 set 方法。
<script>
export default {
  props: ['size'],
  computed: {
    normalizedSize() {
      return this.size.trim().toLowerCase()
    }
  }
}
sciprt>

复制代码

Vue 响应式原理

核心源码位置:vue/src/core/observer/index.js

响应式原理3个步骤:数据劫持、依赖收集、派发更新。

数据分为两类:对象、数组。

对象

遍历对象,通过Object.defineProperty为每个属性添加 getter 和 setter,进行数据劫持。getter 函数用于在数据读取时进行依赖收集,在对应的 dep 中存储所有的 watcher;setter 则是数据更新后通知所有的 watcher 进行更新。

核心源码

function defineReactive(obj, key, val, shallow{
  // 实例化一个 dep, 一个 key 对应一个 dep
  const dep = new Dep()
 
  // 获取属性描述符
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  // 通过递归的方式处理 val 为对象的情况,即处理嵌套对象
  let childOb = !shallow && observe(val)
  
  Object.defineProperty(obj, key, {
    enumerabletrue,
    configurabletrue,
    // 拦截obj.key,进行依赖收集
    getfunction reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      // Dep.target 是当前组件渲染的 watcher
      if (Dep.target) {
        // 将 dep 添加到 watcher 中
        dep.depend()
        if (childOb) {
          // 嵌套对象依赖收集
          childOb.dep.depend()
          // 响应式处理 value 值为数组的情况
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    setfunction reactiveSetter (newVal{
      // 获取旧值
      const value = getter ? getter.call(obj) : val
      // 判断新旧值是否一致
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }

      if (getter && !setter) return
      // 如果是新值,用新值替换旧值
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 新值做响应式处理
      childOb = !shallow && observe(newVal)
      // 当响应式数据更新,依赖通知更新
      dep.notify()
    }
  })
}
复制代码

数组

用数组增强的方式,覆盖原属性上默认的数组方法,保证在新增或删除数据时,通过 dep 通知所有的 watcher 进行更新。

核心源码

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]
  // 分别在 arrayMethods 对象上定义7个方法
  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
  })
})
复制代码

手写观察者模式

当对象间存在一对多的关系,使用观察者模式。比如:当一个对象被修改,会自动通知依赖它的对象。

let uid = 0
class Dep {
  constructor() {
    this.id = uid++
    // 存储所有的 watcher
    this.subs = []
  }
  addSub(sub) {
    this.subs.push(sub)
  }
  removeSub(sub) {
    if(this.subs.length) {
      const index = this.subs.indexOf(sub)
      if(index > -1return this.subs.splice(index, 1)
    }
  }
  notify() {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}

class Watcher {
  constructor(name) {
    this.name = name
  }
  update() {
    console.log('更新')
  }
}
复制代码

手写发布订阅模式

与观察者模式相似,区别在于发布者和订阅者是解耦的,由中间的调度中心去与发布者和订阅者通信。

Vue响应式原理个人更倾向于发布订阅模式。其中 Observer 是发布者,Watcher 是订阅者,Dep 是调度中心。

vue中数据绑定原理的设计模式到底观察者还是发布订阅?[4],知乎有相关争论,感兴趣的可以看下。

class EventEmitter {
  constructor() {
    this.events = {}
  }
  on(type, cb) {
    if(!this.events[type]) this.events[type] = []
    this.events[type].push(cb)
  }
  emit(type, ...args) {
    if(this.events[type]) {
      this.events[type].forEach(cb => {
        cb(...args)
      })
    }
  }
  off(type, cb) {
    if(this.events[type]) {
      const index = this.events[type].indexOf(cb)
      if(index > -1this.events[type].splice(index, 1)
    }
  }
}
复制代码

关于 Vue.observable 的了解

Vue.observable 可使对象可响应。返回的对象可直接用于渲染函数计算属性内,并且在发生变更时触发相应的更新。也可以作为最小化的跨组件状态存储器。

Vue 2.x 中传入的对象和返回的对象是同一个对象。
Vue 3.x 则不是一个对象,源对象不具备响应式功能。

适用的场景:在项目中没有大量的非父子组件通信时,可以使用 Vue.observable 去替代 eventBusvuex方案。

用法如下

// store.js
import Vue from 'vue'
export const state = Vue.observable({
  count1
})
export const mutations = {
  setCount(count) {
    state.count = count
  }


// vue 文件