面试题记系列之VUE

104 阅读9分钟

响应式

在 initState 的时候会对props、data、computed与watch进行响应式处理

  • 创建一个 Observer (响应式对象)
  • 如果 value 是对象,遍历对象上的每个 key,为每个 key 设置响应式,如果是数组则遍历数组为数组的每一项创建 Observer
  • 响应式通过 Object.defineProperty 拦截对对象key的 get 和 set
  • 当获取对象key的时候会触发 get,将 watcher(观察者)添加到 dep.subs 中完成依赖收集
  • 当设置对象key的时候会触发 set,通知 dep.subs 收集的每个 watcher.update 更新视图

watcher

computed-watcher

在 initComputed 的时候创建,

// state.js
// 懒加载,lazy = true,即在创建 watcher 的时候并不会对 watcher 进行依赖收集
const computedWatcherOptions = { lazy: true }
...
watchers[key] = new Watcher(
  vm,
  getter || noop,
  noop,
  computedWatcherOptions
)
...
// 定义响应式 get
sharedPropertyDefinition.get = createComputedGetter(key)
...
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key];
    if (watcher) {
      // 当 watcher.dirty 为 true 的时候
      if (watcher.dirty) {
        /*
        * 执行 watcher.get 方法,计算结果并完成依赖收集
        * 将 watcher.dirty 置为 false
        */ 
        watcher.evaluate();
      }
      // 依赖收集,不会和上面冲突,因为如果执行了上面的依赖收集后 Dep.target = null
      if (Dep.target) {
        watcher.depend();
      }
      return watcher.value;
    }
  };
}
...
// 拦截对 target.key 的 get 和 set,target = vm
Object.defineProperty(target, key, sharedPropertyDefinition)

组件每次渲染的时候,只有第一次访问 vm.computedProperty 时会执行回调函数计算值,后面因为 watcher.dirty = false 就不会去计算而是直接返回第一次计算的结果(即缓存),待下一次视图更新的时候 wathcer.update 方法会将 watcher.dirty 重新置为 true

user-watcher

即用户定义的 watch,用于观察一个属性的更新,在 initWatch 的时候创建,由于 watch 不是懒加载 lazy = false

 // watcher.js
 this.value = this.lazy ? undefined : this.get()
 get() {
    // 开始收集依赖
    pushTarget(this);
    let value;
    const vm = this.vm;
    try {
      // 执行回调函数(函数或者字符串)必然会获取值触发get,进行依赖收集
      value = this.getter.call(vm, vm);
    } catch (e) {
      ...
    } finally {
     ...
    }
    return value;
  }

所以在创建 watcher 的时候就完成了依赖的收集

render-watcher

在 mountComponent(挂载组件) 的时候创建,在 vm._update 的时候收集依赖

初始化watcher

// core/instance/init.js 
Vue.prototype._init = function (options?: Object) {
 ...
 vm.$mount(vm.$options.el);
}

// platforms/web/entry-runtime-with-compiler.js
const mount = Vue.prototype.$mount;
Vue.prototype.$mount = function (..): Component {
  ...
  return mount.call(this, el, hydrating);
};

// platforms/web/runtime/index.js
Vue.prototype.$mount = function (..) {
  ...
  return mountComponent(this, el, hydrating)
}

// core/instance/lifecycle.js
function mountComponent (vm, el, hydrating) {
  vm.$el = el;
  if (!vm.$options.render) {
    // render函数不存在的时候创建一个空的VNode节点
    vm.$options.render = createEmptyVNode;
  }
  // 触发beforeMount钩子
  callHook(vm, "beforeMount");
  // updateComponent 作为 Watcher 对象的 getter 函数,用来依赖收集
  const updateComponent = () => {
    vm._update(vm._render(), hydrating);
  };
  new Watcher(
    vm,
    updateComponent,
    noop,
    {
      before() {
        if (vm._isMounted && !vm._isDestroyed) {
          callHook(vm, "beforeUpdate");
        }
      },
    },
    true /* isRenderWatcher */
  );
  hydrating = false;
  if (vm.$vnode == null) {
    // 表示该组件已经挂载
    vm._isMounted = true;
    // 调用 mounted 钩子
    callHook(vm, "mounted");
  }
  return vm;
}

