技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

总结 Vue 性能优化方式及原理

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/56-万字长文!总结Vue性能优化方式及原理
本文目录16 个章节
  1. 一、先测量,再优化
  2. 二、插槽使用现代语法
  3. 三、使用 computed,但不要把缓存当成万能优化
  4. 四、函数式组件:Vue 2 与 Vue 3 不要混用结论
  5. 五、按场景选择 v-if 和 v-show
  6. 六、keep-alive 是时间和空间的取舍
  7. 七、不要在同一元素上混用 v-if 和 v-for
  8. 八、key 要描述业务身份
  9. 九、延迟渲染、异步组件和分帧
  10. 十、只读大数据:Vue 2 的 Object.freeze 与 Vue 3 的 shallowRef
  11. 十一、编译器、渲染函数和 JSX
  12. 十二、v-once、v-memo 与稳定 props
  13. 十三、SSR、Nuxt 4 与 hydration
  14. 十四、性能指标与工具
  15. 十五、总结
  16. 参考资料

总结 Vue 性能优化方式及原理

原文主要以 Vue 2 的性能经验为背景。本文尽可能保留原有主题和示例,但把 Vue 2.7、Vue 3.5、Nuxt 4 和 Core Web Vitals 分开说明。

版本基线:Vue 2.7 是 Vue 2 的最终 minor 版本,Vue 2 已于 2023 年 12 月 31 日结束官方维护;Vue 3.5 使用 Proxy 响应式、编译器优化和现代 renderer。旧项目可以继续阅读 Vue 2 小节,新项目优先采用 Vue 3。

一、先测量,再优化

性能优化不是“把所有技巧都用一遍”,而是:

明确问题
  ↓
区分首屏加载性能和交互更新性能
  ↓
使用 Performance、Vue DevTools、Lighthouse、CrUX/RUM 定位
  ↓
只修改瓶颈
  ↓
在相同数据和设备条件下回归

Vue 官方也建议区分 page-load performanceupdate performance。一次 Lighthouse 得分不能代表所有真实用户,更不能证明某个 Vue 写法一定更快。

二、插槽使用现代语法

2.1 Vue 2.7 和 Vue 3.5 推荐 v-slot / #

Vue 2.6 引入了统一的 v-slot 语法,Vue 2.7 仍然支持旧的 slotslot-scope,但旧语法已经过时。Vue 3 使用 v-slot 或缩写 #

<!-- Vue 2.7 / Vue 3 都可以使用的具名插槽写法 -->
<ChildPanel>
  <template #header>
    {{ title }}
  </template>
</ChildPanel>

作用域插槽需要由子组件显式提供 slot props:

<!-- ChildList.vue -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot name="item" :item="item">
        {{ item.name }}
      </slot>
    </li>
  </ul>
</template>

<script setup lang="ts">
defineProps<{
  items: Array<{ id: number; name: string }>
}>()
</script>
<!-- Parent.vue -->
<script setup lang="ts">
import ChildList from './ChildList.vue'

const items = [
  { id: 1, name: 'Vue' },
  { id: 2, name: 'Nuxt' },
]
</script>

<template>
  <ChildList :items="items">
    <template #item="{ item }">
      <strong>{{ item.name }}</strong>
    </template>
  </ChildList>
</template>

旧写法仍可以在维护 Vue 2 项目时看到:

<!-- Vue 2 旧语法:可以迁移,但不要在新代码中继续扩散 -->
<ChildPanel>
  <template slot="header">{{ title }}</template>
</ChildPanel>

原文根据编译结果得出“新语法一定让依赖从父组件转移到子组件”的结论过于绝对。插槽表达式的变量仍来自父组件作用域;Vue 3 的插槽以函数传递,子组件在渲染过程中调用它,有机会进行更精确的依赖跟踪,但是否减少更新要由具体组件树和性能分析验证。

参考:Vue 2 插槽Vue 3 插槽

