Vue深入浅出之原理篇

227 阅读6分钟

Vue深入浅出之原理篇

响应式

Vue2实现响应式--Object.defineProperty

缺点:

  • Object.defineProperty不能监听数组,因此需要特殊处理(重新定义数组原型)
  • 实现深度监听是利用递归,需要一次去递归到底,计算量大,data层级不要太深
  • 无法监听新增属性/删除属性(Vue.setVue.delete解决)

下面是简化后的模拟vue2响应式原理代码:

// 触发视图更新
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)
  }
})

// 重新定义属性监听
function defineReactive(target, key, value) {
  // 深度监听
  observer(value)

  // 核心api
  Object.defineProperty(target, key, {
    get() {
      return value
    },
    set(newValue) {
      if (newValue !== value) {
        // 深度监听
        observer(newValue)

        value = newValue

        // 触发更新
        updateView()
      }
    }
  })
}

// 监听对象属性
function observer(target) {
  if (typeof target !== 'object' || target === null) {
    // 不是对象或者数组
    return target
  }

  if (Array.isArray(target)) {
    target.__proto__ = arrProto
  }

  // 重新定义各个属性
  for (let key in target) {
    defineReactive(target, key, target[key])
  }
}

// 数据
const data = {
  name: 'AirHua',
  age: 20,
  info: {
    address: '成都'
  },
  nums: [10, 20, 30]
}
// 监听数据
observer(data)

// 测试
data.name = 'huabyte'
data.info.address = '上海' // 需要深度监听
data.nums.push(40) // 监听数组失败,需要单独处理

Vue3实现响应式--Proxy

  • 主要靠代理配置实现监听,通过get和set来获取响应式,只有用到才会去监听
  • 对象、数组都可以监听
  • 搭配Reflect使用

下面是简化后的模拟vue3响应式原理代码:

// 触发视图更新
function updateView() {
  console.log('视图更新')
}

// 创建响应式
function reactive(target = {}) {
  if (typeof target !== 'object' || target == null) {
    // 不是对象或数组,则返回
    return target
  }

  // 代理配置
  const proxyConf = {
    get(target, key, receiver) {
      // 只处理本身属性
      const ownKeys = Reflect.ownKeys(target)
      if (ownKeys.includes(key)) {
        console.log('get', key)
      }

      const result = Reflect.get(target, key, receiver)

      return reactive(result)
    },

    set(target, key, val, receiver) {
      // 重复数据不处理
      if (val === target[key]) {
        return true
      }

      const ownKeys = Reflect.ownKeys(target)
      if (ownKeys.includes(key)) {
        console.log('修改属性')
        updateView()
      } else {
        console.log('新增属性')
        updateView()
      }

      const result = Reflect.set(target, key, val, receiver)
      // 是否设置成功
      return result
    },

    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      console.log('删除属性')
      return result
    }
  }

  // 生成代理对象
  const observed = new Proxy(target, proxyConf)
  return observed
}


// 数据
const data = {
  name: 'AirHua',
  age: 20,
  info: {
    city: '成都',
    tags: {
      life: {
        days: 365,
        year: 1
      },
      flag: [1, 3, 5]
    }
  }
}

// 开启监听
const proxyData = reactive(data)

// 测试
proxyData.info.city = 'huabyte11'
delete proxyData.info

vdom和diff算法(重点)

再聊vdom前,我们先来想想为什么会有vdom这个概念?在传统的网页里,我们操控网页都是直接操作DOM,而当业务需求大起来的时候,需要大量操作DOM,这无疑很消耗性能

前端框架中,都不约而同地采用了同一个思路,前端框架会将组件先转换为虚拟 DOM 节点,即 Virtual DOM,之后再将虚拟 DOM 节点渲染成实际的 DOM 节点,Virtual DOM 也会被组织成树形结构,即 Virtual DOM 树。这样把DOM操作先通过js计算出最小变更,去操作DOM,这里面算法用到的就是diff算法。

vdom

vdom节点是一个规范化的数据结构,类似如下:

{
  tag: 'div',
  props: {
    className: 'container',
    id: 'div1'
  },
  children: [{
      tag: 'p',
      children: '测试文本'
    },
    {
      tag: 'ul',
      props: {
        style: 'font-size: 20px'
      },
      children: [{
        tag: 'li',
        children: 'li-text'
      }]
    }
  ]
}