nextTick

场景

for (let i = 0; i < 1000; i++) {
  this.num++;
}

如果有一个方法会依次执行1000次num++的话,那不是每执行一次视图就会被更新一次,显然是不合理的,那样对性能消耗太大

原理

当响应式数据更新后,会调用 dep.notify 方法,通知 dep.subs 中收集的 watcher 执行 watcher.update 方法将 watcher 放入一个队列 queue(全局定义的数组)中。

// scheduler.js
const queue = [];
let has = {};
let waiting = false;
let flushing = false;

function queueWatcher(watcher) {
  const id = watcher.id;
  // 判重,保证 watcher 不重复,比如上面场景中添加的 watcher
  if (has[id] == null) {
    has[id] = true;
    // 将 watcher 放入队列 queue 中
    if (!flushing) {
      queue.push(watcher);
    } else {
      let i = queue.length - 1;
      while (i > index && queue[i].id > watcher.id) {
        i--;
      }
      queue.splice(i + 1, 0, watcher);
    }
    if (!waiting) {
      waiting = true;
      // 
      nextTick(flushSchedulerQueue);
    }
  }
}

// 遍历队列 queue,依次执行队列中存放的 watcher.run 方法触发 patch 更新视图
function flushSchedulerQueue() {
  flushing = true;
  let watcher, id;

  queue.sort((a, b) => a.id - b.id);

  for (index = 0; index < queue.length; index++) {
    watcher = queue[index];
    if (watcher.before) {
      watcher.before();
    }
    id = watcher.id;
    has[id] = null;
    // 执行 watcher.run 更新视图
    watcher.run();
  }
  ...
  // has = {}、waiting = flushing = false
  resetSchedulerState();
  ...
}

通过 nextTick 方法将一个刷新 watcher 队列的方法(flushSchedulerQueue)放入一个全局的 callbacks 数组中。

将 flushCallbacks 函数放入浏览器的异步任务队列中,如果队列中已经有 flushCallbacks 则不用重复推送。

// next-tick.js
const callbacks = [];
let pending = false;

// 遍历执行 callbacks 中的每个 flushSchedulerQueue 将其所管理的 watcher 队列 queue 刷新
function flushCallbacks() {
  pending = false;
  const copies = callbacks.slice(0);
  callbacks.length = 0;
  // 遍历 callbacks 数组,执行其中存储的每个 flushSchedulerQueue 函数
  for (let i = 0; i < copies.length; i++) {
    copies[i]();
  }
}

/* 
* 源码中分别用 Promise、setTimeout、setImmediate、setTimeout 等方式在 microtask(或是task)中创建一
* 个事件,目的是在当前调用栈执行完毕以后(不一定立即)才会去执行这个事件。
* 这里优先选用 Promise
*/
const timerFunc = () => {
  Promise.resolve().then(flushCallbacks);
};

function nextTick(cb) {
  // 将 flushSchedulerQueue 放入 callbacks 中
  callbacks.push(() => {
    try {
      cb.call(ctx);
    } catch (e) {
      ...
    }
  });
  if (!pending) {
    pending = true;
    // 将 flushCallbacks 函数放入浏览器的异步任务队列中
    timerFunc();
  }
  ...
}

template编译

// init.js
function initMixin(Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    ...
    if (vm.$options.el) {
      // 挂载组件
      vm.$mount(vm.$options.el);
    }
  };
}
// entry-runtime-with-compiler.js

const mount = Vue.prototype.$mount;

