Vue核心机制:props与ref的正确使用与常见坑位解析
2026/9/10 4:26:01 网站建设 项目流程

做 Vue 项目做久了,你会发现真正决定代码上限的,往往不是那些花哨的框架 API,而是几个最基础的东西用得够不够扎实。今天想聊的是 props 配置项和 ref 属性。props 负责把父组件的数据交到子组件手里,ref 负责让父组件能触达子组件内部的 DOM 节点或者实例,这两样几乎每个组件里都会出现。可一到面试或者排查线上 bug,很多人对“为什么 props 不能被直接改”“为什么 ref 在 created 里取不到”这类问题却说不清楚。这篇文章我尽量一次讲透,结合几个真实项目里见过的场景,帮你把这两个基础点彻底打牢。

1. props配置项:组件的入参接口,写不好就是灾难

1.1 组件为什么需要props

“组件化”这个词现在已经是前端标配了,但很多人把组件化理解成“把页面拆成文件”。真正的组件化,核心是组件之间能像函数一样传参、调用和返回结果。props 就是组件对外声明的参数列表,它决定了你这个组件“能吃几碗饭”,也决定了调用方要用什么姿势给你投喂数据。

举个很常见的例子。你写了一个日志展示组件LogViewer,用来展示不同模块的日志。如果不用 props,你只能在组件内部写死日志来源,或者用一个全局变量来切换模块,换模块就得改组件代码。这显然不合理。但有了 props,父组件只需要写:

<LogViewer :module="'auth'" />

同一个组件就能展示 auth 模块的日志;再写一个:

<LogViewer :module="'order'" />

它就展示订单模块的日志。组件从“一次性页面”变成了“可复用零件”,这才是组件化的实际价值。

props 背后还有一个很重要的设计原则:单向数据流。父组件拥有数据,子组件只是“借来看”,不能随便改。这个限制初看像是麻烦,实际是在保护你。如果每个子组件都能随意修改 props,数据来源就不可预测了,一个地方改了值,另一个地方拿到的是新值还是旧值,根本说不清。到项目中期组件多了以后,这种 bug 排查起来相当痛苦。

1.2 数组声明与对象声明:不只是写法差异

props 的声明有两种写法,数组写法和对象写法。很多入门教程只教你数组写法:

export default { props: ['title', 'index', 'onChange'] }

这种写法够简单,但它有两个明显的短板:没有类型校验,也不支持默认值。比如index本来应该是一个数字,父组件不小心传成了字符串"1",模板里再做index + 1计算,结果变成"11",这种问题往往要跑到页面上才能发现。

所以到了正式项目,我更推荐用对象写法:

export default { props: { title: { type: String, required: true, default: '默认标题' }, index: { type: Number, default: 0 } } }

对象写法相当于给组件加了一份“使用说明书”。团队里其他人拿到这个组件,看一眼 props 定义就知道该传什么类型、哪些必填、不传会怎样,不需要去翻模板里的实际调用。

这里有一个很容易被忽略的细节:如果 props 类型是 Object 或 Array,default必须写成工厂函数,也就是返回一个新对象的函数:

list: { type: Array, default: () => [] }

不能直接写default: []。原因在于对象和数组是引用类型,如果直接给了一个默认数组,那么所有没有传list的组件实例会共享同一个数组引用。某处往数组里push一条数据,其他实例也会跟着变。这个 bug 非常隐蔽,线上环境里出现“我这个组件怎么多了一条数据”的时候,多半就是默认值写错了。

1.3 类型校验、默认值和自定义校验

props 对象写法不仅仅是声明类型,还能做更细的约束。比如某个size属性只允许几个固定值,可以在validator里自定义校验函数:

size: { type: String, default: 'medium', validator: (value) => ['small', 'medium', 'large'].includes(value) }

如果调用方传了mini,Vue 会在控制台给出警告,方便你提前发现问题。

