理解 Vue 组件

我们在第 2 章 “Nuxt 入门” 中简要介绍了 /components/ 目录,但我们还没有实际操作过它。我们目前只知道,如果你使用 Nuxt 脚手架工具安装 Nuxt 项目,那么这个目录下会有一个 Logo.vue 组件。这个目录下的所有组件都是 Vue 组件,就像 /pages/ 目录下的页面组件一样。主要的区别在于,/components/ 目录下的这些组件不支持 asyncData 方法。让我们以 /chapter-4/nuxt-universal/sample-website/ 中的 copyright.vue 组件为例:

// components/copyright.vue
<template>
  <p v-html="copyright"></p>
</template>

<script>
export default {
  data () {
    return { copyright: '&copy; Lau Tiam Kok' }
  }
}
</script>

让我们尝试将前面代码中的 data 函数替换为 asyncData 函数,如下所示:

// components/copyright.vue
export default {
  asyncData () {
    return { copyright: '&copy; Lau Tiam Kok' }
  }
}

你会在浏览器的控制台中收到一个警告错误,提示 Property or method "copyright" is not defined…​。那么,我们如何动态地获取版权数据呢?我们可以直接在组件中使用 fetch 方法通过 HTTP 客户端(例如,axios)请求数据,如下所示:

  1. 在项目目录下通过 npm 安装 axios 包:

    $ npm i axios
  2. 导入 axios 并在 fetch 方法中请求数据,如下所示:

    // components/copyright.vue
    import axios from 'axios'
    
    export default {
      data () {
        return { copyright: null }
      },
      fetch () {
        const { data } = axios.get('http/path/to/site-info.json')
        this.copyright = data.copyright
      }
    }

这种方法可以正常工作,但使用 HTTP 请求来获取一小段数据并不理想,这些数据最好一次性请求,然后将数据片段从父作用域传递给其子组件,如下所示:

// components/copyright.vue
export default {
  props: ['copyright']
}

在上面的代码片段中,子组件是 /components/ 目录下的 copyright.vue 文件。这个解决方案的魔力在于仅仅使用了组件中的 props 属性。它更简单、更整洁,因此是一个优雅的解决方案!但是,如果我们想了解它是如何工作的以及如何使用它,我们需要理解 Vue 的组件系统。

什么是组件?

组件是具有自定义名称的、独立且可复用的 Vue 实例。

我们通过 Vuecomponent 方法来定义组件。例如,若要定义一个名为 post-item 的组件,可以这样实现:

Vue.component('post-item', {
  data() {
    return {
      text: 'Hello World!'
    }
  },
  template: '<p>{{ text }}</p>'
})

完成此操作后,我们就可以在 HTML 文档中使用 <post-item> 组件了,前提是已通过 new 语句创建了根 Vue 实例,如下所示:

<div id="post">
  <post-item></post-item>
</div>

<script type="text/javascript">
  Vue.component('post-item', {
    ...
  })

  new Vue({
    el: '#post'
  })
</script>

所有组件本质上都是 Vue 实例。这意味着它们拥有与 new Vue 相同的选项(如 datacomputedwatchmethods 等),除了一些根实例特有的选项(如 el)。此外,组件可以嵌套在其他组件中,最终形成树状组件结构。然而,当这种情况发生时,在组件之间传递数据会变得棘手。因此,在这种情况下,直接在特定组件中使用 fetch 方法获取数据可能更为合适。或者,您也可以使用 Vuex 存储(我们将在第 10 章《添加 Vuex 存储》中详细介绍)。

不过,我们暂时先将深度嵌套的组件放在一边,本章将重点讨论简单的父子组件,并学习如何在它们之间传递数据。数据既可以从父组件传递给子组件,也可以从子组件传递给父组件。但具体该如何实现呢?首先,让我们了解如何将数据从父组件传递给子组件。

使用 props 向子组件传递数据

让我们从创建一个名为 user-item 的子组件开始,构建一个简单的 Vue 应用:

Vue.component('user-item', {
  template: '<li>John Doe</li>'
})

可以看到,这只是一个静态组件,功能有限 - 你无法对其进行抽象或复用。只有当我们可以通过 template 属性动态传递数据时,组件才真正变得可复用。这可以通过 props 属性来实现。让我们重构这个组件:

Vue.component('user-item', {
  props: ['name'],
  template: '<li>{{ name }}</li>'
})

