3. 开发环境搭建

上一节我们聊了 Vue 的响应式系统和模板语法。今天咱们来点更实战的东西——组件化开发。说白了,就是把页面拆成一个个独立的小块,每个块只管自己的事。

我在项目里见过不少新手,一个单文件组件写了两三千行。维护起来?那叫一个酸爽。所以,理解组件通信,是写出可维护代码的第一步。

3.1 Props:父组件给子组件传数据

Props 是父组件向子组件传递数据的标准方式。你可以把它想象成函数的参数——子组件声明它需要什么,父组件就传什么。

核心原则:单向数据流

数据从父组件流向子组件。子组件不能直接修改 props。为什么?因为这样会导致数据流向混乱,调试起来非常痛苦。

来看个例子。假设我们有个语音识别结果展示组件:

<!-- 子组件:ResultDisplay.vue -->
<template>
  <div class="result-card">
    <h3>识别结果</h3>
    <p>{{ text }}</p>
    <span>置信度:{{ confidence }}%</span>
  </div>
</template>

<script>
export default {
  props: {
    text: {
      type: String,
      required: true
    },
    confidence: {
      type: Number,
      default: 0,
      validator: value => value >= 0 && value <= 100
    }
  }
}
</script>

父组件使用时:

<ResultDisplay :text="resultText" :confidence="resultConfidence" />

嗯,这里要注意:required 和 default 要配合好。如果一个 prop 是 required,就别给 default 了,不然容易让人困惑。

我的习惯

所有 props 都写完整校验——类型、默认值、必要性和自定义校验器。这能帮你提前发现很多低级错误。我曾经因为漏写类型校验,把一个数字传成了字符串,结果页面渲染出来全是 NaN,排查了半小时。

3.2 $emit:子组件给父组件发消息

子组件不能直接改 props,那它想通知父组件做点事怎么办?用 $emit。它就像子组件给父组件发了个信号:「嘿,我这边有情况,你处理一下。」

继续用语音识别的场景。假设子组件有个「重新识别」按钮:

<!-- 子组件 -->
<template>
  <div>
    <button @click="handleRetry">重新识别</button>
  </div>
</template>

<script>
export default {
  methods: {
    handleRetry() {
      this.$emit('retry', { reason: '用户手动触发' })
    }
  }
}
</script>

父组件监听这个事件:

<ResultDisplay @retry="onRetry" />

<script>
methods: {
  onRetry(payload) {
    // 重新调用语音识别接口
    this.startRecognition()
  }
}
</script>

避坑指南

我曾经在项目里看到有人用 this.$parent 直接调用父组件方法。千万别这么干!这会让组件之间的耦合变得非常紧密,改一个组件可能牵一发而动全身。用 $emit 才是正道。

3.3 插槽(Slot):让组件更灵活

Props 和 $emit 解决的是数据传递问题。但有时候,我们想传递的是模板内容——比如一个通用的卡片组件,不同页面用的卡片内容不一样。这时候就需要插槽了。

插槽说白了就是占位符。子组件留个坑,父组件往里填东西。

3.3.1 默认插槽

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header">默认标题</slot>
    </div>
    <div class="card-body">
      <slot />
    </div>
  </div>
</template>

使用:

<Card>
  <template #header>
    <h2>语音识别结果</h2>
  </template>
  <p>这是识别出来的文本内容...</p>
</Card>

3.3.2 作用域插槽

有时候子组件需要把内部数据暴露给父组件,让父组件决定怎么渲染。这就是作用域插槽的用武之地。

<!-- 子组件 -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot :item="item" :index="index">
        {{ item.name }}
      </slot>
    </li>
  </ul>
</template>

父组件可以自定义渲染逻辑:

<ListComponent>
  <template #default="{ item, index }">
    <span>{{ index + 1 }}. {{ item.name }}</span>
    <button @click="deleteItem(item.id)">删除</button>
  </template>
</ListComponent>

我的经验

作用域插槽在封装表格、列表这类组件时特别有用。你想想看,表格的列头、列内容、操作按钮都可能不一样,用作用域插槽就能让父组件完全控制渲染,子组件只负责数据和布局。

3.4 组件通信全景图

上面三种是最常用的通信方式。但实际项目中,组件关系可能更复杂——兄弟组件、跨层级组件怎么通信?我整理了一张图,帮你理清思路。

Vue 组件通信方式全景图 父组件 子组件 A 子组件 B 孙组件 兄弟组件 props props $emit $emit props $emit 兄弟通信:通过父组件中转 跨层级:provide / inject

从这张图可以看出:

  • 父子通信:props(父→子)、$emit(子→父)
  • 兄弟通信:通过共同的父组件中转——子A $emit 给父,父再通过 props 传给子B
  • 跨层级通信:用 provide / inject,祖先组件提供数据,后代组件注入使用

注意

provide / inject 虽然方便,但它会让组件之间的依赖关系变得隐式。我建议只在「祖先-后代」关系明确且稳定的场景下使用,比如主题配置、用户信息等全局数据。别用它来替代 props,否则代码会变得很难追踪。

3.5 实战:封装一个语音识别控制组件

说了这么多理论,咱们来写个真实例子。假设我们要封装一个语音识别控制组件,包含开始、停止、状态显示等功能。

<!-- VoiceControl.vue -->
<template>
  <div class="voice-control">
    <div class="status">
      <slot name="status" :status="currentStatus">
        <span>{{ statusText }}</span>
      </slot>
    </div>
    
    <div class="actions">
      <button 
        v-if="currentStatus === 'idle'"
        @click="start"
        :disabled="loading">
        开始识别
      </button>
      
      <button 
        v-if="currentStatus === 'recording'"
        @click="stop"
        class="danger">
        停止识别
      </button>
    </div>
    
    <div class="result">
      <slot :text="resultText" :confidence="confidence">
        <p>{{ resultText }}</p>
      </slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    loading: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      currentStatus: 'idle',
      resultText: '',
      confidence: 0
    }
  },
  computed: {
    statusText() {
      const map = {
        idle: '待机中',
        recording: '正在录音...',
        processing: '正在识别...',
        done: '识别完成'
      }
      return map[this.currentStatus] || '未知状态'
    }
  },
  methods: {
    start() {
      this.currentStatus = 'recording'
      this.$emit('start')
    },
    stop() {
      this.currentStatus = 'processing'
      this.$emit('stop')
    },
    setResult(text, confidence) {
      this.resultText = text
      this.confidence = confidence
      this.currentStatus = 'done'
    }
  }
}
</script>

父组件使用:

<VoiceControl 
  :loading="isLoading"
  @start="handleStart"
  @stop="handleStop">
  
  <template #status="{ status }">
    <div :class="['badge', status]">
      {{ status === 'recording' ? '🎤 录音中' : '⏸ 待机' }}
    </div>
  </template>
  
  <template #default="{ text, confidence }">
    <div class="result-card">
      <p>{{ text }}</p>
      <progress :value="confidence" max="100"></progress>
    </div>
  </template>
</VoiceControl>

我的建议

写组件时,先想清楚「这个组件要暴露哪些 props 和事件」,再想「哪些部分应该让父组件通过插槽自定义」。好的组件设计,就像搭积木——每个块职责清晰,组合起来又很灵活。

3.6 总结

组件化开发的核心就三件事:

  • Props:父传子,单向数据流
  • $emit:子传父,事件驱动
  • Slot:模板分发,灵活组合

你想想看,掌握了这三样,大部分组件通信场景都能搞定。至于更复杂的跨层级通信、状态管理,那是后面章节的事。先把基础打牢,后面学起来就轻松多了。