{T}

概述

组件是 Vue.js 最核心的功能之一,它提供了一种抽象,允许我们使用小型、独立和通常可复用的组件构建大型应用。组件可以扩展 HTML 元素,封装可重用的代码。在较高层面上,组件是自定义元素,Vue.js 的编译器为它添加特殊功能。

💡 组件精讲补充:一个再复杂的组件,都是由三部分组成的:prop、event、slot,它们构成了 Vue.js 组件的 API。如果你开发的是一个通用组件,一定要事先设计好这三部分,因为组件一旦发布,后面再修改 API 就很困难了。如果你阅读别人写的组件,也可以从这三个部分展开,它们可以帮助你快速了解一个组件的所有功能。

组件与 Vue 实例的关系

组件是可复用的 Vue 实例,它们与 new Vue 接收相同的选项,例如 datacomputedwatchmethods 以及生命周期钩子等。仅有的例外是像 el 这样根实例特有的选项。

javascript
// 定义一个名为 button-counter 的新组件
Vue.component('button-counter', {
  data: function () {
    return {
      count: 0
    }
  },
  template: '<button v-on:click="count += 1">点击了 {{ count }} 次</button>'
})

组件基础

组件的定义与使用

基本示例

html
<!DOCTYPE html>
<html>
  <head>
    <script src="https://unpkg.com/vue@2"></script>
  </head>
  <body>
    <div id="components-demo">
      <button-counter></button-counter>
    </div>

    <script>
      Vue.component('button-counter', {
        data: function () {
          return {
            count: 0
          }
        },
        template: '<button v-on:click="count += 1">点击了 {{ count }} 次</button>'
      })

      new Vue({ el: '#components-demo' })
    </script>
  </body>
</html>

组件的复用

组件可以进行任意次数的复用,每使用一次组件,就会有一个新的实例被创建。

html
<div id="components-demo">
  <button-counter></button-counter>
  <button-counter></button-counter>
  <button-counter></button-counter>
</div>

注意:当点击按钮时,每个组件都会各自独立维护它的 count。因为每用一次组件,就会有一个新的实例被创建。

data 必须是一个函数

组件的 data 选项必须是一个函数,因此每个实例可以维护一份被返回对象的独立拷贝:

javascript
// 正确 ✓
Vue.component('button-counter', {
  data: function () {
    return {
      count: 0
    }
  }
})

// 错误 ✗ - 如果 data 是一个对象,所有实例将共享同一个数据对象
Vue.component('button-counter', {
  data: {
    count: 0  // 这会导致所有组件实例共享同一个 count
  }
})

单个根元素

每个组件必须只有一个根元素。当模板包含多个元素时,必须用一个父元素包裹它们:

html
<!-- 错误 ✗ -->
<h3>{{ title }}</h3>
<div v-html="content"></div>

<!-- 正确 ✓ -->
<div class="blog-post">
  <h3>{{ title }}</h3>
  <div v-html="content"></div>
</div>

组件注册

在注册一个组件的时候,需要给它一个名字。定义组件名的方式有两种:

组件命名规范

kebab-case (短横线分隔命名)

javascript
Vue.component('my-component-name', { /* ... */ })

当使用 kebab-case 定义一个组件时,在引用自定义元素时也要使用 kebab-case,例如 <my-component-name>

PascalCase (首字母大写命名)

javascript
Vue.component('MyComponentName', { /* ... */ })

使用 PascalCase 定义一个组件时,在引用这个自定义元素时两种命名法都可以使用。<my-component-name><MyComponentName> 都是可接受的。

注意:直接在 DOM (即非字符串的模板) 中使用时只有 kebab-case 是有效的。

全局注册

全局注册的组件可以用在任何新创建的 Vue 根实例 (new Vue) 的模板中,也可以在其所有子组件中使用。

javascript
Vue.component('component-a', { /* ... */ })
Vue.component('component-b', { /* ... */ })
Vue.component('component-c', { /* ... */ })

new Vue({ el: '#app' })
html
<div id="app">
  <component-a></component-a>
  <component-b></component-b>
  <component-c></component-c>
