Vue 
虚拟 DOM 树 
虚拟 DOM 树是 Vue 的核心概念,它实际上是一个 JavaScript 对象,用来描述真实 DOM 树的结构。为什么要采用这种虚拟 DOM 树呢,好好的 DOM 树不好吗?
一方面是,真实 DOM 的属性很多,修改 DOM 节点开销很大,而虚拟 DOM 只是普通 JavaScript 对象,描述属性并不需要很多,修改开销很小。此外,我们希望利用 JavaScript 的强大能力来跟踪状态的变化,实现响应式,并且在状态变化时只更新必要的部分。另外,由于 JavaScript 的跨平台特性,一个虚拟 DOM 稍微改一改就可以在不同平台上运行,实现了应用的跨平台。
使用虚拟 DOM 一定是更快的吗?
其实也不一定。虚拟 DOM 也有一定的开销,比如即使只是改了一点文字,也需要比较新旧虚拟 DOM 树,然后再更新;此外各种绑定的事件也会有一定的开销。因此,如果只是简单的页面,虚拟 DOM 可能并不会比直接操作 DOM 快。但是,对于复杂的页面,虚拟 DOM 的优势就体现出来了。
Vue 中的虚拟 DOM 是通过 VNode 类来实现的。VNode 是一个树形结构,与真实的 DOM 树相对应。每个节点都有 tag、data、children、text、elm 等属性。
其中,tag 代表节点的标签名,例如 div、span 等;data 代表节点的数据,它是一个 VNodeData 类型,包含了节点的属性、事件等;children 代表子节点,同样是一个 VNode 类型的数组;text 代表文本内容;而 elm 代表对应的真实 DOM 节点,是一个 HTMLElement 类型。
双向绑定机制 
在 Vue 的组件初始化阶段,会通过 observe 和 defineReactive 等方法对配置对象中的 data 和 props 属性进行响应式化处理。这一过程会为每个属性注册 getter/setter,同时为每个属性创建一个对应的 Dep 实例(依赖收集器)。Dep 实例内部维护一个订阅者数组 subs,用于管理依赖关系。
组件初始化完成后,会创建一个 Watcher 实例(与渲染逻辑绑定)。此时,Watcher 会立即调用组件的 render 函数生成虚拟 DOM。在调用 render 函数时,如果访问了 data 或 props 的属性值,便会触发属性的 getter。此时,Vue 通过 Dep.target(一个全局指针,指向当前正在计算的 Watcher)将 Watcher 注册到对应属性的 Dep 实例中(即 dep.depend()),完成依赖收集。
当修改 data 或 props 的属性值时,会触发属性的 setter。此时,setter 会调用 dep.notify(),通知 Dep 实例中所有订阅的 Watcher 执行 update 逻辑。Watcher 的 update 方法会将自身加入异步更新队列,在下一个事件循环中批量执行重新渲染(通过 render 生成新虚拟 DOM 并 patch 到真实 DOM)。
computed 和 watch 的响应式
对于 computed 计算属性而言,实际上会在内部创建一个 computed watcher,每个 computed watcher 会持有一个 Dep 实例,当我们访问 computed 属性的时候,会调用 computed watcher 的 evaluate 方法,这时候会触发其持有的 depend 方法用于收集依赖,同时也会收集到正在计算的 watcher,然后把它计算的 watcher 作为 Dep 的 Subscriber 订阅者收集起来,收集起来的作用就是当计算属性所依赖的值发生变化以后,会触发 computed watcher 重新计算,如果重新计算过程中计算结果变了也会调用 dep 的 notify 方法,然后通知订阅 computed 的订阅者触发相关的更新。
对于 watch 而言,会创建一个 user watcher,可以理解为用户的 watcher,也就是用户自定义的一些 watch,它可以观察 data 的变化,也可以观察 computed 的变化。当这些数据发生变化以后,我们创建的这个 watcher 去观察某个数据或计算属性,让他们发生变化就会通知这个 Dep 然后调用这个 Dep 去遍历所有 user watchers,然后调用它们的 update 方法,然后求值发生新旧值变化就会触发 run 执行用户定义的回调函数(user callback)。
Diff 算法 
Vue 的 Diff 算法是一种高效的更新 DOM 树的算法。它顾名思义,主要是用于比较新旧虚拟 DOM 树,找出差异,然后在真实的 DOM 树上尽可能只更新差异部分。Diff 的主要流程可以概括为几个部分。
patch 函数 
首先,当数据发生改变时,对应属性或对象的 setter(可能是由 Object.defineProperty 或 Proxy 实现)会调用 Dep.notify() 通知所有订阅者 Watcher,订阅者(们)就会调用 patch 函数给真实的 DOM 打补丁,更新相应的视图,实现响应式。
数据发生改变的时机
当数据发生变化,Vue 将开启一个异步更新队列,视图需要等队列中所有数据变化完成之后,再统一进行更新。这样可以避免多次更新视图,提高性能。
当然,也可以通过 Vue.nextTick 来手动触发更新。
patch 函数主要参数即 oldVnode 和 Vnode,代表前后的虚拟 DOM 节点。这一函数主要是对传入的两个节点进行基本的比较,并对节点本身进行增删改操作。
这一步主要做了这些事情:
- 如果 
oldVnode不存在,说明是初始化,直接调用createElm函数创建新的DOM节点。 - 如果 
Vnode不存在,说明是删除节点,直接触发旧节点的destroy钩子。 - 调用 
sameVnode函数判断两个节点是否相同:- 如果不同,直接删除旧节点,创建新节点。
 - 如果相同,调用 
