VUE

Vue 原理整理

转载:https://blog.csdn.net/gxll499294075/article/details/123667632

1. 组件化基础=>(MVVM模型)


传统组件,知识静态渲染,更新依赖于操作DOM。

Vue的核心理念是数据驱动的理念,所谓的数据驱动的理念:当数据发生变化的时候,用户界面也会发生相应的变化,开发者并不需要手动的去修改dom。

优点:不需要在代码中去频繁的操作dom了,这样提高了开发的效率,同时也避免了在操作Dom的时候出现的错误。

Vue.js的数据驱动是通过MVVM这种框架来实现的,MVVM 框架主要包含三部分:Model, View, ViewMode

数据驱动视图 – Vue MVVM

MVVM是Model-View-ViewModel缩写,也就是把MVC中的Controller演变成ViewModel。Model代表数据模型,View代表UI组件,ViewModel是View和Model层的桥梁,数据会绑定到ViewModel层并自动将数据渲染到页面中,视图变化的时候通知viewModel层更新数据。

2. Vue的响应式原理


 核心实现类:

  • Observer : 它的作用是给对象的属性添加 getter 和 setter,用于依赖收集和派发更新。
  • Dep : 用于收集当前响应式对象的依赖关系,每个响应式对象包括子对象都拥有一个 Dep 实例(里面 subs 是 Watcher 实例数组),当数据有变更时,会通过 dep.notify()通知各个 watcher。
  • Watcher : 观察者对象 , 实例分为渲染 watcher (render watcher),计算属性 watcher (computed watcher),侦听器 watcher(user watcher)三种

Watcher 和 Dep 的关系:watcher 中实例化了 dep 并向 dep.subs 中添加了订阅者,dep 通过 notify 遍历了 dep.subs 通知每个 watcher 更新。

Object.defineProperty 实现响应式:

  • 监听对象,监听数组
  • 复杂对象,深度监听
const obj = {};
const data = {};
const name = 'zhangsan';
Object.defineProperty(data, 'name', {
    get: function () {
        console.log('get');
        return name;
    },
    set: function (newVal) {
        console.log('set');
        obj.name = newVal;
    }
})
console.log(data.name);
data.name = 'lisi';
console.log(obj.name);  

Object.defineProperty 的缺点:

  • 深度监听需要递归到底,一次性计算量大
  • 无法监听新增属性、删除属性(要使用 Vue.set Vue.delete)
  • 无法原生监听数组,需要特殊处理【对数组的方法重写,[‘push’, ‘pop’, ‘shift’, ‘unshift’, ‘splice’,’sort’,’reverse’] 这几个方法更改数组才会响应式变化,直接更改索引不会响应式改变】

对数组的特殊处理:

// 触发更新视图
function updateView() {
    console.log('视图更新')
}
 
// 重新定义数组原型
const oldArrayProperty = Array.prototype
// 创建新对象,原型指向 oldArrayProperty ,再扩展新的方法不会影响原型
const arrProto = Object.create(oldArrayProperty);
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(methodName => {
    arrProto[methodName] = function () {
        updateView() // 触发视图更新
        oldArrayProperty[methodName].call(this, ...arguments)
        // Array.prototype.push.call(this, ...arguments)
    }
})
 
// 重新定义属性,监听起来
function defineReactive(target, key, value) {
    // 深度监听
    observer(value)
 
    // 核心 API
    Object.defineProperty(target, key, {
        get() {
            return value
        },
        set(newValue) {
            if (newValue !== value) {
                // 深度监听
                observer(newValue)
 
                // 设置新值
                // 注意,value 一直在闭包中,此处设置完之后,再 get 时也是会获取最新的值
                value = newValue
 
                // 触发更新视图
                updateView()
            }
        }
    })
}
 
// 监听对象属性
function observer(target) {
    if (typeof target !== 'object' || target === null) {
        // 不是对象或数组
        return target
    }
 
    // 污染全局的 Array 原型
    // Array.prototype.push = function () {
    //     updateView()
    //     ...
    // }
 
    if (Array.isArray(target)) {
        target.__proto__ = arrProto
    }
 
    // 重新定义各个属性(for in 也可以遍历数组)
    for (let key in target) {
        defineReactive(target, key, target[key])
    }
}
 
// 准备数据
const data = {
    name: 'zhangsan',
    age: 20,
    info: {
        address: '北京' // 需要深度监听
    },
    nums: [10, 20, 30]
}
 