</div>

注意:全局注册的所有组件即便不再使用,它仍然会被包含在最终的构建结果中,这可能造成用户下载的 JavaScript 的无谓增加。

局部注册

局部注册可以避免全局注册带来的问题。可以通过一个普通的 JavaScript 对象来定义组件:

javascript
var ComponentA = { /* ... */ }
var ComponentB = { /* ... */ }
var ComponentC = { /* ... */ }

new Vue({
  el: '#app',
  components: {
    'component-a': ComponentA,
    'component-b': ComponentB
  }
})

注意:局部注册的组件在其子组件中不可用。如果希望 ComponentAComponentB 中可用,需要这样写:

javascript
var ComponentA = { /* ... */ }

var ComponentB = {
  components: {
    'component-a': ComponentA
  },
  // ...
}

ES2015+ 模块系统

在 ES2015+ 中,在对象中放一个类似 ComponentA 的变量名其实是 ComponentA: ComponentA 的缩写:

javascript
import ComponentA from './ComponentA.vue'

export default {
  components: {
    ComponentA  // 等价于 ComponentA: ComponentA
  },
  // ...
}

模块系统中的组件注册

推荐创建一个 components 目录,并将每个组件放置在其各自的文件中,然后在局部注册之前导入每个想使用的组件:

javascript
import ComponentA from './ComponentA'
import ComponentC from './ComponentC'

export default {
  components: {
    ComponentA,
    ComponentC
  },
  // ...
}

基础组件的自动化全局注册

如果使用 webpack (或在内部使用了 webpack 的 Vue CLI 3+),可以使用 require.context 只全局注册这些非常通用的基础组件:

javascript
import Vue from 'vue'
import upperFirst from 'lodash/upperFirst'
import camelCase from 'lodash/camelCase'

const requireComponent = require.context(
  './components',
  false,
  /Base[A-Z]\w+\.(vue|js)$/
)

requireComponent.keys().forEach(fileName => {
  const componentConfig = requireComponent(fileName)

  const componentName = upperFirst(
    camelCase(
      fileName.split('/').pop().replace(/\.\w+$/, '')
    )
  )

  Vue.component(
    componentName,
    componentConfig.default || componentConfig
  )
})

注意:全局注册的行为必须在根 Vue 实例创建之前发生。

组件选项

数据选项

data

类型Object | Function
限制:组件的定义只接受 function

Vue 实例的数据对象。Vue 会递归地把 data 的 property 转换为 getter/setter,从而让 data 的 property 能够响应数据变化。

javascript
// 直接创建一个实例
var data = { a: 1 }
var vm = new Vue({
  data: data
})
vm.a // => 1
vm.$data === data // => true

// Vue.extend() 中 data 必须是函数
var Component = Vue.extend({
  data: function () {
    return { a: 1 }
  }
})

注意

  • _$ 开头的 property 不会被 Vue 实例代理,因为可能和 Vue 内置的 property、API 方法冲突
  • 如果为 data property 使用了箭头函数,则 this 不会指向组件实例
javascript
// 使用箭头函数时,可以通过第一个参数访问实例
data: vm => ({ a: vm.myProp })

props

类型Array<string> | Object

props 用于接收来自父组件的数据,可以是简单的数组,或者使用对象配置高级选项。

javascript
// 简单语法
Vue.component('props-demo-simple', {
  props: ['size', 'myMessage']
})

// 对象语法,提供验证
Vue.component('props-demo-advanced', {
  props: {
    // 检测类型
    height: Number,
    // 检测类型 + 其他验证
    age: {
      type: Number,
      default: 0,
      required: true,
      validator: function (value) {
        return value >= 0
      }
    }
  }
})

验证选项

  • type:可以是下列原生构造函数中的一种:StringNumberBooleanArrayObjectDateFunctionSymbol、任何自定义构造函数、或上述内容组成的数组
  • default:为该 prop 指定默认值。对象或数组的默认值必须从一个工厂函数返回
  • required:定义该 prop 是否是必填项
  • validator:自定义验证函数

