vue

Vue3 基础 – 快速上手 & 常用指令 & :key 的原理

内容纲要

版权归作者 ©刘龙宾 所有,本文章未经作者允许,禁止私自转载!

1. 在 HTML 网页中使用 vue3 的3个基本步骤

  1. 通过 script 标签的 src 属性,在当前网页中全局引入 vue3 的脚本文件:

    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  2. 创建 vue3 的单页面应用程序实例:

    // 2.1 从 Vue 对象中解构出 createApp 函数
    const { createApp } = Vue
    
    // 2.2 调用 createApp 这个函数,就能够创建出一个单页面应用程序的实例
    const app = createApp()
    
    // 2.3 调用 app 实例对象上的 mount() 函数,
    // 指定单页面应用程序 app,实际要控制页面上哪个区域的渲染
    app.mount('#app')
  3. 声明 vue3 的单页面应用程序实例,实际要控制的页面区域:

    <!-- 注意:如果内容为空,则 vue3 会在提示一个警告消息:
    [Vue warn]: Component is missing template or render function. at <App> -->
    <div id="app"></div>

2. 定义和渲染数据

  1. 在调用 createApp() 函数时,可以提供一个对象作为配置参数,例如:

    const app = createApp({ /*配置对象*/ })
  2. 如果想提供要渲染的数据,可以在步骤1的配置对象中,通过 data 节点提供渲染期间要使用的数据:

    const app = createApp({
     // 2.1 注意:data 节点是一个函数
     data() {
       // 2.2 在 data 函数内部,return 的这个对象,就是数据对象,
       // 要渲染的数据,可以直接写到这个对象中,例如 return { name: 'zs' }
       return {}
     }
    })
  3. 在步骤2的 data 节点中,定义一个名为 name 的数据,值是 liulongbin

    const app = createApp({
     data() {
       return {
         name: 'liulongbin'
       }
     }
    })
  4. 在 vue3 控制的模板结构中,使用 {{ 数据名 }} 语法,把数据渲染出来:

    <div id="app">
    <h1>大家好,我是:{{ name }}</h1>
    </div>
  5. 拓展:当我们修改 data 节点下的数据后,即可看到页面上的 HTML 内容会自动被刷新。这就是 vue 的强大之处:数据驱动视图。修改 data 数据的示例代码如下:

    app._instance.proxy.name = 'escook'

3. vue3 中常用的渲染指令

在 vue 中,指令是带有 v- 前缀的特殊 attribute,它是 vue 提供的特殊语法,大家有必要掌握 vue 中常用指令的使用。

指令能够辅助前端程序员高效地把数据渲染为 HTML 的结构,而程序员不需要调用任何操作 DOM 的 API。

3.0 常用指令的分类

  1. 内容渲染指令
  2. 属性绑定指令
  3. 双向绑定指令
  4. 条件渲染指令
  5. 事件绑定指令
  6. 列表渲染指令

3.1 内容渲染指令

1. 插值表达式

插值表达式(又叫做:Mustache)的语法为 {{ }},vue 在解析模板期间,会把 {{ }} 所在的位置,替换为对应的数据值,例如:

<h1>大家好,我是:{{ name }}</h1>

vue 会把 name 的值,替换到 {{ name }} 所在的位置。

注意:插值表达式 {{ }} 是唯一一个不以 v- 前缀开头的指令。

2. v-text

v-text 指令用来填充 HTML 元素的内容,如果 HTML 元素内部有其它内容存在,则会被覆盖掉。语法格式如下:

<h3 v-text="msg">是兄弟就来</h3>

对应的数据为:

const app = createApp({
  data() {
    return {
      msg: '砍我吧'
    }
  }
})

注意:由于 v-text 指令存在覆盖已有内容的问题,所以在实际开发中它很少被用到。最常用的还是 {{ }} 插值表达式,因为它只是占位符,不会覆盖已有内容。

3. v-html

v-html 指令用来渲染带有 HTML 标记的文本内容,它可以把 HTML 标记解析为真正的 HTML 元素,并插入到模板中渲染。

而插值表达式和 v-text 指令只会把 HTML 标记渲染为纯文本,而不是 HTML。

v-html 的语法格式如下:

<div v-html="rawHtml"></div>

对应的数据为:

const app = createApp({
  data() {
    return {
      rawHtml: '<span style="color: red;">少年强则国强</span>'
    }
  }
})

3.2 属性绑定指令

1. v-bind

v-bind 指令用来为元素的属性绑定动态的属性值。指令语法如下:

<div v-bind:title="titleMsg">xxx</div>

对应的数据为:

const app = createApp({
  data() {
    return {
      titleMsg: '哇哈哈'
    }
  }
})

又例如,为图片的 src 属性动态绑定属性的值:

<img v-bind:src="url" />

对应的数据为:

const app = createApp({
  data() {
    return {
      url: 'https://img.yzcdn.cn/vant/cat.jpeg'
    }
  }
})

2. v-bind 的简写

在实际开发中,v-bind 指令的使用频率非常高,为了简化它的写法,vue 规定 v-bind 指令可以简写为英文的 : 且二者是完全等价的。如上面的例子可以使用 : 简写为:

<div :title="titleMsg">xxx</div>
<img :src="url" />

对应的数据为:

const app = createApp({
  data() {
    return {
      titleMsg: '哇哈哈',
      url: 'https://img.yzcdn.cn/vant/cat.jpeg'
    }
  }
})

注意:今后在 vue 项目开发中,只要看到某个属性前面出现了英文的 : 那么,一定是为这个属性绑定了动态的值。

3. 绑定布尔值

在 vue 中,某些属性的取值可以是布尔值 true 或 false,表示当前的属性是否应该应用于当前的元素。例如 disabled 属性:

<!-- 禁用按钮A -->
<button :disabled="true">按钮A</button>

<!-- 不禁用按钮B -->
<button :disabled="false">按钮B</button>

与之类似的,还有 radio 和 checkbox 的 checked 属性:

<!-- 默认选中“男” -->
<input type="radio" name="gender" :checked="true">男
<input type="radio" name="gender">女

<!-- 默认选中“足球”和“乒乓球” -->
<input type="checkbox" name="hobby">篮球
<input type="checkbox" name="hobby" :checked="true">足球
<input type="checkbox" name="hobby" :checked="true">乒乓球

另外,表单元素 select 下的 option 选项的 selected 属性,也可以绑定布尔值:

<select>
  <option value="北京">北京</option>
  <option value="上海" :selected="true">上海</option>
  <option value="广州">广州</option>
</select>

4. 动态绑定多个值

如果要为某个元素同时绑定多个动态的属性值,可以把多个动态属性封装为一个 JavaScript 对象:

const app = createApp({
  data() {
    return {
      // propObj 对象中封装了一系列属性的键值对
      attrsObj: {
        id: 'box',
        class: 'container',
        title: '布局容器'
      }
    }
  }
})

通过不带参数的 v-bind 指令,即可方便的把 attrsObj 对象中封装的属性,一次性绑定到对应的元素上:

<div v-bind="attrsObj">顶部 header 区域</div>

注意:不带参数的 v-bind 指令,指的是省略了 :属性名 的用法。