// 监听数据
observer(data)
 
// 测试
// data.name = 'lisi'
// data.age = 21
// // console.log('age', data.age)
// data.x = '100' // 新增属性,监听不到 —— 所以有 Vue.set
// delete data.name // 删除属性,监听不到 —— 所有已 Vue.delete
// data.info.address = '上海' // 深度监听
data.nums.push(4) // 监听数组

3.为什么在 Vue3.0 采用了 Proxy,抛弃了 Object.defineProperty?


Object.defineProperty 本身有一定的监控到数组下标变化的能力,但是在 Vue 中,从性能/体验的性价比考虑,尤大大就弃用了这个特性(Vue 为什么不能检测数组变动 )。为了解决这个问题,经过 vue 内部处理后可以使用以下几种方法来监听数组 [‘push’, ‘pop’, ‘shift’, ‘unshift’, ‘splice’,’sort’,’reverse’]

由于只针对了以上 7 种方法进行了 hack 处理,所以其他数组的属性也是检测不到的,还是具有一定的局限性。

Object.defineProperty 只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历。Vue 2.x 里,是通过 递归 + 遍历 data 对象来实现对数据的监控的,如果属性值也是对象那么需要深度遍历,显然如果能劫持一个完整的对象是才是更好的选择。

Proxy 可以劫持整个对象,并返回一个新的对象。Proxy 不仅可以代理对象,还可以代理数组。还可以代理动态增加的属性。

4.computed 的实现原理


computed 本质是一个惰性求值的观察者。

computed 内部实现了一个惰性的 watcher,也就是 computed watcher,computed watcher 不会立刻求值,同时持有一个 dep 实例。

其内部通过 this.dirty 属性标记计算属性是否需要重新求值。

当 computed 的依赖状态发生改变时,就会通知这个惰性的 watcher,

computed watcher 通过 this.dep.subs.length 判断有没有订阅者,

有的话,会重新计算,然后对比新旧值,如果变化了,会重新渲染。 (Vue 想确保不仅仅是计算属性依赖的值发生变化,而是当计算属性最终计算的值发生变化时才会触发渲染 watcher 重新渲染,本质上是一种优化。)

没有的话,仅仅把 this.dirty = true。 (当计算属性依赖于其他数据时,属性并不会立即重新计算,只有之后其他地方需要读取属性的时候,它才会真正计算,即具备 lazy(懒计算)特性。)

5. computed 和 watch 有什么区别及运用场景? 



区别:

  • computed 计算属性 : 依赖其它属性值,并且 computed 的值有缓存,只有它依赖的属性值发生改变,下一次获取 computed 的值时才会重新计算 computed 的值。
  • watch 侦听器 : 更多的是「观察」的作用,无缓存性,类似于某些数据的监听回调,每当监听的数据变化时都会执行回调进行后续操作。 

运用场景:

  • 当我们需要进行数值计算,并且依赖于其它数据时,应该使用 computed,因为可以利用 computed 的缓存特性,避免每次获取值时,都要重新计算。
  • 当我们需要在数据变化时执行异步或开销较大的操作时,应该使用 watch,使用 watch 选项允许我们执行异步操作 ( 访问一个 API ),限制我们执行该操作的频率,并在我们得到最终结果前,设置中间状态。这些都是计算属性无法做到的。

6. Vue 中的 key 到底有什么用?


key 是给每一个 vnode 的唯一 id,依靠 key,我们的 diff 操作可以更准确、更快速 (对于简单列表页渲染来说 diff 节点也更快,但会产生一些隐藏的副作用,比如可能不会产生过渡效果,或者在某些节点有绑定数据(表单)状态,会出现状态错位。)

diff 算法的过程中,先会进行新旧节点的首尾交叉对比,当无法匹配的时候会用新节点的 key 与旧节点进行比对,从而找到相应旧节点.

更准确 : 因为带 key 就不是就地复用了,在 sameNode 函数 a.key === b.key 对比中可以避免就地复用的情况。所以会更加准确,如果不加 key,会导致之前节点的状态被保留下来,会产生一系列的 bug。

更快速 : key 的唯一性可以被 Map 数据结构充分利用,相比于遍历查找的时间复杂度 O(n),Map 的时间复杂度仅仅为 O(1),源码如下:

