
1. props 到底是什么组件化开发里绕不开的核心概念做前端这几年面试过不少人也带过几个新人几乎每轮我都会问一句“props 怎么理解”。这个问题看着基础但能一次说清楚的候选人真不多。大多数人的回答是“父组件传数据给子组件的参数”方向对了但再往下问“为什么不能直接改 props”“父组件更新后子组件何时重新渲染”“props 传对象和传字符串有什么区别”就开始含糊了。先给一个我觉得最贴切的结论props 是父组件往子组件传递数据的“入参”它定义了组件的对外接口让组件变成可配置、可复用的黑盒。换句话说props 就是组件和外部世界对话的那扇窗窗外的人通过这扇窗告诉组件“我要什么”组件根据收到的内容决定“我怎么渲染”。1.1 用生活场景理解 props 的定位把组件想象成一家餐厅的后厨父组件就是前厅点餐的顾客。顾客不会直接跑进后厨指挥厨师炒菜而是把想吃什么、辣度多少、要不要忌口写在一张点菜单上递进去后厨按单出菜。这张点菜单就是 props它上面写的内容不是菜品本身而是做出菜品所需要的“配置信息”。这个类比里藏着两个很重要的点。第一后厨不能自己修改点菜单顾客说微辣就是微辣后厨不能私自改成重辣——对应到代码里子组件不能修改 propsprops 是只读的。第二后厨做好的菜要端回给顾客但后厨不能直接伸手去改顾客手里的单子只能通过“上菜”这个动作把结果传递出去——对应到组件里就是通过事件或回调把数据反向传给父组件也就是大家常搜的“组件通信父传子子传父”这套机制。这样想就顺了props 不承载业务逻辑它只负责把父组件的意图“翻译”成子组件能理解的数据结构。你封装一个轮播图组件接收图片列表、自动播放间隔、是否显示指示器这些就是 props你封装一个分页组件接收总条数、当前页、每页条数这也是 props。组件内部怎么实现你不用关心只要把 props 喂对组件就能按要求工作。1.2 props 和组件内部状态的分工很多人把 props 和组件内部的 stateVue 里叫 data搞混其实它们的职责边界非常清楚。props 是外部传入的、组件自己不能改的数据state/data 是组件自己维护的、可以自由修改的数据。一个负责“对接外部”一个负责“管理内部”。维度propsstate / data数据来源父组件传入组件自身定义是否只读只读不能修改可变通过 setState / 赋值修改触发更新父组件重新渲染时组件自身状态变化时主要用途对外接口、配置项内部交互、临时状态举个具体例子一个带删除确认弹窗的表格组件表格数据、列配置、是否多选这些应该由父组件通过 props 传进来而弹窗当前的开关状态、正在删除的是哪一行、确认按钮是否在加载中这些是组件内部的临时状态应该放在 state/data 里自己管。有个非常典型的反面教材子组件接收到一个 props 里的初始值用户操作后需要修改这个值于是直接props.value newValue或者往 props 对象里塞新字段。这在 Vue 里会直接报警告在 React 里虽然不报错但会因为改变了父组件的数据引用导致渲染错乱。正确做法是把 props 的值先拷贝到本地状态在本地状态上修改需要通知父组件时再通过事件把新值抛出去。2. 主流框架里的 props 传递写法对比props 这个理念在不同的前端框架里长得不太一样但核心思想高度一致。React 叫 propsVue 叫 props微信小程序叫 propertiesAngular 叫 Input名字不同、写法不同本质都是父组件向子组件传参。如果你能在几个主流框架之间做到“概念迁移”那说明你是真的理解了而不是只会背某个框架的 API。2.1 React 中的 props从函数组件到 TypeScript 约束React 里 props 的传递是最直白的就是函数传参。父组件在 JSX 上写属性子组件通过函数参数接收// 父组件 function Parent() { const user { name: 张三, age: 28 }; return Child name{user.name} age{user.age} /; } // 子组件 function Child(props) { return div{props.name}{props.age}岁/div; }这种写法的问题在于props没有类型约束别人调用 Child 时不知道要传什么传错也不会在编译期报错。所以现在写 React 基本都配 TypeScript用泛型接口把 props 的形状定义清楚type ChildProps { name: string; age: number; onUpdate?: () void; children?: React.ReactNode; }; function Child({ name, age, onUpdate }: ChildProps) { return ( div span{name}{age}岁/span {onUpdate button onClick{onUpdate}更新/button} /div ); }这里有个细节值得注意函数组件里的第唯一参数就是 props我们可以直接用解构赋值把需要的字段拿出来而不是每次写props.name。但解构后字段如果太多建议在定义处用 interface 或 type 集中声明可读性会好很多。React 里props.children是个特殊角色它代表子组件标签内部嵌套的内容属于“槽位”的概念。父组件可以在标签中间传 JSX然后子组件通过children渲染这部分内容这是 props 的一种补充用法适合做布局组件、弹窗组件这类需要包裹内容的场景。这套机制的灵活性很高但滥用 children 会让组件的 props 约定变得不清晰我一般只会在明确的容器组件里使用。2.2 Vue 里的 propsOptions API 与 Composition APIVue 的 props 写法比 React 更“配置化”。在 Vue 2 的 Options API 里子组件通过props选项声明需要接收的属性// 子组件 export default { name: ChildComponent, props: { title: { type: String, required: true, default: }, count: { type: Number, default: 0 } } };父组件通过模板语法传递数据注意区分静态传值和动态绑定。写titlehello是静态字符串写:titlehelloVar或v-bind:titlehelloVar才是把变量绑定进去。新手最容易踩的坑就是把变量名当字符串传过去了组件里收到的不是变量的值而是变量的名字。Vue 3 的 Composition API 里写法换成了defineProps尤其是配合script setup使用后代码简化了很多script setup const props defineProps({ title: String, count: { type: Number, default: 0 } }); /script template div{{ title }}{{ count }}/div /template如果你用的是 TypeScriptVue 3.3 之后还支持泛型写法更简洁也更接近 React 的体验script setup langts const props defineProps{ title: string; count?: number; }(); /script这里要注意一个 Vue 特有的规范在模板里使用 props 时推荐用 kebab-case 短横线命名比如:user-namexxx而在 JS 代码里声明时用 camelCase比如userName。Vue 会自动做转换但如果你没注意大小写很容易出现传了但收不到的情况。我自己习惯是 props 声明统一 camelCase模板里如果不是特别简单的单字符 props基本都会用短横线形式。2.3 小程序、Uniapp 等跨端组件的 props 形式小程序生态里微信小程序原生组件用properties写法上更像 Vue 风格的配置对象Component({ properties: { title: { type: String, value: }, items: { type: Array, value: [] } } });在页面或父组件中使用时和 Vue 一样通过属性绑定传入数据也可以用数据绑定的方式传入变量。Uniapp 基本沿用 Vue 的语法用props接收Taro 则完全采用 React 风格用函数参数接收。这种多端场景下你只要掌握一把钥匙——“父组件通过属性传值子组件声明属性并接收”到哪个框架都不会懵。做跨端项目时我最头疼的是 props 数据的类型不统一比如从接口拿到的列表数据有时候是 null有时候是空数组。所以不管哪个框架我在组件里都会对复杂 props 加默认值尤其是数组和对象类型不给默认值很容易在渲染时直接报Cannot read properties of undefined。3. 核心实操props 传递的最佳实践与一个完整案例理解了概念、看过了写法下面进入实战。我会从一个真实的组件封装需求出发把父传子、子传父、props 校验、默认值处理全流程走一遍。这个案例就选热搜里反复出现的“轮播图组件”它非常适合讲 props因为轮播图对外需要暴露的配置项非常多而内部又依赖自身状态管理。3.1 单向数据流原则为什么不能直接修改 props先说一个核心设计原则单向数据流。React 官方文档把这个原则讲得很透数据只能从父组件流向子组件子组件收到 props 后只能读不能写。Vue 也明确规定了 props 是只读的任何尝试在子组件里修改 props 的操作都会触发警告。为什么这么设计因为如果所有组件都能随意修改自己收到的 props那整个应用的数据流向会变成一团乱麻。父组件刚把数据传下去子组件偷偷改了一下父组件不知道下次父组件重新渲染时又把旧值传下来子组件一脸懵这种问题极难排查。单向数据流保证了数据变化的源头只有一个谁的数据谁负责更新出了问题追查起来很容易。我见过不少项目里因为图省事直接在子组件里改props对象上的属性结果引发隐藏 bug。比如一个表格组件接收了父组件的dataList用户在子组件里直接 push 了一条数据父组件页面重新请求接口后数据明明变了但表格没有重新渲染或者渲染了两次。这类问题根源就在于破坏了单向数据流。正确做法是如果子组件需要修改 props 传入的数据先拷贝到本地状态再改// Vue 3 const props defineProps({ initialList: { type: Array, default: () [] } }); const localList ref([...props.initialList]); // 之后只操作 localList需要通知父组件时用 emitReact 里则是通过父组件传入的回调函数让父组件去改数据源。用户操作触发了事件子组件把“我要改什么”告诉父组件父组件在自己的作用域里执行修改数据流就保持单向且可追踪了。3.2 父传子完整案例封装一个可复用的轮播图组件需求场景首页要展示三个不同的轮播图一个在顶部做活动 banner一个在产品详情页展示商品图片一个在用户中心做公告轮播。三个轮播图的样式、交互基本相同只是数据源和配置不同。这个需求非常适合抽一个 Carousel 组件把差异项全部做成 props。定义组件时我会把 props 分成两类一类是“必须有”的比如图片列表一类是“可选项”比如自动播放间隔、是否显示指示器、是否支持手势滑动。Vue 3 里这样写script setup defineProps({ // 必传轮播图数据 slides: { type: Array, required: true, validator: (value) value.every(item item item.image) }, // 可选自动播放间隔默认 4 秒 interval: { type: Number, default: 4000 }, // 可选是否自动播放 autoplay: { type: Boolean, default: true }, // 可选是否显示指示器 showIndicators: { type: Boolean, default: true } }); /script父组件里传参时注意把静态和动态分开。图片列表是从接口拿的用:slides绑定间隔时间如果用默认值干脆不传也行template Carousel :slidesbannerList :interval3000 :autoplayisMobile :show-indicatorstrue / /template script setup import Carousel from /components/Carousel.vue; import { ref } from vue; const bannerList ref([ { image: /images/banner1.png, link: /activity/1 }, { image: /images/banner2.png, link: /activity/2 }, { image: /images/banner3.png, link: /activity/3 } ]); const isMobile ref(true); /script这样的组件设计三个页面共用一套轮播逻辑差异全靠 props 控制。以后要加一个新功能“循环播放”只需要在组件里加一个loop的 props默认值设为false需要的地方传入:looptrue完全不用动其他页面。3.3 子传父通过自定义事件 / 回调实现反向通信props 只解决了“父传子”的问题但实际场景里子组件经常需要把数据回传给父组件这就是“子传父”。Vue 和 React 的机制略有不同理念一致子组件不能直接修改父组件的数据只能发出一个信号由父组件决定如何处理。Vue 里通过emit触发自定义事件script setup // 子组件 Carousel.vue const emit defineEmits([slideChange]); function handleSlideChange(index) { emit(slideChange, index); } /script父组件监听事件template Carousel :slidesbannerList slide-changeonSlideChange / /template script setup function onSlideChange(index) { console.log(当前轮播索引, index); } /scriptReact 里则是父组件把回调函数作为 props 传入function Parent() { const handleSlideChange (index) { console.log(当前轮播索引, index); }; return Carousel slides{bannerList} onSlideChange{handleSlideChange} /; } function Carousel({ slides, onSlideChange }) { const handleIndexChange (index) { onSlideChange(index); }; // ... }这里有一个很关键的命名习惯React 生态里习惯用onXxx前缀命名回调 props比如onChange、onClick、onSlideChange这和小程序里bindchange、Vue 里change的语义是对应的。你在开发组件库或者写公共组件时保持统一的命名规范会大大降低团队协作成本。3.4 props 命名、默认值与类型校验的规范项目做得多了你会发现 props 写得好不好直接决定组件好不好用。我总结了几条实用性很强的规范。命名要尽量语义化。用visible而不是show用disabled而不是canClick用defaultChecked而不是isChecked。表单类组件尤其要注意value和defaultValue的区别前者是受控的值后者只是初始值。布尔类型的 props 要特别注意。Vue 里传布尔值必须写成:visibletrue或:visiblefalse不能写visiblefalse因为后者传的是字符串。React 里visible{true}可以直接简写成visible但我见过不少新人把:visiblefalse写反导致组件一直显示。默认值要区分数据类型。Vue 中对象和数组类型的默认值必须写成工厂函数// 错误写法 props: { list: { type: Array, default: [] } } // 正确写法 props: { list: { type: Array, default: () [] } }原因很简单如果默认值直接写[]所有没有传 list 的组件实例会共享同一个数组引用一个组件往里面 push 数据其他组件也会看到变化这个问题在列表组件里非常隐蔽。React 里则可以在函数组件外用defaultProps定义默认值或者在解构时直接给默认值function Carousel({ slides [], interval 4000 }) { // ... }给 props 做校验时required和default不要同时使用语义上就是冲突的。如果要求必传就用required: true如果有默认值就说明它本身不是必传项。只有在极少数情况下比如“如果没有传则必须有全局默认值兜底”我才会两个都写。4. 常见问题与排查技巧实录props 相关的 bug 是我过去几年排查最多的前端问题之一而且很多问题不报错只是表现不符合预期特别难定位。这里整理几个高频问题基本覆盖了日常开发里 props 相关的所有坑。4.1 子组件收不到 props 的几个典型原因第一个场景子组件里打印 props 是空的但父组件明明传了值。最常见的原因就是命名大小写不一致。比如父组件里写了:user-namexxx子组件里声明的是userName虽然 Vue 会自动转换但如果父组件写的是:userNamexxx而子组件声明的是username大小写对不上就收不到。React 里也有类似问题JSX 属性名是严格区分大小写的username和userName是两个不同的 props。第二个场景传的值是 undefined。比如父组件里:listtableData但这个tableData在初始时是undefined等接口返回数据后才赋值。子组件拿到的 props 一开始是 undefined渲染时直接报错。解决办法是给 props 设置正确的默认值或者在子组件里加空值判断。第三个场景组件被重复渲染/卸载重建。用 React 开发时如果父组件没有给列表项加key或者 key 不稳定子组件会被重复创建props 自然不稳定。Vue 里如果v-for的 key 不正确也会导致组件复用出错数据对不上。4.2 props 传引用类型时常见的深坑props 传对象和数组时传递的是引用不是拷贝。这既带来了便利也埋了坑。一个典型的坑是子组件内部修改了 props 对象的某个属性父组件没有感知到更新但子组件内部可能已经发生了一堆次生变化。比如父组件传了一个配置对象settings子组件在某个方法里直接props.settings.theme dark父组件的settings引用地址没变Vue 的响应式系统可能检测不到这个深层次变化或者检测到了但父组件没有重新渲染最终表现就是“改了但没生效”非常迷惑。另一个坑是 Vue 的响应式丢失。如果你把一个响应式对象作为 props 传入子组件子组件内部却把它赋值给了普通变量修改这个普通变量不会触发响应式更新。这种情况常见于团队里有人习惯用const data props.data先把 props 取出来然后直接改data——数据变了视图却不刷新。我的经验是不管在哪个框架里都不要在子组件里修改 props 对象的内部属性。如果确实有修改需求要么把需要的字段拷贝到本地状态要么通过事件通知父组件去修改。4.3 常见问题速查表现象常见原因解决办法子组件打印 props 为空对象命名大小写不一致 / 父组件没有绑定变量检查 props 命名确认使用冒号绑定变量子组件收的 props 是字符串而不是数字模板里写count1忘了加冒号改为:count1传入对象后修改内部属性不生效Vue 响应式丢失 / 修改了 props 引用用reactive包裹对象或通过 emit 通知父组件数组类型默认值导致组件间数据串Vue 默认值直接传[]改为default: () []父组件更新了但子组件不重新渲染React 中子组件被 memo 且 props 未变化检查 props 是否创建了新的引用或调整 memo 比较函数Vue 模板里报 “Avoid mutating prop directly”子组件直接修改了 props用本地状态代替修改后通过 emit 通知父组件组件接收的 props 是 undefined 导致渲染崩溃数据初始为空给 props 加默认值或添加 v-if / 空值判断4.4 我平时写 props 的几个习惯最后分享几个实操习惯都是被坑出来的经验。第一复杂组件的 props 尽量少而精。如果一个组件需要传十个以上的 props我会停下来想想是不是组件拆分不合理或者该不该用 context / provide-inject 做跨层级传递。props 太多调用方记不住维护成本高本质上是组件职责过重。第二props 的类型声明一定要写完整。JavaScript 项目里也建议用 JSDoc 注释或 PropTypesReactTypeScript 项目里用 interface 定义好每个字段的类型和是否可选。类型声明不止是为了让编辑器有提示更是给未来接手的人看的文档。第三事件型 props 统一前缀。React 里用onXxxVue 里用xxx风格统一后组件调用方一眼就能看出哪些是数据、哪些是事件。第四传递对象型 props 时尽量传一个稳定的引用。比如 React 里父组件每次渲染都重新创建一个对象传入子组件会让子组件即使没有任何数据变化也重复渲染。这时候用useMemo或useCallback缓存一下性能能明显改善。第五新人在写组件时可以先把“这组 props 要满足哪些场景”列出来再写代码。比如封装一个分页组件先想清楚需要接收total、page、pageSize这几个核心值还是额外接收pageSizeOptions让调用方决定分页大小的候选值。先把接口定清楚代码写起来顺很多。element ui 自带的 el-pagination 组件能被广泛使用就是因为它的 props 设计得足够清晰把页码、总数、每页条数这些边界情况都考虑周全了。写组件这件事技术上不难难的是对接口的思考。props 就是组件对外的承诺书承诺写得越清晰、越克制组件用起来就越顺手。我早期写代码时总想把所有配置项都暴露出去最后组件又肥又难维护后来学会做减法把真正必要的 props 留在外面内部逻辑自己消化组件质量反而上来了。如果你刚开始接触组件化开发不妨从手写几个带 props 的小组件开始轮播图、分页器、弹窗每个都试着用 props 去控制它的行为练上几次你就能体会到 props 设计的分寸感了。