5. 拓展:使用 JavaScript 表达式

  1. 在 vue 的数据绑定中,除了支持简单的属性名绑定之外,还支持完整的 JavaScript 表达式绑定

  2. 例如,以下这些都属于简单的属性名绑定,它们是直接把 data 中数据项的名字,绑定到了模板中:

    <div>我是:{{ name }}</div>
    
    <div v-text="msg"></div>
    
    <img :src="url" />
  3. 除此之外,还支持表达式的绑定,例如:

    <!-- 函数的调用 & 数学运算 -->
    <div>我是:{{ name.toUpperCase() }},我今年{{ age + 1 }}岁了。</div>
    
    <!-- 函数的调用 -->
    <div v-text="msg.split('').reverse().join('')"></div>
    
    <!-- 字符串的拼接 -->
    <img :src="'https://img.yzcdn.cn/vant/' + url" />
    
    <!-- 三元表达式 -->
    <div>{{ age >= 18 ? '抽烟喝酒烫头' : '可乐牛奶娃哈哈' }}</div>

    对应的数据如下:

    const app = createApp({
     data() {
       return {
         name: 'liulongbin',
         age: 17,
         msg: '冯绍峰',
         url: 'cat.jpeg'
       }
     }
    })

3.3 双向绑定指令

v-model 双向绑定指令,简化了表单元素赋值和取值操作。

v-model 的作用:

  1. data 数据源发生变化,自动重新渲染页面
  2. 表单数据发生变化,自动更新到 data 数据源中

1. 文本框的双向绑定

input 元素通过 v-model 指令,可以方便地进行赋值和取值,示例代码如下:

<p>Message 的值是:{{ message }}</p>
<input type="text" v-model="message">

对应的数据如下:

const app = createApp({
  data() {
    return {
      message: 'hello'
    }
  }
})

2. 多行文本框的双向绑定

textarea 元素通过 v-model 指令,可以方便地进行赋值和取值,示例代码如下:

<p>Message 的值是:</p>
<pre>{{ message }}</pre>
<textarea v-model="message"></textarea>

对应的数据如下:

const app = createApp({
  data() {
    return {
      // 注意:这里的 \n 是换行符
      message: 'hello \nworld.'
    }
  }
})

3. 复选框的双向绑定

  1. 单一复选框的双向绑定,绑定的是布尔类型的值:

    <p>复选框选中的flag值为:{{flag}}</p>
    <input type="checkbox" v-model="flag">

    对应的数据如下:

    const app = createApp({
     data() {
       return {
         // 是否被选中
         flag: false
       }
     }
    })
  2. 多个复选框的双向绑定,绑定的是数组类型的值,而且每个 checkbox 必须通过 value 属性提供选中项的值

    <p>多个复选框选中的 hobbies 值为:{{ hobbies }}</p>
    <label><input type="checkbox" v-model="hobbies" value="篮球">篮球</label>
    <label><input type="checkbox" v-model="hobbies" value="足球">足球</label>
    <label><input type="checkbox" v-model="hobbies" value="冰球">冰球</label>

    对应的数据如下:

    const app = createApp({
     data() {
       return {
         // 选中的值
         hobbies: []
       }
     }
    })

4. 单选按钮的双向绑定

单选按钮的特点是多选一,所以对单选按钮进行双向绑定时,需要把多个单选按钮通过 v-model 指令绑定到同一个数据源,并通过 value 属性指定选中后的值:

<p>单选按钮选中的 gender 值为:{{ gender }}</p>
<label><input type="radio" v-model="gender" value="男">男</label>
<label><input type="radio" v-model="gender" value="女">女</label>

对应的数据如下:

const app = createApp({
  data() {
    return {
      // 选中的值
      gender: '男'
    }
  }
})

5. 选择器的双向绑定

  1. 单选选择器的双向绑定,只允许选中一个值:

    <p>选中的城市为:{{ city }}</p>
    <select v-model="city">
     <option value="">请选择</option>
     <option value="beijing">北京</option>
     <option value="shanghai">上海</option>
     <option value="nanjing">南京</option>
    </select>

    对应的数据如下:

    const app = createApp({
     data() {
       return {
         city: ''
       }
     }
    })
  2. 多选选择器的双向绑定,允许选中多个值,所以需要绑定数组格式的数据源:

    <p>选中的城市为:{{ areas }}</p>
    <select v-model="areas" multiple>
     <option value="shunyi">顺义区</option>
     <option value="haidian">海淀区</option>
     <option value="daxing">大兴区</option>
    </select>

    对应的数据如下:

    const app = createApp({
     data() {
       return {
         areas: []
       }
     }
    })

6. v-model 的 .lazy 修饰符

默认情况下,v-model 会在每次 input 事件后更新数据。可以添加 .lazy 修饰符来改为在每次 change 事件后更新数据:

<input v-model.lazy="msg" />

7. v-model 的 .number 修饰符

如果你想让用户输入自动转换为数字,你可以在 v-model 后添加 .number 修饰符来管理输入:

<input v-model.number="age" />

注意:

  1. 如果该值无法被 parseFloat() 处理,那么将返回原始值。
  2. number 修饰符会在输入框有 type="number" 时自动启用。

8. v-model 的 .trim 修饰符

如果你想要默认自动去除用户输入内容中两端的空格,你可以在 v-model 后添加 .trim 修饰符:

<input v-model.trim="msg" />

3.4 条件渲染指令

条件渲染指令用来条件性地渲染页面上的某一部分内容。只有表达式的条件成立,才会真正渲染这一部分的内容。

常用的条件渲染指令是 v-ifv-elsev-else-if。其中,v-if 指令可以单独使用,也可以结合 v-elsev-else-if 指令实现两个多个条件的按需渲染。

1. v-if 的使用

v-if 的语法格式如下:

<div v-if="表达式"></div>

其中,只有表达式的返回值为 true 时,才会真正渲染被 v-if 指令控制的 div 元素。

如果 v-if 的表达式返回值为 false,则被 v-if 指令控制的 div 不会被渲染到浏览器中。

例如:

<div v-if="flag">无敌是多么的寂寞</div>

对应的数据为:

const app = createApp({
  data() {
    return {
      flag: true
    }
  }
})

2. v-if 结合 v-else 的使用

v-if 指令可以结合 v-else 指令一起使用。

条件为真时渲染被 v-if 指令控制的元素,当条件为假时渲染被 v-else 指令控制的元素。例如:

<div v-if="age >= 18">抽烟喝酒烫头</div>
<div v-else>牛奶可乐娃哈哈</div>

注意:v-else 指令不需要通过 = 指定相应的表达式,因为 v-else 是兜底的条件,只要前面的所有条件都不满足,那么必然会触发 v-else 的执行。

3. v-if 结合 v-else-if 和 v-else 的使用

v-if 指令可以结合 v-else-ifv-else 指令一起使用,从而组成复杂的条件渲染逻辑。

v-if 或某个 v-else-if 相应的条件为真时,被控制的元素才会被渲染。

最后的 v-else 依然是兜底的条件,当所有的 v-ifv-else-if 条件都不成立时,才会触发 v-else 的执行。例如:

<div v-if="score === 'A'">优秀</div>
<div v-else-if="score === 'B'">良好</div>
<div v-else-if="score === 'C'">一般</div>
<div v-else>差</div>

对应的数据为:

const app = createApp({
  data() {
    return {
      score: 'A'
    }
  }
})

4. <template> 上的 v-if

正常情况下 v-if 指令只能控制单个元素的显示和隐藏。如果需要使用 v-if 控制一组元素的显示和隐藏,就需要在这一组元素之外包裹一个 div 作为容器,并将 v-if 指令应用于 div 容器之上,例如:

<div v-if="true">
  <h1>咏鹅</h1>
  <p>鹅鹅鹅,曲项向天歌。</p>
  <p>白毛浮绿水,红掌拨清波。</p>
</div>

这么做虽然能实现需求,但会在页面上渲染出一个多余的 div 容器。

更好的方案是使用 vue 内置的 <template> 元素作为外层包裹性质的容器,因为它不会被渲染为实际的元素,只起到包裹性质的作用。例如:

<template v-if="true">
  <h1>咏鹅</h1>
  <p>鹅鹅鹅,曲项向天歌。</p>
  <p>白毛浮绿水,红掌拨清波。</p>
</template>

5. v-show 指令的使用

另一个可以用来实现条件渲染的指令是 v-show。它的语法格式如下:

<h1 v-show="flag">Hello!</h1>

如果表达式的值为 true,则被控制的元素会被显示

如果表达式的值为 false,则被控制的元素会被隐藏

注意:v-show 指令不支持在 <template> 元素上使用,也不能和 v-else 搭配使用。

6. v-if 和 v-show 的对比

相同点:

v-ifv-show 指令都能控制元素的条件渲染。

  1. 如果表达式的值为 true,则被控制的元素会被显示
  2. 如果表达式的值为 false,则被控制的元素会被隐藏

不同点:

  1. 控制元素显示和隐藏的手段不同
    • v-if 指令会动态创建删除被控制的元素,从而达到切换元素显示和隐藏的目的;
    • v-show 指令仅切换了被控制元素上名为 display 的 CSS 属性,从而达到切换元素显示和隐藏的目的;
  2. 初始渲染性能不同:
    • 如果初始渲染时,表达式的值为 false,则 v-if 的性能更好。
    • 如果初始渲染时,表达式的值为 true,则二者性能相近。
  3. 频繁切换时的性能不同:
    • 如果需要频繁切换元素的显示和隐藏,则 v-show 的性能更好。
    • 如果不需要频繁切换元素的显示和隐藏,则可以忽略二者的性能差别。
  4. 总结:
    • v-if 有更高的切换开销
    • v-show 有更高的初始渲染开销

3.5 事件绑定指令

1. 事件绑定的基本语法

为了响应用户对 DOM 元素的操作,vue 提供了事件绑定指令 v-on(简写为 @)。

当监听到 DOM 事件的触发时,会执行对应的 JavaScript 逻辑。它的语法格式为 v-on:事件名="handler"@事件名="handler"。例如:

<!-- v-on 是事件绑定指令 -->
<button v-on:click="show">按钮</button>

<!-- @ 是 v-on 指令的简写形式 -->
<button @click="show">按钮</button>

上述代码演示了如何为 button 按钮绑定 click 点击事件。

除此之外,vue 还支持绑定其它类型的事件,这里就不再一一例举了。因为把 DOM 原生事件前面的 on 替换成 v-on:@ 就变成了 vue 的事件绑定形式,例如:

  1. onclick –> @click
  2. oninput –> @input
  3. onchange –> @change

2. 方法事件处理器

方法事件处理器指的是:指定一个方法作为事件的处理器。例如下面的代码所示,指定了一个 show 方法作为 click 事件的处理器:

<button @click="show">按钮</button>

show 方法作为事件处理器,需要定义在 methods 节点下,例如:

const app = createApp({
  data() {
    return {}
  },
  methods: {
    show(event) { 
      console.log('ok')
      console.log(event.target.tagName)
    }
  }
})

在方法事件处理器的参数列表中,第一个形参 event事件对象

3. 基于方法事件处理器实现数值自增

  1. 声明模板结构如下:

    <p>count的值为:{{ count }}</p>
    <button @click="add">+1</button>
  2. 在 data 中声明数据源 count,在 methods 中声明事件处理器 add,代码如下:

    const app = createApp({
     data() {
       return {
         count: 0
       }
     },
     methods: {
       add() {
         app._instance.proxy.count++
       }
     }
    })

    注意:methods 节点下的方法中,this 指向的就是 app._instance.proxy。所以上述代码完全可以替换为 this.count++

4. 内联事件处理器

内联事件处理器相当于原生 DOM 中的内联 JavaScript,例如数值自增的操作,可以简写成内联事件处理器的形式:

<p>count的值为:{{ count }}</p>
<button @click="count++">+1</button>

对应的数据为:

const app = createApp({
  data() {
    return {
      count: 0
    }
  }
})

注意:内联事件处理器通常用于简单的业务场景,如果涉及到复杂的业务逻辑,请使用方法事件处理器在内联处理器中调用方法

5. 在内联处理器中调用方法

首先,我们要能够明确的区分开方法事件处理器内联事件处理器

如果事件绑定的处理器是个纯粹的方法名,则是方法事件处理器,例如:

<button @click="show">按钮A</button>

除此之外,其它绑定事件处理器的形式,都是内联事件处理器,例如:

<!-- 绑定内联的 JavaScript -->
<button @click="count++">按钮C</button>

<!-- 绑定了一个方法的调用 -->
<button @click="show()">按钮B</button>

<!-- 绑定了方法的调用的同时,传递参数 -->
<button @click="show('Hello world.')">按钮B</button>

内联事件处理器的优点:解锁了模板向处理器方法传递参数的能力。

6. 在内联事件处理器中访问事件对象

内联事件处理器的缺点:事件对象丢失了,无法在处理器方法中访问到事件对象 event。

上述问题的解决方案有两个,分别是:

  • 使用特殊的 $event 变量

  • 使用内联箭头函数接收并传递 event 对象

解决方案1:使用特殊的 $event 变量

<button @click="showMsg('hello world.', $event)">按钮</button>

对应的 showMsg 处理器为:

const app = createApp({
  methods: {
    showMsg(msg, event) {
      // 改变按钮显示的文本
      event.target.innerHTML = msg
      // 改变按钮的背景颜色
      event.target.style.backgroundColor = 'cyan'
    }
  }
})

解决方案2:使用内联箭头函数接收并传递 event 对象

<button @click="(event) => showMsg('你好,世界。', event)">按钮</button>

对应的 showMsg 处理器为:

const app = createApp({
  methods: {
    showMsg(msg, event) {
      // 改变按钮显示的文本
      event.target.innerHTML = msg
      // 改变按钮的背景颜色
      event.target.style.backgroundColor = 'cyan'
    }
  }
})

7. 事件修饰符

在原生 DOM 的事件处理函数中,如果想要阻止冒泡行为,则需要调用 event.stopPropagation();如果想要阻止默认行为,则需要调用 event.preventDefault()。为了提高用户的开发体验,vue 提供了更优雅的方式来阻止事件冒泡或默认行为,即:事件修饰符。

在 vue 中最常用的两个事件修饰符分别是:

  • .prevent
  • .stop

其中 .prevnet 用来阻止默认行为,例如:

<!-- 使用 .prevent 修饰了 a 链接的 click 事件 -->
<!-- 点击超链接后,会阻止超链接的默认跳转行为 -->
<a href="https://www.escook.cn/" @click.prevent="showMsg">超链接</a>

另外 .stop 用来阻止事件冒泡,例如:

<div @click="outerHandler">
  <!-- 点击内部的 button 按钮,click 事件不会向外冒泡 -->
  <!-- 所以外层的 outerHandler 处理器不会执行 -->
  <button @click.stop="innerHandler">按钮</button>
