人人都会AI编程

21.1 减少不必要的组件重渲染

更新时间:2026-07-09

Vue 的响应式系统已经足够智能:数据变了,Vue 知道该更新哪个组件的哪个部分。但“知道该更新”不等于“更新的代价为零”。如果你的页面组件层级深、数量多,或者某个高频更新的数据触发了大面积的重渲染,用户依然会感到卡顿或掉帧。本节聚焦于如何避免那些“理论上需要,但实际上没必要”的重渲染,让 Vue 尽可能少干活。

1. v-once:只渲染一次,永不更新

如果你的组件里有一部分内容在首次渲染后绝不可能改变(比如静态的帮助文本、页脚版权、展示型标题),可以用 v-once 指令把它标记为“一次性渲染”。Vue 在首次渲染后会缓存其 VNode 和真实 DOM,后续任何数据变化都不会再触碰它。

<template>
  <div>
    <!-- 无论外部数据怎么变,这个 h1 永远不会重新渲染 -->
    <h1 v-once>{{ siteTitle }}</h1>
    <p>{{ dynamicContent }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const siteTitle = '我的博客' // 即使将来你改了 siteTitle,h1 也不会变
const dynamicContent = ref('今天天气真好')
</script>

使用建议v-once 适用于完全确定的静态内容,不要把它用到依赖响应式数据且数据未来可能会变的节点上。如果节点包含子组件,其子组件也会被一次性渲染并缓存。

2. v-memo:选择性缓存子树

Vue 3.2 引入的 v-memo 提供更精细的控制:你告诉 Vue “只有当依赖列表中的任何一个值变化时,这部分子树才需要重新渲染”。这非常适合长列表中每个条目的优化。

<template>
  <div v-for="item in list" :key="item.id" v-memo="[item.name === 'Vue']">
    <p>{{ item.name }}</p>
    <p>{{ item.desc }}</p>
  </div>
</template>

这里 v-memo="[item.name === 'Vue']" 的含义是:当 item.name === 'Vue' 这个布尔值不变时,该条目的子树直接复用缓存,跳过重新渲染。通常你会配合一个用于判断“是否需要更新”的条件表达式,例如:

<div v-for="item in list" :key="item.id" v-memo="[item.selected]">
  <!-- 只有 item.selected 改变时,这个 div 才会更新 -->
</div>

v-once 的区别v-once 是永不再变,v-memo 是“条件性不变”,依赖数组一旦变化,照样重新渲染。v-memo 还可以用在非列表的单个元素上。

3. 组件拆分与状态下放:让更新更局部

很多时候一个组件“看起来”很大、重渲染开销高,是因为不该放在这个组件里的状态被放在了顶层。一个经典反模式:

<!-- 父组件:逻辑臃肿,频繁更新的状态导致子组件也跟着重渲染 -->
<script setup>
import { ref } from 'vue'
import ExpensiveChild from './ExpensiveChild.vue'

const inputText = ref('')
</script>

<template>
  <input v-model="inputText" />
  <ExpensiveChild /> <!-- inputText 每输入一个字,这个组件都会重新渲染 -->
</template>

问题在于 ExpensiveChild 根本不需要知道 inputText,但因为它的父组件发生了更新,Vue 默认会对子组件进行 patch,导致无谓的渲染判断。

解决方案:将输入逻辑下沉到独立子组件中。

<!-- Parent.vue -->
<template>
  <SearchInput />
  <ExpensiveChild />
</template>

<!-- SearchInput.vue -->
<script setup>
import { ref } from 'vue'
const inputText = ref('')
</script>
<template>
  <input v-model="inputText" />
</template>

现在 inputText 的变化只会更新 SearchInput 组件内部,ExpensiveChild 因为它的父组件 Parent 没有自己的响应式数据变更,所以直接被跳过更新。这就是“状态下放”原则:将响应式状态尽可能放在使用它的最小组件中,不要堆积在父组件。

另一种模式:当子组件接收的 Props 没有变化时,Vue 也会跳过子组件的更新,但前提是父组件自身不因其他状态变化而重新渲染。如果你确实无法拆分状态,可以用 v-memo 或下面提到的 computed 缓存来降低开销。

4. 计算属性缓存:避免模板中的重复计算

模板里直接写方法调用看似方便,实则蕴藏性能陷阱:

<template>
  <p>{{ heavyComputation(data) }}</p>  <!-- 每次重新渲染都会执行 -->
  <p>{{ heavyComputation(data) }}</p>  <!-- 又执行一次 -->
</template>

heavyComputation 是一个纯函数,但模板里每引用一次就会执行一次。如果该函数里有遍历、排序等耗时操作,再加上频繁的重渲染,页面就会变慢。

用计算属性解决:

<script setup>
import { computed } from 'vue'
const props = defineProps(['data'])
const processed = computed(() => heavyComputation(props.data)) // 只执行一次,且缓存
</script>

<template>
  <p>{{ processed }}</p>
  <p>{{ processed }}</p> <!-- 直接从缓存拿 -->
</template>

计算属性 computed 会缓存它的计算结果,只有当它的响应式依赖(这里 props.data)变化时,才会重新求值。而且无论模板里用了多少次 processed,底层函数都只执行一次。这比手写 watch + 手动赋值更简洁,也比 methods 更高效。

5. 避免在模板里创建内联函数和对象

每次组件渲染,模板中的箭头函数或对象字面量都会创建新的引用,导致依赖此属性的子组件误以为 Props 发生了变化,从而触发不必要的更新:

<!-- 反模式 -->
<Child :onClick="() => handleClick(item.id)" />
<Child :style="{ color: 'red' }" />

改为提前绑定或使用计算属性:

<script setup>
import { computed } from 'vue'
const childStyle = { color: 'red' }
// 或 computed,如果引用动态值
</script>
<template>
  <Child :onClick="handleClick" />
  <Child :style="childStyle" />
</template>

如果必须传递参数,可以在子组件内使用 $emit 或回调模式,而不是每次都创建新函数。

6. 善用 shallowRefshallowReactive 控制深层响应范围

如果你的某个共享状态只关心顶层引用的替换,而不关心其内部属性的变更,可以使用浅层响应式 API,避免深层代理的开销和多余的追踪:

const settings = shallowRef({ theme: 'dark' })
// 只有 settings.value = newObj 时才触发更新,内部属性修改不受追踪

适用于大型只读数据、第三方实例对象的包裹,能有效减少不必要的依赖收集。

总结:将性能优化变成习惯

减少不必要的组件重渲染,核心是为 Vue 提供更清晰的依赖信息——告诉它哪些模块是“永远不会变”、“依赖这几个值时才变”或“只在特定条件下变”。这些优化小而具体,却能在大型项目中积累出肉眼可见的流畅度提升。多数场景下,写下 v-once、拆分一个小组件、把重复计算挪到 computed,就足以化解潜在的性能瓶颈。