computed

类型{ [key: string]: Function | { get: Function, set: Function } }

计算属性将被混入到 Vue 实例中。所有 getter 和 setter 的 this 上下文自动地绑定为 Vue 实例。

javascript
var vm = new Vue({
  data: { a: 1 },
  computed: {
    // 仅读取
    aDouble: function () {
      return this.a * 2
    },
    // 读取和设置
    aPlus: {
      get: function () {
        return this.a + 1
      },
      set: function (v) {
        this.a = v - 1
      }
    }
  }
})

vm.aPlus   // => 2
vm.aPlus = 3
vm.a       // => 2
vm.aDouble // => 4

计算属性的结果会被缓存,除非依赖的响应式 property 变化才会重新计算。

注意:如果为一个计算属性使用了箭头函数,则 this 不会指向组件实例:

javascript
computed: {
  aDouble: vm => vm.a * 2
}

methods

类型{ [key: string]: Function }

methods 将被混入到 Vue 实例中,方法中的 this 自动绑定为 Vue 实例。

javascript
var vm = new Vue({
  data: { a: 1 },
  methods: {
    plus: function () {
      this.a++
    }
  }
})
vm.plus()
vm.a // 2

注意:不应该使用箭头函数来定义 method 函数,因为箭头函数绑定了父级作用域的上下文,this 将不会指向 Vue 实例。

watch

类型{ [key: string]: string | Function | Object | Array }

一个对象,键是需要观察的表达式,值是对应回调函数。

javascript
var vm = new Vue({
  data: {
    a: 1,
    b: 2,
    c: 3,
    d: 4,
    e: {
      f: {
        g: 5
      }
    }
  },
  watch: {
    a: function (val, oldVal) {
      console.log('new: %s, old: %s', val, oldVal)
    },
    // 方法名
    b: 'someMethod',
    // 深度监听
    c: {
      handler: function (val, oldVal) { /* ... */ },
      deep: true
    },
    // 立即执行
    d: {
      handler: 'someMethod',
      immediate: true
    },
    // 回调数组
    e: [
      'handle1',
      function handle2 (val, oldVal) { /* ... */ },
      {
        handler: function handle3 (val, oldVal) { /* ... */ },
      }
    ],
    // 监听嵌套属性
    'e.f': function (val, oldVal) { /* ... */ }
  }
})

watch 选项

  • handler:回调函数
  • deep:深度监听,当监听对象内部值的变化时使用
  • immediate:立即执行回调,在侦听开始之后被立即调用

DOM 选项

el

类型string | Element
限制:只在用 new 创建实例时生效

提供一个在页面上已存在的 DOM 元素作为 Vue 实例的挂载目标。

javascript
new Vue({
  el: '#app'
})

// 或
new Vue({}).$mount('#app')

template

类型string

一个字符串模板作为 Vue 实例的标识使用。模板将会替换挂载的元素。

javascript
Vue.component('my-component', {
  template: '<div>自定义组件</div>'
})

// 使用 script 标签
<script type="x-template" id="my-template">
  <div>自定义组件</div>
</script>

Vue.component('my-component', {
  template: '#my-template'
})

render

类型(createElement: () => VNode) => VNode

字符串模板的代替方案,允许你发挥 JavaScript 最大的编程能力。

javascript
Vue.component('my-component', {
  render: function (createElement) {
    return createElement('div', '自定义组件')
  }
})

资源选项

directives

类型Object

包含 Vue 实例可用指令的哈希表。

javascript
new Vue({
  directives: {
    focus: {
      inserted: function (el) {
        el.focus()
      }
    }
  }
})

filters

类型Object

包含 Vue 实例可用过滤器的哈希表。