function createKeyToOldIdx(children, beginIdx, endIdx) {
 
  let i, key;
 
  const map = {};
 
  for (i = beginIdx; i <= endIdx; ++i) {
 
    key = children[i].key;
 
    if (isDef(key)) map[key] = i;
 
  }

7.谈一谈 nextTick 的原理


JS 运行机制

JS 执行是单线程的,它是基于事件循环的。事件循环大致分为以下几个步骤:

所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
主线程不断重复上面的第三步。


主线程的执行过程就是一个 tick,而所有的异步结果都是通过 “任务队列” 来调度。 消息队列中存放的是一个个的任务(task)。 规范中规定 task 分为两大类,分别是 macro task 和 micro task,并且每个 macro task 结束后,都要清空所有的 micro task。

for (macroTask of macroTaskQueue) {
 
  // 1. Handle current MACRO-TASK
 
  handleMacroTask();
 
 
  // 2. Handle all MICRO-TASK
 
  for (microTask of microTaskQueue) {
 
    handleMicroTask(microTask);
 
  }
 

在浏览器环境中 :

  • 常见的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate
  • 常见的 micro task 有 MutationObsever 和 Promise.then

异步更新队列:

可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。

如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。

然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。

Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

在 vue2.5 的源码中,macrotask 降级的方案依次是:setImmediate、MessageChannel、setTimeout

vue 的 nextTick 方法的实现原理:

vue 用异步队列的方式来控制 DOM 更新和 nextTick 回调先后执行 microtask 因为其高优先级特性,能确保队列中的微任务在一次事件循环前被执行完毕考虑兼容问题,vue 做了 microtask 向 macrotask 的降级方案

8.vue 是如何对数组方法进行变异的 ? 


源码:

const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);
const methodsToPatch = ["push","pop","shift","unshift","splice","sort","reverse"];
 
/**
 * Intercept mutating methods and emit events
 */
 
methodsToPatch.forEach(function(method) {
  // cache original 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);
 
    // notify change
 
    ob.dep.notify();
 
    return result;
 
  });
 
});
 
/**
 * Observe a list of Array items.
 */
 
Observer.prototype.observeArray = function observeArray(items) {
 
  for (var i = 0, l = items.length; i < l; i++) {
 
    observe(items[i]);
 
  }
 
};

简单来说,Vue 通过原型拦截的方式重写了数组的 7 个方法,首先获取到这个数组的ob,也就是它的 Observer 对象,如果有新的值,就调用 observeArray 对新的值进行监听,然后手动调用 notify,通知 render watcher,执行 update

9.Vue 组件 data 为什么必须是函数 ? 


new Vue()实例中,data 可以直接是一个对象,为什么在 vue 组件中,data 必须是一个函数呢?

因为组件是可以复用的,JS 里对象是引用关系,如果组件 data 是一个对象,那么子组件中的 data 属性值会互相污染,产生副作用

所以一个组件的 data 选项必须是一个函数,因此每个实例可以维护一份被返回对象的独立的拷贝new Vue 的实例是不会被复用的,因此不存在以上问题

10. 谈谈 Vue 事件机制, 手写$on,$off,$emit,$once 

Vue 事件机制 本质上就是 一个 发布-订阅 模式的实现。

class Vue {
 
  constructor() {
 
    //  事件通道调度中心
 
    this._events = Object.create(null);
 
  }
 
  $on(event, fn) {
 
    if (Array.isArray(event)) {
 
      event.map(item => {
 
        this.$on(item, fn);
 
      });
 
    } else {
 
      (this._events[event] || (this._events[event] = [])).push(fn);
 
    }
 
    return this;
 
  }
 
  $once(event, fn) {
 
    function on() {
 
      this.$off(event, on);
 
      fn.apply(this, arguments);
 
    }
 
    on.fn = fn;
 
    this.$on(event, on);
 
    return this;
 
  }
 
  $off(event, fn) {
 
    if (!arguments.length) {
 
      this._events = Object.create(null);
 
      return this;
 
    }
 
    if (Array.isArray(event)) {
 
      event.map(item => {
 
        this.$off(item, fn);
 
      });
 
      return this;
 
    }
 
    const cbs = this._events[event];
 
    if (!cbs) {
 
      return this;
 
    }
 
    if (!fn) {
 
      this._events[event] = null;
 
      return this;
 
    }
 
    let cb;
 
    let i = cbs.length;
 
    while (i--) {
 
      cb = cbs[i];
 
      if (cb === fn || cb.fn === fn) {
 
        cbs.splice(i, 1);
 
        break;
 
      }
 
    }
 
    return this;
 
  }
 