从某种意义上说,props 的行为类似于变量,我们可以通过 v-bind 指令为其设置数据,如下所示:

<ol>
  <user-item
    v-for="user in users"
    v-bind:name="user.name"
    v-bind:key="user.id"
  ></user-item>
</ol>

在这个重构后的组件中,我们使用 v-bind 指令将 item.name 绑定到 name 属性(如 v-bind:name)。组件内部的 props 必须声明接受 name 作为属性。然而在更复杂的应用中,我们可能需要传递更多数据,为每个数据单独编写 prop 会适得其反。因此,让我们重构 <user-item> 组件,使其接受一个名为 userprop

<ol>
  <user-item
    v-for="user in users"
    v-bind:user="user"
    v-bind:key="user.id"
  ></user-item>
</ol>

现在我们需要再次重构组件代码:

Vue.component('user-item', {
  props: ['user'],
  template: '<li>{{ user.name }}</li>'
})

让我们将这些代码整合到单个 HTML 页面中,以便您能更全面地理解:

  1. <head> 区块添加以下 CDN 链接:

    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
  2. <body> 区块创建以下标记:

    <div id="app">
      <ol>
        <user-item
          v-for="user in users"
          v-bind:user="user"
          v-bind:key="user.id"
        ></user-item>
      </ol>
    </div>
  3. <script> 区块添加以下代码:

Vue.component('user-item', {
  props: ['user'],
  template: '<li>{{ user.name }}</li>'
})

new Vue({
  el: '#app',
  data: {
    users: [
      { id: 0, name: 'John Doe' },
      { id: 1, name: 'Jane Doe' },
      { id: 2, name: 'Mary Moe' }
    ]
  }
})

在这个示例中,我们将应用拆分为更小的单元:子组件和父组件。它们通过 props 属性进行绑定,现在我们可以进一步优化它们,而不用担心彼此干扰。

您可以在本书 GitHub 仓库的 /chapter-5/vue/component/basic.html 中找到这个示例代码。

然而在实际的复杂应用中,我们应该将这个应用拆分为更易管理的独立文件(单文件组件)。我们将在 "创建单文件 Vue 组件" 章节中展示如何创建它们。但现在,让我们探索如何将数据从子组件传递到父组件。

监听子组件事件

目前,您已经了解了如何通过 props 属性将数据从父组件向下传递到子组件。但如何将数据从子组件向上传递到父组件呢?我们可以通过 $emit 方法和自定义事件实现,如下所示:

$emit(<event>)

您可以为子组件中广播的自定义事件指定任意名称。然后,父组件可以通过 v-on 指令监听这个广播事件,并决定后续操作,格式如下:

v-on:<event>="<event-handler>"

例如,如果您触发了一个名为 done 的自定义事件,父组件将通过 v-on:done 监听该事件,并关联一个事件处理器(如 v-on:done=handleDone)。这个事件处理器可以是普通的 JavaScript 函数。让我们通过一个简单应用来演示:

  1. 创建应用标记:

    <div id="todos">
      <todo-item v-on:completed="handleCompleted"></todo-item>
    </div>
  2. 创建子组件:

    Vue.component('todo-item', {
      template: '<button v-on:click="clicked">Task completed</button>',
      methods: {
        clicked() {
          this.$emit('completed')
        }
      }
    })
  3. 创建作为父组件的 Vue 根实例:

    new Vue({
      el: '#todos',
      methods: {
        handleCompleted() {
          alert('Task Done')
        }
      }
    })

在这个示例中,当子组件的 clicked 方法被触发时,会发射一个 completed 事件。父组件通过 v-on 接收该事件,并触发自身的 handleCompleted 方法。

您可以在本书 GitHub 仓库的 /chapter-5/vue/component/emit/emit-basic.html 中找到这个示例。

使用事件传递值

然而,有时仅触发事件是不够的。在某些情况下,携带值触发事件会更加实用。我们可以通过 $emit 方法的第二个参数实现:

$emit(<event>, <value>)

当父组件监听该事件时,可以通过 $event 访问子组件传递的值,格式如下:

v-on:<event>="<event-handler> = $event"

若事件处理器是方法,该值将作为方法的第一个参数:

methods: {
  handleCompleted (<value>) { ... }
}

现在我们可以轻松改造之前的应用:

// 子组件
clicked() {
  this.$emit('completed', 'Task done')
}

// 父组件
methods: {
  handleCompleted(value) {
    alert(value)
  }
}

在这里,你可以看到在父组件和子组件之间向下或向上传递数据既有趣又容易。但是,如果你的子组件中有一个 <input> 元素,你如何将输入字段中的值以双向数据绑定的方式传递给父组件呢?如果你理解 Vue 中双向数据绑定 “底层” 的运作方式,这其实并不难。我们将在下一节学习相关知识。

你可以在本书 GitHub 仓库的 /chapter-5/vue/component/emit/value.html 中找到一个简单的范例,更复杂的范例则在 /chapter-5/vue/component/emit/emit-valuewith-props.html 中。

使用 v-model 创建自定义输入组件

我们同样可以通过组件创建自定义的双向绑定输入框,其工作原理与 v-model 指令触发事件到父组件的方式相同。让我们创建一个基础的自定义输入组件:

<custom-input v-model="newTodoText"></custom-input>
Vue.component('custom-input', {
  props: ['value'],
  template: `<input v-on:input="$emit('input', $event.target.value)">`,
})

这是如何实现的呢?要理解这个机制,我们需要剖析 v-model 的底层原理。以一个简单的 v-model 输入框为例:

<input v-model="handler">

上述代码实际上是以下写法的语法糖:

<input
  v-bind:value="handler"
  v-on:input="handler = $event.target.value"
>

因此,在我们的自定义输入组件中使用 v-model="newTodoText" 等价于:

v-bind:value="newTodoText"
v-on:input="newTodoText = $event.target.value"

这意味着组件底层必须满足两个条件:

  1. props 中包含 value 属性以接收父级数据

  2. 通过 $emit('input', $event.target.value) 向上传递数据

在这个示例中:

  • 当用户在子组件 custom-input 中输入时触发值更新

  • 父组件通过 v-model="newTodoText" 监听变化

  • data 对象中的 newTodoText 实时更新:

<p>{{ newTodoText }}</p>
new Vue({
  el: '#todos',
  data: {
    newTodoText: null
  }
})

当您理解了 Vue 双向数据绑定的核心机制——v-model 指令的工作原理后,这一切就变得顺理成章了。但对于复选框和单选按钮,如果您不希望使用默认值传递,而需要向父组件发送自定义值时该怎么办?我们将在下一节探讨这个场景。

您可以在本书 GitHub 仓库查看两个典型示例:

  • 基础实现:/chapter-5/vue/component/custom-inputs/basic.html

  • 进阶案例(含 props 处理):/chapter-5/vue/component/custom-inputs/props.html

在自定义输入组件中自定义 model

在自定义输入组件中,默认情况下模型(model)会使用 value 作为属性(prop),以 input 作为事件。以前文的 custom-input 组件为例,其默认配置可显式声明如下:

Vue.component('custom-input', {
  props: {
    value: null
  },
  model: {
    prop: 'value',  // <-- 默认值
    event: 'input'  // <-- 默认值
  }
})

虽然此例中无需显式声明 propevent(因为这是默认行为),但当我们处理复选框(checkbox)或单选按钮(radio)等特殊输入类型时,这种配置方式就变得尤为重要——此时我们可能需要将 value 属性用于其他用途。

例如,在提交数据时,我们可能希望通过复选框同时向服务器发送特定值和名称:

Vue.component('custom-checkbox', {
  model: {
    prop: 'checked',  // 覆盖默认的 value prop
    event: 'change'   // 覆盖默认的 input 事件
  },
  props: {
    checked: Boolean
  },
  template: `
    <input
      type="checkbox"
      v-bind:checked="checked"
      v-on:change="changed"
      name="subscribe"
      value="newsletter"
    >
  `,
  methods: {
    changed($event) {
      this.$emit('change', $event.target.checked)
    }
  }
})

在这个范例中,我们想要将这两段数据发送到服务器:

name="subscribe"
value="newsletter"

我们也可以在使用 JSON.stringify 执行序列化之后,以 JSON 格式来完成这个操作,通过此配置,服务器将接收到结构化数据:

[{
  "name": "subscribe",
  "value": "newsletter"
}]

若未自定义模型配置(即不覆盖默认行为),则只能发送默认格式数据:

[{
  "name": "subscribe",
  "value": "on"  // 浏览器默认值
}]