patchVnode函数,更新节点。 
 
patchVnode 函数 
这一函数同样会接受 oldVnode 和 Vnode 参数。这一函数主要是对节点的属性以及子节点进行基本比较,然后进行相应的更新,可能的话会借助 updateChildren 函数来对子节点进行更新。
- 如果新旧节点都是静态的,直接返回。
 - 如果新节点是文本节点,且和旧节点不同,直接更新文本内容。
 - 如果新旧节点都有子节点,且不同,调用 
updateChildren函数更新子节点。 - 否则: 
- 如果新节点有子节点,旧节点没有,直接添加子节点。
 - 如果旧节点有子节点,新节点没有,直接删除子节点。
 - 如果新旧节点都没有子节点,直接更新属性。
 
 
updateChildren 函数 
这一函数是 Diff 算法的重点,主要任务是比对新老节点的子节点,尽量复用 oldVnode 的子节点,减少 DOM 操作。
大致的流程如下:
- 初始化:初始化 
oldStartIdx、oldEndIdx、newStartIdx、newEndIdx,分别代表新旧节点的开始和结束索引(四个指针)。 - 比较新旧索引的首首、尾尾节点,如果相同,直接更新节点 
patchVnode。首/尾指针相应地向中间移动。 - 比较新旧索引的首尾、尾首节点,如果相同,先更新节点 
patchVnode,然后将更新后的节点移到新位置(否则子元素的相对位置还是原来的)。首/尾指针相应地向中间移动。 - 如果以上都不满足,进入特殊情况。特殊情况我们会考虑在 
oldStartIdx和oldEndIdx之间的旧节点是否有与newStartIdx指向的这一节点相同的。这一查找过程可以通过哈希表来实现,将旧节点的key作为哈希表的key,旧节点的索引设置为index。- 如果有的话,我们会:调用 
patchVnode更新节点;删除旧节点,防止之后重复寻找;然后将更新后的节点移到相应的新位置。 - 否则,直接在 
newStartIdx位置调用createElm创建新节点。 这之后,newStartIdx指针向后移动。 
 - 如果有的话,我们会:调用 
 - 跳转到 
2反复执行,直到新节点或旧节点指针相遇。 - 收尾:如果新旧节点有一个指针先到达终点,那么就说明新旧节点的子节点有增加或者删除,直接添加或者删除节点即可。
 
题外话
如果没有设置 key,那么在第四步那里,会因为找不到 key 而直接创建新节点,这样会导致大量的 DOM 操作,性能会大打折扣。
Keep-Alive 实现 
keep-alive 是 vue 中的内置组件,能在组件切换过程中将状态保留在内存中,防止重复渲染 DOM,提高性能。基本用法如下:
<keep-alive>
  <component :is="view"></component>