三、使用 computed,但不要把缓存当成万能优化

computed 适合有明确依赖的同步派生值。它会缓存 getter 结果,依赖没有变化时重复访问可以复用结果:

<script setup lang="ts">
import { computed, ref } from 'vue'

const count = ref(1)
const superCount = computed(() => {
  let result = count.value
  for (let index = 0; index < 10_000; index++) {
    result++
  }
  return result
})
</script>

<template>
  <p>{{ superCount }}</p>
  <button @click="count++">增加</button>
</template>

Options API 写法也仍然有效:

export default {
  data: () => ({ count: 1 }),
  computed: {
    superCount() {
      return this.count + 10_000
    },
  },
}

注意:

  • computed getter 应该是纯函数,不要在 getter 中修改其他状态;
  • 异步请求、日志、写缓存等副作用应使用 watch 或事件处理函数;
  • 如果计算本身很便宜,强行增加 computed 不一定更快;
  • Vue 3.4+ 的 computed 还有稳定值相关优化,但仍需根据实际依赖分析。

四、函数式组件:Vue 2 与 Vue 3 不要混用结论

4.1 Vue 2.7 的函数式组件

Vue 2 的函数式组件没有组件实例、响应式状态和常规生命周期,适合纯展示或包装组件:

// Vue 2.7
export default {
  functional: true,
  props: {
    title: String,
  },
  render(h, { props }) {
    return h('span', props.title)
  },
}

Vue 2 SFC 还可以使用:

<!-- 仅 Vue 2 语法 -->
<template functional>
  <div class="user-profile">{{ props.name }}</div>
</template>

4.2 Vue 3.5 不要为了性能机械改成 functional

Vue 3 已移除 { functional: true }<template functional>。函数式组件只是一个接收 props/context 并返回 VNode 的函数:

// Vue 3
import { h, type FunctionalComponent } from 'vue'

const UserProfile: FunctionalComponent<{ name: string }> = props => {
  return h('div', { class: 'user-profile' }, props.name)
}

export default UserProfile

不过在 Vue 3 中,普通组件的运行时开销已经降低,函数式组件相对有状态组件的优势通常很小。原文“500 个组件约 30ms,函数式组件约 10–15ms”的个人设备测试不能外推到 Vue 3.5 或生产环境。

更可靠的做法是先减少不必要的组件抽象、稳定传给子组件的 props,再使用 Vue DevTools 和 Performance 面板确认更新范围。参考:Vue 3 函数式组件迁移Vue 性能指南

五、按场景选择 v-ifv-show

两者的基本成本模型仍然是:

  • v-if 是真正的条件渲染,条件为 false 时不会创建分支;切换时可能创建/卸载组件;
  • v-show 会先创建并保留 DOM,只切换 display;初始化成本较高,但频繁切换通常更合适。
<!-- 低频显示、隐藏时不需要保留实例 -->
<UserProfile v-if="visible" :user="user" />

<!-- 高频切换且希望保留实例和 DOM -->
<UserProfile v-show="visible" :user="user" />

经验规则可以保留,但不能绝对化:

  • 首次可能长期不可见、初始化很重的组件,通常先考虑 v-if 或异步组件;
  • 高频显示/隐藏且保留状态比销毁更重要时,可以考虑 v-show
  • 还要考虑请求、定时器、事件订阅、内存和可访问性;
  • 如果只是一个简单元素,性能差异通常不值得复杂化。

Vue 3 支持无额外 DOM 的条件块:

<template v-if="visible">
  <h2>标题</h2>
  <p>说明</p>
</template>

参考:条件渲染

六、keep-alive 是时间和空间的取舍

动态组件频繁切换时,可以缓存已经创建的组件实例:

<KeepAlive>
  <component :is="currentComponent" />
</KeepAlive>

Vue 3 可以使用 includeexcludemax

