Skip to content

Vue.js 运行时性能优化笔记

核心理念:具体情况,具体分析

优化是一个庞大的话题,没有一成不变的公式。本笔记中提到的方法都是常见的优化手段,但不应盲目应用到所有项目中。

  • 核心目标:软件是为商业服务的工具,追求的是利润(收益 - 成本)。
  • 优化原则:在投入的时间成本和带来的性能收益之间找到平衡。避免为了微不足道的性能提升(如减少 0.01ms 渲染时间)而花费大量开发时间。
  • 开发者能力:作为开发者,核心能力是掌握这些优化工具,并能在具体项目中分析性能瓶颈,针对性地解决问题。

优化技巧详解

1. v-for 循环中的 key

这是最基础也是最重要的优化点之一。Vue 的 diff 算法依赖 key 来高效地更新 DOM。

  • 目的:在列表项发生变动时,最大程度地复用已有的 DOM 元素,减少不必要的删除、新增和修改操作。

  • key 的问题:当列表顺序改变(如在列表头部插入新元素)时,Vue 会采用“就地复用”策略。它会逐个对比新旧列表的节点,如果节点类型相同,则尝试修改内容。这可能导致大量不必要的 DOM 更新。

    示例:

    • 旧列表: [A, B, C, D]
    • 新列表: [E, A, B, C, D] (在头部插入 E)
    • keydiff 过程:
      1. 对比旧 A 和新 E,发现内容不同 -> 修改 AE
      2. 对比旧 B 和新 A,发现内容不同 -> 修改 BA
      3. 对比旧 C 和新 B,发现内容不同 -> 修改 CB
      4. 对比旧 D 和新 C,发现内容不同 -> 修改 DC
      5. 新增一个元素 D
    • 结果: 4 次修改,1 次新增。
  • key 的优势:Vue 通过 key 识别出每个节点的唯一性,从而知道哪些元素只是移动了位置,而不是内容变了。

    • keydiff 过程:
      1. 发现 A, B, C, Dkey 都在,只是位置移动了。
      2. 发现 E 是一个全新的 key
      3. 直接在 A 的前面插入一个新元素 E
    • 结果: 1 次新增,0 次修改。效率远高于前者。

2. 使用冻结对象 (Object.freeze)

对于纯展示、不需要响应式更新的大型数据,冻结对象可以显著提升性能。

  • 冻结对象特性:被 Object.freeze() 处理过的对象,其属性无法被修改、添加或删除。
javascript
const user = { name: 'Alice', id: 1 };
Object.freeze(user);

user.name = 'Bob'; // 静默失败或在严格模式下报错
console.log(user.name); // 输出 'Alice'

console.log(Object.isFrozen(user)); // 输出 true
  • Vue 中的优化:当 Vue 在初始化数据时,它会深度遍历 data 对象,将每个属性转换为响应式的(通过 getter/setter)。但如果它检测到一个对象是冻结的 (Object.isFrozen()),它会跳过对该对象及其所有子属性的响应式处理。
  • 优势:免去了将大型、深度嵌套的数据转换为响应式数据所需的时间和内存开销。
  • 适用场景
    • 只需要在界面上展示,后续不会改变的数据。
    • 例如:文章列表、评论列表、历史记录等。
  • 注意:一旦冻结,该数据就不再是响应式的。任何对它的修改都不会触发视图更新。

3. 使用函数式组件 (functional)

对于无状态、无实例的纯展示组件,可以声明为函数式组件以节约性能。

  • 特点
    • 没有 this 上下文。
    • 没有 data、计算属性等状态。
    • 没有生命周期钩子。
    • 本质上只是一个接收 props 并返回渲染结果的函数。
  • 优势
    • 创建开销小:Vue 不需要为它创建完整的组件实例,减少了初始化时间和内存占用。
    • 渲染速度快。
  • 适用场景:只依赖外部 props 来展示内容的组件,如自定义按钮、图标、列表项等。
  • 语法
vue
<template functional>
  <div>{{ props.count }}</div>
</template>