</keep-alive>2
3
其中还可以用 include 和 exclude 属性来通过组件的 name 指定哪些组件需要缓存,哪些不需要缓存。需要缓存的组件会多出来一个 keep-alive 的生命周期钩子函数 activated 和 deactivated。
keep-alive 这一组件中有一个属性 cache,它是一个 Map 对象,用于保存缓存的组件。还有一个属性 keys,它是一个数组,用于实现 LRU(Least Recently Used) 算法。
keep-alive 的强大缓存功能是在它的 render 函数中实现。这一函数主要做了以下事情:
- 获取 
slot中的第一个子节点,即component组件,然后获取它的名称,用于判断是否需要缓存。如果不需要缓存,直接返回; - 获取组件的 
key值,然后去this.cache对象中去寻找是否有该值,如果有则表示该组件有缓存,即命中缓存,把它移出再插回keys数组的最后,用于实现 LRU。然后返回缓存的组件; - 如果没有缓存,把它设置到 
cache中,并设置keys数组,然后返回。如果keys数组的长度超过了max,则删除第一个元素。 - 如果 
include或exclude发生了变化,或者组件的name发生了变化,或者组件被销毁,都会执行pruneCache或pruneCacheEntry(只维护一条)方法,维护缓存。 
缓存后,如果有更新数据的需求,可以通过 activated 或者 beforeRouteEnter 钩子函数来实现。
权限管理 
前端权限管理主要有以下几种方式,各种方式之间可以相互配合。
需要注意的是,以下的方式只是对权限的一种弱约束,用户可以通过改 sessionStorage、直接调接口等绕过前端的权限控制,因此必须在后端做鉴权(也就是接口权限)。
接口权限 
登录时获取 token,将 token 存入 sessionStorage 等,利用 axios 的拦截器在每个请求前带上 token。后端利用 jwt 进行鉴权,如果未通过的话一般会返回 401 或者自定义代码,这时前端应当跳转到登录界面进行重新登录。
axios.interceptors.request.use(config => {
    config.headers['token'] = cookie.get('token')
    return config
})
axios.interceptors.response.use((res)=>{
    if (response.data.code === 40099 || response.data.code === 40098) { 
      	// token 过期或者错误
        router.push('/login')
    }
})2
3
4
5
6
7
8
9
10
什么是 jwt?
jwt 即 JSON Web Token,是一种开放标准(RFC 7519),它定义了一种紧凑且独立的方式,可以在各方之间作为JSON 对象进行安全传输信息。这种信息可以被验证和信任,因为它是数字签名的。
jwt 由三部分组成,分别是头部 header、载荷 payload 和签名 signature。这三部分都是基于 Base64 编码的字符串,中间用 . 分隔。头部一般包含了加密算法和类型,载荷为实际的 json 数据,签名则一般是服务端根据头部和载荷,利用内部的密钥对头部和载荷进行生成的。
这三部分共同组成了一个 token,在登录时服务端会颁发这一 token,而在之后的请求中,客户端会将这一 token 作为 header 的一部分发送给服务端,服务端会根据这一 token 来判断用户的身份,并避免用户私自篡改信息(因为需要通过签名验证,不同的头部和载荷会导致不同的签名)。
路由权限 
路由级别的权限控制主要有两种方式:初始化挂载全部路由与登录时按需挂载。
初始化挂载全部路由方式初始化时挂载全部路由,当用户登录时获取权限信息,在每次路由跳转的时候在路由守卫中判断用户权限与路由表中目标页面的权限是否匹配。
登录时按需挂载方式,初始化只挂载必要的页面如登录页和 404 页,当用户登录时获取权限信息后根据权限信息查路由表,过滤出所有可达的页面,然后挂载。这样每次路由跳转的时候就不需要判断(如果权限动态改变的话还是要判断)。
按钮权限 
按钮权限主要思路即通过比较 sessionStorage 的 userRole 数组(登录时从后端获取)以及相应按钮的使用权限数组 btnPermissionsArr(如果没有定义的话可以使用路由中定义的页面权限)进行比较,如果不通过的话可以直接将该按钮从虚拟 DOM-Tree 中移除,从而达到了权限的控制。
主要的实现方式有两类:使用 v-if 和使用 Vue.directive 自定义 vue 指令。
import Vue from 'vue'
const has = Vue.directive('has', {
    bind: function (el, binding, vnode) {
        // 获取页面按钮权限
        let btnPermissionsArr = [];
        if(binding.value){
            // 如果指令传值,获取指令参数,根据指令参数和当前登录人按钮权限做比较。
            btnPermissionsArr = Array.of(binding.value);
        }else{
            // 否则获取路由中的参数,根据路由的btnPermissionsArr和当前登录人按钮权限做比较。
            btnPermissionsArr = vnode.context.$route.meta.btnPermissions;
        }
        if (!Vue.prototype.$_has(btnPermissionsArr)) {
            el.parentNode.removeChild(el);
        }
    }
});
Vue.prototype.$_has = function (value) {
    let isExist = false;
    // 获取用户按钮权限
    let btnPermissionsStr = sessionStorage.getItem("btnPermissions");
    if (btnPermissionsStr == undefined || btnPermissionsStr == null) {
        return false;
    }
    if (value.indexOf(btnPermissionsStr) > -1) {
        isExist = true;
    }
    return isExist;
};
export {has}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
