Skip to content

Props

Props 声明

一个组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传 attribute。

在使用 <script setup> 的单文件组件中,props 可以使用 defineProps() 宏来声明:

vue
<script setup>
const props = defineProps(['foo'])

console.log(props.foo)
</script>

在没有使用 <script setup> 的组件中,prop 可以使用 props 选项来声明:

js
export default {
  props: ['foo'],
  setup(props) {
    // setup() 接收 props 作为第一个参数
    console.log(props.foo)
  }
}

Props 需要使用 props 选项来定义:

js
export default {
  props: ['foo'],
  created() {
    // props 会暴露到 `this` 上
    console.log(this.foo)
  }
}

除了使用字符串数组来声明 prop 外,还可以使用对象的形式:

js
// 使用 <script setup>
defineProps({
  title: String,
  likes: Number
})
js
export default {
  props: {
    title: String,
    likes: Number
  }
}

对于以对象形式声明中的每个属性,key 是 prop 的名称,而值则是该 prop 预期类型的构造函数。比如,如果要求一个 prop 的值是 number 类型,则可使用 Number 构造函数作为其声明的值。

对象形式的 props 声明不仅可以一定程度上作为组件的文档,而且如果其他开发者在使用你的组件时传入了错误的类型,也会在浏览器控制台中抛出警告。我们将在本章节稍后进一步讨论有关 prop 校验的更多细节。

如果你正在搭配 TypeScript 使用 <script setup>,也可以使用类型标注来声明 props:

vue
<script setup lang="ts">
defineProps<{
  title?: string
  likes?: number
}>()
</script>

更多细节:为组件的 props 标注类型

传递 prop 的细节

Prop 名字格式

如果一个 prop 的名字很长,应使用 camelCase 形式,因为它们是合法的 JavaScript 标识符,可以直接在模板的表达式中使用,也可以避免在作为属性 key 时必须加上引号。

js
defineProps({
  greetingMessage: String
})
js
export default {
  props: {
    greetingMessage: String
  }
}
template
<span>{{ greetingMessage }}</span>

虽然理论上你也可以在向子组件传递 props 时使用 camelCase 形式 (除了在 DOM 模板中),但实际上为了和 HTML attribute 对齐,我们通常会将其写为 kebab-case 形式:

template
<MyComponent greeting-message="hello" />

对于组件名我们推荐使用 PascalCase,因为这提高了模板的可读性,能帮助我们区分 Vue 组件和原生 HTML 元素。然而对于传递 props 来说,使用 camelCase 并没有太多优势,因此我们推荐更贴近 HTML 的书写风格。

静态 vs. 动态 Prop

至此,你已经见过了很多像这样的静态值形式的 props:

template
<BlogPost title="My journey with Vue" />

相应地,还有使用 v-bind 或缩写 : 来进行动态绑定的 props:

template
<!-- 动态赋予一个变量的值 -->
<BlogPost :title="post.title" />

<!-- 动态赋予一个复杂表达式的值 -->
<BlogPost :title="post.title + ' by ' + post.author.name" />

传递不同的值类型

在上述的两个例子中,我们只传入了字符串值,但实际上任何类型的值都可以作为 props 的值被传递。

Number

template
<!-- 虽然 `42` 是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :likes="42" />

<!-- 根据一个变量动态传入 -->
<BlogPost :likes="post.likes" />

Boolean

template
<!-- 仅写上 prop 但不传值,会隐式转换为 `true` -->
<BlogPost is-published />

<!-- 虽然 `false` 是静态的值,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :is-published="false" />

<!-- 根据一个变量动态传入 -->
<BlogPost :is-published="post.isPublished" />

Array

template
<!-- 虽然这个数组是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :comment-ids="[234, 266, 273]" />

<!-- 根据一个变量动态传入 -->
<BlogPost :comment-ids="post.commentIds" />

Object

template
<!-- 虽然这个对象字面量是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost
  :author="{
    name: 'Veronica',
    company: 'Veridian Dynamics'
  }"
 />

<!-- 根据一个变量动态传入 -->
<BlogPost :author="post.author" />

使用一个对象绑定多个 prop

