白板中间排着三张结构相同、细节却逐渐走散的商品卡片。
起初直接复制三份似乎最快;需求一轮轮加进来后,复制开始失配:第一张多了推荐标记,第二张漏了价格格式,第三张的按钮类名又和前两张不同。更麻烦的是,每张卡片都要独立记住“详情是否展开”,还要接收各自的名称、价格和标签。此时继续复制的不只是 HTML,而是一整套容易走散的结构、样式、局部状态和输入约定。
第 7 章已经把单个组件内部的逻辑理顺了:源状态保存事实,计算属性负责派生结果,侦听器处理变化之后的动作。现在逻辑各归其位,我们换一个角度评审这份页面:这些反复出现的界面,应该以什么为边界,又怎样在复用结构的同时保留各自的状态?
白板上真正需要切开的,是界面边界与实例边界。我们把“商品卡片”圈成一个能独立说明职责的小界面,再规定它从外部接收哪些数据;同一份定义使用三次,Vue 就创建三个实例,让每张卡片各自保管自己的展开状态。组件不是把文件切得更碎,而是让重复界面拥有同一份定义,让每个具体使用位置又拥有彼此独立的状态空间。
这里先把两类重构分清楚。本章拆的是界面:哪些结构、样式和交互应该成为一个 .vue 组件,每个实例的边界在哪里,父组件又通过什么输入契约使用它。第 12 章才会处理另一件事——把多个组件都可能使用的有状态功能抽成 composable。前者回答“页面由哪些界面单元组成”,后者回答“同一套状态逻辑怎样跨组件复用”;组合式函数不会替我们决定商品卡片该不该成为组件。
把商品卡片圈成组件后,白板上还要补一条输入契约:名称、价格等数据由父组件从 defineProps 声明的入口送进来,展开状态则留给当前实例。接下来再顺着这条边界标清组件从哪里导入、props 为什么只读,以及写在组件标签上的 class、id 和原生事件最终应该落到哪个真实元素。局部注册还是全局注册、默认透传还是主动指定落点,判断依据都回到同一件事:这个界面对外公开什么,又把什么留在自己内部。

假设页面里有三张一模一样的计数卡片。每张卡片都有自己的数字和按钮。点击第一张卡片,只有第一张的数字增加;另外两张不动。
这件事看起来很自然,却揭示了组件最基本的特征:每使用一次组件,Vue 就会创建一个新的组件实例。它们共享同一份组件定义,却各自拥有自己的局部状态。
先创建 src/components/ClickCounter.vue:
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button class="counter" @click="count++">
已点击 {{ count }} 次
</button>
</template>
<style scoped>
.counter {
padding: 10px 16px;
border: 1px solid #8b5cf6;
border-radius: 10px;
color: #6d28d9;
background: #f5f3ff;
cursor: pointer;
}
</style>再在 src/App.vue 中使用三次:
<script setup>
import ClickCounter from './components/ClickCounter.vue'
</script>
<template>
<main class="counter-list">
<ClickCounter />
<ClickCounter />
<ClickCounter />
</main>
</template>
<style scoped>
页面开始时会显示三个“已点击 0 次”的按钮。如果依次点击第一张两次、第二张一次,界面会变成:
已点击 2 次 已点击 1 次 已点击 0 次为什么三个 count 没有串在一起?因为 <script setup> 中的代码会在每个组件实例创建时执行。三次 <ClickCounter /> 对应三次组件实例,也就得到三个不同的 ref(0)。
这和普通 JavaScript 模块里的顶层变量很不一样。模块通常只在首次加载时执行一次,而组件实例里的状态要跟着实例创建。以后调试“为什么两个卡片互相影响”时,可以先检查共享数据是否被放到了组件外部,或者是否把同一个对象从父组件同时传给了多个子组件。
组件定义像一张制作说明,组件实例才是按照说明做出来的具体物件。复用组件时,我们复用的是结构和逻辑,不是把某一个实例的局部状态复制到所有地方。
并不是看到一段 <div> 就要新建文件。拆组件之前,可以问三个更实际的问题:
只要其中一项很明确,拆出来通常就有价值。反过来,如果一个组件只包了一层没有语义的 <div>,没有独立行为,也不会复用,那么它可能只是增加跳转文件的次数。
组件边界也不等于视觉边框。页面上一整块白色卡片可能由多个组件组成;一个看不见的表单校验提示也可以是组件。真正的判断标准是:这块界面的责任能不能独立说明,它与外部的数据往来能不能明确列出。
Vue 项目里最常见的组件文件以 .vue 结尾,这类文件叫单文件组件。一个典型文件有三块:
<script setup>
// 状态、计算和交互逻辑
</script>
<template>
<!-- 界面结构 -->
</template>
<style scoped>
/* 当前组件的样式 */
</style><template> 描述组件要渲染什么,<script setup> 准备模板会用到的数据和函数,<style scoped> 编写主要作用于当前组件的样式。三块内容不是三套互不相关的技术,它们共同描述同一个小界面。

