Vue3学习 --- 条件-列表渲染-diff算法

240 阅读6分钟

条件渲染

v-if,v-else-if, v-else

v-if、v-else、v-else-if用于根据条件来渲染某一块的内容:

  • 这些内容只有在条件为true时,才会被渲染出来
  • 这三个指令与JavaScript的条件语句if、else、else if类似

v-if的渲染原理:

  • v-if是惰性的

  • 当条件为false时,其判断的内容完全不会被渲染或者会被销毁掉

  • 当条件为true时,才会真正渲染条件块中的内容

<template id="template">
   <h2 v-if="score >= 90">优秀</h2>
   <h2 v-else-if="score >= 60">及格</h2>
   <h2 v-else>不及格</h2>
</template>

template

因为v-if是一个指令,所以必须将其添加到一个元素上

如果我们希望切换的是多个元素, 我们可以按照如下方式进行操作

<div v-if="isShow">
  <h2>Hello World</h2>
  <h2>Hello Vue</h2>
</div>

<!-- 
    如果此时isShow的值为true,那么渲染后的实际dom结构为
    <div>
      <h2>Hello World</h2>
      <h2>Hello Vue</h2>
    </div>
-->

此时很明显,最外层的div是没有存在的必要的, 这个时候,我们可以选择使用template

template元素可以当做不可见的包裹元素,并且在v-if上使用,但是最终template不会被渲染出来

<template v-if="isShow">
  <h2>Hello World</h2>
  <h2>Hello Vue</h2>
</template>

<!-- 
  如果此时isShow的值为true,那么渲染后的实际dom结构为
  <h2>Hello World</h2>
  <h2>Hello Vue</h2>
-->

v-show

v-show和v-if在功能上是基本类似的,但是在实际上他们依旧是存在区别的:

  1. v-show不支持template, 不可以和v-else,v-else-if等一起使用

  2. v-show元素无论是否需要显示到浏览器上,它的DOM实际都是有渲染的,只是通过CSS的display属性来进行切换,而v-if当条件为false时,其对应的元素压根不会被渲染到DOM中。

所以:

如果我们的元素需要在显示和隐藏之间频繁的切换,那么使用v-show;

如果不会频繁的发生切换,那么使用v-if;

<h2 v-if="isShow">Hello World</h2>
<h2 v-if="isShow">Hello World</h2>

<!-- 当isShow的值为false的时候,实际渲染结果如下: -->

<!--v-if-->
<h2 style="display: none;">Hello World</h2>

列表渲染

v-for

v-for的基本格式是 "数据项 in 可迭代数据(如对象,数组)"

遍历对象

<!--
  括号可以省略,但是并不推荐

  单个值的时候
  例如: value in userInfo

  多个值的时候
  value, key, index in userInfo

  可以使用of来替换in, 来实现相同的功能
  (value, key, index) of userInfo
-->
<template v-for="(value, key, index) in userInfo">
  <div>index: {{ index }}</div>
  <div>key: {{ key }}</div>
  <div>value: {{ value }}</div>
</template>

<script>
  Vue.createApp({
    template: '#template',

    data() {
      return {
        userInfo: {
          name: 'Klaus',
          age: 23
        }
      }
    }
  }).mount('#app')
</script>

遍历数组

<div v-for="(value, index) in users">
  {{ index }} --- {{ value }}
</div>

<script>
  Vue.createApp({
    template: '#template',

    data() {
      return {
        users: [
          'Alex',
          'Klaus',
          'Steven'
        ]
      }
    }
  }).mount('#app')
</script>

遍历数字

<div v-for="(value, index) in 3">
  {{ index }} --- {{ value }}
</div>

<!--
  渲染结果为 
  0 --- 1
  1 --- 2
  2 --- 3
-->

同样的,我们可以使用 template 元素来循环渲染一段包含多个元素的内容,以避免在渲染的过程中,出现多余的不必要元素