您可以在本书 GitHub 仓库查看完整示例: /chapter-5/vue/component/custom-inputs/checkbox.html

当理解 Vue 组件的底层机制后,这种定制化操作就变得顺理成章。/components/ 目录中的 Vue 组件运作原理与您刚学到的完全一致。不过在编写 Nuxt 应用组件前,还需要理解使用 v-for 指令时 key 属性的重要性——这将是接下来的重点。

理解 v-for 循环中的 key 属性

在本书之前的许多示例和练习中,您可能已经注意到所有 v-for 循环中都包含 key 属性,例如:

<ol>
  <user-item
    v-for="user in users"
    v-bind:user="user"
    v-bind:key="user.id"
  ></user-item>
</ol>

这个 key 属性究竟有何作用?它实际上是每个 DOM 节点的唯一标识符,让 Vue 能够精准追踪节点变化,从而高效复用和重新排序现有元素。虽然 Vue 默认会使用数组索引(index)来跟踪 v-for 渲染的节点,但像下面这样显式使用索引作为 key 是冗余的:

<div v-for="(user, index) in users" :key="index">
  <!-- ... -->
</div>

若要确保 Vue 能准确追踪每个项目的身份标识,必须通过 v-bind 指令为 key 绑定唯一值(推荐使用简写语法):

<div v-for="user in users" :key="user.id">
  <!-- ... -->
</div>

需特别注意:keyVue 的保留属性,不能作为组件 prop 使用。以下写法会导致控制台报错:

Vue.component('user-item', {
  props: ['key', 'user'] // 错误!key 是保留属性
})

当在组件中使用 v-for 时,key 属性是必须的。因此,最佳实践是尽可能显式地使用 key,无论是否在组件中使用 v-for

为了演示这个问题,我们将创建一个 Vue 应用,其中使用索引作为 key,并借助 jQuery 来辅助说明:

  1. <head> 区块引入必要的 CDN 链接和 CSS 样式:

    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script src="http://code.jquery.com/jquery-3.3.1.js"></script>
    <style type="text/css">
      .removed {
        text-decoration: line-through;
      }
      .removed button {
        display: none;
      }
    </style>
  2. <body> 区块创建应用 HTML 结构:

    <div id="todo-list-example">
      <form v-on:submit.prevent="addNewTodo">
        <label for="new-todo">添加待办事项</label>
        <input
          v-model="newTodoText"
          id="new-todo"
          placeholder="E.g. Feed the cat"
        >
        <button>Add</button>
      </form>
      <ul>
        <todo-item
          v-for="(todo, index) in todos"
          v-bind:key="index"
          v-bind:title="todo.title"
        ></todo-item>
      </ul>
    </div>
  3. <script> 区块定义组件:

    Vue.component('todo-item', {
      template: `<li>{{ title }} <button v-on:click="remove($event)">Remove</button></li>`,
      props: ['title'],
      methods: {
        remove: function ($event) {
          $($event.target).parent().addClass('removed')
        }
      }
    })
  4. 创建待办事项列表:

    new Vue({
      el: '#todo-list-example',
      data: {
        newTodoText: '',
        todos: [
          { id: 1, title: 'Do the dishes' },
          //...
        ],
        nextTodoId: 4
      },
      methods: {
        addNewTodo: function () {
          this.todos.unshift({
            id: this.nextTodoId++,
            title: this.newTodoText
          })
          this.newTodoText = ''
        }
      }
    })

    在这个例子中,由于我们的 todos 数组进行了一个 unshift 操作,我们将一个新的待办事项添加到了列表的顶部。我们通过给 li 元素添加一个 removed 的类名来移除一个待办事项。然后,我们使用 CSS 给被移除的待办事项添加删除线,并隐藏 “移除” 按钮。

  5. 删除 Do the dishes 事项后,您会看到:

    Do the dishes(带删除线)
  6. 添加新事项 Feed the cat 后,错误显现:

    Feed the cat(带删除线)

这是因为 Feed the cat 继承了原 Do the dishes 的索引 0Vue 直接复用了 DOM 元素而非重新渲染。本质上,Vue 会根据数组索引来更新 DOM,导致非预期结果。

您可以在本书 GitHub 仓库查看此案例: /chapter-5/vue/component/key/using-index.html 对比使用 id 作为 key 的正确版本: /chapter-5/vue/component/key/using-id.html