<KeepAlive
  :include="['UserList', 'UserDetail']"
  :exclude="['LoginView']"
  :max="10"
>
  <component :is="currentComponent" />
</KeepAlive>

缓存组件被切换出去时会进入 deactivated,重新显示时进入 activated,不会按普通卸载流程立即销毁。应在这两个钩子中暂停和恢复轮询、订阅等外部资源;被淘汰或父级卸载时再做最终清理。

<script setup lang="ts">
import { onActivated, onDeactivated, onUnmounted } from 'vue'

let timer: number | undefined

onActivated(() => {
  timer = window.setInterval(() => {
    // 刷新当前页面数据
  }, 10_000)
})

onDeactivated(() => {
  if (timer !== undefined) window.clearInterval(timer)
  timer = undefined
})

onUnmounted(() => {
  if (timer !== undefined) window.clearInterval(timer)
})
</script>

KeepAlive 会占用内存,缓存过多页面可能适得其反。它不是“初始化性能优化神器”,要和路由缓存、组件 name、数据刷新策略一起设计。

七、不要在同一元素上混用 v-ifv-for

这是 Vue 2 与 Vue 3 需要特别区分的地方。

7.1 Vue 2.7:v-for 优先

Vue 2 中同一元素同时使用时,v-for 的优先级高于 v-if

<!-- Vue 2.7 可以访问 user,但每次渲染仍会遍历整个 users -->
<li
  v-for="user in users"
  v-if="user.isActive"
  :key="user.id"
>
  {{ user.name }}
</li>

推荐过滤列表:

export default {
  computed: {
    activeUsers() {
      return this.users.filter(user => user.isActive)
    },
  },
}

7.2 Vue 3.5:v-if 优先

Vue 3 中优先级已经改变。同一元素的 v-if 不能访问同级 v-for 声明的变量,因此不要直接迁移上面的写法:

<!-- Vue 3 推荐:用 computed 过滤 -->
<li v-for="user in activeUsers" :key="user.id">
  {{ user.name }}
</li>

如果必须保留原数组,可以使用 <template v-for>

<template v-for="user in users" :key="user.id">
  <li v-if="user.isActive">{{ user.name }}</li>
</template>

过滤大列表时,computed 可以避免在其他无关响应式状态更新时重复执行相同的过滤逻辑,但仍应结合数据规模和 profile 判断。参考:Vue 2 风格指南Vue 3 v-if/v-for 迁移

八、key 要描述业务身份

稳定 key 让 Vue 在列表变化时复用正确的节点或组件实例:

<ListItem
  v-for="item in items"
  :key="item.id"
  :item="item"
/>

当列表会排序、插入、删除、过滤,或列表项包含 input 和组件内部状态时,不要使用 index:

<!-- 有重排风险时不推荐 -->
<ListItem
  v-for="(item, index) in items"
  :key="index"
  :item="item"
/>

但“index 绝对不能用”也过于绝对。对静态、简单、不可重排、没有局部状态的列表,不写 key 或有意识地使用 index 可能是可接受的。关键是先判断列表身份是否会变化,而不是背诵禁令。

九、延迟渲染、异步组件和分帧

原文使用 requestAnimationFrame 逐帧增加优先级,将多个 Heavy 组件分散到不同帧:

<script>
export default {
  data: () => ({ displayPriority: 0 }),
  mounted() {
    const step = () => {
      requestAnimationFrame(() => {
        this.displayPriority++
        if (this.displayPriority < 4) step()
      })
    }
    step()
  },
  methods: {
    defer(priority) {
      return this.displayPriority >= priority
    },
  },
}
</script>

<template>
  <div>
    <HeavyOne v-if="defer(1)" />
    <HeavyTwo v-if="defer(2)" />
    <HeavyThree v-if="defer(3)" />
  </div>
</template>

这个方法只是在拆分时间,不会减少总工作量,而且会产生多次父组件更新。它不适合 LCP 内容、用户点击后必须立即出现的内容,也要注意占位高度,否则可能造成 CLS。