  $emit(event) {
 
    let cbs = this._events[event];
 
    if (cbs) {
 
      const args = [].slice.call(arguments, 1);
 
      cbs.map(item => {
 
        args ? item.apply(this, args) : item.call(this);
 
      });
 
    }
 
    return this;
 
  }
 
}

11.说说 Vue 的渲染过程

 调用 compile 函数,生成 render 函数字符串 ,编译过程如下:

  • parse 函数解析 template,生成 ast(抽象语法树)
  • optimize 函数优化静态节点 (标记不需要每次都更新的内容,diff 算法会直接跳过静态节点,从而减少比较的过程,优化了 patch 的性能)
  • generate 函数生成 render 函数字符串

调用 new Watcher 函数,监听数据的变化,当数据发生变化时,Render 函数执行生成 vnode 对象

调用 patch 方法,对比新旧 vnode 对象,通过 DOM diff 算法,添加、修改、删除真正的 DOM 元素

12.聊聊 keep-alive 的实现原理和缓存策略 


源码

export default {
 
  name: "keep-alive",
 
  abstract: true, // 抽象组件属性 ,它在组件实例建立父子关系的时候会被忽略,发生在 initLifecycle 的过程中
 
  props: {
 
    include: patternTypes, // 被缓存组件
 
    exclude: patternTypes, // 不被缓存组件
 
    max: [String, Number] // 指定缓存大小
 
  },
 
 
 
  created() {
 
    this.cache = Object.create(null); // 缓存
 
    this.keys = []; // 缓存的VNode的键
 
  },
 
 
 
  destroyed() {
 
    for (const key in this.cache) {
 
      // 删除所有缓存
 
      pruneCacheEntry(this.cache, key, this.keys);
 
    }
 
  },
 
 
 
  mounted() {
 
    // 监听缓存/不缓存组件
 
    this.$watch("include", val => {
 
      pruneCache(this, name => matches(val, name));
 
    });
 
    this.$watch("exclude", val => {
 
      pruneCache(this, name => !matches(val, name));
 
    });
 
  },
 
 
 
  render() {
 
    // 获取第一个子元素的 vnode
 
    const slot = this.$slots.default;
 
    const vnode: VNode = getFirstComponentChild(slot);
 
    const componentOptions: ?VNodeComponentOptions =
 
      vnode && vnode.componentOptions;
 
    if (componentOptions) {
 
      // name不在inlcude中或者在exlude中 直接返回vnode
 
      // check pattern
 
      const name: ?string = getComponentName(componentOptions);
 
      const { include, exclude } = this;
 
      if (
 
        // not included
 
        (include && (!name || !matches(include, name))) ||
 
        // excluded
 
        (exclude && name && matches(exclude, name))
 
      ) {
 
        return vnode;
 
      }
 
 
 
      const { cache, keys } = this;
 
      // 获取键,优先获取组件的name字段,否则是组件的tag
 
      const key: ?string =
 
        vnode.key == null
 
          ? // same constructor may get registered as different local components
 
            // so cid alone is not enough (#3269)
 
            componentOptions.Ctor.cid +
 
            (componentOptions.tag ? `::${componentOptions.tag}` : "")
 
          : vnode.key;
 
      // 命中缓存,直接从缓存拿vnode 的组件实例,并且重新调整了 key 的顺序放在了最后一个
 
      if (cache[key]) {
 
        vnode.componentInstance = cache[key].componentInstance;
 
        // make current key freshest
 
        remove(keys, key);
 
        keys.push(key);
 
      }
 
      // 不命中缓存,把 vnode 设置进缓存
 
      else {
 
        cache[key] = vnode;
 
        keys.push(key);
 
        // prune oldest entry
 
        // 如果配置了 max 并且缓存的长度超过了 this.max,还要从缓存中删除第一个
 
        if (this.max && keys.length > parseInt(this.max)) {
 
          pruneCacheEntry(cache, keys[0], keys, this._vnode);
 
        }
 
      }
 
      // keepAlive标记位
 
      vnode.data.keepAlive = true;
 
    }
 
    return vnode || (slot && slot[0]);
 
  }
 
};

原理