如果你想要将一个对象的所有属性都当作 props 传入,你可以使用没有参数的 v-bind,即只使用 v-bind 而非 :prop-name。例如,这里有一个 post 对象:

js
const post = {
  id: 1,
  title: 'My Journey with Vue'
}
js
export default {
  data() {
    return {
      post: {
        id: 1,
        title: 'My Journey with Vue'
      }
    }
  }
}

以及下面的模板:

template
<BlogPost v-bind="post" />

而这实际上等价于:

template
<BlogPost :id="post.id" :title="post.title" />

单向数据流

所有的 props 都遵循着单向绑定的原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。

另外,每次父组件更新后,所有的子组件中的 props 都会被更新到最新值,这意味着你不应该在子组件中去更改一个 prop 的值。若你这么做了,Vue 会在控制台上向你抛出警告:

js
const props = defineProps(['foo'])

// ❌ 警告!prop 是只读的!
props.foo = 'bar'
js
export default {
  props: ['foo'],
  created() {
    // ❌ 警告!prop 是只读的!
    this.foo = 'bar'
  }
}

导致你想要更改一个 prop 的需求通常来源于以下两种场景:

  1. prop 被用于传入初始值;而子组件想在之后将其作为一个局部数据属性。在这种情况下,最好是新定义一个局部数据属性,从 props 上取得初始值:

    js
    const props = defineProps(['initialCounter'])
    
    // 计数器只是将 props.initialCounter 作为初始值
    // 像下面这样做就使 prop 和后续更新无关了
    const counter = ref(props.initialCounter)
    js
    export default {
      props: ['initialCounter'],
      data() {
        return {
          // 计数器只是将 this.initialCounter 作为初始值
          // 像下面这样做就使 prop 和后续更新无关了
          counter: this.initialCounter
        }
      }
    }
  2. 需要对传入的 prop 值做进一步的转换。在这种情况下,最好是基于该 prop 值定义一个计算属性:

    js
    const props = defineProps(['size'])
    
    // 该 prop 变更时计算属性也会自动更新
    const normalizedSize = computed(() => props.size.trim().toLowerCase())
    js
    export default {
      props: ['size'],
      computed: {
        // 该 prop 变更时计算属性也会自动更新
        normalizedSize() {
          return this.size.trim().toLowerCase()
        }
      }
    }

更改对象 / 数组类型的 props

当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为 JavaScript 的对象和数组是按引用传递,而对 Vue 来说,禁止这样的改动,虽然可能生效,但有很大的性能损耗,比较得不偿失。

这种更改的主要缺陷是它允许了子组件以某种不明显的方式影响父组件的状态,可能会使数据流在将来变得更难以理解。在最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件来通知父组件做出改变。

Prop 校验

Vue 组件可以更细致地声明对传入的 props 的校验要求。比如我们上面已经看到的类型声明,如果传入的值不满足类型要求,Vue 会在浏览器控制台中抛出警告来提醒开发者。这在开发给其他开发者使用的组件时非常有用。

要声明对 props 的校验,你可以向 defineProps()props 选项提供一个带有 props 校验选项的对象,例如:

js
defineProps({
  // 基础类型检查
  // (给出 `null` 和 `undefined` 值则会跳过任何类型检查)
  propA: Number,
  // 多种可能的类型
  propB: [String, Number],
  // 必传,且为 String 类型
  propC: {
    type: String,
    required: true
  },
  // Number 类型的默认值
  propD: {
    type: Number,
    default: 100
  },
  // 对象类型的默认值
  propE: {
    type: Object,
    // 对象或数组的默认值
    // 必须从一个工厂函数返回。
    // 该函数接收组件的 props 作为参数。
    default(rawProps) {
      return { message: 'hello' }
    }
  },
  // 自定义类型校验函数
  propF: {
    validator(value) {
      // The value must match one of these strings
      return ['success', 'warning', 'danger'].includes(value)
    }
  },
  // 函数类型的默认值
  propG: {
    type: Function,
    // 不像对象或数组的默认,这不是一个
    // 工厂函数。这会是一个用来作为默认值的函数
    default() {
      return 'Default function'
    }
  }
})

TIP