.vue 文件不能直接交给浏览器执行。构建工具会先处理它:模板被编译成 JavaScript 渲染逻辑,样式被整理为浏览器能使用的 CSS,脚本也会成为标准 JavaScript 模块。
这解释了几个看似“特殊”的现象:
<script setup> 里的顶层变量能直接在模板中使用,不需要手动 return。.vue 文件能直接写成组件标签。<style scoped> 可以让选择器主要匹配当前组件生成的元素。换句话说,<template> 不是浏览器每次更新时重新做字符串替换。它在构建阶段已经被分析和编译了。页面状态变化时,Vue 执行编译后的渲染逻辑,只更新需要变化的部分。
<script setup> 开始Vue 同时支持 Options API 和 Composition API。这两套写法都能建立组件,旧项目中也经常能看到 Options API:
<script>
export default {
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count++
}
}
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</同样的逻辑用 <script setup> 写出来更直接:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>本课程后续都以第二种为主。原因不是 Options API “不能用了”,而是单文件组件配合 <script setup> 已经是新 Vue 项目里很常见的组合:导入的组件可以直接使用,props 和事件都有专门的编译宏,相关逻辑也能靠近放置。
如果你接手 Options API 项目,只要能认出 data 是局部状态、methods 是方法、props 是外部输入,就不会完全陌生。没有必要为了追求形式统一立刻重写旧代码,先理解项目现有边界往往更重要。
组件写好以后,父组件要知道它在哪里。使用 <script setup> 时,最常见的方式就是导入:
<script setup>
import ProductCard from './components/ProductCard.vue'
</script>
<template>
<ProductCard />
</template>ProductCard 是一个 JavaScript 标识符,也是模板中的组件标签。导入发生在当前组件文件中,所以它只在当前模板中可用。这就是局部注册。
局部注册有一个很实在的好处:打开父组件顶部,你立刻能看到它依赖哪些子组件。编辑器也能沿着导入路径跳到具体文件。没有用到的组件不会因为某个全局入口顺手注册过就悄悄进入依赖关系。