Vue 3 更常见的优先方案是异步组件和代码分割:

import { defineAsyncComponent } from 'vue'

const HeavyPanel = defineAsyncComponent(
  () => import('./HeavyPanel.vue'),
)

Nuxt 4 还可以使用 Lazy 组件和 lazy hydration,把“下载代码”和“使 SSR HTML 具有交互性”分开处理。首屏主内容、关键图片和 SEO 内容不应为了“延迟”而盲目推后。

十、只读大数据:Vue 2 的 Object.freeze 与 Vue 3 的 shallowRef

10.1 Vue 2.7:只读树可以使用 Object.freeze

Vue 2 的 getter/setter 响应式会递归观察普通对象。对确定不会修改的大型数据使用 Object.freeze,可以阻止 Vue 2 对其进行响应式转换:

export default {
  data() {
    const rows = Array.from({ length: 10_000 }, (_, id) => ({
      id,
      name: `item-${id}`,
    }))

    return {
      rows: Object.freeze(rows),
    }
  },
}

Object.freeze() 默认只冻结传入的那一层;Object.freeze(rows) 会冻结数组容器,但不会自动冻结每个 row 对象。Vue 2 从这个冻结数组根开始也不会把它转换为响应式数组,因此嵌套对象的原地修改不会得到 Vue 的更新通知,但 JavaScript 仍然允许修改这些未冻结的嵌套对象。若确实需要整棵只读树,要显式深度冻结,并接受不可变数据的约束。文章中的“40–50ms 变成 0–1ms”只是个人设备和数据形状下的样本,不能作为保证;如果数据需要更新,应该用新的根引用替换,而不是冻结后再原地修改。

10.2 Vue 3.5:优先考虑浅层响应式和不可变更新

Vue 3 的大规模不可变数据可以使用 shallowRef

<script setup lang="ts">
import { shallowRef } from 'vue'

const rows = shallowRef(
  Array.from({ length: 10_000 }, (_, id) => ({
    id,
    name: `item-${id}`,
  })),
)

function replaceRows(nextRows: typeof rows.value) {
  rows.value = nextRows
}
</script>

浅层 ref 只追踪 .value 的替换,不追踪嵌套对象的原地修改。因此必须遵守不可变更新约定:

rows.value = rows.value.map(row =>
  row.id === targetId ? { ...row, name: 'new name' } : row,
)

这不是虚拟列表的替代品。数据量很大时,还要考虑分页、服务端筛选和只渲染可视区域。参考:Vue 性能指南:减少大型不可变数据的响应式开销

十一、编译器、渲染函数和 JSX

模板、render function 和 JSX 最终都要产生 VNode,但模板更容易被 Vue 编译器静态分析。Vue 2 构建阶段会分析静态节点,生成类似 _m(0) 的静态树缓存;这属于 Vue 2 编译器内部实现。

不要直接复制或依赖 _mstaticRenderFnsisStatic 等私有字段。它们的名称和结构不是业务 API,文章中的 markStatic 片段也只是源码分析示意。

11.1 Vue 3.5 的 compiler-informed VDOM

Vue 3 在模板编译和运行时之间协同优化:

  • 静态节点提升,避免每次重新创建;
  • 静态文本合并,减少 DOM 创建;
  • Patch Flags 标记可能变化的 text、class、style 或 props;
  • Block Tree 收集动态后代,更新时跳过稳定的静态树;
  • keyed children 在必要时使用更高效的比较和 LIS 移动策略。

这些优化通常由模板编译器自动完成。手写 JSX 不能天然得到更高性能,反而可能失去部分静态分析机会。只有模板表达力不足或确实需要动态生成 VNode 时,才选择 render function/JSX。

参考:Vue 3 渲染机制

十二、v-oncev-memo 与稳定 props

