在 Vue 应用中,不必要的组件重渲染是消耗性能的主要来源之一。很多开发者会陷入一个误区:一旦页面变慢,就去加 v-once、v-memo 或手动 shouldComponentUpdate,但其实代码结构本身才是决定渲染范围的根本因素。合理地拆分组件、把状态放在最需要它的地方,能从源头上减少重渲染。
问题根源:状态提升过度
来看一个典型场景:一个父组件包含一个表单区和一个展示区,表单里有一个输入框,展示区渲染一个复杂列表。
<template>
<div>
<input v-model="keyword" placeholder="搜索" />
<ExpensiveList :items="filteredList" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const keyword = ref('')
const list = ref([...]) // 假设是很大的数组
const filteredList = computed(() => list.value.filter(item => item.includes(keyword.value)))
</script>
当用户在输入框中敲入每个字符,keyword 变化,整个父组件重新渲染,连带 ExpensiveList 也重新渲染,即使 list 根本没有变。这里的核心错误是:keyword 这个状态本应由表单内部自己管理,却被提升到了父组件,导致不相关的子组件被迫参与更新。
原则一:状态就近放置
一个状态应该被定义在使用它的最小公共祖先组件内。如果只有输入框需要知道 keyword,那就把它放在输入框组件内部,通过事件通知父组件,而不是把原始状态暴露给父级。
<!-- Parent.vue -->
<template>
<div>
<SearchBox @search="onSearch" />
<ExpensiveList :items="items" />
</div>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([...])
function onSearch(keyword) {
// 只在真正需要过滤时才触发更新
items.value = originalList.filter(item => item.includes(keyword))
}
</script>
<!-- SearchBox.vue -->
<template>
<input v-model="keyword" @input="emit('search', keyword)" />
</template>
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['search'])
const keyword = ref('')
</script>
现在父组件不再持有 keyword 这个高频变化的响应式数据。每次键盘输入只发生在 SearchBox 内部,父组件完全不受影响,ExpensiveList 只有在用户提交搜索或停止输入一段时间后才会更新。这就是“状态下放”:让动态变化尽可能在小范围内消化,不向父级扩散。
原则二:拆分组件隔离变化
当父组件中包含一些不经常变化的部分,以及一些频繁变化的部分时,应把它们拆成独立的子组件。由于 Vue 的更新粒度是组件级,一个组件的响应式数据变化只会触发该组件自身的重渲染(以及它可能传递 props 改变而触发的子组件),不会直接触发兄弟组件更新。拆分后,变化就被隔离在各自的“单元格”里。
<!-- 拆分前:一个巨型组件 -->
<template>
<div>
<header>...</header>
<aside><LiveClock /></aside>
<main><ProductList /></main>
</div>
</template>
LiveClock 每秒钟更新一次时间,如果不拆分,整个父组件每秒都要重渲染一次,ProductList 也会被连带更新(即使它的数据没变)。将 LiveClock 抽离成独立子组件后,时钟变化只影响它自己,父组件和其他子组件不受干扰。
拆分的颗粒度可以这样判断:如果一个组件的某个数据变化时,只有自己的一小块 DOM 需要刷新,而其余大片 DOM 根本不变,那这个组件就可能太大了,需要继续拆分。
原则三:让子组件成为“纯展示组件”
对于列表项、卡片、标签等,尽量让它们成为接收 props 的纯展示组件,不使用任何本地响应式状态。这样当父组件因为其他原因重渲染时,只要传给它的 props 引用没变,Vue 就会跳过该子组件的更新(因为 props 比对结果为相同)。这种组件也被称为“无状态组件”,它们是性能优化中最稳定的单元。
<!-- ListItem.vue -->
<template>
<div>{{ item.name }}</div>
</template>
<script setup>
defineProps(['item'])
</script>
在父组件中 v-for 循环渲染时,如果 item 对象引用没有变化,即使父组件因为其他状态更新而重新渲染,ListItem 也不会执行渲染逻辑。这一点依赖于 Vue 对组件 props 的浅比较,因此要注意保持 props 的引用稳定性。
什么时候不应该拆分
虽然拆分对性能有利,但不是越细越好。过度拆分会带来大量小型组件文件,增加项目理解和维护成本。只有确实存在频繁更新且范围较大的场景才值得针对性拆分。对于一次性的静态区域,或者更新频率极低的区域,保留在原组件中完全没问题。
实践总结
- 寻找“热点”:通过 Vue DevTools 的性能面板或浏览器的 Performance 工具,找出渲染耗时高或被频繁触发的组件。
- 检查状态归属:问自己“这个状态是否必要放在当前组件?它只影响哪个子组件?”,然后把它下沉。
- 隔离变化源:定时器、动画、高频输入等行为,务必封装在最小组件内。
- 善用 computed 与 props:尽量在父组件通过 computed 计算出稳定结果再传给子组件,避免子组件内部重复计算或依赖不稳定状态。
遵循这些原则,不需要任何黑科技,就能让 Vue 应用的渲染范围自然收敛,达到“代码即性能优化”的效果。