对于 JavaScript 内置构造函数之外的类型,props 的type也可以填自定义构造函数。比如你有一个User类:

props: { user: { type: User } }

Vue 内部会用instanceof去判断传入的值是不是User的实例,不是就给警告。在领域模型比较重的项目里,这种写法能让组件输入的约束更严格,比单纯写Object要靠谱得多。

还有一个布尔值 props 的经典坑,我几乎每年都要提醒一遍。模板里写:

<MyComponent disabled="false" />

这个disabled拿到的是字符串"false",不是布尔值false。而字符串"false"在条件判断里是 truthy,按钮还是会处于禁用状态。正确写法是:

<MyComponent :disabled="false" />

也就是必须加v-bind(即冒号)才能让值变成布尔类型。如果你只是想让组件默认不禁用,那干脆这个属性都不用传,直接用默认值。

2. ref属性:拿DOM、拿组件实例的正确姿势

2.1 普通元素上的ref:比querySelector更省心的选择

在 jQuery 流行的年代,大家都习惯用document.getElementById去拿一个元素,再操作它的 value、className。Vue 里的ref属性,就是用来替代这种“用选择器满世界找元素”的做法。

给普通 DOM 元素加一个ref,然后在组件实例上通过this.$refs.xxx就能直接拿到这个 DOM 节点:

<template> <div> <input ref="input" type="text" /> </div> </template> <script> export default { mounted() { this.$refs.input.focus() } } </script>

为什么说它比 querySelector 更省心?有三个原因。

第一,作用域被限制在当前组件的模板里。哪怕页面其他地方也有一个 id 为input的元素,也不会影响你这边拿到的引用。

第二,ref本身就是唯一标识,不需要担心 id 会不会跟别的组件重复。大型项目里 CSS 类名和 id 很容易撞车,但ref不会,它是 Vue 在组件内部单独管理的。

第三,它的性能更好。querySelector需要去 DOM 树里做一次查找,而$refs是 Vue 在渲染过程中直接挂载好的引用,几乎零成本。

2.2 组件标签上的ref:拿到的是实例而不是DOM

ref放在自定义组件标签上时,性质就变了。这时候你拿到的不是子组件的根 DOM 节点,而是子组件的 Vue 实例。

<template> <ChildComponent ref="child" /> </template> <script> export default { mounted() { this.$refs.child.reset() } } </script>

这里$refs.childChildComponent的实例,你可以调用它内部定义的方法,比如上面的reset()。这在“父组件需要主动触发子组件行为”的场景里非常常见。比如表格组件内部封装了筛选逻辑,父组件点击“重置”按钮时,需要调用子组件的resetFilters方法:

methods: { onReset() { this.$refs.table.resetFilters() } }

直接用实例方法调用,比用 props 传一个resetFlag然后再 watch 要直观得多。

不过这里也有一个隐患:Vue2 里通过$refs拿到的子组件实例,几乎可以访问它内部所有 data 和 methods。方便是方便,但如果你的代码里出现了this.$refs.child.$refs.grandChild这种写法,说明组件封装已经坏掉了,该考虑通过 props 和事件来通信,或者把状态提升到公共的父组件。

2.3 $refs的可用时机与$nextTick的配合

$refs不是组件一创建就存在的。created生命周期里,模板还没编译成真实 DOM,$refs是一个空对象,你访问任何this.$refs.xxx都是undefined。要到mounted之后,Vue 完成了 DOM 挂载,$refs才会被填充。

这个知识点很多人知道,但遇上v-ifv-for之后还是会踩坑。比如:

<div v-if="show" ref="content">hello</div>

show的初始值是false,那么组件挂载时,$refs.content是不存在的。点击按钮把show改成true,如果紧接着访问this.$refs.content,依然会是null

原因是 Vue 的 DOM 更新是异步的。你改数据之后,真实 DOM 不会立刻变化,要等 Vue 的更新队列执行完。所以必须用$nextTick