对确定不会再变化的内容,Vue 提供了专门提示:

<div v-once>{{ staticMessage }}</div>

Vue 3 还支持 v-memo,但它应该用于经过 profile 证明有收益的局部大型子树,而不是到处添加:

<div v-memo="[item.id, item.version]">
  <ExpensiveRow :item="item" />
</div>

父组件向列表项传递“真正影响该项的值”,通常比把整个变化中的全局状态传给每个子组件更稳定:

<!-- activeId 改变时,通常只有旧 active 项和新 active 项的 active prop 改变 -->
<ListItem
  v-for="item in items"
  :key="item.id"
  :active="item.id === activeId"
/>

是否减少更新仍要通过组件更新分析验证。

十三、SSR、Nuxt 4 与 hydration

Vue SSR/SSG 可以让服务器或构建阶段先输出 HTML,改善首屏可见内容和 SEO,但也引入 hydration 一致性要求:服务端和客户端第一次渲染必须得到相同结构。

Nuxt 4 中,首屏数据优先使用 useFetchuseAsyncData,让数据进入 payload,避免客户端 hydration 时重复请求:

<script setup lang="ts">
const { data, error } = await useFetch('/api/articles')
</script>

不要在 SSR setup 中直接依赖 windowdocument、随机数或本地时区。浏览器专用逻辑放到 onMounted 或客户端插件中。随机数、无效 HTML 嵌套、服务端和客户端数据不一致都可能导致 hydration mismatch,进而造成修复、闪动和交互延迟。

Nuxt 4 还提供 route rules、预渲染、SWR/ISR、Lazy 组件和 lazy hydration。它们需要按路由和内容类型选择,不应把所有页面都强制改为 CSR 或把所有内容都延迟到客户端。

十四、性能指标与工具

当前 Core Web Vitals 的“良好”阈值通常按真实用户第 75 百分位判断:

指标含义良好阈值
LCP最大内容绘制,反映主要内容加载速度≤ 2.5s
INP交互到下一次绘制的响应性≤ 200ms
CLS累积布局偏移,反映画面稳定性≤ 0.1

FID 已被 INP 取代,TTI 主要作为历史或诊断指标。Lighthouse 是实验室诊断工具,不能代替 CrUX、PageSpeed Insights、Search Console 或自己的 RUM;实验室中可以用 TBT 作为潜在交互问题的 proxy,但生产 INP 必须看真实用户交互数据。

14.1 LCP

  • 不要给 LCP 图片统一加 loading="lazy"
  • 确保首屏主图片可以从初始 HTML 发现;
  • 优化 TTFB、图片大小、格式和资源优先级;
  • 避免在首屏执行过重的 JavaScript 和 hydration。

14.2 INP

  • 减少事件处理器中的同步大计算;
  • 拆分长任务,必要时使用 Web Worker;
  • 减少不必要的组件更新和 hydration;
  • 虚拟列表、异步组件和分帧渲染要用实际交互 profile 证明收益。

14.3 CLS

  • 图片和视频声明 width/heightaspect-ratio
  • 异步组件、广告和嵌入内容预留空间;
  • 不要在内容出现后突然插入顶部区域;
  • 过渡和延迟渲染要保持稳定布局。

十五、总结

性能优化的优先级可以概括为:

  1. 先用真实数据定位瓶颈;
  2. 首屏关注 HTML 可发现性、TTFB、LCP 和 hydration;
  3. 更新性能关注稳定 props、合理的响应式边界、长任务和 INP;
  4. 列表关注稳定 key、分页、虚拟列表和服务端筛选;
  5. 按 Vue 2.7 与 Vue 3.5 分开理解 v-if/v-for、functional component、响应式和编译器;
  6. 任何“快了多少毫秒”的结论都必须带上设备、浏览器、数据规模和测量方式。

参考资料

原文作者:zhangzp。原文链接:总结 Vue 性能优化方式及原理

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS