Cookbook:優化 Vue 組件的運行時性能

做者: 前端小透明 from 迅雷前端javascript

原文地址:Cookbook:優化 Vue 組件的運行時性能html

前言

Vue 2.0 在發佈之初,就以其優秀的運行時性能著稱,你能夠經過這個第三方 benchmark 來對比其餘框架的性能。Vue 使用了 Virtual DOM 來進行視圖渲染,當數據變化時,Vue 會對比先後兩棵組件樹,只將必要的更新同步到視圖上。前端

Vue 幫咱們作了不少,但對於一些複雜場景,特別是大量的數據渲染,咱們應當時刻關注應用的運行時性能。vue

本文仿照 Vue Cookbook 組織形式,對優化 Vue 組件的運行時性能進行闡述。java

基本的示例

在下面的示例中,咱們開發了一個樹形控件,支持基本的樹形結構展現以及節點的展開與摺疊。node

咱們定義 Tree 組件的接口以下。data 綁定了樹形控件的數據,是若干顆樹組成的數組,children 表示子節點。expanded-keys 綁定了展開的節點的 key 屬性,使用 sync 修飾符來同步組件內部觸發的節點展開狀態的更新。git

<template>
  <tree :data="data" expanded-keys.sync="expandedKeys"></tree>
</template>

<script> export default { data() { return { data: [{ key: '1', label: '節點 1', children: [{ key: '1-1', label: '節點 1-1' }] }, { key: '2', label: '節點 2' }] } } }; </script>
複製代碼

Tree 組件的實現以下,這是個稍微複雜的例子,須要花幾分鐘時間閱讀一下。github

<template>
  <ul class="tree">
    <li v-for="node in nodes" v-show="status[node.key].visible" :key="node.key" class="tree-node" :style="{ 'padding-left': `${node.level * 16}px` }" >
      <i v-if="node.children" class="tree-node-arrow" :class="{ expanded: status[node.key].expanded }" @click="changeExpanded(node.key)" >
      </i>
      {{ node.label }}
    </li>
  </ul>
</template>

