十道中高阶vue面试题解析

1,343 阅读17分钟

1、什么是发布/订阅模式、观察者模式?

观察者模式

观察者模式指的是一个对象(Subject)维持一系列依赖于它的对象(Observer),当有关状态发生变更时 Subject 对象则通知一系列 Observer 对象进行更新。

在观察者模式中,Subject 对象拥有添加、删除和通知一系列 Observer 的方法等等,而 Observer 对象拥有更新方法等等。 在 Subject 对象添加了一系列 Observer 对象之后,Subject 对象则维持着这一系列 Observer 对象,当有关状态发生变更时 Subject 对象则会通知这一系列 Observer 对象进行更新。

function Subject(){
  this.observers = [];
}

Subject.prototype = {
  add:function(observer){  // 添加
    this.observers.push(observer);
  },
  remove:function(observer){  // 删除
    var observers = this.observers;
    for(var i = 0;i < observers.length;i++){
      if(observers[i] === observer){
        observers.splice(i,1);
      }
    }
  },
  notify:function(){  // 通知
    var observers = this.observers;
    for(var i = 0;i < observers.length;i++){
      observers[i].update();
    }
  }
}

function Observer(name){
  this.name = name;
}

Observer.prototype = {
  update:function(){  // 更新
    console.log('My name is '+this.name);
  }
}

var sub = new Subject();

var obs1 = new Observer('zhang');
var obs2 = new Observer('li');

sub.add(obs1);
sub.add(obs2);
sub.notify();  //My name is zhang、My name is li

上述代码中,创建了 Subject 对象和两个 Observer 对象,当有关状态发生变更时则通过 Subject 对象的 notify 方法通知这两个 Observer 对象,这两个 Observer 对象通过 update 方法进行更新。在 Subject 对象添加了一系列 Observer 对象之后,还可以通过 remove 方法移除某个 Observer 对象对它的依赖。

发布订阅模式

发布订阅模式指的是希望接收通知的对象(Subscriber)基于一个主题通过自定义事件订阅主题,被激活事件的对象(Publisher)通过发布主题事件的方式通知各个订阅该主题的 Subscriber 对象。

let pubSub = {
  list:{},
  subscribe:function(key,fn){  // 订阅
    if (!this.list[key]) {
      this.list[key] = [];
    }
    this.list[key].push(fn);
  },
  publish:function(){  // 发布
    let arg = arguments;
    let key = [].shift.call(arg);
    let fns = this.list[key];

    if(!fns || fns.length<=0) return false;

    for(var i=0,len=fns.length;i<len;i++){
      fns[i].apply(this, arg);
    }

  },
  unSubscribe(key) {  // 取消订阅
    delete this.list[key];
  }
};

pubSub.subscribe('name', (name) => {
  console.log('your name is ' + name);
});
pubSub.subscribe('sex', (sex) => {
  console.log('your sex is ' + sex);
});
pubSub.publish('name', 'ttsy1');  // your name is ttsy1
pubSub.publish('sex', 'male');  // your sex is male

上述代码的订阅是基于 name 和 sex 主题来自定义事件,发布是通过 name 和 sex 主题并传入自定义事件的参数,最终触发了特定主题的自定义事件。 可以通过 unSubscribe 方法取消特定主题的订阅。

观察者模式 && 发布订阅模式

观察者模式与发布订阅模式都是定义了一个一对多的依赖关系,当有关状态发生变更时则执行相应的更新。

不同的是,在观察者模式中依赖于 Subject 对象的一系列 Observer 对象在被通知之后只能执行同一个特定的更新方法,而在发布订阅模式中则可以基于不同的主题去执行不同的自定义事件。相对而言,发布订阅模式比观察者模式要更加灵活多变。

2、如何理解Vue2响应式原理?

vue通过 Object.defineProperty 操作其访问器属性,即对象拥有了 gettersetter 方法。

8T7c1e.png

Vue 的初始化的时候,其 _init() 方法会调用执行 initState(vm) 方法。initState 方法主要是对 props、methods、data、computedwathcer 等属性做了初始化操作。

调用 observe 方法观测整个 data 的变化,把 data 也变成响应式(可观察),可以通过 vm._data.[key] 访问到定义 data 返回函数中对应的属性。

defineReactive 方法最开始初始化 Dep 对象的实例,然后通过对子对象递归调用observe 方法,使所有子属性也能变成响应式的对象。并且在 Object.definePropertygettersetter 方法中调用 dep 的相关方法。

Dep 收集订阅者 Watcher 并添加到观察者列表 subs, 接收发布者的事件, 通知订阅者目标更新,让订阅者执行自己的 update 方法。

3、vuex的工作原理是什么?

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。

877aqA.png

vuex 引入 State、Getter 的概念对状态进行定义;使用 MutationAction对状态进行变更;引入Module对状态进行模块化分割;引入插件对状态进行快照、记录、以及追踪等;提供了mapState、mapGetters、 mapActions、 mapMutations 辅助函数方便开发者在vm中处理store。具体构成关系如下:

vuex

// vuexInit
function vuexInit () {
    const options = this.$options;
    if (options.store) {
        this.$store = options.store;
    } else {
        this.$store = options.parent.$store;
    }
}

// 安装vuex
install (_Vue) {
    Vue.mixin({ beforeCreate: vuexInit });
    Vue = _Vue;
}

利用vue的use机制将 实例化后的store对象 注入vue实例,store注入 vue的实例组件的方式,是通过vue的 mixin机制,借助vue组件的生命周期 钩子 beforeCreate 完成的。 即 每个vue组件实例化过程中,会在 beforeCreate 钩子前调用 vuexInit 方法。

在 Store 的构造函数中对 state 进行 响应式化,这个步骤以后,state 会将需要的依赖收集在 Dep 中,在被修改时更新对应视图。

4、谈一谈nextTick的原理以及运行机制?

运行机制

  • JS执行是单线程的,它是基于事件循环的。
  • 所有同步任务都在主线程上执行,形成一个执行栈。
  • 主线程之外,会存在一个任务队列,只要异步任务有了结果,就在任务队列中放置一个事件。
  • 当执行栈中的所有同步任务执行完后,就会读取任务队列。那些对应的异步任务,会结束等待状态,进入执行栈。主线程不断重复第三步。
  • 主线程的执行过程就是一个tick,而所有的异步结果都是通过任务队列来调度。Event Loop 分为宏任务和微任务,无论是执行宏任务还是微任务,完成后都会进入到一下tick,并在两个tick之间进行UI渲染。
  • 由于Vue DOM更新是异步执行的,即修改数据时,视图不会立即更新,而是会监听数据变化,并缓存在同一事件循环中,等同一数据循环中的所有数据变化完成之后,再统一进行视图更新。为了确保得到更新后的DOM,所以设置了 Vue.nextTick()方法。

源码分析

// 空函数,可用作函数占位符
import { noop } from 'shared/util' 

 // 错误处理函数
import { handleError } from './error'

 // 是否是IE、IOS、内置函数
import { isIE, isIOS, isNative } from './env'

// 使用 MicroTask 的标识符,这里是因为火狐在<=53时 无法触发微任务,在modules/events.js文件中引用进行安全排除
export let isUsingMicroTask = false 

 // 用来存储所有需要执行的回调函数
const callbacks = []

// 用来标志是否正在执行回调函数
let pending = false 

// 对callbacks进行遍历,然后执行相应的回调函数
function flushCallbacks () {
    pending = false
    // 这里拷贝的原因是:
    // 有的cb 执行过程中又会往callbacks中加入内容
    // 比如 $nextTick的回调函数里还有$nextTick
    // 后者的应该放到下一轮的nextTick 中执行
    // 所以拷贝一份当前的,遍历执行完当前的即可,避免无休止的执行下去
    const copies = callbcks.slice(0)
    callbacks.length = 0
    for(let i = 0; i < copies.length; i++) {
        copies[i]()
    }
}

let timerFunc // 异步执行函数 用于异步延迟调用 flushCallbacks 函数

// 在2.5中,我们使用(宏)任务(与微任务结合使用)。
// 然而,当状态在重新绘制之前发生变化时,就会出现一些微妙的问题
// (例如#6813,out-in转换)。
// 同样,在事件处理程序中使用(宏)任务会导致一些奇怪的行为
// 因此,我们现在再次在任何地方使用微任务。
// 优先使用 Promise
if(typeof Promise !== 'undefined' && isNative(Promise)) {
    const p = Promise.resolve()
    timerFunc = () => {
        p.then(flushCallbacks)
        
        // IOS 的UIWebView, Promise.then 回调被推入 microTask 队列,但是队列可能不会如期执行
        // 因此,添加一个空计时器强制执行 microTask
        if(isIOS) setTimeout(noop)
    }
    isUsingMicroTask = true
} else if(!isIE && typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) || MutationObserver.toString === '[object MutationObserverConstructor]')) {
    // 当 原生Promise 不可用时,使用 原生MutationObserver
    // e.g. PhantomJS, iOS7, Android 4.4
 
    let counter = 1
    // 创建MO实例,监听到DOM变动后会执行回调flushCallbacks
    const observer = new MutationObserver(flushCallbacks)
    const textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
        characterData: true // 设置true 表示观察目标的改变
    })
    
    // 每次执行timerFunc 都会让文本节点的内容在 0/1之间切换
    // 切换之后将新值复制到 MO 观测的文本节点上
    // 节点内容变化会触发回调
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter) // 触发回调
    }
    isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = () => {
        setImmediate(flushCallbacks)
    }
} else {
    timerFunc = () => {
        setTimeout(flushCallbacks, 0)
    }
}