Vue.prototype.$mount = function (el, hydrating) {
  ...
  const options = this.$options;
  if (!options.render) {
    let template = options.template;
    // template存在的时候取template,不存在的时候取el的outerHTML
    if (template) {
     ...
    } else if (el) {
      template = getOuterHTML(el);
    }
    if (template) {
      // 编译模版,得到 动态渲染函数和静态渲染函数
      const { render, staticRenderFns } = compileToFunctions(
        template,
        ...,
        this
      );
      // 渲染函数放到 this.$options 上
      options.render = render;
      options.staticRenderFns = staticRenderFns;
    }
    // 挂载
    return mount.call(this, el, hydrating);
  }
  ...

编译器的核心

  1. 解析,将类 html 模版转换为 AST 对象
  2. 优化,也叫静态标记,遍历 AST 对象,标记每个节点是否为静态节点,以及标记出静态根节点
  3. 生成渲染函数,将 AST 对象生成渲染函数render(生成VNode)

虚拟DOM

创建一个虚拟DOM

<div id="app" style="color: red" class="txt">
    <button @click="clickHandle">click</button>
</div>
var vnode = {
  tag: "div",
  key: "",
  data: {
    attrs: { id: "app" },
    staticClass: "txt",
    staticStyle: { color: "red" },
  },
  context: vm, // 执行上下文
  children: [
    {
      tag: "button",
      data: {
        on: {
          click: function (e) {
            return clickHandle();
          },
        },
      },
      children: [
        {
          text: "click",
        },
      ],
    },
  ],
};

patch

当响应式数据更新时,set 方法会让闭包中的 Dep 调用 notify 通知所有订阅者 Watcher,Watcher 通过 get 方法执行

this.getter.call(vm, vm);

getter 就是实例化 watcher 时的第二个参数(回调函数),如果是 computed-watcher 或者 user-watcher

computed: {
  double: function () {
    return this.count * 2;
},
},
watch: {
  count: function (n, o) {
    console.log(n);
  },
},

分别对应的是 computed.double()watch.count()

如果是 render-watcher 会执行 vm._update(vm._render(), hydrating) 进入patch阶段。

// core/instance/lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this;
    // 挂载对象
    const prevEl = vm.$el;
    // 获取旧的 vnode
    const prevVnode = vm._vnode;
    // 用新的 vnode 替换旧的 vnode
    vm._vnode = vnode;
    // 这下面走的就是 patch
    if (!prevVnode) {
      // 首次渲染,即初始化页面时走这里
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
    } else {
      // 响应式数据更新时,即更新页面时走这里
      vm.$el = vm.__patch__(prevVnode, vnode);
    }
    ...
  };

由源码可知,最终调用的是 createPatchFunction 方法返回的 patch 对新旧 vnode 进行 patch

// core/vdom/patch.js
function patch(oldVnode, vnode, hydrating, removeOnly) {
  // vnode 不存在则直接调用销毁钩子
  if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode);
      return;
  }
  let isInitialPatch = false;
  const insertedVnodeQueue = [];
  if (isUndef(oldVnode)) {
      isInitialPatch = true;
      // oldVnode 未定义的时候,其实也就是 root 节点,创建一个新的节点
      createElm(vnode, insertedVnodeQueue);
  } else {
    // 标记旧的 VNode 是否有 nodeType
    const isRealElement = isDef(oldVnode.nodeType);
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // 核心
        // 是同一个节点的时候直接修改现有的节点
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
    } else {
        // ...
        // 如果不是服务端渲染或者合并到真实Dom失败,则创建一个空的 VNode 节点替换它
        oldVnode = emptyNodeAt(oldVnode);
    }
    // 取代现有元素
    const oldElm = oldVnode.elm;
    const parentElm = nodeOps.parentNode(oldElm);
    // 创建新节点
    createElm(
        vnode,
        insertedVnodeQueue,
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
    );
    // ...
    // 销毁老节点
    if (isDef(parentElm)) {
      removeVnodes([oldVnode], 0, 0);
    } else if (isDef(oldVnode.tag)) {
      invokeDestroyHook(oldVnode);
    }
  }
  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
  return vnode.elm;
}