</div>

拓展:其它事件修饰符还有 .self、.capture、.once、.passive。具体用法请参考 vue3 官方文档 – 事件修饰符

8. 按键修饰符

在监听键盘事件时,我们经常需要检查特定的按键,从而执行特定的操作。例如:

  • 用户在输入框中按下了 enter 键,则触发提交的函数
  • 用户在输入框中按下了 esc 键,则清空文本框的内容

示例代码如下:

<input type="text" v-model="msg" @keyup.enter="submit" @keyup.esc="clear">

对应的 JS 处理逻辑为:

const app = createApp({
  data() {
    return {
      msg: '' // 文本框的数据
    }
  },
  methods: {
    // 该处理函数仅在用户按下 enter 键时触发
    submit() {
      console.log('提交的数据为:' + this.msg)
    },
    // 该处理函数仅在用户按下 esc 键时触发
    clear() {
      this.msg = ''
    }
  }
})

9. 按键别名与按键名的获取

vue 为常用的按键提供了官方内置的按键别名,列表如下:

  • .enter
  • .tab
  • .delete (捕获“Delete”和“Backspace”两个按键)
  • .esc
  • .space
  • .up
  • .down
  • .left
  • .right

如果上述列表中没有你想监听的按键,则可以使用 $event.key 先获取按键的名称,再把获取到的按键名称转为 kebab-case 形式,最后利用转换得到的按键名进行监听即可,例如下面的代码监听了 CapsLock 按键:

<p>输入状态:{{ isUpperCase ? '大写' : '小写' }}</p>
<input type="text" v-model="msg" @keyup.caps-lock="changeMode">

对应的 JS 逻辑为:

const app = createApp({
  data() {
    return {
      msg: '', // 文本框的数据
      isUpperCase: false // 是否为大写输入模式
    }
  },
  methods: {
    // 仅当用户按下的是 CapsLock 键,才触发此函数的执行
    changeMode() {
      this.isUpperCase = !this.isUpperCase
    }
  }
})

10. 系统按键修饰符

如果在触发事件的时候,想要判断用户是否同时按下了 Ctrl、Alt 等系统按键。此时可以使用 vue 内置的系统按键修饰符,主要有以下4个:

  • .ctrl
  • .alt
  • .shift
  • .meta [注意:meta 在 Windows 键盘上指的是 Windows 键(⊞),在 Mac 键盘上指的是 Command 键(⌘)]

例如,下面的代码监听了触发 div 的 click 事件时,是否同时按下了特定的系统按键,从而改变 div 的形状和外观:

<!-- 点击 div 的时候, -->
<!-- 1. 如果同时按下了 Ctrl 键,则添加 square 类样式 -->
<!-- 2. 如果同时按下了 Alt 键,则添加 round 类样式 -->
<!-- 3. 如果同时按下了 Shift 键,则还原为默认的 box 类样式 -->
<div class="box" :class="shape"
     @click.ctrl="changeShape('square')"
     @click.alt="changeShape('round')"
     @click.shift="changeShape('')">
</div>

对应的 JS 逻辑为:

const app = createApp({
  data() {
    return {
      // 类样式的名称
      shape: ''
    }
  },
  methods: {
    // 事件的处理函数
    changeShape(shape) {
      this.shape = shape
    }
  }
})

配套的 CSS 样式为:

<style>
  .box {
    width: 300px;
    height: 300px;
    background-color: #efefef;
    transition: all 1s ease;
  }

  .square {
    border-radius: 20px;
    background-color: cyan;
    transition: all 1s ease;
  }

  .round {
    border-radius: 50%;
    background-color: lightgreen;
    transition: all 1s ease;
  }
</style>

11. .exact 修饰符

上述的例子中,存在一个很明显的 Bug:

  • 我们希望在触发 div 的 click 事件时,仅当用户按下了 CtrlAltShift 按键时,才触发 changeShape 函数
  • 现在的 Bug 是,用户在触发 click 事件时,如果按下了 Ctrl + Alt 的组合按键,也会触发 changeShape 函数

.exact 修饰符可以完美的解决这个问题。.exact 修饰符表示精确匹配系统按键。

因此,我们可以针对上述的例子进行修改,在特定的系统按键修饰符的后面应用 .exact 修饰符,表示精确匹配系统按键:

<div class="box" :class="shape" 
     @click.ctrl.exact="changeShape('square')" 
     @click.alt.exact="changeShape('round')"
     @click.shift.exact="changeShape('')">
</div>

注意:所谓的精确匹配系统按键,仅对系统按键修饰符生效,如果用户按下了 Ctrl + A 的组合键,也会触发 @click.ctrl.exact 所绑定的事件处理器。

12. 鼠标按键修饰符

vue 还提供了鼠标按键修饰符,用来监听事件是否由特定的鼠标按键触发:

  • .left
  • .right
  • .middle

例如,下面的代码演示了如何阻止h1 元素上显示鼠标的右键菜单

<h1 @click.right.prevent>这是一个标题</h1>

注意:绑定事件时,不一定非要提供事件的处理器,我们也可只提供事件修饰符,从而达到特定的目的。

3.6 列表渲染指令

v-for 指令是 vue 提供的列表渲染指令。

如果您有一个数组,想把数组中的每一项渲染为格式相似的 HTML 结构,那么 v-for 指令可以帮助您实现列表数据的渲染。

使用场景:商品列表、用户列表等。

1. v-for 的基本使用

v-for 的基本语法格式为:

v-for="当前循环项 in 数组"

其中关键字 in 前面的是当前循环项,关键字 in 后面的要循环的数组。例如:

<ul>
  <li v-for="item in list">姓名:{{ item.name }},年龄:{{ item.age }}</li>
</ul>

对应的数据为:

const app = createApp({
  data() {
    return {
      // 数组
      list: [
        { name: 'zs', age: 20 },
        { name: 'liulongbin', age: 21 },
        { name: 'escook', age: 22 }
      ]
    }
  }
})

2. v-for 中的索引

v-for 的完整语法格式为:

v-for="(循环项, 循环项的索引) in 数组"

其中 in 关键字左侧的 ( ) 里面,分别是当前循环项当前循环项的索引。例如:

<ul>
  <li v-for="(item, index) in goods">{{ index + 1 }}. {{ item }}</li>
</ul>

对应的数据为:

const app = createApp({
  data() {
    return {
      // 数组
      goods: ['手表', '手机', '手串']
    }
  }
})

注意:v-for 中的索从 0 开始递增。

3. v-for 中的解构

如果 v-for 指令中的循环项 item 是一个对象,则可以在 v-for 指令中进行解构操作,语法格式为:

v-for="{数据A, 数据B} in 数组"
或
v-for="({数据A, 数据B}, 索引) in 数组"

其中 in 关键字左侧的 { } 表示解构操作。例如:

<ul>
  <li v-for="{name, age} in list">姓名:{{ name }},年龄:{{ age }}</li>
</ul>

对应的数据为:

const app = createApp({
  data() {
    return {
      // 数组
      list: [
        { name: 'zs', age: 20 },
        { name: 'liulongbin', age: 21 },
        { name: 'escook', age: 22 }
      ]
    }
  }
})

4. template 上的 v-for

v-for 指令每次只能循环生成一个元素,如果想在每次循环期间生成一组元素,则必须在这一组元素之外包裹一层 div 标签作为容器,并把 v-for 指令作用于外层的 div 容器之上。例如:

<ul>
  <div v-for="(item, index) in list">
    <li class="divider" v-if="index!== 0"></li>
    <li>姓名:{{ item.name }},年龄:{{ item.age }}</li>
  </div>
</ul>

对应的 css 样式为:

<style>
  .divider {
    border-top: 1px solid #888;
    list-style: none;
    margin: 10px 0;
  }
</style>

如上,虽然可以实现列表数据的渲染,但却不尽完美。因为 div 标签在循环中只起到容器的作用,在整个列表结构中没有任何意义。

所以推荐的做法是利用 vue 内置的 <template> 标签替代上述的 div 标签,因为 <template> 是一个虚拟的容器不会被渲染为实际的元素

最终,优化过后的代码如下:

<ul>
  <template v-for="(item, index) in list">
    <li class="divider" v-if="index!== 0"></li>
    <li>姓名:{{ item.name }},年龄:{{ item.age }}</li>
  </template>
</ul>

思考:分割线的 <li> 能否放在数据的 <li> 之后?如果能,则 v-if 的条件是什么?如果不能,请说出为什么。

5. v-for 与 v-if 的优先级

注意:vue 官方不推荐在一个元素上,同时使用 v-if 和 v-for 指令。因为这样使用无法明确体现出二者的优先级。降低代码的阅读性和维护性。

v-ifv-for 同时存在于一个元素上的时候,v-ifv-for 的优先级更高。这意味着 v-if 的条件将无法访问到 v-for 作用域内定义的变量别名:

<ul>
  <!-- 注意:这里的 v-if 指令中,无法访问到 item.done 对应的数据, -->
  <!-- 因为 v-if 比 v-for 的优先级高, -->
  <!-- 当 v-if 执行的时候访问不到 item 对象,因为 v-for 此时还未执行! -->
  <li v-for="item in todos" v-if="!item.done">{{item.task}}</li>
</ul>

对应的数据为:

const app = createApp({
  data() {
    return {
      // 任务列表,done 为 true 表示完成;done 为 false 表示未完成
      todos: [
        { task: '晨练', done: true },
        { task: '吃早餐', done: true },
        { task: '吃午饭', done: false },
        { task: '午休', done: false }
      ]
    }
  }
})

解决的方案很简单,先循环再判断即可。在 li 元素的外层包裹一个 template 组件,并把 v-for 指令从 li 上挪到 template 组件上,示例代码如下:

<ul>
  <template v-for="item in todos">
    <li v-if="!item.done">{{item.task}}</li>
  </template>
</ul>

改造后的代码除了解决了 v-if 优先级高导致的报错问题之外,还有这3个明显的特征:

  1. <template> 是一个虚拟容器,不会被渲染为任何实际元素,因此不会导致 DOM 结构的冗余
  2. 代码的可读性更强,外层用来循环数组从而得到每个列表项,内部根据列表项的状态实现 DOM 结构的按需渲染
  3. 如果 item.done 值为 false,则不会渲染对应的 DOM 结构,因此初始的渲染性能较好

3.7 v-for 中的 key 的基本使用

key 值是 v-for 指令中很重要的一个内容,在接下来的课程中,我们先来学习 key 值的基本使用,然后再深入探讨一下 key 值的内部原理。

1. 案例:在 v-for 列表的头部追加元素

在 data 节点下,定义任务列表的 todos 数据:

data() {
  return {
    // 任务列表
    todos: [
      { task: '晨练' },
      { task: '吃早餐' },
      { task: '吃午饭' }
    ]
  }
}

并在模板结构中,使用 v-for 指令循环渲染出列表的结构:

<!-- 列表区域 -->
<ul>
  <li v-for="item in todos">
    <span>{{ item.task }}</span>
  </li>
</ul>

为了能够实现在任务列表的头部添加新任务的功能,还需要在 data 节点下定义名为 taskName 的数据项,表示即将添加到头部的新任务的名称,同时,还要在 methods 节点下定义名为 add 的方法,用来当做添加按钮的 click 事件处理器:

const app = createApp({
  data() {
    return {
      // 要添加的任务名称
      taskName: '',
      // 任务列表
      todos: [
        { task: '晨练' },
        { task: '吃早餐' },
        { task: '吃午饭' }
      ]
    }
  },
  methods: {
    // 添加操作
    add() {
      // 1. 如果要添加的任务名称为空,则提示警告信息
      if (this.taskName.length === 0) return console.warn('任务名称不能为空!')
      // 2. 向列表头部添加一个新任务
      this.todos.unshift({ task: this.taskName })
      // 3. 清空文本框的内容
      this.taskName = ''
    }
  }
})

最后,在模板结构中,新增添加区域的 UI 结构如下:

<div id="app">

  <!-- 添加区域 -->
  <div>
    <input type="text" v-model.trim="taskName">
    <button @click="add">添加</button>
  </div>

  <hr>
  <!-- 列表区域 -->
  <ul>
    <li v-for="item in todos">
      <span>{{ item.task }}</span>
    </li>
  </ul>
</div>

至此,在头部追加新任务的功能就实现了,经过测试,代码没有任何问题,功能也可以正常使用。

2. 新功能:勾选任务的完成状态

我们在上一个案例的基础之上,继续添加一个勾选任务完成状态的新功能。

修改模板结构中的列表区域,在每个 li 元素中,都添加一个 checkbox 复选框,作为当前任务的完成状态,代码如下:

<!-- 列表区域 -->
<ul>
  <li v-for="item in todos">
    <!-- 任务的完成状态 -->
    <input type="checkbox">
    <!-- 任务的名称 -->
    <span>{{ item.task }}</span>
  </li>
</ul>

改造完成后立即测试当前的功能,我们会发现复选框的勾选状态发生了紊乱

不用担心,这个问题是由 vue 默认的更新策略所导致的。具体原因我们需要在原理层面进行深入讲解。

那么接下来,我们首先来看一下如何解决这个勾选状态紊乱的问题。

3. BugFix:解决勾选状态紊乱的方案

解决方案,就是每个复选框的勾选状态数据源进行绑定,这样 vue 就能够正确更新每个复选框的勾选状态了。

首先,修改 data 下的数据节点,为 todos 下的每个任务,都添加一个 done 属性,用来表示当前任务是否完成:

data() {
  return {
    // 要添加的任务名称
    taskName: '',
    // 任务列表
    todos: [
      { task: '晨练', done: true },
      { task: '吃早餐', done: false },
      { task: '吃午饭', done: false }
    ]
  }
}

其次,修改 methods 下的 add 方法,在每次添加新任务时,为其附加一个 done: false 的初始完成状态:

methods: {
  // 添加操作
  add() {
    // 1. 如果要添加的任务名称为空,则提示警告信息
    if (this.taskName.length === 0) return console.warn('任务名称不能为空!')
    // 2. 向列表头部添加一个新任务
    this.todos.unshift({ task: this.taskName })
    // 3. 清空文本框的内容
    this.taskName = ''
  }
}

最后,修改模板结构,为每个任务中的 checkbox 复选框进行 v-model 的双向数据绑定:

<!-- 列表区域 -->
<ul>
  <li v-for="item in todos">
    <!-- 任务的完成状态 -->
    <input type="checkbox" v-model="item.done">
    <!-- 任务的名称 -->
    <span>{{ item.task }}</span>
  </li>
</ul>