defineProps() 宏中的参数不可以访问 <script setup> 中定义的其他变量,因为在编译时整个表达式都会被移到外部的函数中。

js
export default {
  props: {
    // 基础类型检查
    //(给出 `null` 和 `undefined` 值则会跳过任何类型检查)
    propA: Number,
    // 多种可能的类型
    propB: [String, Number],
    // 必传,且为 String 类型
    propC: {
      type: String,
      required: true
    },
    // Number 类型的默认值
    propD: {
      type: Number,
      default: 100
    },
    // 对象类型的默认值
    propE: {
      type: Object,
      // 对象或数组的默认值
      // 必须从一个工厂函数返回。
      // 该函数接收组件的 props 作为参数。
      default(rawProps) {
        return { message: 'hello' }
      }
    },
    // 自定义类型校验函数
    propF: {
      validator(value) {
        // The value must match one of these strings
        return ['success', 'warning', 'danger'].includes(value)
      }
    },
    // 函数类型的默认值
    propG: {
      type: Function,
      // 不像对象或数组的默认,这不是一个
      // 工厂函数。这会是一个用来作为默认值的函数
      default() {
        return 'Default function'
      }
    }
  }
}

一些补充细节:

  • 所有 prop 默认都是可选的,除非声明了 required: true

  • Boolean 外的未传递的可选 prop 将会有一个默认值 undefined

  • Boolean 类型的未传递 prop 将被转换为 false。这可以通过为它设置 default 来更改——例如:设置为 default: undefined 将与非布尔类型的 prop 的行为保持一致。

  • 如果声明了 default 值,那么在 prop 的值被解析为 undefined 时,无论 prop 是未被传递还是显式指明的 undefined,都会改为 default 值。

当 prop 的校验失败后,Vue 会抛出一个控制台警告 (在开发模式下)。

如果使用了基于类型的 prop 声明 ,Vue 会尽最大努力在运行时按照 prop 的类型标注进行编译。举例来说,defineProps<{ msg: string }> 会被编译为 { msg: { type: String, required: true }}

运行时类型检查

校验选项中的 type 可以是下列这些原生构造函数:

  • String
  • Number
  • Boolean
  • Array
  • Object
  • Date
  • Function
  • Symbol

另外,type 也可以是自定义的类或构造函数,Vue 将会通过 instanceof 来检查类型是否匹配。例如下面这个类:

js
class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }
}

你可以将其作为一个 prop 的类型:

js
defineProps({
  author: Person
})
js
export default {
  props: {
    author: Person
  }
}

Vue 会通过 instanceof Person 来校验 author prop 的值是否是 Person 类的一个实例。

Boolean 类型转换

为了更贴近原生 boolean attributes 的行为,声明为 Boolean 类型的 props 有特别的类型转换规则。以带有如下声明的 <MyComponent> 组件为例:

js
defineProps({
  disabled: Boolean
})
js
export default {
  props: {
    disabled: Boolean
  }
}

该组件可以被这样使用:

template
<!-- 等同于传入 :disabled="true" -->
<MyComponent disabled />

<!-- 等同于传入 :disabled="false" -->
<MyComponent />

当一个 prop 被声明为允许多种类型时,例如:

js
defineProps({
  disabled: [Boolean, Number]
})
js
export default {
  props: {
    disabled: [Boolean, Number]
  }
}

无论声明类型的顺序如何,Boolean 类型的特殊转换规则都会被应用。

实际应用示例

基础 Props 使用

vue
<!-- ParentComponent.vue -->
<script setup>
import { ref } from 'vue'
import UserCard from './UserCard.vue'

const users = ref([
  {
    id: 1,
    name: '张三',
    email: 'zhangsan@example.com',
    avatar: 'https://example.com/avatar1.jpg',
    isOnline: true,
    role: 'admin'
  },
  {
    id: 2,
    name: '李四',
    email: 'lisi@example.com',
    avatar: 'https://example.com/avatar2.jpg',
    isOnline: false,
    role: 'user'
  }
])
</script>

<template>
  <div class="user-list">
    <h2>用户列表</h2>
    <UserCard
      v-for="user in users"
      :key="user.id"
      :name="user.name"
      :email="user.email"
      :avatar="user.avatar"
      :is-online="user.isOnline"
      :role="user.role"
      :show-actions="true"
    />
  </div>
