组件的复用

组件最大的价值在于复用。一个组件可以创建多个实例,每个实例相互独立,互不干扰。理解组件复用的原理,是掌握 Vue 组件化的关键。

组件实例的独立性

每次使用组件,都会创建一个新的实例。这些实例各自维护自己的状态。

基本示例

<div id="app">
  <counter></counter>
  <counter></counter>
  <counter></counter>
</div>

<script>
Vue.component('counter', {
  data() {
    return {
      count: 0
    }
  },
  template: `
    <div>
      <button @click="count++">点击</button>
      <span>{{ count }}</span>
    </div>
  `
})

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

三个计数器各自独立,点击一个不会影响其他两个。

为什么能独立

因为 data 是函数,每次创建组件实例时,都会调用这个函数,返回一个新的数据对象:

// 第一次使用 <counter>
data() { return { count: 0 } }  // 返回对象 A

// 第二次使用 <counter>
data() { return { count: 0 } }  // 返回对象 B

// 第三次使用 <counter>
data() { return { count: 0 } }  // 返回对象 C

// A、B、C 是三个不同的对象

数据污染问题

如果 data 不是函数,所有实例会共享同一个数据对象。

错误示例

// ❌ 错误:data 是对象
var sharedData = {
  count: 0
}

Vue.component('counter', {
  data: sharedData,
  template: '<button @click="count++">{{ count }}</button>'
})

所有 <counter> 实例共享 sharedData,点击任意一个,其他都会变化。

Vue 的保护机制

Vue 会检测组件的 data 是否为函数:

Vue.component('my-component', {
  data: {
    message: 'Hello'
  }
})

// 控制台警告:
// [Vue warn]: The "data" option should be a function 
// that returns a per-instance value in component definitions.

Vue 2.x 会警告,但仍会运行

Vue 2.x 只是警告,不会阻止运行。Vue 3.x 会直接报错。

复用的正确姿势

使用 props 传递不同数据

<div id="app">
  <product-item 
    v-for="product in products"
    :key="product.id"
    :product="product"
  ></product-item>
</div>

<script>
Vue.component('product-item', {
  props: ['product'],
  template: `
    <div class="product">
      <h3>{{ product.name }}</h3>
      <p>价格:{{ product.price }}</p>
      <button @click="addToCart">加入购物车</button>
    </div>
  `,
  methods: {
    addToCart() {
      this.$emit('add', this.product)
    }
  }
})

new Vue({
  el: '#app',
  data: {
    products: [
      { id: 1, name: '商品A', price: 99 },
      { id: 2, name: '商品B', price: 199 },
      { id: 3, name: '商品C', price: 299 }
    ]
  }
})
</script>

每个 <product-item> 接收不同的 product,显示不同内容。

使用插槽定制内容

<div id="app">
  <alert-box>这是一条成功消息</alert-box>
  <alert-box>这是一条警告消息</alert-box>
  <alert-box>这是一条错误消息</alert-box>
</div>

<script>
Vue.component('alert-box', {
  template: `
    <div class="alert">
      <strong>提示:</strong>
      <slot></slot>
    </div>
  `
})
</script>

同一个组件,通过插槽展示不同内容。

使用事件与父组件通信

<div id="app">
  <p>总数:{{ total }}</p>
  <counter @increment="updateTotal"></counter>
  <counter @increment="updateTotal"></counter>
  <counter @increment="updateTotal"></counter>
</div>

<script>
Vue.component('counter', {
  data() {
    return {
      count: 0
    }
  },
  template: `
    <button @click="increment">{{ count }}</button>
  `,
  methods: {
    increment() {
      this.count++
      this.$emit('increment', this.count)
    }
  }
})

new Vue({
  el: '#app',
  data: {
    total: 0
  },
  methods: {
    updateTotal(count) {
      this.total++
    }
  }
})
</script>

组件通过 $emit 向父组件发送事件,父组件更新共享状态。

复用的边界情况

单例组件

有时需要组件全局共享状态,可以使用 Vue 实例作为事件总线:

// 创建事件总线
var bus = new Vue()

Vue.component('counter', {
  data() {
    return {
      count: 0
    }
  },
  template: '<button @click="increment">{{ count }}</button>',
  methods: {
    increment() {
      this.count++
      bus.$emit('count-changed', this.count)
    }
  },
  created() {
    bus.$on('reset', () => {
      this.count = 0
    })
  }
})

// 在任何地方触发重置
bus.$emit('reset')

使用 Vuex 管理共享状态

对于复杂应用,推荐使用 Vuex:

// store.js
const store = new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment(state) {
      state.count++
    }
  }
})

// 组件中使用
Vue.component('counter', {
  computed: {
    count() {
      return this.$store.state.count
    }
  },
  template: '<button @click="increment">{{ count }}</button>',
  methods: {
    increment() {
      this.$store.commit('increment')
    }
  }
})