获取 keep-alive 包裹着的第一个子组件对象及其组件名
根据设定的 include/exclude(如果有)进行条件匹配,决定是否缓存。不匹配,直接返回组件实例
根据组件 ID 和 tag 生成缓存 Key,并在缓存对象中查找是否已缓存过该组件实例。如果存在,直接取出缓存值并更新该 key 在 this.keys 中的位置(更新 key 的位置是实现 LRU 置换策略的关键)
在 this.cache 对象中存储该组件实例并保存 key 值,之后检查缓存的实例数量是否超过 max 的设置值,超过则根据 LRU 置换策略删除最近最久未使用的实例(即是下标为 0 的那个 key)
最后组件实例的 keepAlive 属性设置为 true,这个在渲染和执行被包裹组件的钩子函数会用到,这里不细说

LRU 缓存淘汰算法

LRU(Least recently used)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。 

keep-alive 的实现正是用到了 LRU 策略,将最近访问的组件 push 到 this.keys 最后面,this.keys[0]也就是最久没被访问的组件,当缓存实例超过 max 设置值,删除 this.keys[0] 

13. vm.$set()实现原理是什么? 


对于【检测不到对象属性的添加和删除】【无法监控到数组下标的变化】可以用 vue.$set。

受现代 JavaScript 的限制 (而且 Object.observe 也已经被废弃),Vue 无法检测到对象属性的添加或删除。

由于 Vue 会在初始化实例时对属性执行 getter/setter 转化,所以属性必须在 data 对象上存在才能让 Vue 将它转换为响应式的。

对于已经创建的实例,Vue 不允许动态添加根级别的响应式属性。但是,可以使用 Vue.set(object, propertyName, value) 方法向嵌套对象添加响应式属性。

那么 Vue 内部是如何解决对象新增属性不能响应的问题的呢?

内部实现:

export function set(target: Array | Object, key: any, val: any): any {
 
  // target 为数组
 
  if (Array.isArray(target) && isValidArrayIndex(key)) {
 
    // 修改数组的长度, 避免索引>数组长度导致splice()执行有误
 
    target.length = Math.max(target.length, key);
 
    // 利用数组的splice变异方法触发响应式
 
    target.splice(key, 1, val);
 
    return val;
 
  }
 
  // target为对象, key在target或者target.prototype上 且必须不能在 Object.prototype 上,直接赋值
 
  if (key in target && !(key in Object.prototype)) {
 
    target[key] = val;
 
    return val;
 
  }
 
  // 以上都不成立, 即开始给target创建一个全新的属性
 
  // 获取Observer实例
 
  const ob = (target: any).__ob__;
 
  // target 本身就不是响应式数据, 直接赋值
 
  if (!ob) {
 
    target[key] = val;
 
    return val;
 
  }
 
  // 进行响应式处理
 
  defineReactive(ob.value, key, val);
 
  ob.dep.notify();
 
  return val;
 
}

注意:

  • 如果目标是数组,使用 vue 实现的变异方法 splice 实现响应式
  • 如果目标是对象,判断属性存在,即为响应式,直接赋值
  • 如果 target 本身就不是响应式,直接赋值
  • 如果属性不是响应式,则调用 defineReactive 方法进行响应式处理

14.虚拟DOM(vdom)和diff算法 


  • DOM操作非常耗费性能
  • 以前用jQuery,可以自行控制DOM操作时机,手动调整
  • vue和react都是数据驱动试图,如何有效控制DOM操作?

解决方案——vdom

  • 有一定的复杂度,想减少计算次数比较难
  • 难不能把计算,更多的转移为JS计算?因为JS执行比较快
  • vdom——用JS模拟DOM结构,计算出最小的变更,操作DOM

面试题:用JS模拟DOM元素 

包含三部分:标签tag,附着在标签上的属性、样式、事件props,子元素children 

vdom总结

  • 用js模拟DOM结构(vnode)
  • 新旧vnode对比,得出最小的更新范围,最后更新DOM
  • 数据驱动视图的模式下,有效控制DOM操作

diff算法

两个数做diff,如这里的vdom diff

vnode   ->patch ->new vnode

树diff的时间复杂度O(n^3)

第一,遍历tree1;第二,遍历tree2
第三,排序
1000个节点,要计算1亿次,算法不可用
优化时间复杂度到O(n)

只比较同一层级,不跨级比较
tag不相同,直接删掉重建,不再深度比较
tag和key,两者都相同,则认为是相同的节点,不再深度比较

diff算法总结

  • patchVnode
  • addVnodes removeVnodes
  • updateChildren(key的重要性)

vdom和diff总结

  • 细节不重要,updateChildren更新过程也不重要,不要深究
  • vnode核心概念很重要:h vnode patch diff key 等
  • vnode的存在价值更重要:数据驱动试图,控制DOM操作