用这个数据结构,最终可以转化为DOM节点,等价于如下DOM元素:

<div id="div1" class="container">
  <p>测试文本</p>
  <ul style="font-size: 20px;">
    <li>li-text</li>
  </ul>
</div>

接下来我们说说怎么实现的vdom,vue2中基于Snabbdom实现,看看官网给的例子:

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

const patch = init([
  // Init patch function with chosen modules
  classModule, // makes it easy to toggle classes
  propsModule, // for setting properties on DOM elements
  styleModule, // handles styling on elements with support for animations
  eventListenersModule, // attaches event listeners
]);

const container = document.getElementById("container");

const vnode = h("div#container.two.classes", {
  on: {
    click: someFn
  }
}, [
  h("span", {
    style: {
      fontWeight: "bold"
    }
  }, "This is bold"),
  " and this is just normal text",
  h("a", {
    props: {
      href: "/foo"
    }
  }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);

const newVnode = h(
  "div#container.two.classes", {
    on: {
      click: anotherEventHandler
    }
  },
  [
    h(
      "span", {
        style: {
          fontWeight: "normal",
          fontStyle: "italic"
        }
      },
      "This is now italic type"
    ),
    " and this is still just normal text",
    h("a", {
      props: {
        href: "/bar"
      }
    }, "I'll take you places!"),
  ]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
  • h函数:渲染出vnode

  • patch:对比计算新旧vnode变化

其中patch的实现就是用了diff算法

diff算法概述

可以想象,两个vnode,需要对比的话

1644820261749.png

  • 遍历vnode
  • 遍历newVnode
  • 排序

需要实现这样的算法的时间复杂度为O(n^3),算法不可用,因此需要把算法优化。

于是就有了这样最开始的实现:

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

1644820455222.png

这样就把时间复杂度降到了O(n)

那么具体是怎么做到的呢,我们还是以Snabbdom部分源码来看:

patch函数:

return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
  let i: number, elm: Node, parent: Node;
  const insertedVnodeQueue: VNodeQueue = [];
  // 执行 pre hook
  for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

  // 第一个参数不是 vnode
  if (!isVnode(oldVnode)) {
    // 创建一个空的 vnode ,关联到这个 DOM 元素
    oldVnode = emptyNodeAt(oldVnode);
  }

  // 相同的 vnode(key 和 sel 都相等)
  if (sameVnode(oldVnode, vnode)) {
    // vnode 对比
    patchVnode(oldVnode, vnode, insertedVnodeQueue);

    // 不同的 vnode ,直接删掉重建
  } else {
    elm = oldVnode.elm!;
    parent = api.parentNode(elm);

    // 重建
    createElm(vnode, insertedVnodeQueue);

    if (parent !== null) {
      api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
      removeVnodes(parent, [oldVnode], 0, 0);
    }
  }

  for (i = 0; i < insertedVnodeQueue.length; ++i) {
    insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]);
  }
  for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
  return vnode;
};

可以看到,最为关键的是当vnode中相同时,patchVnode的执行,看看它的源码:

function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
  // 执行 prepatch hook
  const hook = vnode.data ? .hook;
  hook ? .prepatch ? .(oldVnode, vnode);

  // 设置 vnode.elem
  const elm = vnode.elm = oldVnode.elm!;

  // 旧 children
  let oldCh = oldVnode.children as VNode[];
  // 新 children
  let ch = vnode.children as VNode[];

  if (oldVnode === vnode) return;

  // hook 相关
  if (vnode.data !== undefined) {
    for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
    vnode.data.hook ? .update ? .(oldVnode, vnode);
  }

  // vnode.text === undefined (vnode.children 一般有值)
  if (isUndef(vnode.text)) {
    // 新旧都有 children
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
      // 新 children 有,旧 children 无 (旧 text 有)
    } else if (isDef(ch)) {
      // 清空 text
      if (isDef(oldVnode.text)) api.setTextContent(elm, '');
      // 添加 children
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      // 旧 child 有,新 child 无
    } else if (isDef(oldCh)) {
      // 移除 children
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      // 旧 text 有
    } else if (isDef(oldVnode.text)) {
      api.setTextContent(elm, '');
    }

    // else : vnode.text !== undefined (vnode.children 无值)
  } else if (oldVnode.text !== vnode.text) {
    // 移除旧 children
    if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);
    }
    // 设置新 text
    api.setTextContent(elm, vnode.text!);
  }
  hook ? .postpatch ? .(oldVnode, vnode);
}