javascript
new Vue({
  filters: {
    capitalize: function (value) {
      if (!value) return ''
      value = value.toString()
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
  }
})

components

类型Object

包含 Vue 实例可用组件的哈希表。

javascript
new Vue({
  components: {
    'my-component': {
      template: '<div>自定义组件</div>'
    }
  }
})

组合选项

mixins

类型Array<Object>

mixins 选项接收一个混入对象的数组。这些混入对象可以像正常的实例对象一样包含实例选项。

javascript
var mixin = {
  created: function () {
    console.log('混入对象的钩子被调用')
  }
}

new Vue({
  created: function () {
    console.log('组件钩子被调用')
  },
  mixins: [mixin]
})

// => "混入对象的钩子被调用"
// => "组件钩子被调用"

混入规则

  • Mixin 钩子按照传入顺序依次调用,并在调用组件自身的钩子之前被调用
  • 值为对象的选项(如 methodscomponents 等)将被合并,键冲突时取组件对象的键值

extends

类型Object | Function

允许声明扩展另一个组件,而无需使用 Vue.extend

javascript
var CompA = { /* ... */ }

var CompB = {
  extends: CompA,
  // ...
}

provide / inject

类型

  • provideObject | () => Object
  • injectArray<string> | { [key: string]: string | Symbol | Object }

这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深。

javascript
// 父级组件提供
var Provider = {
  provide: {
    foo: 'bar'
  }
}

// 子组件注入
var Child = {
  inject: ['foo'],
  created () {
    console.log(this.foo) // => "bar"
  }
}

注意provideinject 绑定并不是可响应的,这是刻意为之的。然而,如果传入了一个可监听的对象,那么其对象的 property 还是可响应的。

javascript
// 使用注入的值作为 property 的默认值
const Child = {
  inject: ['foo'],
  props: {
    bar: {
      default () {
        return this.foo
      }
    }
  }
}

// 设置默认值使其变成可选项
const Child = {
  inject: {
    foo: { default: 'foo' }
  }
}

// 从不同名字的 property 注入
const Child = {
  inject: {
    foo: {
      from: 'bar',
      default: 'foo'
    }
  }
}

其他选项

name

类型string
限制:只有作为组件选项时起作用

允许组件模板递归地调用自身,也便于调试。

javascript
Vue.component('recursive-component', {
  name: 'recursive-component',
  template: `
    <div>
      <recursive-component v-if="hasChildren"></recursive-component>
    </div>
  `
})

model

类型{ prop?: string, event?: string }

允许一个自定义组件在使用 v-model 时定制 prop 和 event。

javascript
Vue.component('my-checkbox', {
  model: {
    prop: 'checked',
    event: 'change'
  },
  props: {
    value: String,
    checked: {
      type: Number,
      default: 0
    }
  }
})
html
<!-- 使用 v-model -->
<my-checkbox v-model="foo" value="some value"></my-checkbox>

<!-- 等价于 -->
<my-checkbox :checked="foo" @change="val => { foo = val }" value="some value"></my-checkbox>

inheritAttrs

类型boolean
默认值true

默认情况下父作用域的不被认作 props 的 attribute 绑定将会"回退"且作为普通的 HTML attribute 应用在子组件的根元素上。通过设置 inheritAttrsfalse,可以去掉这些默认行为。

javascript
Vue.component('base-input', {
  inheritAttrs: false,
  props: ['label', 'value'],
  template: `
    <label>
      {{ label }}
      <input
        v-bind="$attrs"
        v-bind:value="value"
        v-on:input="$emit('input', $event.target.value)"
      >
    </label>
  `
})

注意:这个选项不影响 classstyle 绑定。

自定义事件

自定义事件是子组件向父组件通信的核心机制。Vue 组件实例提供了 $emit/$on/$off/$once 四个事件方法,基于发布-订阅模式实现。

📖 详细文档

插槽

插槽(Slot)是 Vue 的内容分发 API,允许父组件向子组件注入模板内容。包括默认插槽、具名插槽、作用域插槽三种类型。

📖 详细文档

  • 完整插槽语法(v-slot、具名插槽、作用域插槽、动态插槽名、解构 prop)→ 插槽
  • 插槽的编译原理($slots vs $scopedSlots、render 函数中的 VNode 映射)→ 插槽 - 插槽原理与底层机制

动态组件

有时在不同组件之间进行动态切换是非常有用的,比如在一个多标签的界面里:

html
<!DOCTYPE html>
<html>
  <head>
    <title>Dynamic Components Example</title>
    <script src="https://unpkg.com/vue@2"></script>
    <style>
      .tab-button {
        padding: 6px 10px;
        border-top-left-radius: 3px;
        border-top-right-radius: 3px;
        border: 1px solid #ccc;
        cursor: pointer;
        background: #f0f0f0;
        margin-bottom: -1px;
        margin-right: -1px;
      }
      .tab-button:hover {
        background: #e0e0e0;
      }
      .tab-button.active {
        background: #e0e0e0;
      }
      .tab {
        border: 1px solid #ccc;
        padding: 10px;
      }
    </style>
  </head>
  <body>
    <div id="dynamic-component-demo">
      <button
        v-for="tab in tabs"
        v-bind:key="tab"
        v-bind:class="['tab-button', { active: currentTab === tab }]"
        v-on:click="currentTab = tab">
        {{ tab }}
      </button>

      <component v-bind:is="currentTabComponent" class="tab"></component>
    </div>

    <script>
      Vue.component("tab-home", {
        template: "<div>Home component</div>"
      })
      Vue.component("tab-posts", {
        template: "<div>Posts component</div>"
      })
      Vue.component("tab-archive", {
        template: "<div>Archive component</div>"
      })

      new Vue({
        el: "#dynamic-component-demo",
        data: {
          currentTab: "Home",
          tabs: ["Home", "Posts", "Archive"]
        },
        computed: {
          currentTabComponent: function () {
            return "tab-" + this.currentTab.toLowerCase()
          }
        }
      })
    </script>
  </body>
</html>

解析 DOM 模板时的注意事项

有些 HTML 元素,诸如 <ul><ol><table><select>,对于哪些元素可以出现在其内部是有严格限制的。

html
<!-- 错误 -->
<table>
  <blog-post-row></blog-post-row>
</table>

<!-- 正确 -->
<table>
  <tr is="blog-post-row"></tr>
</table>

注意:如果从以下来源使用模板的话,这条限制是不存在的:

  • 字符串 (例如:template: '...')
  • 单文件组件 (.vue)

最佳实践

组件设计原则

  1. 单一职责原则:每个组件应该只做一件事
  2. 可复用性:组件应该是独立的、可复用的
  3. 清晰的接口:通过 props 和 events 明确组件的输入输出

Props 最佳实践

  1. 定义清晰的类型验证
javascript
props: {
  status: {
    type: String,
    required: true,
    validator: value => ['active', 'inactive', 'pending'].includes(value)
  }
}
  1. 为对象和数组类型提供默认值
javascript
props: {
  items: {
    type: Array,
    default: () => []
  },
  config: {
    type: Object,
    default: () => ({})
  }
}
  1. 避免修改 Props
javascript
// 错误 ✗
props: ['initialValue'],
methods: {
  updateValue() {
    this.initialValue = newValue  // 不要直接修改
  }
}

// 正确 ✓
props: ['initialValue'],
data() {
  return {
    value: this.initialValue
  }
}

事件命名最佳实践

  1. 使用 kebab-case
javascript
this.$emit('update-user', user)  // ✓
this.$emit('updateUser', user)   // ✗
  1. 事件名称要清晰表达含义
javascript
this.$emit('user-selected', user)  // ✓
this.$emit('select', user)         // ✗ 不够明确

组件通信最佳实践

  1. 父子组件通信:使用 props 和 events
  2. 兄弟组件通信:使用事件总线或 Vuex
  3. 跨层级组件通信:使用 provide/inject 或 Vuex

性能优化

  1. 使用计算属性代替 methods
javascript
// ✓ 使用计算属性(有缓存)
computed: {
  filteredItems() {
    return this.items.filter(item => item.active)
  }
}

// ✗ 使用 methods(每次调用都重新计算)
methods: {
  getFilteredItems() {
    return this.items.filter(item => item.active)
  }
}
  1. 合理使用 v-if 和 v-show
  • v-if:条件很少改变时使用
  • v-show:需要频繁切换时使用
  1. 使用 key 优化列表渲染
html
<div v-for="item in items" :key="item.id">
  {{ item.name }}
</div>

常见问题

1. 为什么组件的 data 必须是函数?

如果 data 是一个对象,那么所有组件实例将共享同一个数据对象。使用函数可以确保每个组件实例都有自己独立的数据副本。

2. 如何在组件中使用 v-model?

需要:

  1. value 属性绑定到 prop 上
  2. input 事件被触发时,通过自定义的 input 事件抛出新值
javascript
Vue.component('custom-input', {
  props: ['value'],
  template: `
    <input
      v-bind:value="value"
      v-on:input="$emit('input', $event.target.value)"
    >
  `
})

3. 父组件如何监听子组件的事件?

使用 v-on 监听子组件实例的事件:

html
<child-component v-on:event-name="handler"></child-component>

4. 如何实现组件的双向绑定?

使用 .sync 修饰符或自定义 v-model:

html
<!-- 使用 .sync -->
<text-document v-bind:title.sync="doc.title"></text-document>

<!-- 自定义 v-model -->
<my-checkbox v-model="checked"></my-checkbox>

5. 如何访问子组件或父组件?

  • 访问父组件:this.$parent
  • 访问子组件:this.$children(不推荐)
  • 推荐使用 ref 访问子组件:
html
<child-component ref="child"></child-component>
javascript
this.$refs.child.someMethod()

6. 插槽内容访问不到子组件的数据怎么办?

使用作用域插槽:

html
<!-- 子组件 -->
<slot :user="user"></slot>

<!-- 父组件 -->
<child-component v-slot="{ user }">
  {{ user.name }}
</child-component>

7. 如何在组件上使用原生事件?

使用 .native 修饰符(已废弃,Vue 3 中移除),或者使用 $listeners

html
<!-- Vue 2 方式(已废弃) -->
<my-component @click.native="handleClick"></my-component>

<!-- 推荐方式 -->
<my-component @click="handleClick"></my-component>

在组件内部:

javascript
Vue.component('my-component', {
  template: `
    <div @click="$emit('click', $event)">
      <!-- content -->
    </div>
  `
})

8. 为什么 Props 的修改没有生效?

Props 是单向数据流,子组件不应该直接修改 props。如果需要修改,应该:

  1. 将 prop 作为本地数据的初始值
  2. 使用计算属性转换 prop
  3. 使用 .sync 或自定义事件通知父组件修改

9. 如何动态加载组件?

使用异步组件:

javascript
Vue.component('async-component', function (resolve) {
  setTimeout(() => {
    resolve({
      template: '<div>I am async!</div>'
    })
  }, 1000)
})

// 或者使用 import
Vue.component('async-component', () => import('./AsyncComponent.vue'))

10. 组件命名有什么规范?

  • 使用 kebab-case(短横线分隔):my-component
  • 使用 PascalCase(首字母大写):MyComponent
  • 避免 HTML 元素名称和 Vue 保留名称
  • 遵循语义化命名原则

总结

Vue 组件是构建 Vue 应用的核心概念,理解和掌握组件的使用对于开发高质量的 Vue 应用至关重要。本文档详细介绍了:

  1. 组件基础:组件的定义、复用、data 必须是函数等
  2. 组件注册:全局注册和局部注册的区别及使用场景
  3. 组件选项:data、props、computed、methods、watch 等选项详解
  4. Props:类型验证、单向数据流、非 Prop 属性等
  5. 自定义事件:事件命名、v-model、.sync 修饰符等
  6. 插槽:具名插槽、作用域插槽等
  7. 动态组件:组件切换和 DOM 模板解析注意事项
  8. 最佳实践:组件设计、性能优化等
  9. 常见问题:开发中的常见问题和解决方案

通过合理使用这些特性,可以构建出高质量、可维护、可复用的 Vue 组件。