假设组件树是这样的:
App.vue
└── ProductList.vue
└── ProductCard.vueApp.vue 导入了 ProductList.vue,不代表 ProductList.vue 可以自动使用 App.vue 导入的所有组件。ProductList.vue 若要渲染 ProductCard,仍然需要自己导入它:
<script setup>
import ProductCard from './ProductCard.vue'
</script>
<template>
<section>
<ProductCard />
</section>
</template>这不是多余手续。它让每一层组件都能清楚表达自己的直接依赖,组件被搬到别处时也不必猜测祖先组件曾经做过什么。
全局注册通常写在应用入口,例如 src/main.js:
import { createApp } from 'vue'
import App from './App.vue'
import BaseButton from './components/BaseButton.vue'
const app = createApp(App)
app.component('BaseButton', BaseButton)
app.mount('#app')这样一来,当前应用里的任何组件模板都能使用 <BaseButton />,不需要逐个导入。
它确实方便,但方便背后有成本:
因此,我会把局部注册当成默认选择。只有极少数真正遍布应用、语义稳定的基础组件,才考虑全局注册,例如已经形成规范的 BaseButton、BaseIcon。即便如此,也要集中在一个清晰的入口管理,别在多个文件里零散调用 app.component()。
“很多页面都可能用到”并不足以支持全局注册。普通业务组件即使被多个页面使用,也可以各自导入。全局注册更像应用级公共词汇,数量应该少,而且名字与行为都要稳定。
在 .vue 单文件组件中,组件文件和标签推荐使用 PascalCase:
ProductCard.vue
UserAvatar.vue
OrderSummary.vue对应模板:
<template>
<ProductCard />
<UserAvatar />
<OrderSummary />
</template>大写开头让组件标签与 <div>、<button>、<main> 这类原生元素明显不同,也让文件名、导入名、模板标签保持一致。
props 则遵循另一套更贴合各自语言的习惯:JavaScript 声明用 camelCase,模板传递用 kebab-case。
<script setup>
defineProps({
productName: String,
unitPrice: Number
})
</script>
<template>
<ProductCard
product-name="手冲咖啡壶"
:unit-price="269"
/>
</template>这里的 product-name 会对应子组件中的 productName,unit-price 对应 unitPrice。
如果模板直接写在浏览器 DOM 里,HTML 解析会忽略大小写差异,这时需要使用 kebab-case 且写完整闭合标签。不过现代 Vue 工程的 .vue 模板会在构建阶段编译,我们这一节都使用 PascalCase 自闭合标签,不把两种环境混在一起。
Card.vue、Item.vue、Box.vue 太宽泛。项目里出现多个同名概念后,你很快会分不清它们。ProductCard.vue、CartItem.vue、ShippingAddressCard.vue 更容易搜索,也更能说明组件负责什么。
如果一个组件只能在某个父组件里使用,可以让名字体现这种关系,例如:
CheckoutPage.vue
CheckoutAddress.vue
CheckoutPayment.vue
CheckoutSummary.vue这不是硬性语法,而是一种阅读提示。组件越多,名字越应该帮助人理解边界,而不是只追求短。
目前的 ClickCounter 完全靠自己工作。但很多组件需要父组件提供内容:商品卡片需要商品名和价格,用户头像需要图片地址与姓名,提示条需要状态和文案。
props 就是子组件公开声明的外部输入。先看一个最小例子:
<!-- ProductBadge.vue -->
<script setup>
defineProps({
label: String,
color: String
})
</script>
<template>
<span :style="{ color }">{{ label }}</span>
</template>父组件这样传入:
<ProductBadge label="新品" color="#c2410c" />defineProps 不需要从 vue 导入。它是 <script setup> 中由编译器识别的宏。声明 props 后,模板可以直接使用 label 和 color;如果脚本逻辑也要读取,则保存返回对象:
<script setup>
import { computed } from 'vue'
const props = defineProps({
price: Number,
discount: Number
})
const finalPrice = computed(() => {
return props.price * props.discount
})
</script>把 defineProps 想成收件清单很有帮助。父组件传来的内容分成两类:清单里声明过的是 props;没声明的 class、id、原生事件等,可能会变成稍后要讲的透传属性。