在一次task代码中,数据可能被多次修改。而我们不能在每次修改时都立马通知watcher去更新dom,替代的做法是将watcher加入到更新数组中。等到task代码执行完毕后(即所有同步代码执行完毕),则代表这一轮的数据修改已经结束。这时候我们可以去触发watcher的更新操作,于是无论之前task代码修改了多少次,最终我们只会更新DOM一次。

nextTick的重点在于将flushBatcherQueue这步遍历watcher的操作放在microtask中执行,至于使用MO或者Promise.then都无所谓。在这两者都不能很好兼容的环境下会被迫使用setTimeout来代替。但setTimeout是将回调函数放在macrotask队列,而浏览器在清理完microtask队列时会触发ui rendering,这样setTimeout就会浪费了它之前的浏览器ui rendering机会。(即至少要两次ui rendering才能把更新后的DOM渲染出来)

[参考](https://juejin.cn/post/6844904000542736398)-->

5、聊聊keep-alive 的实现原理和缓存策略

keep-alivevue 内置的抽象组件,可以用于包裹需要缓存的组件。保存组件状态,避免重新渲染,增强性能。

keep-alive 组件接收三个参数,分别为:

  • include - 数组、字符串或正则表达式。只有名称匹配的组件会被缓存。
  • exclude - 数组、字符串或正则表达式。任何名称匹配的组件都不会被缓存。
  • max - 数字。最多可以缓存多少组件实例。

源码分析

export default {
  name: 'keep-alive',
  abstract: true, // 抽象组件:它自身不会渲染一个 DOM 元素,也不会出现在组件的父组件链中。
  // 具体关于抽象组件不会渲染出DOM,在 src/core/instance/lifecycle.js

  props: {
    include: patternTypes, // [String, RegExp, Array]
    exclude: patternTypes,
    max: [String, Number]
  },

  created () {
    this.cache = Object.create(null) // 新建缓存对象 { key: vnode }
    this.keys = [] // 按序保存组件的 key
  },

  destroyed () {
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys) 
    }
  },

  /**
  * 监听 include、exclude 动态变化
  */
  mounted () {
    this.$watch('include', val => {
      pruneCache(this, name => matches(val, name)) // function filter (name) { return matches(val, name); } 
    })
    this.$watch('exclude', val => {
      pruneCache(this, name => !matches(val, name))
    })
  },

  render () {
    const slot = this.$slots.default // 获取插槽默认内容
    const vnode: VNode = getFirstComponentChild(slot) // 获取第一个子节点
    // <keep-alive> 是用在其一个直属的子组件被开关的情形。如果你在其中有 v-for 则不会工作。如果有上述的多个条件性的子元素,<keep-alive> 要求同时只有一个子元素被渲染。
    const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
    if (componentOptions) {
      // check pattern
      const name: ?string = getComponentName(componentOptions)
      const { include, exclude } = this
      /**
       * 未匹配到相应组件,不缓存
       */
      if (
        (include && (!name || !matches(include, name))) ||
        (exclude && name && matches(exclude, name)) 
      ) {
        return vnode
      }

      const { cache, keys } = this
      // 生成 key
      const key: ?string = vnode.key == null
        ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
        : vnode.key
      if (cache[key]) {
        // 命中缓存
        vnode.componentInstance = cache[key].componentInstance
        // make current key freshest 刷新 keys 数组
        remove(keys, key)
        keys.push(key)
      } else {
        // 未命中
        cache[key] = vnode
        keys.push(key)
        // prune oldest entry
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode) // 超出了最大缓存数量,则删除第一个节点(最长时间未使用的节点)
        }
      }

      vnode.data.keepAlive = true
    }
    return vnode || (slot && slot[0])
  }
}