使用索引作为 key 的问题也可以通过以下伪代码来解释,其中生成了一个数字列表,并将索引设置为每个数字的 key

// 初始状态
let numbers = [1,2,3]
<div v-for="(number, index) in numbers" :key="index">
1 - 0  // 数字-索引对应关系
2 - 1
3 - 2
</div>

// 添加数字4后
<div v-for="(number, index) in numbers" :key="index">
4 - 0  // 索引重新分配
1 - 1  // 原有元素状态丢失!
2 - 2
3 - 3
</div>

通过这个例子可以看到,数字 1、2、3 都丢失了原有状态并需要重新渲染。这正是为什么在这种情况下必须使用唯一 key 的原因——每个项目都应该保持其索引标识,而不是在每次变更时重新分配。

正确的做法应该是:

<user-item
  v-for="(user, index) in users"
  v-bind:key="user.id"
  v-bind:name="user.name"
></user-item>

经验法则:当对列表进行以下操作导致索引变化时,务必使用 key 保证 Vue 能正确更新 DOM

  • 在数组非末尾位置添加元素

  • 从数组非末尾位置删除元素

  • 对数组进行任意重新排序

例外情况:

  • 当组件生命周期内列表永不改变时

  • 仅使用 push() 在数组末尾追加元素时 在这些特殊情况下可以使用索引作为 key。但若您试图区分何时需要 key、何时不需要,最终很可能会因误解 Vue 的运作机制而产生 "bug"。

黄金准则:当不确定是否该使用索引作为 key 时,优先在 v-for 循环中使用不可变的 ID 作为 key 属性。值得注意的是,key 属性配合唯一值的用法不仅对 v-for 指令至关重要,在 HTML 表单的 <input> 元素中同样关键——我们将在下一节深入探讨这个问题。

如果你的列表在组件的生命周期内永远不会改变,或者你只像之前的例子那样使用 push 函数追加项目而不是 unshift 函数,那么使用索引作为 key 是可以的。但是,如果你试图跟踪哪些地方需要 key 而哪些地方不需要,你最终会遇到 “bug”,因为你可能会误解 Vue 的行为。

如果你不确定是否应该使用索引作为 key,那么在 v-for 循环中最好使用带有不可变 IDkey 属性。使用带有唯一值的 key 属性不仅在 v-for 指令中很重要,在 HTML 表单的 <input> 元素中也很重要。我们将在下一节中讨论这个问题。

使用 key 属性控制可复用元素

为了提升性能,我们发现 Vue 总是倾向于复用 DOM 节点而非重新渲染,但这可能导致一些不符合预期的效果(如前一节所示)。下面这个不涉及 v-for 的例子,同样能说明 key 属性的重要性:

<div id="app">
  <template v-if="type === 'fruits'">
    <label>Fruits</label>
    <input />
  </template>
  <template v-else>
    <label>Vegetables</label>
    <input />
  </template>
  <button v-on:click="toggleType">Toggle Type</button>
</div>

<script type="text/javascript">
new Vue({
  el: '#app',
  data: { type: 'fruits' },
  methods: {
    toggleType: function () {
      return this.type = this.type === 'fruits' ? 'vegetables' : 'fruits'
    }
  }
})
</script>

在这个示例中,如果您在水果输入框输入内容后切换类型,会发现蔬菜输入框仍保留着刚才的输入值。这是因为 Vue 为了追求最佳性能,默认复用了相同的 <input> 元素。通过为每个 <input> 添加带唯一值的 key 属性即可解决:

<template v-if="type === 'fruits'">
  <label>Fruits</label>
  <input key="fruits-input"/>
</template>
<template v-else>
  <label>Vegetables</label>
  <input key="vegetables-input"/>
</template>

刷新页面后测试,切换时输入框将不再复用(注意 <label> 元素未添加 key 属性,但视觉上不影响使用)。

您可以在本书 GitHub 仓库的 /chapter-5/vue/component/key/ 目录下查看这两个对比示例:

  • 无 key 版本:toggle-without-key.html

  • 带 key 版本:toggle-with-key.html

至此您已掌握 Vue 组件的基础特性,接下来我们将进入单文件组件开发的进阶阶段。

若需深入了解组件插槽等高级特性,请参阅 Vue 官方文档: https://vuejs.org/v2/guide/components.html