当 oldVnode 与 vnode 在 sameVnode 的时候才会进行 patchVnode ,也就是新旧 VNode 节点判定为同一节点的时候才会进行 patchVnode 这个过程,否则就是创建新的 DOM,移除旧的 DOM。

diff

function patchVnode(oldVnode, vnode) {
  ...
  // 获取 vnode 挂载的节点 在 diff
  const elm = vnode.elm = oldVnode.elm
  
  // 如果 vnode 节点没有text文本时
  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      // 新老节点均有children子节点,则对子节点进行 diff 操作,调用 updateChildren
      if (oldCh !== ch)
        updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
    } else if (isDef(ch)) {
      if (process.env.NODE_ENV !== "production") {
        checkDuplicateKeys(ch);
      }
      // 如果老节点没有子节点而新节点存在子节点,先清空elm的文本内容,然后为当前节点加入子节点
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, "");
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
    } else if (isDef(oldCh)) {
      // 当新节点没有子节点而老节点有子节点的时候,则移除所有ele的子节点
      removeVnodes(oldCh, 0, oldCh.length - 1);
    } else if (isDef(oldVnode.text)) {
      // 当新老节点都无子节点的时候,只是文本的替换,因为这个逻辑中新节点text不存在,所以直接去除ele的文本
      nodeOps.setTextContent(elm, "");
    }
  } else if (oldVnode.text !== vnode.text) {
    // 当新老节点text不一样时,直接替换这段文本
    nodeOps.setTextContent(elm, vnode.text);
  }
}

patchVnode规则

  1. 如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),那么只需要替换elm以及componentInstance即可。
  2. 新老节点均有子节点,则对子节点进行diff操作,调用updateChildren。
  3. 如果老节点没有子节点而新节点存在子节点,先清空老节点DOM的文本内容,然后为当前DOM节点加入子节点。
  4. 当新节点没有子节点而老节点有子节点的时候,则移除该DOM节点的所有子节点。
  5. 当新老节点都无子节点的时候,只是文本的替换。