至此,复选框勾选状态紊乱的问题得到了解决。

但是…如果再为每个任务添加一个文本框的备注呢?文本框的状态会紊乱吗又该如何解决呢

4. 新功能:为任务添加备注

我们继续为案例添加新功能,即为每个任务添加一个文本框的作为备注信息。

修改模板结构中的列表区域,在每个 li 元素中,都添加一个文本框,作为当前任务的备注描述,代码如下:

<!-- 列表区域 -->
<ul>
  <li v-for="item in todos">
    <!-- 任务的完成状态 -->
    <input type="checkbox" v-model="item.done">
    <!-- 任务的名称 -->
    <span>{{ item.task }}</span>
    <!-- 文本框备注 -->
    <input type="text">
  </li>
</ul>

改造完成后立即测试当前的功能,我们会发现在文本框中填写的备注也发生了紊乱

Don’t worry,这个问题依然是由 vue 默认的更新策略所导致的。具体原因稍后详细介绍。

我们还是先来看一下如何解决这个文本框内容紊乱的问题吧。

5. BugFix:解决文本框内容紊乱的方案

本次的解决方案和解决复选框勾选状态紊乱的方案如出一辙,核心思路就是把文本框的内容和数据源进行双向的绑定即可。

首先,修改 data 下的数据节点,为 todos 下的每个任务,都添加一个 remark 属性,用来表示当前任务的备注描述:

data() {
  return {
    // 要添加的任务名称
    taskName: '',
    // 任务列表
    todos: [
      { task: '晨练', done: true, remark: '早晨6点' },
      { task: '吃早餐', done: false, remark: '' },
      { task: '吃午饭', done: false, remark: '' }
    ]
  }
}

其次,修改 methods 下的 add 方法,在每次添加新任务时,为其附加一个 remark: '' 的初始备注描述:

methods: {
  // 添加操作
  add() {
    // 1. 如果要添加的任务名称为空,则提示警告信息
    if (this.taskName.length === 0) return console.warn('任务名称不能为空!')
    // 2. 向列表头部添加一个新任务
    this.todos.unshift({ task: this.taskName, done: false, remark: '' })
    // 3. 清空文本框的内容
    this.taskName = ''
  }
}

最后,修改模板结构,为每个任务中的文本框进行 v-model 的双向数据绑定:

<!-- 列表区域 -->
<ul>
  <li v-for="item in todos">
    <!-- 任务的完成状态 -->
    <input type="checkbox" v-model="item.done">
    <!-- 任务的名称 -->
    <span>{{ item.task }}</span>
    <!-- 文本框备注 -->
    <input type="text" v-model="item.remark">
  </li>
</ul>

至此,文本框内容紊乱的问题也得到了解决。到此为止,我们已经连续两次解决了状态紊乱的问题,是时候做一个小小的总结了:

  1. 如果列表每一项中的表单元素,没有绑定任何数据源,则必然发生数据紊乱的问题
    • 这种没有绑定数据源的情况,我们称之为:临时状态
  2. 如果列表每一项中的表单元素,和数据源进行了绑定,则数据紊乱的问题必然得到解决
    • 这种绑定了数据源的情况,我们称之为:响应式状态

总之,临时状态有可能会发生状态紊乱的问题,而响应式状态不会发生状态紊乱的问题。大家先记住这种表象式的总结,后面会有助于大家在原理层面深入理解这个问题。

6. 基于 key 值解决临时状态紊乱的问题

我们在之前解决临时状态紊乱时,每次都需要为表单元素进行数据的绑定,从而把临时状态变更为响应式状态

有没有其它方案,能让我们在不改变临时状态的前提下,也能解决状态紊乱的问题呢?这就需要用到 v-for 中的 key 值了。

首先,让我们把页面结构重新梳理一遍,把临时状态紊乱的问题完整的还原出来,data 中的数据如下:

data() {
  return {
    // 要添加的任务名称
    taskName: '',
    // 任务列表
    todos: [
      { task: '晨练' },
      { task: '吃早餐' },
      { task: '吃午饭' }
    ]
  }
}

methods 中的方法如下:

methods: {
  // 添加操作
  add() {
    // 1. 如果要添加的任务名称为空,则提示警告信息
    if (this.taskName.length === 0) return console.warn('任务名称不能为空!')
    // 2. 向列表头部添加一个新任务
    this.todos.unshift({ task: this.taskName })
    // 3. 清空文本框的内容
    this.taskName = ''
  }
}

模板结构如下:

<div id="app">

  <!-- 添加区域 -->
  <div>
    <input type="text" v-model.trim="taskName">
    <button @click="add">添加</button>
  </div>

  <hr>
  <!-- 列表区域 -->
  <ul>
    <li v-for="item in todos">
      <!-- 任务的完成状态 -->
      <input type="checkbox">
      <!-- 任务的名称 -->
      <span>{{ item.task }}</span>
      <!-- 文本框备注 -->
      <input type="text">
    </li>
  </ul>
</div>

问题还原出来之后,我们就要着手使用 key 值来解决临时状态紊乱的问题了,4个步骤如下:

步骤1:为 data 下的 todos 数据,添加唯一的 id 标识:

data() {
  return {
    // 要添加的任务名称
    taskName: '',
    // 任务列表
    todos: [
      { id: 1, task: '晨练' },
      { id: 2, task: '吃早餐' },
      { id: 3, task: '吃午饭' }
    ]
  }
}

步骤2:为了保证新增的任务项也能有递增的唯一的 id 值,我们需要在 data 下新增一个名为 nextId 的数据,它的值是下一次添加时可用的 id 值:

data() {
  return {
    // 要添加的任务名称
    taskName: '',
    // 下一次添加时可用的 id 值
    nextId: 4,
    // 任务列表
    todos: [
      { id: 1, task: '晨练' },
      { id: 2, task: '吃早餐' },
      { id: 3, task: '吃午饭' }
    ]
  }
},

步骤3:修改 methods下的 add 方法,为每次新增的任务附带唯一的 id 标识,添加完成后记得更新 nextId 的值:

methods: {
  // 添加操作
  add() {
    // 1. 如果要添加的任务名称为空,则提示警告信息
    if (this.taskName.length === 0) return console.warn('任务名称不能为空!')
    // 2. 向列表头部添加一个新任务
    this.todos.unshift({ id: this.nextId, task: this.taskName })
    // 3. 清空文本框的内容
    this.taskName = ''
    // 4. 更新 nextId 的值
    this.nextId++
  }
}

步骤4:修改模板结构中的 v-for 循环,在 v-for 循环的同时,为当前循环的元素动态绑定 key 属性的值,值为当前循环项的 id,即 item.id。代码如下:

<!-- 列表区域 -->
<ul>
  <li v-for="item in todos" :key="item.id">
    <!-- 任务的完成状态 -->
    <input type="checkbox">
    <!-- 任务的名称 -->
    <span>{{ item.task }}</span>
    <!-- 文本框备注 -->
    <input type="text">
  </li>
</ul>

到此为止,我们成功利用 v-for 配套的 key 值,解决了临时状态紊乱的问题。好处有2个:

  1. 无需把临时状态转变为响应式状态
  2. 只需在循环期间绑定一次 key 值即可解决问题,无需像之前那样为每个表单元素依次定义数据源、再依次进行 v-model 的绑定

7. key 值的使用注意事项

  1. key 值必须具有唯一性,不能重复
  2. key 值必须是 number 或 string 或 symbol 类型
  3. 尽量不要使用索引当做 key 值,因为索引不具有唯一性
  4. 为了避免出现非预期的 DOM 更新问题,推荐给 v-for 循环的元素绑定唯一的 key 值

简单粗暴的记忆方法:只要使用了 v-for 指令,就一定要加 key 值绑定;而且尽量用 id 当做 key 值,没有 id 的情况下找具有唯一性的 number、string 值当做 key。

3.8 v-for 中 key 值的原理

1. 虚拟 DOM 的概念

虚拟 DOM 又叫做 VNode,本质上是用来描述 DOM 结构的 JavaScript 对象。它在 Vue 中用来描述不同类型的节点,例如普通元素节点、组件节点等。

例如,我们提供如下的 DOM 结构:

<h1 id="topicTitle" title="标题">This is an article title.</h1>

将其转化为 VNode 之后,格式如下:

const vnode = {
  type: 'h1',
  props: {
    id: 'topicTitle',
    title: '标题'
  },
  // 子节点是普通文本
  children: 'This is an article title.'
}

其中:

  1. type 属性表示 DOM 的标签类型
  2. props 属性表示 DOM 上的一些附加信息,例如 id、title 等
  3. children 属性表示 DOM 的子节点,可以是文本,也可以是一个 VNode 数组

再例如,我们提供如下的 DOM 结构:

<ul>
  <li>第1个子元素</li>
  <li>第2个子元素</li>
</ul>

将其转化为 VNode 之后,格式如下:

const vnode = {
  type: 'ul',
  props: {},
  // 子节点是 VNode 数组
  children: [{
      type: 'li',
      props: {},
      children: '第1个子元素'
    }, {
      type: 'li',
      props: {},
      children: '第2个子元素'
    }
  ]
}

2. 虚拟 DOM 的优势

最大的优势:跨平台渲染,不同的平台可以基于自己的实现,轻松的把 VNode 转化为各自平台的代码

  • 把 VNode 渲染为小程序平台的组件
  • 把 VNode 渲染为 Weex 移动端的组件

虚拟 DOM 配合 Diff 算法,能够尽可能减少 Vue 框架对 DOM 的操作

image-20230621155055556

如上图所示,如果使用原生操作更新页面结构时,会移除所有旧 DOM 节点,并重新创建新 DOM 节点。

而我们可以明显的观察到,相比于旧 DOM,新 DOM 元素只是顺序上发生了变化:新 DOM 把 P3 移动到了 P1 之前。我们只需要更新 DOM 元素的先后顺序即可,根本不用重新创建所有的元素。

虚拟 DOM 配合 Diff 算法,可以实现旧 DOM 元素的复用,从而减少了不必要的 DOM 销毁和创建的过程。

误区:虚拟 DOM 的性能一定比手动操作原生 DOM 好。请注意,这个说法是错误的

  1. DOM 复用性的角度出发来对比两者,虚拟 DOM 的性能比手动操作原生 DOM 要好,这是毋庸置疑的。
  2. DOM 的创建效率方面出发来对比两者,手动操作原生 DOM 的性能更好。

image-20230621161008916

3. 就地更新策略

假设 data 中存在如下的一组 todos 数据,我们希望把它渲染为页面上的 ul 无序列表:

data() {
  return {
    // 任务列表
    todos: [
      { task: '晨练' },
      { task: '吃早餐' },
      { task: '吃午饭' }
    ]
  }
}

执行的 v-for 指令如下:

<ul>
  <li v-for="item in todos">{{ item.task }}</li>
</ul>

生成的虚拟 DOM (VNode)如下所示:

const vnode = {
  type: 'ul',
  props: {},
  // VNode 数组,通过 v-for 指令渲染的子节点
  children: [{
    type: 'li',
    props: { key: null },
    children: '晨练'
  }, {
    type: 'li',
    props: { key: null },
    children: '吃早餐'
  }, {
    type: 'li',
    props: { key: null },
    children: '吃午饭'
  }]
}

由于这是第一次渲染页面,所以会直接基于虚拟 DOM 创建真实的 DOM 结构,并把上一步生成的 VNode 标记旧虚拟 DOM

<ul>
  <li>晨练</li>
  <li>吃早餐</li>
  <li>吃午饭</li>
</ul>

至此,页面的首次渲染完成

接下来,如果在 todos 数据的头部插入一个新任务,则对应的数据为:

data() {
  return {
    // 任务列表
    todos: [
      { task: '测试' },
      { task: '晨练' },
      { task: '吃早餐' },
      { task: '吃午饭' }
    ]
  }
}

生成的新虚拟 DOM (VNode)如下所示:

const vnode = {
  type: 'ul',
  props: {},
  // VNode 数组,通过 v-for 指令渲染的子节点
  children: [{
    type: 'li',
    props: { key: null },
    children: '测试'
  }, {
    type: 'li',
    props: { key: null },
    children: '晨练'
  }, {
    type: 'li',
    props: { key: null },
    children: '吃早餐'
  }, {
    type: 'li',
    props: { key: null },
    children: '吃午饭'
  }]
}

注意:为了提高 DOM 操作的性能,Vue 会尽可能的复用已存在的旧 DOM 元素,从而减少新 DOM 的创建。此时,Vue 会对新旧 VNode 进行对比。

那么对比的策略是什么呢?由于我们没有给 v-for 指令绑定 key 值,所以当前每个 li 对应的 VNode 的 key 默认等于 null。当 key 值为 null 的时候,Vue 的对比策略是:就地更新

具体来讲,就地更新策略指的是逐层对比新旧 VNode 节点

  1. 如果新旧 VNode 类型相同,则进行更新
  2. 如果新旧 VNode 类型不同,则移除旧元素,创建新元素
  3. 如果旧 VNode 比新 VNode 多,则移除多余的旧元素
  4. 如果新 VNode 比旧 VNode 多,则创建多余的新元素

image-20230621174540396

基于就地更新策略,上述的新旧 VNode 对比完成之后,Vue 得到的更新过程为:

  1. 复用3个 li 的旧 DOM,并更新这三个旧 DOM 的文本子节点
  2. 创建1个新的 li DOM,并把它添加到 ul 中

4. 就地更新策略和临时状态

掌握了 Vue 的就地更新策略之后,我们再用它分析一下 3.7.2 中的复选框勾选状态紊乱的 Bug。

v-for 的指令为:

<ul>
  <li v-for="item in todos">
    <!-- 任务的完成状态 -->
    <input type="checkbox">
    <!-- 任务的名称 -->
    <span>{{ item.task }}</span>
  </li>
</ul>

对应的数据为:

data() {
  return {
    // 任务列表
    todos: [
      { task: '晨练' },
      { task: '吃早餐' }
    ]
  }
}

首次渲染得到的 VNode 为:

const vnode = {
  type: 'ul',
  props: {},
  // VNode 数组,通过 v-for 指令渲染的子节点
  children: [{ // 第1个li(VNode)
    type: 'li',
    props: { key: null },
    children: [{
      type: 'input',
      props: { type: 'checkbox' }
    }, {
      type: 'span',
      props: {},
      children: '晨练'
    }]
  }, { // 第2个li(VNode)
    type: 'li',
    props: { key: null },
    children: [{
      type: 'input',
      props: { type: 'checkbox' }
    }, {
      type: 'span',
      props: {},
      children: '吃早餐'
    }]
  }]
}