</template>

<style scoped>
.user-list {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}
</style>
vue
<!-- UserCard.vue -->
<script setup>
// Props 声明
const props = defineProps({
  name: {
    type: String,
    required: true
  },
  email: {
    type: String,
    required: true,
    validator(value) {
      // 简单的邮箱格式验证
      return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
    }
  },
  avatar: {
    type: String,
    default: 'https://via.placeholder.com/60'
  },
  isOnline: {
    type: Boolean,
    default: false
  },
  role: {
    type: String,
    default: 'user',
    validator(value) {
      return ['admin', 'user', 'guest'].includes(value)
    }
  },
  showActions: {
    type: Boolean,
    default: false
  }
})

// 基于 props 的计算属性
import { computed } from 'vue'

const statusText = computed(() => {
  return props.isOnline ? '在线' : '离线'
})

const roleText = computed(() => {
  const roleMap = {
    admin: '管理员',
    user: '普通用户',
    guest: '访客'
  }
  return roleMap[props.role] || '未知'
})

const statusClass = computed(() => {
  return {
    'status-online': props.isOnline,
    'status-offline': !props.isOnline
  }
})
</script>

<template>
  <div class="user-card">
    <div class="user-avatar">
      <img :src="avatar" :alt="name" />
      <span :class="['status-indicator', statusClass]"></span>
    </div>
    
    <div class="user-info">
      <h3 class="user-name">{{ name }}</h3>
      <p class="user-email">{{ email }}</p>
      <div class="user-meta">
        <span class="user-role">{{ roleText }}</span>
        <span class="user-status">{{ statusText }}</span>
      </div>
    </div>
    
    <div v-if="showActions" class="user-actions">
      <button class="btn btn-primary">编辑</button>
      <button class="btn btn-secondary">删除</button>
    </div>
  </div>
</template>

<style scoped>
.user-card {
  display: flex;
  align-items: center;
  padding: 16px;
  margin-bottom: 12px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: box-shadow 0.2s;
}

.user-card:hover {
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}

.user-avatar {
  position: relative;
  margin-right: 16px;
}

.user-avatar img {
  width: 60px;
  height: 60px;
  border-radius: 50%;
  object-fit: cover;
}

.status-indicator {
  position: absolute;
  bottom: 2px;
  right: 2px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 2px solid white;
}

.status-online {
  background-color: #4caf50;
}

.status-offline {
  background-color: #9e9e9e;
}

.user-info {
  flex: 1;
}

.user-name {
  margin: 0 0 4px 0;
  font-size: 18px;
  font-weight: 600;
  color: #333;
}

.user-email {
  margin: 0 0 8px 0;
  color: #666;
  font-size: 14px;
}

.user-meta {
  display: flex;
  gap: 12px;
}

.user-role,
.user-status {
  padding: 2px 8px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 500;
}

.user-role {
  background-color: #e3f2fd;
  color: #1976d2;
}

.user-status {
  background-color: #f3e5f5;
  color: #7b1fa2;
}

.user-actions {
  display: flex;
  gap: 8px;
}

.btn {
  padding: 6px 12px;
  border: none;
  border-radius: 4px;
  font-size: 12px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.btn-primary {
  background-color: #2196f3;
  color: white;
}

.btn-primary:hover {
  background-color: #1976d2;
}

.btn-secondary {
  background-color: #f5f5f5;
  color: #666;
}

.btn-secondary:hover {
  background-color: #e0e0e0;
}
</style>

复杂 Props 校验

vue
<!-- ProductCard.vue -->
<script setup>
import { computed } from 'vue'

// 自定义类型
class Price {
  constructor(amount, currency = 'CNY') {
    this.amount = amount
    this.currency = currency
  }
  
  toString() {
    const symbols = {
      CNY: '¥',
      USD: '$',
      EUR: '€'
    }
    return `${symbols[this.currency] || this.currency}${this.amount}`
  }
}

// 复杂的 Props 声明
const props = defineProps({
  // 基础必需属性
  id: {
    type: [String, Number],
    required: true
  },
  
  // 产品名称
  name: {
    type: String,
    required: true,
    validator(value) {
      return value.length >= 2 && value.length <= 100
    }
  },
  
  // 价格对象
  price: {
    type: Price,
    required: true
  },
  
  // 图片数组
  images: {
    type: Array,
    default: () => [],
    validator(value) {
      // 验证数组中的每个元素都是有效的 URL
      return value.every(url => {
        try {
          new URL(url)
          return true
        } catch {
          return false
        }
      })
    }
  },
  
  // 产品规格
  specifications: {
    type: Object,
    default: () => ({}),
    validator(value) {
      // 验证规格对象的结构
      const allowedKeys = ['color', 'size', 'material', 'weight']
      return Object.keys(value).every(key => allowedKeys.includes(key))
    }
  },
  
  // 库存状态
  stockStatus: {
    type: String,
    default: 'in-stock',
    validator(value) {
      return ['in-stock', 'low-stock', 'out-of-stock', 'pre-order'].includes(value)
    }
  },
  
  // 折扣信息
  discount: {
    type: Object,
    default: null,
    validator(value) {
      if (value === null) return true
      
      const hasValidType = ['percentage', 'fixed'].includes(value.type)
      const hasValidAmount = typeof value.amount === 'number' && value.amount > 0
      const hasValidExpiry = !value.expiryDate || value.expiryDate instanceof Date
      
      return hasValidType && hasValidAmount && hasValidExpiry
    }
  },
  
  // 评分
  rating: {
    type: Number,
    default: 0,
    validator(value) {
      return value >= 0 && value <= 5
    }
  },
  
  // 是否显示快速购买按钮
  showQuickBuy: Boolean,
  
  // 自定义样式类
  customClass: {
    type: [String, Array, Object],
    default: ''
  }
})

// 计算属性
const displayPrice = computed(() => {
  if (!props.discount) {
    return props.price.toString()
  }
  
  let discountedAmount
  if (props.discount.type === 'percentage') {
    discountedAmount = props.price.amount * (1 - props.discount.amount / 100)
  } else {
    discountedAmount = props.price.amount - props.discount.amount
  }
  
  return new Price(discountedAmount, props.price.currency).toString()
})

const stockStatusText = computed(() => {
  const statusMap = {
    'in-stock': '现货',
    'low-stock': '库存不足',
    'out-of-stock': '缺货',
    'pre-order': '预订'
  }
  return statusMap[props.stockStatus]
})

const stockStatusClass = computed(() => {
  return `stock-${props.stockStatus}`
})

const hasDiscount = computed(() => {
  return props.discount && (!props.discount.expiryDate || props.discount.expiryDate > new Date())
})

const ratingStars = computed(() => {
  const fullStars = Math.floor(props.rating)
  const hasHalfStar = props.rating % 1 >= 0.5
  const emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0)
  
  return {
    full: fullStars,
    half: hasHalfStar ? 1 : 0,
    empty: emptyStars
  }
})
</script>

<template>
  <div :class="['product-card', customClass, stockStatusClass]">
    <!-- 产品图片 -->
    <div class="product-image">
      <img 
        :src="images[0] || 'https://via.placeholder.com/200'" 
        :alt="name"
      />
      <div v-if="hasDiscount" class="discount-badge">
        {{ discount.type === 'percentage' ? `-${discount.amount}%` : `-${discount.amount}` }}
      </div>
    </div>
    
    <!-- 产品信息 -->
    <div class="product-info">
      <h3 class="product-name">{{ name }}</h3>
      
      <!-- 价格 -->
      <div class="price-section">
        <span class="current-price">{{ displayPrice }}</span>
        <span v-if="hasDiscount" class="original-price">{{ price.toString() }}</span>
      </div>
      
      <!-- 评分 -->
      <div v-if="rating > 0" class="rating">
        <span v-for="n in ratingStars.full" :key="`full-${n}`" class="star star-full">★</span>
        <span v-if="ratingStars.half" class="star star-half">★</span>
        <span v-for="n in ratingStars.empty" :key="`empty-${n}`" class="star star-empty">☆</span>
        <span class="rating-text">({{ rating.toFixed(1) }})</span>
      </div>
      
      <!-- 规格 -->
      <div v-if="Object.keys(specifications).length > 0" class="specifications">
        <div v-for="(value, key) in specifications" :key="key" class="spec-item">
          <span class="spec-label">{{ key }}:</span>
          <span class="spec-value">{{ value }}</span>
        </div>
      </div>
      
      <!-- 库存状态 -->
      <div class="stock-status">
        <span :class="['status-indicator', stockStatusClass]"></span>
        {{ stockStatusText }}
      </div>
      
      <!-- 操作按钮 -->
      <div class="actions">
        <button 
          class="btn btn-primary"
          :disabled="stockStatus === 'out-of-stock'"
        >
          加入购物车
        </button>
        <button 
          v-if="showQuickBuy"
          class="btn btn-secondary"
          :disabled="stockStatus === 'out-of-stock'"
        >
          立即购买
        </button>
      </div>
    </div>
  </div>
