Cookbook:優化 Vue 元件的執行時效能

迅雷前端發表於2018-11-23

作者: 前端小透明 from 迅雷前端

原文地址:Cookbook:優化 Vue 元件的執行時效能

前言

Vue 2.0 在釋出之初,就以其優秀的執行時效能著稱,你可以通過這個第三方 benchmark 來對比其他框架的效能。Vue 使用了 Virtual DOM 來進行檢視渲染,當資料變化時,Vue 會對比前後兩棵元件樹,只將必要的更新同步到檢視上。

Vue 幫我們做了很多,但對於一些複雜場景,特別是大量的資料渲染,我們應當時刻關注應用的執行時效能。

本文仿照 Vue Cookbook 組織形式,對優化 Vue 元件的執行時效能進行闡述。

基本的示例

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

我們定義 Tree 元件的介面如下。data 繫結了樹形控制元件的資料,是若干顆樹組成的陣列,children 表示子節點。expanded-keys 繫結了展開的節點的 key 屬性,使用 sync 修飾符來同步元件內部觸發的節點展開狀態的更新。

<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 元件的實現如下,這是個稍微複雜的例子,需要花幾分鐘時間閱讀一下。

<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 計算屬性便會自動更新,保證關聯子節點可見狀態的正確。

一切準備就緒,為了度量 Tree 元件的執行效能,我們設定了兩個指標。

  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 停止錄製

Cookbook:優化 Vue 元件的執行時效能

console.time 輸出的值也會顯示在 Performance 中,幫助我們除錯。更多關於 Performance 的內容可以點選這裡檢視

優化執行時效能

條件渲染

我們往下翻閱 Performance 分析結果,發現大部分耗時都在 render 函式上,並且下面還有很多其他函式的呼叫。

Cookbook:優化 Vue 元件的執行時效能

在遍歷節點時,對於節點的可見性我們使用的是 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 工具嘗試查詢效能瓶頸。

Cookbook:優化 Vue 元件的執行時效能

我們發現,在 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 了。

Cookbook:優化 Vue 元件的執行時效能

效能指標如下,對於初次渲染的提升還是很可觀的。

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 步直到實現目標

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

Cookbook:優化 Vue 元件的執行時效能

相關文章