至此,页面的首次渲染完成

接下来,如果在 todos 数据的头部插入一个新任务,则对应的数据为:

data() {
  return {
    // 任务列表
    todos: [
      { task: '测试' },
      { task: '晨练' },
      { task: '吃早餐' }
    ]
  }
}

生成的新虚拟 DOM (VNode)如下所示:

const vnode = {
  type: 'ul',
  props: {},
  // VNode 数组,通过 v-for 指令渲染的子节点
  children: [{ // 第1个li(VNode)
    type: 'li',
    props: { key: null },
    children: [{ 
      type: 'input',
      props: { type: 'checkbox' }
    }, {
      type: 'span',
      props: {},
      children: '测试'
    }]
  }, { // 第2个li(VNode)
    type: 'li',
    props: { key: null },
    children: [{
      type: 'input',
      props: { type: 'checkbox' }
    }, {
      type: 'span',
      props: {},
      children: '晨练'
    }]
  }, { // 第3个li(VNode)
    type: 'li',
    props: { key: null },
    children: [{
      type: 'input',
      props: { type: 'checkbox' }
    }, {
      type: 'span',
      props: {},
      children: '吃早餐'
    }]
  }]
}

依据就地更新策略,新旧 VNode 具体的对比过程如下图所示:

image-20230621200601419

经过分析发现,每个 checkbox 复选框的勾选状态,不影响就地更新的对比过程。复选框只是简单粗暴的被就地复用了。因此出现了临时状态紊乱的问题。

5. 就地更新策略和数据绑定

3.7.3 中,我们给复选框添加了 v-model 的双向数据绑定,解决了复选框勾选状态紊乱的问题。

这其实很好理解,因为 Vue 在进行新旧 VNode 对比的过程中,会根据数据驱动视图的思想,让真实 DOM 所显示的数据data 中的数据保持一致。

但是,临时状态不受数据驱动视图的影响,因为它没有与任何响应式数据进行绑定。

6. key 值更新策略

不同于默认的就地更新策略,key 值更新策略可以最大限度的实现已有元素的重排重用

在 key 值更新策略中,Vue 认为:

  1. 具有相同 key 值的新旧 VNode 是可复用的元素
  2. 如果新 VNode 对应的 key 值不存在于旧 VNode 中,则当前的新 VNode 需要创建并添加
  3. 如果旧 VNode 对应的 key 值不存在于旧 VNode 中,则当前的旧 VNode 需要被删除(卸载)

3.7.6 中,我们给 v-for 循环生成的 <li> 添加了 :key="item.id" 的 key 值绑定,从而解决了临时状态紊乱的问题。

在列表头部新增一个元素,则 key 值更新策略的执行过程如下:

  1. 初始 Diff 状态,定义三个变量:

    image-20230627134255530

  2. 第1次Diff时,默认从头部进行 VNode 的对比和复用。发现 j=0 指向的新旧 VNode 的 key 值不同,则放弃从头部 Diff,转而从尾部开始进行 Diff。从尾部进行Diff时,发现新旧 VNode 都是P3,于是复用P3,只对P3进行 patch 更新即可。同时,让 newEnd--oldEnd--,为第2次Diff做好准备:

    image-20230627134805537

  3. 第2次Diff时,newEnd=2oldEnd=1,它们所指向的新旧 VNode 都是P2,key 值一样,可以复用,于是对P2进行 patch 更新。同时让 newEnd--oldEnd--,为第3次Diff做好准备:

    image-20230627135107637

  4. 第3次Diff时,newEnd=1oldEnd=0,它们所指向的新旧 VNode 都是P1,key 值一样,可以复用,于是对P1进行 patch 更新。同时让 newEnd--oldEnd--,为第4次Diff做好准备:

    image-20230627135203616

  5. 第4次Diff时,newEnd=0oldEnd=-1,同时 j=0。经过分析我们发现:

    • oldEnd < j,说明旧 VNode 已经处理完毕
    • newEnd >= j,说明新 VNode 尚未处理完,证明这些尚未处理完的新 Node 是新增的,只需创建并添加到P1之前即可

    image-20230627135552658

此次 Diff 过程中,完全复用了 P1 – P3 这三个节点,只新建了 P4 节点。

7. 拓展:key 值更新策略(在中间新增)

  1. 初始状态下,定义三个变量,分别指向头部和两个尾部节点:

    image-20230627141345513

  2. 先从头部进行 Diff 比较,如果 key 值相同,则进行 patch 更新:

    image-20230627141435718

  3. 第3次Diff时 j=2,此时新 VNode 是P4,旧 VNode 是P3,key 值不同,则从尾部开始 Diff:

    image-20230627141606048

  4. 第4次Diff时,newEnd=2oldEnd=1,同时 j=2。经过分析我们发现:

    • oldEnd < j,说明旧 VNode 已经处理完毕
    • newEnd >= j,说明新 VNode 尚未处理完,证明这些尚未处理完的新 Node 是新增的,只需创建并添加到P3之前即可

    image-20230627141750761

此次 Diff 过程中,完全复用了 P1-P3 节点,只新建了一个 P4 节点。

8. 拓展:key 值更新策略(删除元素)

  1. 初始状态下,定义三个变量,分别指向头部和两个尾部节点:

    image-20230627142838470

  2. 先从头部进行比较,如果 key 值相同,则进行 patch 更新:

    image-20230627142934406

  3. 第3次Diff时 j=2,此时新 VNode 是P4,旧 VNode 是P3,key 值不同,则从尾部开始 Diff:

    image-20230627143034292

  4. 第4次Diff时,newEnd=1oldEnd=2,同时 j=2。经过分析我们发现:

    • newEnd < j,说明新 VNode 已经处理完毕
    • oldEnd >= j,说明旧 VNode 尚未处理完,证明这些尚未处理完的旧 Node 是多余的,只需删除它们即可

    image-20230627143225964

此次 Diff 过程中,完全复用了 P1,P2,P4 这三个节点,只删除了一个 P3 节点。

9. 拓展:key 值更新策略(移动元素)

在理想状态下,Diff 算法只需要简单的挂载或卸载节点即可,但在复杂情况下,简单的挂载和卸载节点可能无法处理到所有的节点,例如:

  1. 初始状态如下:

    image-20230627164509899

  2. 进行先头后尾的 Diff 过程:

    image-20230627164552253

  3. 当头和尾都处理完成后,我们发现还剩下了很多中间的节点没有被处理到:

    image-20230627164723129

为了进一步处理中间这些剩余的节点,我们需要了解一下 Diff 算法中最核心最重要的内容:

  1. 为新 VNode 中未处理的元素建立索引表

    image-20230627165245435

  2. 循环旧 VNode,判断是否需要进行移动

    image-20230627165347428

    1. 创建 source 数组,用来记录新 VNode 在旧 VNode 中对应的真实的索引位置

      image-20230627165522202

  3. 循环旧 VNode,填充 source 数组

    image-20230627165618872

  4. 根据 source 数组得到最长递增子序列的索引数组

    image-20230627165725157

  5. 倒序循环每一个新 VNode 节点,根据不同的条件对 DOM 进行创建或移动

    image-20230627165946000

版权归作者 ©刘龙宾 所有,本文章未经作者允许,禁止私自转载!

一个自由の前端程序员

一条评论

留言

您的电子邮箱地址不会被公开。 必填项已用 * 标注