数组更新检测

Vue 将被侦听的数组的变更方法进行了包裹,而数组的更新方法也分为了2类。

  1. 会改变原数组,所以当数组发生改变的时候,会触发视图更新。例如pop,push,shift,unshift,splice,sort,reverse
  2. 不会修改原数组,所以需要使用新数组将旧数组的值覆盖掉,才会触发数组更新,例如slice,concat,filter

key

在使用v-for进行列表渲染时,我们通常会给元素或者组件绑定一个key属性

key属性的作用:

  1. key属性主要用在Vue的虚拟DOM Diff算法,用以给vnode一个唯一的标识, 方便vue在更新的时候,更好的识别新旧VNode,从而提升DOM的更新效率

  2. 不使用key进行dom更新的时候,vue会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素的算法

  3. 如果在更新的时候使用了key,vue会基于key的变化重新排列元素顺序,并且会移除/销毁key不存在的元素

vnode

VNode的全称是Virtual Node,也就是虚拟节点

为了提升更新效率,不频繁操作dom,无论是组件还是元素,vue在解析的时候,会先将template中的内容解析为一个个的VNode

这一个个VNode组合在一个形成了虚拟DOM树(Virtual DOM ===> VDom), 随后vue才会将VDom渲染成真实DOM

VDOM中的每一个VNode真实DOM中的Node是一一对应,一一映射的

VNode的本质是一个用于描述html标签或组件的JavaScript的对象

<!-- 模板中的内容 -->
<div class="title" style="color: red; font-size: 20px">Hello World</div>

经过vue的解析后,会形成类似下边格式的对象

const VNode = {
  type: 'div',

  props: {
    class: 'title',

    style: {
      color: 'red',
      'font-size': '20px'
    }
  },

  // 如果一个节点下有多个子节点,那么children的类型应该为一个数组
  children: 'Hello World'
}

vue的模板浏览器是无法直接解析的,所以需要先有vue-template-compiler将其解析为VDOM

随后才可以将VDOM交给浏览器去渲染成真实DOM

插入F案例

需求

原数组 ==> [ 'A', 'B', 'C', 'D' ]

在C的前面插入F