methods: { onShow() { this.show = true this.$nextTick(() => { this.$refs.content.getBoundingClientRect() }) } }

$nextTick的回调会在 DOM 更新完成后执行,这时候再访问$refs.content就有值了。弹窗打开后自动聚焦输入框、列表加载完成后滚动到指定位置,这类场景都要记住这个套路。

3. 最容易翻车的几个场景,我帮你一条条排查

3.1 手贱改了props,数据却没按预期走

场景很典型:子组件props接收了一个visible,某天需求加了一个“点击遮罩层就关闭弹窗”的功能,你顺手在子组件里写了一句this.visible = false

在 Vue2 开发模式下,控制台会给你一句警告:Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders.但代码不会报错,this.visible也确实被改成了false

真正让你头疼的是后面:这个改动不会同步回父组件。父组件里的visible还是true,一旦父组件因为其他数据变化而重新渲染,子组件会被用原来的visible=true再次刷新,你的修改瞬间被覆盖。如果哪里还监听了visible,时序上完全说不清。

为什么 Vue 要禁止直接修改 props?因为 props 的设计原则是单向数据流。数据所有权在父组件,子组件只是消费者。如果有多个子组件都接收同一个 props,其中一个悄悄地改了它,其他子组件看到的数据就全乱了。这种“跨组件暗改数据”的 bug,定位周期往往很长。

正确的做法,是让子组件发一个事件给父组件,由父组件来更新:

this.$emit('update:visible', false)

父组件配合.sync修饰符:

<Dialog :visible.sync="visible" />

这等于把数据的修改权交还给数据所有者,逻辑链路是通畅的。

3.2 明明写了ref,为什么拿到的却是null

“我明明写了 ref,为什么this.$refs.xxx是 null?”这是社区里高频问题之一。遇到这个问题,可以按下面这个顺序排查。

首先看访问时机。是不是在created里访问的?如果是,那就是生命周期问题,$refs还没挂,移到mounted$nextTick里就行。

其次看条件渲染。ref 所在的元素是不是被v-if控制,而且当前条件为false?如果v-if还没渲染出元素,$refs自然拿不到。

再看是不是被v-for包着。在v-for中,ref拿到的是数组,不是单个对象。你访问this.$refs.xxx时,它其实是一个数组,得用索引访问。

最后看异步时序。接口回调里、setTimeout回调里访问$refs,如果此刻组件已经被销毁,或者对应 DOM 还没有渲染出来,也会拿到null

我把常见的几种情况和处理方式整理成一张表:

现象可能原因处理方式
created 中打印 $refs 为 undefined实例尚未挂载移到 mounted 或 $nextTick
v-if 为 false 时 ref 为 nullDOM 未渲染条件满足后再访问
v-for 中 ref 取到的不是对象循环多实例通过索引或数组方式访问
接口回调里 $refs 为空异步时序不确定先判断 this.$refs.xxx 是否存在

其中最后一条容易被忽略。接口往返是不可控的,很可能组件已经卸载,然后你还在回调里访问$refs,甚至直接调它的方法,这时候不只是 null,还会报“Cannot read properties of null”。稳妥的做法是先判断一下:

if (this.$refs.input) { this.$refs.input.focus() }

3.3 v-for循环里的ref,是一个数组还是只有最后一项

在 Vue2 中,如果在v-for循环里写了ref,拿到的不会只有最后一项,而是一个数组,数组里包含本次循环渲染出来的所有实例或 DOM 节点。

举个例子,之前有个需求是要按页渲染一个 PDF,组件写法长这样:

<PdfPage v-for="i in numPages" :key="i" :page="i" :src="url" ref="pdf" />

然后你会发现this.$refs.pdf是一个数组,每一项对应一个PdfPage实例,想调用第 3 页的方法,就得写this.$refs.pdf[2]

这里有两个容易踩的坑。

第一个坑:数组顺序在绝大多数情况下和数据顺序一致,但如果子组件是异步渲染,或者外层包了<transition>这类会影响渲染时序的结构,数组顺序并不能严格保证。所以如果有“必须精确对应第 N 项”的需求,我更推荐在子组件内部通过 props 把自己的标识传进去,再暴露对应方法,而不是依赖$refs数组的下标。

第二个坑:$refs不是响应式的。它只是 Vue 在每次渲染后把最新引用放上去,Vue 不会因为$refs内容变化而触发重新渲染。所以不要试图把$refs写进 computed 或者模板里做数据驱动,那永远不会按你预期更新。

3.4 想维护一个“由props初始化但可以自己改”的数据

实际开发里经常遇到这种需求:子组件需要把父组件传入的值作为初始值,后续允许用户在当前组件内自由修改。

一个比较稳妥的写法是,在data中用 props 初始化,之后维护这个本地数据:

props: { initValue: { type: String, default: '' } }, data() { return { localValue: this.initValue } }

注意,这个localValue只在子组件创建时被初始化一次。父组件的initValue之后如果变了,localValue不会自动跟着变。如果你的需求真的是“初始值来自父组件,之后整个值由子组件自己管理”,那这个写法完全正确。

但如果你希望“父组件值一变,子组件也跟着变”,就需要用watch

watch: { initValue(newVal) { this.localValue = newVal } }

如果只是需要对 props 做格式化显示,不需要维护一个本地副本,那就更简单了,直接用计算属性,不要动不动就复制一份数据到 data 里。冗余数据越多,同步问题就越多。

4. 进阶用法:props和ref在真实项目里的组合

4.1 props + $emit / .sync,把数据流走通

前面说 props 是父传子,$emit 是子传父,两者配合起来才是一个完整的数据流闭环。一个典型的搜索表单组件是这样用的:

父组件:

<SearchForm :keyword="keyword" @search="handleSearch" />

子组件:

props: { keyword: { type: String, default: '' } }, methods: { onSubmit() { this.$emit('search', this.localKeyword) } }

props 把父组件的值传进来,子组件在合适的时机触发$emit('search'),把结果回传给父组件。父组件拿到新值后更新自己的数据,数据流是完整的。

关于事件命名,我建议统一使用 kebab-case,也就是短横线命名。因为 Vue2 的事件名不像 props 那样有自动的大小写转换,$emit('myEvent')@my-event并不保证能对上。用 kebab-case 可以避免这类问题,也让模板里的写法更统一。

如果你只是想做一个“父组件数据与子组件内部显示状态同步”的组件,可以直接用.sync修饰符,少写很多模板代码:

<Dialog :visible.sync="visible" />

子组件内部只需要:

this.$emit('update:visible', false)

这本质上还是 props + $emit,只是 Vue 帮你把事件名约定成了update:propName的格式,省去了在模板里手动监听和赋值的重复代码。

4.2 ref + $nextTick,解决弹窗、动画、表格联动

前面讲了$nextTick,这里看一个组合场景:点击“新增”按钮,打开弹窗,弹窗中的输入框自动聚焦。

<el-dialog :visible.sync="dialogVisible" @open="handleDialogOpen"> <input ref="nameInput" /> </el-dialog>
methods: { handleDialogOpen() { this.$nextTick(() => { this.$refs.nameInput && this.$refs.nameInput.focus() }) } }

为什么要$nextTick?因为弹窗的open事件触发时,弹窗内容可能还没有完全渲染成 DOM,直接访问this.$refs.nameInput会拿到 null。等一个 tick 之后,Vue 完成 DOM 更新,输入框才真正存在。

另一个常见场景是表格联动。父页面有两个表格,左边选中学历,右边显示对应学历下的学生列表,点搜索时两个表格都要清空选中状态。如果表格组件内部没有暴露清空方法,父组件可以通过 ref 调用:

this.$refs.leftTable.clearSelection() this.$refs.rightTable.clearSelection()

这也是 ref 在组件实例上的典型应用。但如果你把表格包在v-if里,比如条件不满足时不渲染表格,调用前最好先判断this.$refs.leftTable是否真的存在,否则容易报错。

4.3 命名规范与组件封装建议

props 和 ref 用久了,我慢慢总结出一些项目层面的规范,分享几个比较重要的点。

props 声明时用 camelCase,模板中使用 kebab-case。虽然在单文件组件里,:myProp这种写法也可以工作,但 HTML 属性本身是大小写不敏感的,涉及 DOM 模板时容易出现各种奇怪问题。统一用 kebab-case 可以少踩很多坑:

<SearchForm :init-keyword="keyword" />

ref 命名建议带前缀,比如uploadReftableRefformRef。因为一个页面里 ref 多起来后,很容易出现重复,不重复又能让人一眼看出它指向的是什么。在同一个组件里 ref 绝对不要重名,否则后一个会覆盖前一个。

props 的类型尽量定义得完整和准确。如果一个字段又允许String又允许Number,其实基本等于没有约束。项目里经常出现这种“宽松类型”的 direct cause 是接口返回的数据结构不统一,问题根源往往在上游,在组件层用宽泛类型兜底只会掩盖问题,建议尽早推动接口层规范化。

另外,尽量控制 ref 的使用次数。props + $emit 能解决的通信问题,就不要用 ref 去拿实例调方法。ref 的滥用会让组件之间的依赖关系变得隐晦,改一个组件时根本不知道外部谁在调用它的内部方法。我会把 ref 严格限制在“必须由外部动作触发内部行为”的场景,比如表单校验、表格清空选中、手动 setState、元素聚焦这类,其他情况优先走 props 和事件。

4.4 Vue3中的变化:defineProps、defineExpose与函数ref

如果你用的已经是我前面写过的那种 Vue3 项目,有些地方和 Vue2 不太一样,简单说几个关键差异。

props 本身仍然存在,但在<script setup>中,不再使用props: ['xxx']这种选项式声明,而是用defineProps

const props = defineProps({ title: { type: String, required: true } })

使用方式不变,defineProps返回的就是 props 对象,模板里可以直接使用title,脚本里用props.title

ref 属性在 Vue3 中依然可以使用,但有一个重要变化:父组件通过 ref 拿到子组件实例后,默认只能访问子组件通过defineExpose显式暴露的内容,不能像 Vue2 那样把子组件内部的 data 和方法全部看光。这是一种更安全的封装方式:

defineExpose({ reset, validate })

另外,Vue3 还支持函数式 ref。在循环渲染里,可以用回调函数来精确捕获每个节点的引用,避免依赖数组下标带来的顺序问题:

<input :ref="(el) => { inputEl = el }" />

这种写法的好处在列表懒加载、虚拟滚动这类场景下尤其明显,每一行数据自己捕获自己的引用,不依赖数组顺序,也不容易被异步渲染打乱。

我个人目前更倾向于在 Vue3 项目里用函数 ref 做循环列表的引用采集,尤其是表格懒加载这种场景,比之前依赖数组下标要稳得多。

最后说一个我自己的习惯。每次新建一个组件,我都会先问自己三个问题:这个组件需要外部传什么?这些数据是否允许被内部修改?外部是否需要在某个时刻主动调用组件内部的能力?想清楚之后,props 和 ref 分别出现在哪,基本就不会乱。尤其到项目中期,组件多了以后,最怕的就是$refs满天飞,改起来牵一发动全身。所以我后来会刻意控制 ref 的使用次数,能通过 props 和事件解决的就尽量不碰 ref,只有像表单校验、表格清空选中、手动聚焦这类“必须由外部动作触发内部行为”的场景才用 ref。这个习惯帮我节省了很多排查成本,也推荐你试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询