</template>

<style scoped>
.product-card {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  overflow: hidden;
  transition: box-shadow 0.2s;
  background: white;
}

.product-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.product-image {
  position: relative;
  width: 100%;
  height: 200px;
}

.product-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.discount-badge {
  position: absolute;
  top: 8px;
  right: 8px;
  background: #f44336;
  color: white;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 12px;
  font-weight: bold;
}

.product-info {
  padding: 16px;
}

.product-name {
  margin: 0 0 8px 0;
  font-size: 16px;
  font-weight: 600;
  color: #333;
}

.price-section {
  margin-bottom: 8px;
}

.current-price {
  font-size: 18px;
  font-weight: bold;
  color: #f44336;
}

.original-price {
  margin-left: 8px;
  font-size: 14px;
  color: #999;
  text-decoration: line-through;
}

.rating {
  margin-bottom: 8px;
  display: flex;
  align-items: center;
  gap: 2px;
}

.star {
  color: #ffc107;
  font-size: 14px;
}

.star-empty {
  color: #e0e0e0;
}

.rating-text {
  margin-left: 4px;
  font-size: 12px;
  color: #666;
}

.specifications {
  margin-bottom: 8px;
}

.spec-item {
  display: flex;
  justify-content: space-between;
  font-size: 12px;
  margin-bottom: 2px;
}

.spec-label {
  color: #666;
  text-transform: capitalize;
}

.spec-value {
  color: #333;
  font-weight: 500;
}

.stock-status {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 12px;
  font-size: 12px;
}

.status-indicator {
  width: 8px;
  height: 8px;
  border-radius: 50%;
}

.stock-in-stock .status-indicator {
  background-color: #4caf50;
}

.stock-low-stock .status-indicator {
  background-color: #ff9800;
}

.stock-out-of-stock .status-indicator {
  background-color: #f44336;
}

.stock-pre-order .status-indicator {
  background-color: #2196f3;
}

.actions {
  display: flex;
  gap: 8px;
}

.btn {
  flex: 1;
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  font-size: 14px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn-primary {
  background-color: #2196f3;
  color: white;
}

.btn-primary:hover:not(:disabled) {
  background-color: #1976d2;
}

.btn-secondary {
  background-color: #f5f5f5;
  color: #333;
}

.btn-secondary:hover:not(:disabled) {
  background-color: #e0e0e0;
}

.stock-out-of-stock {
  opacity: 0.7;
}
</style>

使用示例

vue
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import ProductCard from './ProductCard.vue'

// 自定义 Price 类
class Price {
  constructor(amount, currency = 'CNY') {
    this.amount = amount
    this.currency = currency
  }
  
  toString() {
    const symbols = {
      CNY: '¥',
      USD: '$',
      EUR: '€'
    }
    return `${symbols[this.currency] || this.currency}${this.amount}`
  }
}

const products = ref([
  {
    id: 1,
    name: 'iPhone 15 Pro',
    price: new Price(7999),
    images: ['https://example.com/iphone.jpg'],
    specifications: {
      color: '深空黑色',
      size: '6.1英寸',
      material: '钛金属'
    },
    stockStatus: 'in-stock',
    discount: {
      type: 'percentage',
      amount: 10,
      expiryDate: new Date('2024-12-31')
    },
    rating: 4.5,
    showQuickBuy: true
  },
  {
    id: 2,
    name: 'MacBook Pro',
    price: new Price(12999),
    images: ['https://example.com/macbook.jpg'],
    specifications: {
      color: '深空灰色',
      size: '14英寸'
    },
    stockStatus: 'low-stock',
    rating: 4.8,
    showQuickBuy: false
  }
])
</script>

<template>
  <div class="product-grid">
    <ProductCard
      v-for="product in products"
      :key="product.id"
      v-bind="product"
      custom-class="featured-product"
    />
  </div>
</template>

<style scoped>
.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 20px;
  padding: 20px;
}

