ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Vue核心机制:props与ref的正确使用与常见坑位解析

Vue核心机制:props与ref的正确使用与常见坑位解析 做 Vue 项目做久了你会发现真正决定代码上限的往往不是那些花哨的框架 API而是几个最基础的东西用得够不够扎实。今天想聊的是 props 配置项和 ref 属性。props 负责把父组件的数据交到子组件手里ref 负责让父组件能触达子组件内部的 DOM 节点或者实例这两样几乎每个组件里都会出现。可一到面试或者排查线上 bug很多人对“为什么 props 不能被直接改”“为什么 ref 在 created 里取不到”这类问题却说不清楚。这篇文章我尽量一次讲透结合几个真实项目里见过的场景帮你把这两个基础点彻底打牢。1. props配置项组件的入参接口写不好就是灾难1.1 组件为什么需要props“组件化”这个词现在已经是前端标配了但很多人把组件化理解成“把页面拆成文件”。真正的组件化核心是组件之间能像函数一样传参、调用和返回结果。props 就是组件对外声明的参数列表它决定了你这个组件“能吃几碗饭”也决定了调用方要用什么姿势给你投喂数据。举个很常见的例子。你写了一个日志展示组件LogViewer用来展示不同模块的日志。如果不用 props你只能在组件内部写死日志来源或者用一个全局变量来切换模块换模块就得改组件代码。这显然不合理。但有了 props父组件只需要写LogViewer :moduleauth /同一个组件就能展示 auth 模块的日志再写一个LogViewer :moduleorder /它就展示订单模块的日志。组件从“一次性页面”变成了“可复用零件”这才是组件化的实际价值。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 或 Arraydefault必须写成工厂函数也就是返回一个新对象的函数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) }如果调用方传了miniVue 会在控制台给出警告方便你提前发现问题。对于 JavaScript 内置构造函数之外的类型props 的type也可以填自定义构造函数。比如你有一个User类props: { user: { type: User } }Vue 内部会用instanceof去判断传入的值是不是User的实例不是就给警告。在领域模型比较重的项目里这种写法能让组件输入的约束更严格比单纯写Object要靠谱得多。还有一个布尔值 props 的经典坑我几乎每年都要提醒一遍。模板里写MyComponent disabledfalse /这个disabled拿到的是字符串false不是布尔值false。而字符串false在条件判断里是 truthy按钮还是会处于禁用状态。正确写法是MyComponent :disabledfalse /也就是必须加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 refinput typetext / /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 refchild / /template script export default { mounted() { this.$refs.child.reset() } } /script这里$refs.child是ChildComponent的实例你可以调用它内部定义的方法比如上面的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-if和v-for之后还是会踩坑。比如div v-ifshow refcontenthello/divshow的初始值是false那么组件挂载时$refs.content是不存在的。点击按钮把show改成true如果紧接着访问this.$refs.content依然会是null。原因是 Vue 的 DOM 更新是异步的。你改数据之后真实 DOM 不会立刻变化要等 Vue 的更新队列执行完。所以必须用$nextTickmethods: { 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一旦父组件因为其他数据变化而重新渲染子组件会被用原来的visibletrue再次刷新你的修改瞬间被覆盖。如果哪里还监听了visible时序上完全说不清。为什么 Vue 要禁止直接修改 props因为 props 的设计原则是单向数据流。数据所有权在父组件子组件只是消费者。如果有多个子组件都接收同一个 props其中一个悄悄地改了它其他子组件看到的数据就全乱了。这种“跨组件暗改数据”的 bug定位周期往往很长。正确的做法是让子组件发一个事件给父组件由父组件来更新this.$emit(update:visible, false)父组件配合.sync修饰符Dialog :visible.syncvisible /这等于把数据的修改权交还给数据所有者逻辑链路是通畅的。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 或 $nextTickv-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-fori in numPages :keyi :pagei :srcurl refpdf /然后你会发现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不会自动跟着变。如果你的需求真的是“初始值来自父组件之后整个值由子组件自己管理”那这个写法完全正确。但如果你希望“父组件值一变子组件也跟着变”就需要用watchwatch: { initValue(newVal) { this.localValue newVal } }如果只是需要对 props 做格式化显示不需要维护一个本地副本那就更简单了直接用计算属性不要动不动就复制一份数据到 data 里。冗余数据越多同步问题就越多。4. 进阶用法props和ref在真实项目里的组合4.1 props $emit / .sync把数据流走通前面说 props 是父传子$emit 是子传父两者配合起来才是一个完整的数据流闭环。一个典型的搜索表单组件是这样用的父组件SearchForm :keywordkeyword searchhandleSearch /子组件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.syncvisible /子组件内部只需要this.$emit(update:visible, false)这本质上还是 props $emit只是 Vue 帮你把事件名约定成了update:propName的格式省去了在模板里手动监听和赋值的重复代码。4.2 ref $nextTick解决弹窗、动画、表格联动前面讲了$nextTick这里看一个组合场景点击“新增”按钮打开弹窗弹窗中的输入框自动聚焦。el-dialog :visible.syncdialogVisible openhandleDialogOpen input refnameInput / /el-dialogmethods: { 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-keywordkeyword /ref 命名建议带前缀比如uploadRef、tableRef、formRef。因为一个页面里 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]这种选项式声明而是用definePropsconst 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。这个习惯帮我节省了很多排查成本也推荐你试试。
返回列表