<script>
export default {
  // 关键配置
  functional: true,
  props: {
    count: Number
  }
}
</script>
  • 性能对比:在渲染大量(如上万个)组件的场景下,函数式组件相比普通组件能显著减少脚本执行时间和内存消耗。

4. 使用计算属性 (computed)

当模板中多次使用到基于某个数据源的复杂运算时,应优先使用计算属性。

  • 问题:如果在模板中直接写方法调用或复杂表达式,每次组件重新渲染时,这些方法和表达式都会被重新执行
    html
    <div>{{ money.toFixed(2) }}</div>
    <p>总计:{{ money.toFixed(2) }}</p>
  • 优势:计算属性是基于它的响应式依赖进行缓存的。只有在相关依赖发生改变时,它才会重新求值。如果依赖不变,多次访问计算属性会立即返回之前的计算结果,而不会再次执行函数。
  • 适用场景:任何在模板中需要进行数据转换、格式化或复杂计算的场景。
  • 权衡:计算属性不接受参数。如果需要根据不同参数动态计算,还是需要使用方法。

5. 非实时绑定的表单项

v-model 会在每次 input 事件触发时同步数据,这可能导致性能问题,尤其是在有动画或其他高频任务同时进行的页面。

  • 问题:高频的 input 事件 -> 高频的数据更新 -> 高频的 re-render -> JS 线程长时间占用 -> 阻塞浏览器渲染线程 -> 动画卡顿。
  • 解决方案
    1. v-model.lazy:将数据同步的时机从 input 事件改为 change 事件(即输入框失去焦点时)。
      html
      <input v-model.lazy="message">
    2. 手动绑定:不使用 v-model,而是监听特定事件(如 keydown.enter 或按钮 click),手动从 DOM 元素获取值并更新数据。
      html
      <input :value="message" @keydown.enter="updateMessage">
      javascript
      methods: {
        updateMessage(event) {
          // 直接从事件目标获取值,而不是通过 data
          const newValue = event.target.value;
          // 在需要的时候才更新数据
          this.addToList(newValue);
          event.target.value = ''; // 手动清空输入框
        }
      }
  • 权衡:手动绑定虽然性能更好,但背离了 Vue 数据驱动的核心思想,引入了直接的 DOM 操作,可能会降低代码的可读性和可维护性。v-model.lazy 是一个很好的折中方案。

6. 保持对象引用稳定

Vue 通过 === 来判断数据是否变化。如果一个对象的引用地址没有变,Vue 会认为它没有变化,从而跳过不必要的更新。

  • 反面模式:每次更新列表(如添加一项)后,都从服务器重新请求整个列表数据,然后用新数据完全替换掉 data 中的旧数组。
    javascript
    // 错误做法:每次都获取全量数据并替换
    async addComment() {
      await api.add(); // 请求服务器添加
      // 重新获取所有评论,这会生成一个全新的数组和全新的对象
      this.comments = await api.getComments(); 
    }
  • 问题:即使列表中的旧数据内容完全没变,但由于 JSON.parse 会为每个对象创建新的内存地址,导致新数组中所有对象的引用都与之前不同。当这些新对象作为 props 传递给子组件时,所有子组件都会因为 prop 引用变化而强制重新渲染
  • 正确做法:只对数据进行增量更新。让后端在添加成功后返回新创建的那一项数据,然后手动将其 push 到现有数组中。
    javascript
    // 正确做法:增量更新
    async addComment() {
      // API 返回新增的那条评论
      const newComment = await api.add();
      // 只在原数组上追加,不改变原有对象的引用
      this.comments.push(newComment);
    }
  • 好处:保证了已有对象的引用稳定,只有新增的组件会被创建和渲染,其他组件不会进行不必要的 update

7. 合理使用 v-ifv-show

  • v-if:是“真正”的条件渲染。如果条件为假,组件及其子组件会被完全销毁,DOM 中不存在对应节点。切换时有较高的创建和销毁开销。
  • v-show:无论条件真假,元素总会被渲染,只是通过 CSS 的 display: none; 来控制显隐。初始渲染开销较高,但后续切换开销很小。
  • 选择原则
    • 频繁切换:使用 v-show
    • 运行时条件很少改变:使用 v-if
    • 优化首屏加载:如果一个模块初始不需要显示,用 v-if 可以避免其初始渲染开销。
    • 元素内容复杂/渲染成本高:并且需要频繁切换时,v-show 是更好的选择。

8. 使用延迟装载(分帧渲染)

对于初始加载时需要渲染大量组件导致的“白屏”问题,可以通过分批、分帧渲染来优化用户体验。

  • 问题:页面初始加载时,JS 需要执行大量计算来创建所有组件的虚拟 DOM,同时浏览器需要渲染大量真实 DOM 节点,这两个过程都会长时间阻塞主线程,导致页面长时间白屏。
  • 核心思路:不一次性渲染所有内容,而是将渲染任务拆分成小块,利用 requestAnimationFrame 将每个小任务分配到浏览器的不同渲染帧中去执行。
  • 实现原理
    1. 利用 requestAnimationFrame 可以在浏览器每次重绘之前执行一个回调函数的特性。
    2. 在组件 mounted 后,启动一个 requestAnimationFrame 循环,每一帧都增加一个内部的“帧计数器”。
    3. 提供一个方法(如 differ(frame)),它会判断当前的“帧计数器”是否达到了指定的 frame 数。
    4. 在模板中,用 v-if="differ(index)" 来包裹需要渲染的组件。
  • 效果
    • 第 0 个组件在第 0 帧渲染。
    • 第 1 个组件在第 1 帧渲染。
    • ...
    • 将一个巨大的、阻塞性的渲染任务,分解成了多个小的、非阻塞的任务。
  • 用户体验:虽然总加载时间可能略有增加,但用户会先看到一部分优先加载的内容,然后看到其他内容逐步出现,而不是长时间面对白屏,体验大大提升。
  • 本质:这是一种时间分片(Time Slicing)思想的应用,用总时长换取响应速度,避免页面假死。

实现示例

下面是一个具体的代码实现,用于将 20 个“重组件”的渲染任务分散到 20 个渲染帧中。

1. defer.js (延迟加载混入) 创建一个混入(Mixin),它负责追踪渲染帧并提供一个判断方法。

javascript
// defer.js
export default function (maxFrameCount) {
  return {
    data() {
      return {
        // 当前渲染的帧数
        frameCount: 0,
      };
    },
    mounted() {
      this.updateFrameCount();
    },
    methods: {
      updateFrameCount() {
        // 使用 requestAnimationFrame 在下一帧渲染前更新帧数
        requestAnimationFrame(() => {
          this.frameCount++;
          // 如果当前帧数还未达到最大目标帧数,则继续循环
          if (this.frameCount < maxFrameCount) {
            this.updateFrameCount();
          }
        });
      },
      // 判断当前组件是否达到了渲染时机
      // n 是组件的目标渲染帧
      differ(n) {
        // 只有当前帧数达到或超过了目标帧数,才渲染组件
        return this.frameCount >= n;
      },
    },
  };
}

2. HeavyComponent.vue (模拟的重组件) 这个组件模拟一个内部结构复杂、渲染耗时的组件。

vue
<template>
  <div class="heavy-component">
    <div v-for="i in 5000" :key="i" class="cell"></div>
  </div>
</template>

<style scoped>
.heavy-component {
  border: 1px solid #ccc;
  margin: 2px;
  height: 50px;
  background: #eee;
  display: flex;
  flex-wrap: wrap;
}
.cell {
  width: 1px;
  height: 1px;
  background: #aaa;
}
</style>

3. App.vue (主组件) 在主组件中使用混入和 v-if 来实现延迟加载。

vue
<template>
  <div id="app">
    <HeavyComponent 
      v-for="n in 20" 
      :key="n" 
      v-if="differ(n)" 
    />
  </div>
</template>

<script>
import HeavyComponent from "./components/HeavyComponent.vue";
import defer from "./mixins/defer";

export default {
  name: "App",
  components: {
    HeavyComponent,
  },
  // 使用混入,并传入最大帧数。
  // 这意味着我们计划在 20 帧内完成所有组件的渲染。
  mixins: [defer(20)],
};
</script>