.featured-product {
  border-color: #2196f3;
}
</style>

选项式 API

如果你使用的是选项式 API,Props 的使用方式如下:

vue
<script>
export default {
  props: {
    title: String,
    likes: Number,
    isPublished: Boolean,
    commentIds: Array,
    author: Object,
    callback: Function,
    contactsPromise: Promise
  },
  
  computed: {
    // 基于 props 的计算属性
    displayTitle() {
      return this.title.toUpperCase()
    }
  },
  
  methods: {
    // 使用 props 的方法
    handleClick() {
      console.log('Title:', this.title)
      console.log('Likes:', this.likes)
    }
  },
  
  created() {
    // 在生命周期钩子中访问 props
    console.log('Component created with title:', this.title)
  }
}
</script>

<template>
  <div>
    <h1>{{ displayTitle }}</h1>
    <p>Likes: {{ likes }}</p>
    <button @click="handleClick">Click me</button>
  </div>
</template>

最佳实践

1. Props 命名

  • 使用 camelCase 在 JavaScript 中声明
  • 使用 kebab-case 在模板中传递
  • 选择描述性的名称
vue
<!-- 好的做法 -->
<script setup>
defineProps({
  userProfile: Object,
  isLoggedIn: Boolean,
  maxRetryCount: Number
})
</script>

<template>
  <!-- 在模板中使用 kebab-case -->
  <UserCard 
    :user-profile="user"
    :is-logged-in="loggedIn"
    :max-retry-count="3"
  />
</template>

2. 类型声明和校验

  • 始终声明 prop 类型
  • 为必需的 props 添加 required: true
  • 为可选的 props 提供合理的默认值
  • 使用自定义校验器确保数据质量
vue
<script setup>
defineProps({
  // 好的做法:完整的类型声明
  email: {
    type: String,
    required: true,
    validator: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
  },
  
  // 好的做法:提供默认值
  theme: {
    type: String,
    default: 'light',
    validator: (value) => ['light', 'dark'].includes(value)
  },
  
  // 好的做法:对象/数组使用工厂函数
  config: {
    type: Object,
    default: () => ({
      timeout: 5000,
      retries: 3
    })
  }
})
</script>

3. 避免 Prop 变更

  • 不要直接修改 props
  • 使用本地数据或计算属性
  • 通过事件与父组件通信
vue
<script setup>
const props = defineProps({
  initialValue: String
})

// 好的做法:使用本地状态
const localValue = ref(props.initialValue)

// 好的做法:使用计算属性进行转换
const formattedValue = computed(() => {
  return props.initialValue?.toUpperCase() || ''
})

// 好的做法:通过事件通信
const emit = defineEmits(['update'])

const handleChange = (newValue) => {
  localValue.value = newValue
  emit('update', newValue)
}
</script>

4. 性能优化

  • 避免在 props 中传递大型对象
  • 使用 shallowRef 处理大型数据
  • 合理使用 prop 校验(仅在开发环境)
vue
<script setup>
import { shallowRef, computed } from 'vue'

const props = defineProps({
  // 对于大型数据,考虑传递 ID 而不是整个对象
  userId: {
    type: [String, Number],
    required: true
  },
  
  // 或者使用 shallow 响应式
  largeDataset: {
    type: Array,
    default: () => []
  }
})

// 使用 computed 进行数据处理
const processedData = computed(() => {
  return props.largeDataset.filter(item => item.active)
})
</script>

下一步

  • 组件事件
  • 组件 v-model
  • 透传 Attributes

vue study guide - 专业的 Vue.js 学习平台