<script> export default { props: { data: Array, expandedKeys: { type: Array, default: () => [], }, }, computed: { // 將 data 轉爲一維數組,方便 v-for 進行遍歷 // 同時添加 level 和 parent 屬性 nodes() { return this.getNodes(this.data); }, // status 是一個 key 和節點狀態的一個 Map 數據結構 status() { return this.getStatus(this.nodes); }, }, methods: { // 對 data 進行遞歸,返回一個全部節點的一維數組 getNodes(data, level = 0, parent = null) { let nodes = []; data.forEach((item) => { const node = { level, parent, ...item, }; nodes.push(node); if (item.children) { const children = this.getNodes(item.children, level + 1, node); nodes = [...nodes, ...children]; node.children = children.filter(child => child.level === level + 1); } }); return nodes; }, // 遍歷 nodes,計算每一個節點的狀態 getStatus(nodes) { const status = {}; nodes.forEach((node) => { const parentStatus = status[node.parent && node.parent.key] || {}; status[node.key] = { expanded: this.expandedKeys.includes(node.key), visible: node.level === 0 || (parentStatus.expanded && parentStatus.visible), }; }); return status; }, // 切換節點的展開狀態 changeExpanded(key) { const index = this.expandedKeys.indexOf(key); const expandedKeys = [...this.expandedKeys]; if (index >= 0) { expandedKeys.splice(index, 1); } else { expandedKeys.push(key); } this.$emit('update:expandedKeys', expandedKeys); }, }, }; </script>
複製代碼

展開或摺疊節點時,咱們只需更新 expanded-keysstatus 計算屬性便會自動更新,保證關聯子節點可見狀態的正確。web

一切準備就緒,爲了度量 Tree 組件的運行性能,咱們設定了兩個指標。chrome

  1. 初次渲染時間
  2. 節點展開 / 摺疊時間

在 Tree 組件中添加代碼以下,使用 console.timeconsole.timeEnd 能夠輸出某個操做的具體耗時。

export default {
  // ...
  methods: {
    // ...
    changeExpanded(key) {
      // ...
      this.$emit('update:expandedKeys', expandedKeys);

      console.time('expanded change');

      this.$nextTick(() => {
        console.timeEnd('expanded change');
      });
    },
  },
  beforeCreate() {
    console.time('first rendering');
  },
  mounted() {
    console.timeEnd('first rendering');
  },
};
複製代碼

同時,爲了放大可能存在的性能問題,咱們編寫了一個方法來生成可控數量的節點數據。

<template>
  <tree :data="data" :expanded-keys.sync="expandedKeys"></tree>
</template>

<script> export default { data() { return { // 生成一個有 3 層,每層 10 個共 1000 個節點的節點樹 data: this.getRandomData(3, 10), expandedKeys: [], }; }, methods: { getRandomData(layers, count, parent) { return Array.from({ length: count }, (v, i) => { const key = (parent ? `${parent.key}-` : '') + (i + 1); const node = { key, label: `節點 ${key}`, }; if (layers > 1) { node.children = this.getRandomData(layers - 1, count, node); } return node; }); }, }, }; <script> 複製代碼

你能夠經過這個 CodeSandbox 完整示例來實際觀察下性能損耗。點擊箭頭展開或摺疊某個節點,在 Chrome DevTools 的控制檯(不要使用 CodeSandbox 的控制檯,不許確)中輸出以下。

first rendering: 406.068115234375ms
expanded change: 231.623779296875ms
複製代碼

在筆者的低功耗筆記本下,初次渲染耗時 400+ms,展開或摺疊節點 200+ms。下面咱們來優化 Tree 組件的運行性能。

若你的設備性能強勁,可修改生成的節點數量,如 this.getRandomData(4, 10) 生成 10000 個節點。

使用 Chrome Performance 查找性能瓶頸

Chrome 的 Performance 面板能夠錄製一段時間內的 js 執行細節及時間。使用 Chrome 開發者工具分析頁面性能的步驟以下。

  1. 打開 Chrome 開發者工具,切換到 Performance 面板
  2. 點擊 Record 開始錄製
  3. 刷新頁面或展開某個節點
  4. 點擊 Stop 中止錄製

console.time 輸出的值也會顯示在 Performance 中,幫助咱們調試。更多關於 Performance 的內容能夠點擊這裏查看

優化運行時性能

條件渲染

咱們往下翻閱 Performance 分析結果,發現大部分耗時都在 render 函數上,而且下面還有不少其餘函數的調用。

在遍歷節點時,對於節點的可見性咱們使用的是 v-show 指令,不可見的節點也會渲染出來,而後經過樣式使其不可見。所以嘗試使用 v-if 指令來進行條件渲染。

<li v-for="node in nodes" v-if="status[node.key].visible" :key="node.key" class="tree-node" :style="{ 'padding-left': `${node.level * 16}px` }" >
  ...
</li>
複製代碼

v-if 在 render 函數中表現爲一個三目表達式:

visible ? h('li') : this._e() // this._e() 生成一個註釋節點
複製代碼

v-if 只是減小每次遍歷的時間,並不能減小遍歷的次數。且 Vue.js 風格指南中明確指出不要把 v-ifv-for 同時用在同一個元素上,由於這可能會致使沒必要要的渲染。

咱們能夠更換爲在一個可見節點的計算屬性上進行遍歷:

<li v-for="node in visibleNodes" :key="node.key" class="tree-node" :style="{ 'padding-left': `${node.level * 16}px` }" >
  ...
</li>

<script> export { // ... computed: { visibleNodes() { return this.nodes.filter(node => this.status[node.key].visible); }, }, // ... } </script>
複製代碼

優化後的性能耗時以下。

first rendering: 194.7890625ms
expanded change: 204.01904296875ms
複製代碼

你能夠經過改進後的示例 (Demo2) 來觀察組件的性能損耗,相比優化前有很大的提高。

雙向綁定

在前面的示例中,咱們使用 .syncexpanded-keys 進行了「雙向綁定」,其其實是 prop 和自定義事件的語法糖。這種方式能很方便地讓 Tree 的父組件同步展開狀態的更新。

可是,使用 Tree 組件時,不傳 expanded-keys,會致使節點沒法展開或摺疊,即便你不關心展開或摺疊的操做。這裏把 expanded-keys 做爲外界的反作用了。

<!-- 沒法展開 / 摺疊節點 -->
<tree :data="data"></tree>
複製代碼

這裏還存在一些性能問題,展開或摺疊某一節點時,觸發父組件的反作用更新 expanded-keys。Tree 組件的 status 依賴了 expanded-keys,會調用 this.getStatus 方法獲取新的 status。即便只是單個節點的狀態改變,也會致使從新計算全部節點的狀態。

咱們考慮將 status 做爲一個 Tree 組件的內部狀態,展開或摺疊某個節點時,直接對 status 進行修改。同時定義默認的展開節點 default-expanded-keysstatus 只在初始化時依賴 default-expanded-keys

export default {
  props: {
    data: Array,
    // 默認展開節點
    defaultExpandedKeys: {
      type: Array,
      default: () => [],
    },
  },
  data() {
    return {
      status: null, // status 爲局部狀態
    };
  },
  computed: {
    nodes() {
      return this.getNodes(this.data);
    },
  },
  watch: {
    nodes: {
      // nodes 改變時從新計算 status
      handler() {
        this.status = this.getStatus(this.nodes);
      },
      // 初始化 status
      immediate: true,
    },
    // defaultExpandedKeys 改變時從新計算 status
    defaultExpandedKeys() {
      this.status = this.getStatus(this.nodes);
    },
  },
  methods: {
    getNodes(data, level = 0, parent = null) {
      // ...
    },
    getStatus(nodes) {
      // ...
    },
    // 展開或摺疊節點時直接修改 status,並通知父組件
    changeExpanded(key) {
      console.time('expanded change');

      const node = this.nodes.find(n => n.key === key); // 找到該節點
      const newExpanded = !this.status[key].expanded; // 新的展開狀態
      
      // 遞歸該節點的後代節點,更新 status
      const updateVisible = (n, visible) => {
        n.children.forEach((child) => {
          this.status[child.key].visible = visible && this.status[n.key].expanded;
          if (child.children) updateVisible(child, visible);
        });
      };

      this.status[key].expanded = newExpanded;

      updateVisible(node, newExpanded);

      // 觸發節點展開狀態改變事件
      this.$emit('expanded-change', node, newExpanded, this.nodes.filter(n => this.status[n.key].expanded));

      this.$nextTick(() => {
        console.timeEnd('expanded change');
      });
    },
  },
  beforeCreate() {
    console.time('first rendering');
  },
  mounted() {
    console.timeEnd('first rendering');
  },
};
複製代碼

使用 Tree 組件時,即便不傳 default-expanded-keys,節點也能正常地展開或收起。

<!-- 節點能夠展開或收起 -->
<tree :data="data"></tree>

<!-- 配置默認展開的節點 -->
<tree :data="data" :default-expanded-keys="['1', '1-1']" @expanded-change="handleExpandedChange" >
</tree>
複製代碼

優化後的性能耗時以下。

first rendering: 91.48193359375ms
expanded change: 20.4287109375ms
複製代碼

你能夠經過改進後的示例 (Demo3) 來觀察組件的性能損耗。

凍結數據

到此爲止,Tree 組件的性能問題已經不是很明顯了。爲了進一步擴大性能問題,查找優化空間。咱們把節點數量增長到 10000 個。

// 生成 10000 個節點
this.getRandomData(4, 1000)
複製代碼

這裏,咱們故意製造一個可能存在性能問題的改動。雖然這不是必須的,當它能幫助咱們瞭解接下來所要介紹的問題。

將計算屬性 nodes 修改成在 datawatcher 中去獲取 nodes 的值。

export default {
  // ...
  watch: {
    data: {
      handler() {
        this.nodes = this.getNodes(this.data);
        this.status = this.getStatus(this.nodes);
      },
      immediate: true,
    },
    // ...
  },
  // ...
};
複製代碼

這種修改對於實現的功能是沒有影響的,那麼性能狀況如何呢。

first rendering: 490.119140625ms
expanded change: 183.94189453125ms
複製代碼

使用 Performance 工具嘗試查找性能瓶頸。

咱們發現,在 getNodes 方法調用以後,有一段耗時很長的 proxySetter。這是 Vue 在爲 nodes 屬性添加響應式,讓 Vue 可以追蹤依賴的變化。getStatus 同理。

當你把一個普通的 JavaScript 對象傳給 Vue 實例的 data 選項,Vue 將遍歷此對象全部的屬性,並使用 Object.defineProperty 把這些屬性所有轉爲 getter/setter。

對象越複雜,層級越深,這個過程消耗的時間越長。當咱們存在 1w 個節點時,proxySetter 的時間就會很是長了。

這裏存在一個問題,咱們不會對 nodes 某個具體的屬性作修改,而是每當 data 變化時從新去計算一次。所以,這裏爲 nodes 添加的響應式是無用的。那麼怎麼把不須要的 proxySetter 去掉呢?一種方法是將 nodes 改回計算屬性,通常狀況下計算屬性沒有賦值行爲。另外一種方法就是凍結數據。

使用 Object.freeze() 來凍結數據,這會阻止修改現有的屬性,也意味着響應系統沒法再追蹤變化。

this.nodes = Object.freeze(this.getNodes(this.data));
複製代碼

查看 Performance 工具,getNodes 方法後已經沒有 proxySetter 了。

性能指標以下,對於初次渲染的提高仍是很可觀的。

first rendering: 312.22998046875ms
expanded change: 179.59326171875ms
複製代碼

你能夠經過改進後的示例 (Demo4) 來觀察組件的性能損耗。

那咱們可否用一樣的辦法優化 status 的跟蹤呢?答案是否認的,由於咱們須要去更新 status 中的屬性值 (changeExpanded)。所以,這種優化只適用於其屬性不會被更新,只會更新整個對象的數據。且對於結構越複雜、層級越深的數據,優化效果越明顯。

替代方案

咱們看到,示例中不論是節點的渲染仍是數據的計算,都存在大量的循環或遞歸。對於這種大量數據的問題,除了上述提到的針對 Vue 的優化外,咱們還能夠從減小每次循環的耗時和減小循環次數兩個方面進行優化。

例如,可使用字典來優化數據查找。

// 生成 defaultExpandedKeys 的 Map 對象
const expandedKeysMap = this.defaultExpandedKeys.reduce((map, key) => {
  map[key] = true;
  return map;
}, {});

// 查找時
if (expandedKeysMap[key]) {
  // do something
}
複製代碼

defaultExpandedKeys.includes 的事件複雜度是 O(n),expandedKeysMap[key] 的時間複雜度是 O(1)。

更多關於優化 Vue 應用性能能夠查看 Vue 應用性能優化指南

這樣作的價值

應用性能對於用戶體驗的提高是很是重要的,也每每是容易被忽視的。試想一下,一個在某臺設備運行良好的應用,到了另外一臺配置較差的設備上致使用戶瀏覽器崩潰了,這必定是一個很差的體驗。又或者你的應用在常規數據下正常運行,卻在大數據量下須要至關長的等待時間,也許你就所以錯失了一部分用戶。

總結

性能優化是一個長久不衰的話題,沒有一種通用的辦法可以解決全部的性能問題。性能優化是能夠持續不端地進行下去的,但隨着問題的深刻,性能瓶頸會愈來愈不明顯,優化也越困難。

本文的示例具備必定的特殊性,但它爲咱們指引了性能優化的方法論。

  1. 肯定衡量運行時性能的指標
  2. 肯定優化目標,例如實現 1W+ 數據的秒出
  3. 使用工具(Chrome Performance)分析性能問題
  4. 優先解決問題的大頭(瓶頸)
  5. 重複 3 4 步直到實現目標

掃一掃關注迅雷前端公衆號

相關文章
相關標籤/搜索