// diff 核心
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  // 以下所指的开始和结束节点均是指开始指针指向的节点和结束指针指向的节点
  // 老的 childs 
  let oldStartIdx = 0 // 开始指针
  let oldEndIdx = oldCh.length - 1 // 结束指针
  let oldStartVnode = oldCh[0] // 开始节点
  let oldEndVnode = oldCh[oldEndIdx] // 结束节点
  
  // 新的 childs
  let newStartIdx = 0
  let newEndIdx = newCh.length - 1
  let newStartVnode = newCh[0]
  let newEndVnode = newCh[newEndIdx]

  let oldKeyToIdx, idxInOld, vnodeToMove, refElm

  // removeOnly is a special flag used only by <transition-group>
  // to ensure removed elements stay in correct relative positions
  // during leaving transitions
  const canMove = !removeOnly

  if (process.env.NODE_ENV !== 'production') {
    checkDuplicateKeys(newCh)
  }

  // 只有当新旧 childs 的开始节点指针 小于等于 结束指针时
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 老de 开始节点不存在,则右移一位 
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
    } 
    // 老de 结束节点不存在,则左移一位
    else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
    } 
    /**
     * 以下四种 sameVnode 情况其实是指定key的时候,判定为同一个 VNode,
     * 则直接 patchVnode 即可,分别比较 oldCh 以及 newCh 的两头节点 2 * 2 = 4 种情况
     * patchVnode 即是一个递归子节点的过程
    */
    // 如果旧开始节点和新开始节点是相同节点 ——> key相同  
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      // 相同节点直接 patchVnode 新旧 child 节点 ——> 接着递归
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      // 新旧开始节点均右移一位
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } 
    // 如果旧结束节点和新结束节点相同
    else if (sameVnode(oldEndVnode, newEndVnode)) {
      // patchVnode 新旧 child 节点
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      // 新旧结束节点均左移一位
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } 
    // 如果旧开始节点和新结束节点相同
    else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // patchVnode 新旧 child 节点
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      // 将真实dom元素上的 旧开始节点移动到新结束节点所指位置(oldEndVnode和newEndVnode所指位置是一样的)
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      // 旧开始节点右移一位
      oldStartVnode = oldCh[++oldStartIdx]
      // 新结束节点左移一位
      newEndVnode = newCh[--newEndIdx]
    } 
    // 如果旧结束节点和新的开始节点相同
    else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      // patchVnode 新旧 child 节点
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      // 将真实dom元素上的 旧结束节点移动到新开始节点所指位置(oldStartVnode和newStartVnode所指位置是一样的)
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      // 旧结束节点左移一位
      oldEndVnode = oldCh[--oldEndIdx]
      // 新开始节点右移一位
      newStartVnode = newCh[++newStartIdx]
    }
    /*
     * 生成一个 key 与旧 VNode 的 key 对应的哈希表(只有第一次进来 undefined 的时候会生成,也为后面检测重复的 key 值做铺垫)
     * 比如 childre 是这样的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}]  beginIdx = 0   endIdx = 2  
     * 结果生成 {key0: 0, key1: 1, key2: 2}
    */
    else {
      // 得到 oldKeyToIdx 的哈希表,里面存放的是所有旧节点的 key
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      /**
       * 新节点key存在则判断 oldKeyToIdx 里面有没有 新节点对应的key,
       * 否则,找旧节点中是否有与新节点处在同一位置(index下标一样的)节点
      */
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

      // 没有在旧节点对应的哈希表中找到,创建一个新DOM
      if (isUndef(idxInOld)) { // New element
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      } else {
        // 旧节点
        vnodeToMove = oldCh[idxInOld]
        // 如果新旧节点相同
        if (sameVnode(vnodeToMove, newStartVnode)) {
          // patchVnode 新旧节点
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          // 将旧节点设置为 undefined,下次走到旧节点所在位置时候就直接跳过
          oldCh[idxInOld] = undefined
          // 将真实DOM元素上的 旧节点移动到新开始节点所在位置
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // same key but different element. treat as new element
          // key 相同,但是却不是相同节点,创建新DOM
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        }
      }
      // 新开始节点右移一位
      newStartVnode = newCh[++newStartIdx]
    }
  }
  if (oldStartIdx > oldEndIdx) {
    /**
     * 全部比较完成以后,发现oldStartIdx > oldEndIdx的话,
     * 说明老节点已经遍历完了,新节点比老节点多,所以这时候多出来的新节点需要一个一个创建出来加入到真实DOM中
    */
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) {
    /**
     * 如果全部比较完成以后发现newStartIdx > newEndIdx的话,
     * 说明新节点已经遍历完了,老节点多余新节点,这个时候需要将多余的老节点从真实DOM中移除
    */
    removeVnodes(oldCh, oldStartIdx, oldEndIdx)
  }
}

diff策略

深度优先,同层比较

同层比较时

  1. 新旧 vnode 头尾两两比较,有相同的就 patchVnode
  2. 看新节点在旧节点里面有没有相同节点,有就 patchVnode ,没有的话就新建一DOM
  3. 新节点或者就节点比较完之后,如果新节点有多余的则新增一个DOM插入到真实DOM中,如果旧节点有多余的则将多余的DOM从真实DOM中移除

www.bilibili.com/video/BV1b5…

github.com/answershuto…

keep-alive

Vue.js内部将DOM节点抽象成了一个个的VNode节点,keep-alive组件的缓存也是基于VNode节点的而不是直接存储DOM结构。它将满足条件(pruneCache与pruneCache)的组件在cache对象中缓存起来,在需要重新渲染的时候再将vnode节点从cache对象中取出并渲染。

相关文章

vue常见面试题

30 道 Vue 面试题,内含详细讲解(涵盖入门到精通,自测 Vue 掌握程度)

「面试题」20+Vue面试题整理

史上最强vue总结---面试开发全靠它了