实战示例

可复用的模态框

<div id="app">
  <button @click="showModal1 = true">打开模态框1</button>
  <button @click="showModal2 = true">打开模态框2</button>
  
  <modal v-if="showModal1" @close="showModal1 = false">
    <h2>模态框1</h2>
    <p>这是第一个模态框的内容</p>
  </modal>
  
  <modal v-if="showModal2" @close="showModal2 = false">
    <h2>模态框2</h2>
    <p>这是第二个模态框的内容</p>
  </modal>
</div>

<script>
Vue.component('modal', {
  template: `
    <div class="modal-overlay" @click.self="$emit('close')">
      <div class="modal-content">
        <button class="close" @click="$emit('close')">×</button>
        <slot></slot>
      </div>
    </div>
  `
})

new Vue({
  el: '#app',
  data: {
    showModal1: false,
    showModal2: false
  }
})
</script>

可复用的标签页

<div id="app">
  <tabs>
    <tab title="首页">
      <h2>首页内容</h2>
      <p>欢迎来到首页</p>
    </tab>
    <tab title="关于">
      <h2>关于我们</h2>
      <p>这是关于页面</p>
    </tab>
    <tab title="联系">
      <h2>联系方式</h2>
      <p>联系我们</p>
    </tab>
  </tabs>
</div>

<script>
Vue.component('tabs', {
  template: `
    <div class="tabs">
      <ul class="tab-header">
        <li 
          v-for="(tab, index) in tabs" 
          :key="index"
          :class="{ active: index === activeIndex }"
          @click="activeIndex = index"
        >
          {{ tab.title }}
        </li>
      </ul>
      <div class="tab-content">
        <slot></slot>
      </div>
    </div>
  `,
  data() {
    return {
      activeIndex: 0,
      tabs: []
    }
  },
  mounted() {
    this.tabs = this.$children
  }
})

Vue.component('tab', {
  props: ['title'],
  template: `
    <div v-show="active">
      <slot></slot>
    </div>
  `,
  computed: {
    active() {
      return this.$parent.activeIndex === this.$parent.$children.indexOf(this)
    }
  }
})
</script>

可复用的表单验证

<div id="app">
  <form @submit.prevent="submit">
    <form-input 
      v-model="form.username"
      label="用户名"
      :rules="[
        { required: true, message: '请输入用户名' },
        { min: 3, message: '用户名至少3个字符' }
      ]"
    ></form-input>
    
    <form-input 
      v-model="form.email"
      label="邮箱"
      type="email"
      :rules="[
        { required: true, message: '请输入邮箱' },
        { type: 'email', message: '邮箱格式不正确' }
      ]"
    ></form-input>
    
    <button type="submit">提交</button>
  </form>
</div>

<script>
Vue.component('form-input', {
  props: ['value', 'label', 'type', 'rules'],
  data() {
    return {
      error: ''
    }
  },
  template: `
    <div class="form-group">
      <label>{{ label }}</label>
      <input 
        :type="type || 'text'"
        :value="value"
        @input="$emit('input', $event.target.value)"
        @blur="validate"
      >
      <span v-if="error" class="error">{{ error }}</span>
    </div>
  `,
  methods: {
    validate() {
      this.error = ''
      
      if (!this.rules) return
      
      for (let rule of this.rules) {
        if (rule.required && !this.value) {
          this.error = rule.message
          return
        }
        
        if (rule.min && this.value.length < rule.min) {
          this.error = rule.message
          return
        }
        
        if (rule.type === 'email') {
          const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
          if (!re.test(this.value)) {
            this.error = rule.message
            return
          }
        }
      }
    }
  }
})

new Vue({
  el: '#app',
  data: {
    form: {
      username: '',
      email: ''
    }
  },
  methods: {
    submit() {
      console.log('提交:', this.form)
    }
  }
})
</script>

注意事项

不要在 data 中引用外部对象

// ❌ 错误:外部对象被所有实例共享
var sharedState = { count: 0 }

Vue.component('my-component', {
  data() {
    return sharedState
  }
})

// ✅ 正确:每个实例返回新对象
Vue.component('my-component', {
  data() {
    return {
      count: 0
    }
  }
})

props 的响应式

props 是响应式的,父组件数据变化会传递给子组件。但子组件不应该直接修改 props:

// ❌ 错误:直接修改 props
props: ['message'],
methods: {
  updateMessage() {
    this.message = 'new value'  // 警告
  }
}

// ✅ 正确:使用本地数据或计算属性
props: ['message'],
data() {
  return {
    localMessage: this.message
  }
}

小结

组件复用要点:

  1. 实例独立:每个组件实例有自己的数据
  2. data 必须是函数:确保数据独立
  3. 使用 props 传递数据:让组件可配置
  4. 使用事件通信:保持组件解耦