下面两行看起来接近,得到的类型却不同:
<ProductCard price="269" />
<ProductCard :price="269" />第一行传入字符串 '269',第二行的 : 是 v-bind 简写,后面会按 JavaScript 表达式求值,所以传入数字 269。
布尔值、数组和对象也要通过绑定传递:
<ProductCard
featured
:in-stock="true"
:tags="['厨房', '手冲']"
:seller="{ name: '山野器物', level: 4 }"
/>单独写 featured 表示传入布尔值 true。如果写 featured="false",得到的并不是布尔值 false,而是一个非空字符串。要传布尔值,写成 :featured="false"。
当父组件已有响应式数据时,绑定会继续保持联系:
<script setup>
import { ref } from 'vue'
import ProductCard from './components/ProductCard.vue'
const currentPrice = ref(269)
</script>
<template>
<ProductCard :price="currentPrice" />
<button @click="currentPrice -= 20">父组件降价</button>
</template父组件把 currentPrice 从 269 改成 249 后,子组件收到的 price 也会更新。这条连接由父到子,方向非常明确。
数组语法足够短:
defineProps(['title', 'price'])但课程示例会优先使用对象语法,因为它能同时表达类型、必填、默认值和自定义校验:
<script setup>
const props = defineProps({
title: {
type: String,
required: true
},
price: {
type: Number,
required: true
},
status: {
type: String,
default: 'available',
validator(value) {
return ['available', 'sold-out', 'preorder'].includes(value)
这段声明可以直接读成组件契约:title 和 price 必须传;status 不传时是 'available',而且只允许三个状态;tags 不传时是一个空数组。
type 常见值包括 String、Number、Boolean、Array、Object、Date 和 Function。开发环境中类型或校验不符合时,Vue 会在控制台给出警告。警告不会替你修复数据,也不等同于面向用户的表单校验,它主要帮助开发者尽早发现组件使用错误。
下面的写法是对的:
tags: {
type: Array,
default() {
return []
}
},
preferences: {
type: Object,
default() {
return {
compact: false
}
}
}工厂函数会为每个组件实例返回新的数组或对象。这样,一张卡片往自己的默认标签数组里放内容时,不会影响另一张卡片。字符串和数字不会共享同一个可变对象,可以直接写 default: 'available' 或 default: 0。
还有一个容易误判的点:默认值只会在 prop 解析结果为 undefined 时使用。父组件如果明确传入 null,通常不会自动替换成默认值。null 往往表达“我知道这里没有值”,而 undefined 更接近“没有提供”。
自定义 validator 适合检查枚举范围、数字区间或简单格式:
rating: {
type: Number,
default: 0,
validator(value) {
return value >= 0 && value <= 5
}
}校验发生在组件实例完整创建之前。因此不要在 default 或 validator 里尝试读取组件自己的 ref、计算属性或 DOM。它们描述的是输入规则,不是组件运行后的业务流程。
也不要把校验器当成安全边界。浏览器里的前端校验始终可能被绕过;真正要写入服务端的数据仍需在服务端验证。这里的目标是让组件使用方式更清楚,让错误更早暴露在开发控制台。
props 的方向是从父到子。父组件更新数据,新的值流到子组件;子组件不能直接给 prop 顶层绑定赋值。
<script setup>
const props = defineProps({
price: Number
})
function applyDiscount() {
props.price = props.price - 20
}
</script>这段代码会触发只读警告。不是因为 Vue “小气”,而是因为 price 的所有权在父组件。
想象父组件同时把一个价格传给商品详情、购物车摘要和结算栏。如果某个子组件能悄悄修改它,另外两个组件就会在没有明显操作入口的情况下变化。调试时你只看到价格变了,却不知道是谁改的。只读规则迫使修改回到数据拥有者那里,变化路径更容易追踪。

有时父组件只是提供初始值,之后子组件应该独立编辑。例如一个可展开面板,父组件决定首次是否展开,用户点击后由面板自己管理。
<script setup>
import { ref } from 'vue'
const props = defineProps({
initialOpen: {
type: Boolean,
default: false
}
})
const isOpen = ref(props.initialOpen)
</script>
<template>
<section>
<button @click="isOpen = !isOpen"
isOpen 使用 prop 作为初始值,之后与 initialOpen 分开。父组件后来再改变 initialOpen,局部 isOpen 不会自动重置。这个行为是否正确,要根据产品含义决定。名字里加上 initial,正是在提醒使用者:它只负责初始化。
如果只是想显示处理后的值,保留原始 prop,再创建计算属性:
<script setup>
import { computed } from 'vue'
const props = defineProps({
productName: {
type: String,
required: true
}
})
const normalizedName = computed(() => props.productName.trim())
</script>
<template>
<h3>{{ normalizedName }}</h3>
父组件传入新名字,normalizedName 会跟着重新计算。原始输入和展示变换各自清楚,不需要修改 prop。
props 顶层只读,但父子组件拿到的可能是同一个 JavaScript 对象或数组。于是这段代码虽然不该写,却可能真的改到父组件状态:
<script setup>
const props = defineProps({
product: {
type: Object,
required: true
}
})
function rename() {
props.product.name = '被子组件改掉的名字'
}
</script>props.product = otherProduct 会被只读规则拦住,props.product.name = ... 却是在改同一个对象内部的成员。Vue 若要彻底阻止所有深层变更,需要为任意嵌套结构付出很高成本,所以这里不会替你做深冻结。
这也是很多“父组件数据怎么自己变了”的来源。除非父子组件在设计上就是紧密协作、并且团队明确接受这种共享修改,否则子组件应该发出事件,让父组件完成变更:
<script setup>
const props = defineProps({
product: {
type: Object,
required: true
}
})
const emit = defineEmits(['rename'])
function requestRename() {
emit('rename', {
id: props.product.id,
name: '新的商品名'
})
}
</script>
事件通信会在下一节展开。这里先记住一个判断:props 告诉子组件“你拿到了什么”,事件告诉父组件“用户想做什么”。一个负责输入,一个负责通知,数据所有权不会含糊。
看到 props 是对象或数组时,不要因为“没有给 props 本身重新赋值”就认为修改安全。继续检查 .push()、.splice() 和嵌套属性赋值,它们可能直接改变父组件持有的同一份数据。
现在把前面的知识放进一个能直接运行的例子。页面由父组件保存商品数组,ProductCard.vue 只负责展示一件商品。每张卡片收到不同 props,内部保留自己的“是否展开详情”状态,并把收藏操作通知给父组件。
项目结构如下:
src/
├── components/
│ └── ProductCard.vue
├── App.vue
└── main.jsProductCard.vue<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
id: {
type: Number,
required: true
},
name: {
type: String,
required: true
},
price: {
type: Number,
required: true,
validator(value) {
return value >= 0
}
App.vue<script setup>
import { computed, ref } from 'vue'
import ProductCard from './components/ProductCard.vue'
const products = ref([
{
id: 1,
name: '手冲咖啡壶',
price: 269,
description: '细口壶嘴让水流更稳定,适合练习分段注水。',
tags: ['厨房', '手冲'],
featured: true,
favorite:
main.js 保持很短:
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')页面打开后会并排出现两张商品卡片。第一张带淡黄色推荐背景,显示“今日推荐”;第二张的按钮显示“已收藏”。右上角汇总是“已收藏 1 件”。
点击第一张的“查看说明”,只会展开第一张的说明,因为 detailsOpen 是每个 ProductCard 实例自己的状态。点击第一张“收藏”,子组件发出商品 id,父组件找到对应商品并修改 favorite。随后第一张按钮变成“已收藏”,汇总变成“已收藏 2 件”。
注意数据所有权的安排:
App.vue 持有,因为汇总和所有卡片都需要它们。ProductCard.vue 内部。ProductCard 通过 props 阅读商品,不直接修改 product.favorite。这比“所有状态都放父组件”或“让子组件随手修改父对象”更平衡。判断状态放在哪里,不看文件大小,而看哪些界面需要共享它、谁应该对修改负责。
我们经常这样使用按钮组件:
<BaseButton id="save-button" class="wide" @click="saveOrder">
保存订单
</BaseButton>假设 BaseButton 没有把 id、class 或 click 声明成 props 与事件,它们会成为透传属性。若组件只有一个根元素,Vue 默认把这些内容加到根元素上:
<!-- BaseButton.vue -->
<template>
<button class="base-button" type="button">
<slot />
</button>
</template>最后的原生按钮会同时得到 id="save-button"、组件内部的 base-button 类、父组件传来的 wide 类和点击监听。class 与 style 会合并,而不是简单覆盖。
这个默认行为让基础组件很好用:父组件可以补充布局类、无障碍属性或原生事件,不必让子组件为每个 HTML 属性都声明一个 prop。

后来为了加图标,我们给按钮套了一层:
<template>
<div class="button-wrapper">
<button class="base-button" type="button">
<slot />
</button>
</div>
</template>这时父组件写的 @click 默认落在外层 <div> 上,id 也落在外层,而你可能希望它们属于真正可点击的 <button>。可以关闭自动继承,再把 $attrs 绑定到正确位置:
<script setup>
defineOptions({
inheritAttrs: false
})
</script>
<template>
<div class="button-wrapper">
<button
class="base-button"
type="button"
v-bind="$attrs"
>
<slot />
</
v-bind="$attrs" 会一次绑定所有未被 props 或已声明事件消费的属性。现在 id、额外的 class、aria-* 与监听器都落在内层按钮。
Vue 3 允许模板有多个根节点:
<template>
<header>标题栏</header>
<main>主要内容</main>
<footer>页脚</footer>
</template>如果父组件给它传 class 或 id,Vue 无法猜测应该放在 header、main 还是 footer,于是会在运行时警告。解决方法不是随便再包一层,而是根据语义选定落点:
<script setup>
defineOptions({
inheritAttrs: false
})
</script>
<template>
<header>标题栏</header>
<main v-bind="$attrs">主要内容</main>
<footer>页脚</footer>
</template>如果三个区域都不适合继承,就不要绑定 $attrs,并在组件接口中明确提供需要的 props。属性透传看似是一个小语法,背后仍然是边界设计:父组件交来的附加内容,究竟属于子组件里的哪个真实元素?
调试点击事件“怎么点外边也触发”或样式“怎么加到了奇怪的容器”时,先检查组件的根节点与属性透传。尤其是基础按钮、输入框和链接组件,多包一层元素就可能改变默认落点。
组件报错时,最容易做的是一头扎进内部逻辑。更稳定的顺序是先检查边界,再检查状态和渲染。
先按下面顺序看:
.vue 文件,路径和文件名大小写是否一致。ProductCard 却写成 <ProductsCard />。v-if 挡住,或 v-for 的数据是否为空。在 <script setup> 中不需要再写 components: { ProductCard }。如果照搬 Options API 示例又补一份注册配置,反而会把两套写法混在一起。
看到“期望 Number,实际 String”时,先检查父组件有没有漏写冒号:
<!-- 错误地传入字符串 -->
<ProductCard price="269" />
<!-- 传入数字 -->
<ProductCard :price="269" />看到必填 prop 缺失,检查的不是子组件模板,而是所有使用这个子组件的父模板。一个组件被复用十次,只要其中一次漏传,就会出现警告。
如果 validator 返回 false,把传入值打印出来之前先看声明范围。很多问题只是大小写或枚举值没对齐,例如父组件传 'soldout',子组件只接受 'sold-out'。
搜索对子组件 props 对象的深层修改:
props.product.name = '...'
props.tags.push('...')
props.items.splice(0, 1)再检查是否把同一个对象传给多个子组件。把修改移回父组件,通过事件表达意图,通常能恢复清楚的数据路径。
打开浏览器元素面板,观察 class、id 和事件对应的真实 DOM 节点。组件标签不会原样变成浏览器里的 DOM 元素,它最终会被组件模板生成的节点替代。单根组件默认透传到根节点;包装层和多根节点则要显式安排 $attrs。
如果为了看一张卡片要连续打开五个只含两三行模板的文件,边界可能拆得太细。可以把只服务于同一个小职责、不会独立复用的结构重新合并。
反过来,如果一个组件同时请求数据、管理筛选、渲染列表、处理弹窗、维护表单,它可能承担太多责任。优先沿着用户能感知的界面角色拆分,例如筛选栏、结果列表、编辑弹窗,而不是机械地按 HTML 标签拆。
项目初期看起来省了几行 import,后期却很难知道组件来自哪里。除非它真的是应用级基础词汇,否则就在使用处导入。
const localPrice = ref(props.price)这只把当前值作为初始值。父组件之后更新 price,localPrice 不会自动同步。如果需要一直派生,用 computed;如果只想初始化,请把 prop 命名为 initialPrice,让意图更明显。
对象与数组默认值应该由函数返回,确保每个实例得到独立值。否则实例之间可能共用同一个可变对象,出现“一张卡片改了,其他卡片也变”的问题。
开发环境中的 props 校验主要发出控制台警告。组件仍可能继续渲染,生产数据也不能靠它保证安全。模板要能合理处理可选值,服务端仍要验证外部输入。
class、id、aria-* 和原生事件很适合透传。复杂业务数据和明确的组件能力应该声明为 props 或事件。边界越重要,接口越应该写得清楚,而不是都藏在 $attrs 里。
product-card.vue、Product.vue、<goods-item> 混在一起不会立刻报错,却会持续增加搜索成本。单文件组件项目里尽量保持 ProductCard.vue、import ProductCard、<ProductCard /> 三者一致。
下面的练习不追求大页面。每一道都在验证一个组件边界判断,先动手,再展开答案。
创建 DisclosurePanel.vue,接收 title 和 initialOpen。在 App.vue 使用三次,每个实例能独立展开和收起。思考:哪个值属于父组件,哪个状态属于子组件?
子组件声明如下:
defineProps({
pageSize: Number,
showSummary: Boolean
})父组件写成:
<ResultList page-size="20" show-summary="false" />请修复传值,并解释原代码实际传入了什么。
父组件把 profile 对象传给 ProfileCard。子组件当前通过 props.profile.name = newName 修改姓名。请重新设计这条数据路径。
SearchField.vue 的模板外层是 <label>,内部是 <input>。父组件会传入 id、placeholder、aria-label 和 @input。请让这些属性与监听器落到 <input>,不要落到 <label>。
从你写过的页面中挑一个最大组件,列出它管理的状态、渲染的界面区域和对外输入。尝试拆出一个子组件,同时满足以下条件:能用一句话说明职责;props 不超过当前真正需要的数据;子组件不直接改父组件对象。
回到开头的白板,三张商品卡片已经不再是三份逐渐走样的复制代码。它们来自同一个组件定义,各自又是独立实例:结构与样式统一,名称、价格和标签从 props 进入,展开状态留在每张卡片内部。以后再评审一块准备拆出的界面,可以先为它回答四个问题:
defineProps?能回答这四个问题,界面边界和实例边界大多已经清楚了。到这里,我们仍然没有抽取跨组件复用的有状态逻辑;那是第 12 章讨论 composable 时要解决的问题。眼下更重要的是先让每个组件的职责、局部状态和外部输入各归其位。
白板右下角还留着一张没有处理的便签:商品名已经通过 prop 送进卡片,用户也在卡片里的输入框完成了重命名,但子组件不能直接改掉父组件持有的商品数据。props 已经画出了数据向下的箭头,rename 这次操作意图又该怎样送回去?这根向上的箭头仍然空着,白板任务也停在这里:父组件怎样接住意图,并真正修改数据?