keep-alive 组件创建时,新建 catch 缓存节点,keys 按序保存key。通过传入的 includeexclude判断是否命中缓存,命中,则从缓存中拿vnode组件实例,调整key的顺序。未命中,则添加缓存。判断是否超出最大缓存数量,超出,则删除最久未被使用的节点,即keys第一个 key 对应的 vnode

组件一旦被 缓存,再次渲染就不会执行 createdmounted生命周期。因此 vue 提供了 activateddeactivated 两个生命周期函数,在缓存组件再次渲染时执行一些操作。

<keep-alive>组件通过插槽,获取第一个子节点。根据 includeexclude判断是否需要缓存,通过组件的 key,判断是否命中缓存。利用LRU算法,更新缓存以及对应的 keys 数组。根据max控制缓存的最大组件数量。

[参考](https://juejin.cn/post/6844904099272458253)-->

6、Axios主要有哪些特性?

  1. 在浏览器中发送 XMLHttpRequests 请求;
  2. 在 node.js 中发送 http请求;
  3. 支持 Promise API;
  4. 拦截请求和响应;
  5. 转换请求和响应数据;
  6. 自动转换 JSON 数据;
  7. 客户端支持保护安全免受 XSRF 攻击;

7、Vuex与Redux比较,他们的相同点以及不同点?

VuexReact-Redux:一个是针对VUE优化的状态管理系统,一个仅是常规的状态管理系统(Redux)与React框架的结合版本。它们必然在都具备常规的状态管理的功能之外,针对性地对各自所对应的框架还会有一些更优的特性,并且React-Redux还有一些衍生项目。DVA就是一个基于对React-Redux进行封装并提供了一些优化特性的框架

React-Redux,简单来说,它提供了一些接口,用于Redux的状态和React的组件展示结合起来,以用于实现状态与视图的一一对应。

Vuex,吸收了Redux的思想,并且针对web应用的开发模式和VUE框架做了优化。所以它在实现了全量Redux的思想以外,为了与VUE框架结合,它也具备了类似React-Redux中的与框架结合的功能(尽管具体使用方式可能有差异),此外还一些更好用的特性,下文会说到。

DVA,则是对React-Redux进行了封装,并结合了Redux-Saga等中间件,而且使用了model概念,也相当于在React-Redux的基础上针对web应用开发做了优化。(个人认为DVA框架的开发者可能是对VUEX有所借鉴的)

8、在vue中如何通过createElement创建虚拟dom?

Vue的底层实现上,Vue将模板编译成虚拟DOM渲染函数。结合Vue自带的响应系统,在状态改变时,Vue能够智能地计算出重新渲染组件的最小代价并应到DOM操作上

8HVLOH.png

虚拟 DOM 其实是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。

简单来说,可以把虚拟 DOM 理解为一个简单的JS对象,并且最少包含标签名( tag)、属性(attrs)和子元素对象( children)三个属性。不同的框架对这三个属性的命名会有点差别。

虚拟DOM的最终目标是将虚拟节点渲染到视图上。但是如果直接使用虚拟节点覆盖旧节点的话,会有很多不必要的DOM操作。例如,一个ul标签下很多个li标签,其中只有一个li有变化,这种情况下如果使用新的ul去替代旧的ul,因为这些不必要的DOM操作而造成了性能上的浪费。

为了避免不必要的DOM操作,虚拟DOM在虚拟节点映射到视图的过程中,将虚拟节点与上一次渲染视图所使用的旧虚拟节点(oldVnode)做对比,找出真正需要更新的节点来进行DOM操作,从而避免操作其他无需改动的DOM。

实现过程

  1. 初次渲染的时候,将VDOM渲染成真正的DOM然后插入到容器里面。
function createElement(vnode) {    
  var tag = vnode.tag  
  var attrs = vnode.attrs || {}    
  var children = vnode.children || []    
  if (!tag) {       
  return null  
    }    
  // 创建真实的 DOM 元素    
  var elem = document.createElement(tag)   
  // 属性    
  var attrName    
  for (attrName in attrs) {    
    if (attrs.hasOwnProperty(attrName)) { 
        // 给 elem 添加属性
        elem.setAttribute(attrName, attrs[attrName])
      }
  }
  // 子元素
  children.forEach(function (childVnode) {
      // 给 elem 添加子元素,如果还有子节点,则递归的生成子节点。
      elem.appendChild(createElement(childVnode))  // 递归
  })    // 返回真实的 DOM 元素   
  return elem
}
  1. 再次渲染的时候,将新的vnode和旧的vnode相对比,然后之间差异应用到所构建的真正的DOM树上。
function updateChildren(vnode, newVnode) {
  var children = vnode.children || []
  var newChildren = newVnode.children || []
  // 遍历现有的children
  children.forEach(function (childVnode, index) {
      var newChildVnode = newChildren[index]
      // 两者tag一样
      if (childVnode.tag === newChildVnode.tag) {
          // 深层次对比,递归
          updateChildren(childVnode, newChildVnode)
      } else { 
          // 两者tag不一样
          replaceNode(childVnode, newChildVnode) 
      }
  }
)}

9、如何通过vue, vue-router, vuex进行权限控制?

  1. 登录获取token,并保存到vuex,localStorage或seesionStorage(刷新时有用)
  2. 获取权限目录,并动态注册路由,生成权限目录树
router.beforeEach((to, from, next) => {
  // 获取token
  if (getToken()) {
    // 如果存在token,并且在登录页面,重定向到首页
    if (to.path === '/login') {
      next({ path: '/' })
    } else {
      // 判断当前用户是否已获取完用户信息
      if (store.getters.roles.length === 0) {
        // 用户信息不存在,获取用户信息
        store.dispatch('GetInfo').then(res => {
          // 拉取user_info
          const roles = res.roles
          // 根据用户信息 获取 权限路由
          store.dispatch('GenerateRoutes', { roles }).then(accessRoutes => {
            // 根据roles权限生成可访问的路由表
            accessRoutes.push({ path: '*', redirect: '/index' })
            router.addRoutes(accessRoutes) // 动态添加可访问路由表
            store.commit('SET_PERMISSION', accessRoutes) // 生成权限目录树
            next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
          })
        }).catch(err => {
            store.dispatch('FedLogOut').then(() => {
              next({ path: '/' })
            })
          })
      } else {
        next()
      }
    }
  } else {
    // 没有token
    if (whiteList.indexOf(to.path) !== -1) {
      // 在免登录白名单,直接进入
      next()
    } else {
      next(`/login`) // 否则全部重定向到登录页
    }
  }
})
  1. 进入页面,根据生成的目录树,渲染菜单
  2. 刷新页面后,根据router.beforeEach的判断,有token但是没用户信息、权限目录,会重新触发action去获取路由的,所以无需担心。
  3. 退出登陆后,需要刷新页面,因为我们是通过addRoutes添加的,router没有deleteRoutes这个api,所以清除token,清除用户信息、权限目录等,刷新页面是最保险的。
  4. 每次请求得带上token, 可以对axios封装一下来处理
const instance = axios.create({
    timeout: 30000,
    baseURL
})

// 添加请求拦截器
instance.interceptors.request.use(
    function(config) {
        // 请求头添加token
        if (store.state.UserToken) {
            config.headers.Authorization = store.state.UserToken
        }
        return config
    },
    function(error) {
        return Promise.reject(error)
    }
)

export default instance

10、vue的数据驱动原理及如何实现?

数据驱动是vuejs最大的特点。在vuejs中,所谓的数据驱动就是当数据发生变化的时候,用户界面发生相应的变化,开发者不需要手动的去修改dom。

vue 在实例化的过程中,会对实例化对象选项中的data 选项进行遍历,遍历其所有属性并使用Object.defineProperty把这些属性全部转为 getter/setter

同时每一个实例对象都有一个watcher实例对象,他会在模板编译的过程中,用getter去访问data的属性,watcher此时就会把用到的data属性记为依赖,这样就建立了视图与数据之间的联系。

当之后我们渲染视图的数据依赖发生改变(即数据的 被调用)的时候, 会对比前后两个的数值是否发生变化,然后确定是否通知视图进行重新渲染这样就实现了所谓的数据对于视图的驱动。

通俗地讲,它意味着我们在普通 HTML 模板中使用特殊的语法将 DOM “绑定”到底层数据。

一旦创建了绑定,DOM 将与数据保持同步。每当修改了数据,DOM 便相应地更新。这样我们应用中的逻辑就几乎都是直接修改数据了,不必与 DOM 更新搅在一起。这让我们的代码更容易撰写、理解与维护。

Vuejs的数据驱动是通过MVVM这种框架来实现的。MVVM框架主要包含3个部分:modelviewviewmodel。数据(Model)和视图(View)是不能直接通讯的,而是需要通过ViewModel来实现双方的通讯。当数据变化的时候,viewModel能够监听到这种变化,并及时的通知view做出修改。同样的,当页面有事件触发时,viewMOdel也能够监听到事件,并通知model进行响应。Viewmodel就相当于一个观察者,监控着双方的动作,并及时通知对方进行相应的操作。

实现源码见 vue源码简单实现