新数组 ==> [ 'A', 'B', 'F', C', 'D' ]

在数组中,以数组的值,即A,B,C,D,F,作为每一个节点的key

实现

Vue事实上会对于有key和没有key会调用两个不同的方法:

  1. 有key,那么就使用 patchKeyedChildren方法
  2. 没有key,那么久使用 patchUnkeyedChildren方法

unkeyed

I8XzS8.png

  • c和d来说它们事实上并不需要有任何的改动
  • 但是因为我们的c被f所使用了,所有后续所有的内容都要一次进行改动,并且最后进行新增
  • 所以如果没有key的时候,默认的dom diff算法的效率并不是很高
const patchUnkeyedChildren = (oldChildren = [], newChildren = []) => {
  const oldLength = oldChildren.length
  const newLength = newChildren.length
  // 以长度短的那个进行遍历
  const commonLength = Math.min(oldLength, newLength)

  for (let i = 0; i < commonLength; i++) {
    const nextChild = newChildren[i]

    // 依次遍历比较更新
    patch( oldChildren[i], nextChild)
  }

  if (oldLength > newLength) {
    // remove old
    unmountChildren(oldChildren)
  } else {
    // mount new
    mountChildren(newChildren)
  }
}

keyed

const patchKeyedChildren = (oldChildren, newChildren) => {
  let i = 0
  const newChilrenLength= newChildren.length
  let endIndexOfOldChildren = oldChildren.length - 1 // prev ending index
  let newIndexOfOldChildren = newChilrenLength- 1 // next ending index

  // 1. 从头往前对相同类型节点进行更新
  // 2. 从后往前对相同类型节点进行更新
  // 3. common sequence + mount
  // 4. common sequence + unmount
  // 5. unknown sequence + vue会尝试移动可以复用的节点并移除和新增对应的节点
}
function isSameVNodeType(oldNode, newNode) {
  // 如果一个节点的类型一致,且key是一致的,那么vue就认为该节点没有发生改变,为同一个节点
  return oldNode.type === newNode.type && oldNode.key === newNode.key
}

从前往后进行相同节点的更新

I8XCue.png

  • a和b是一致的会继续进行比较
  • c和f因为key不一致,所以就会break跳出循环
// 1. sync from start
// (a b) c
// (a b) d e
while (i <= endIndexOfOldChildren && i <= newIndexOfOldChildren) {
  const oldNode = oldChildren[i]
  const newNode = newChildren[i]
  if (isSameVNodeType(oldNode, newNode)) {
    patch(oldNode, newNode)
  } else {
    // 不是同一节点,跳出循环
    break
  }
  i++
}

从后往前进行遍历

I8XiQR.png

// 2. sync from end
// a (b c)
// d e (b c)
while (i <= endIndexOfOldChildren && i <= newIndexOfOldChildren) {
  // 分别取出新旧children的最后一个节点
  const oldNode = oldChildren[endIndexOfOldChildren]
  const newNode = newChildren[newIndexOfOldChildren]
  if (isSameVNodeType(oldNode, newNode)) {
    patch(oldNode, newNode)
  } else {
    // 节点不一致跳出循环
    break
  }
  endIndexOfOldChildren--
  newIndexOfOldChildren--
}

common sequence + mount

I8XxSz.png

// 3. common sequence + mount
// (a b)
// (a b) c
// i = 2, endIndexOfOldChildren = 1, newIndexOfOldChildren = 2
// (a b)
// c (a b)
// i = 0, endIndexOfOldChildren = -1, newIndexOfOldChildren = 0
if (i > endIndexOfOldChildren) {
  if (i <= newIndexOfOldChildren) {
    while (i <= newIndexOfOldChildren) {
      // patch函数中如果旧节点不存在就是新增新节点
      // 如果旧节点存在,就是更新旧节点
      patch(null, newChildren[i])
      i++
    }
  }
}

common sequence + unmount

I8cnQE.png

// 4. common sequence + unmount
// (a b) c
// (a b)
// i = 2, endIndexOfOldChildren = 2, newIndexOfOldChildren = 1
// a (b c)
// (b c)
// i = 0, endIndexOfOldChildren = 0, newIndexOfOldChildren = -1
else if (i > newIndexOfOldChildren) {
  while (i <= endIndexOfOldChildren) {
    unmount(oldChildren[i])
    i++
  }
}

unknown sequence

vue会尽可能的尝试移动可以复用的节点,并移除不使用的旧节点,并新增对应的节点

I8c5YQ.png

在经历了前4步的比较后,发现unknown sequence内容如下:

unknown sequence

  1. 根据新节点的个数,创建同等长度数组 ---> [0, 0, 0, 0]

  2. 根据新节点的顺序,遍历旧的节点

  • 如果旧节点存在,那么就将新节点在旧节点的索引+1,存储到数组对应位置 (新增记为0,为了避免冲突,所以对应索引值+1)
  • 如果旧节点不存在,那么就是新增,对应数组位置记为0
  • 如果旧节点没有在新节点数组中存在映射,那么移除该旧节点
  • 在遍历旧节点列表的时候,如果在旧节点列表中存在,那么在记录索引的同时,会对新旧节点进行patch操作

所以此时数组变为了[3, 4, 0, 1]

  1. 根据最长递增子序列, 我们可以知道3,4为最长递增子序列,所以D, E是不需要移动的
  2. I是新节点,执行mount操作
  3. C是需要移动的节点,从索引为0的位置移动到索引为3的位置

在循环中,为每一个循环项,添加上独一无二的,不会改变的key的时候

vue在进行DOM diff的时候,可以极大程度的提升我们的更新性能