接着可以看到当新旧vnode都有children时需要去执行updateChildren

function updateChildren (parentElm: Node,
  oldCh: VNode[],
  newCh: VNode[],
  insertedVnodeQueue: VNodeQueue) {
  let oldStartIdx = 0, newStartIdx = 0;
  let oldEndIdx = oldCh.length - 1;
  let oldStartVnode = oldCh[0];
  let oldEndVnode = oldCh[oldEndIdx];
  let newEndIdx = newCh.length - 1;
  let newStartVnode = newCh[0];
  let newEndVnode = newCh[newEndIdx];
  let oldKeyToIdx: KeyToIndexMap | undefined;
  let idxInOld: number;
  let elmToMove: VNode;
  let before: any;

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx];
    } else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx];
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx];

    // 开始和开始对比
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    
    // 结束和结束对比
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];

    // 开始和结束对比
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
      api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!));
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];

    // 结束和开始对比
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
      api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];

    // 以上四个都未命中
    } else {
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
      }
      // 拿新节点 key ,能否对应上 oldCh 中的某个节点的 key
      idxInOld = oldKeyToIdx[newStartVnode.key as string];

      // 没对应上
      if (isUndef(idxInOld)) { // New element
        api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!);
        newStartVnode = newCh[++newStartIdx];
      
      // 对应上了
      } else {
        // 对应上 key 的节点
        elmToMove = oldCh[idxInOld];

        // sel 是否相等(sameVnode 的条件)
        if (elmToMove.sel !== newStartVnode.sel) {
          // New element
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!);
        
        // sel 相等,key 相等
        } else {
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
          oldCh[idxInOld] = undefined as any;
          api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }
  }
  if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
    if (oldStartIdx > oldEndIdx) {
      before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    } else {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }
}

说说updateChildren里面是怎么实现的吧:

1644821421604.png

  • 开始和开始比较
  • 结束和结束比较
  • 开始和结束比较
  • 结束和开始比较

如果匹配成功,则循环向前或者向后比较,若四种情况都没有,则通过key来找oldCh中是否有匹配项,这里也说明key的重要性。

v-for的key为什么必要

1644822732074.png

  • 也解释了为什么key需要唯一,当没有key则省略去了一个匹配规则
  • key也最好不要设置为index,因为随机的当顺序换了,index也随之改变,导致也匹配不到

描述组件渲染过程

初次渲染过程

  • 解析模板为render函数
  • 触发响应式,监听Data,通过getter获取值,setter修改值
  • 执行render函数,生成vnode,初次patch(elem, vnode)

更新过程

  • 修改Data,触发setter
  • 重新执行render函数,生成newVnode
  • patch(vnode, newVnode)

完整流程图

1644823606854.png

对MVVM模型的理解

  • Model:代表数据模型,数据和业务逻辑都在Model层中定义
  • View:代表UI视图,负责数据的展示
  • ViewMdel:负责监听Model中数据改变控制View视图的更新

用图表示就是这样:

1645065766649.png

用代码描述的话:

  1. template可以理解为View
<template>
  <div>
    <ul ref="ul">
      <li v-for="(item, index) in list" :key="index">{{item}}</li>
    </ul>
    <button @click="addItem">添加</button>
  </div>
</template>
  1. data可以理解为Model
data() {
  return {
    list: ['a', 'b', 'c'],
  }
},
  1. template里面绑定的方法,和script里面定义的方法就可以理解为ViewModel
methods: {
  addItem() {
    this.list.push(`${new Date()}`)
    this.list.push(`${new Date()}`)
    this.list.push(`${new Date()}`)

    this.$nextTick(() => {
      const ulElem = this.$refs.ul
      console.log(ulElem.childNodes.length)
    })
  },
},
// ...