Author: Front end small transparent from thunderbolt front end

Cookbook: Optimize the runtime performance of Vue components

preface

When Vue 2.0 was released, it was known for its excellent runtime performance. You can use this third party Benchmark to compare the performance of other frameworks. Vue uses Virtual DOM for view rendering. When data changes, Vue will compare the two component trees before and after, and only the necessary updates will be synchronized to the view.

Vue does a lot for us, but for complex scenarios, especially large data rendering, we should always be concerned about the runtime performance of the application.

This article describes optimizing the runtime performance of Vue components, modeled on the Vue Cookbook organization.

Basic example

In the following example, we have developed a tree control that supports basic tree structure display and node expansion and collapse.

We define the interface to the Tree component as follows. Data is bound to the data of the tree control, which is an array composed of several trees. Children represents the child node. Expanded keys binds the key property of the expanded node, using the sync modifier to synchronize updates to the node expansion status triggered internally by the component.

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

<script>
export default {
  data() {
    return {
      data: [{
        key: '1'.label: Nodes' 1 '.children: [{
          key: 1-1 ' '.label: '1-1 nodes'}]}, {key: '2'.label: Nodes' 2 '}}}};</script>
Copy the code

The Tree component is implemented as follows, a slightly more complex example that will take a few minutes to read.

<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: (a)= >[],}},computed: {
    // Convert data into a one-dimensional array for v-for traversal
    // Add both level and parent attributes
    nodes() {
      return this.getNodes(this.data);
    },
    // Status is a Map data structure of key and node status
    status() {
      return this.getStatus(this.nodes); }},methods: {
    // Recurse data to return a one-dimensional array of all nodes
    getNodes(data, level = 0, parent = null) {
      let nodes = [];
      data.forEach((item) = > {
        constnode = { 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;
    },
    // Traverse nodes to calculate the status of each node
    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;
    },
    // Switch the expansion status of the node
    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>
Copy the code

When a node is expanded or collapsed, we simply update the expanded keys, and the status calculation property is automatically updated, ensuring that the visible state of the associated child node is correct.

In order to measure the performance of the Tree component, we set two metrics.

  1. First render time
  2. Node expansion/collapse time

Add the following code to the Tree component, using console.time and console.timeEnd to output the specific time of an operation.

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

      console.time('expanded change');

      this.$nextTick((a)= > {
        console.timeEnd('expanded change');
      });
    },
  },
  beforeCreate() {
    console.time('first rendering');
  },
  mounted() {
    console.timeEnd('first rendering'); }};Copy the code

Also, to amplify potential performance problems, we wrote a method to generate a manageable amount of node data.

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

<script>
export default {
  data() {
    return {
      // Generate a tree of 1000 nodes with 3 layers of 10 nodes per layer
      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: ` node${key}`};if (layers > 1) {
          node.children = this.getRandomData(layers - 1, count, node);
        }
        returnnode; }); ,}}};<script>
Copy the code

You can see the performance penalty in action with this complete CodeSandbox example. Click the arrow to expand or collapse a node and print the following in the Chrome DevTools console (not CodeSandbox console, not exactly).

First Rendering: 406.068115234375ms Expanded change: 231.623779296875msCopy the code

On my low-power laptop, it takes 400+ms for the first rendering and 200+ms for the expansion or collapse node. Let’s optimize the performance of the Tree component.

If your device is powerful, you can change the number of nodes generated, such as this.getrandomData (4, 10), which generates 10,000 nodes.

Use Chrome Performance to find Performance bottlenecks

Chrome’s Performance panel can record details and timing of JS execution over a period of time. Here are the steps to analyze page performance using Chrome Developer tools.

  1. Open the Chrome Developer Tools and switch to the Performance panel
  2. Click Record to start recording
  3. Refresh a page or expand a node
  4. Click Stop to Stop recording

The value of the output from console.time is also displayed in Performance to help us debug. More information about Performance can be found here.

Optimize runtime performance

Conditions apply colours to a drawing

As we scroll down through the Performance analysis results, most of the time is spent on the render function, and there are many other functions called below.

When traversing the nodes, we use the V-show command for visibility of the nodes, and the invisible nodes are rendered and then styled to make them invisible. So try using the V-if directive for conditional rendering.

<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>
Copy the code

V-if is represented as a triplet expression in the render function:

visible ? h('li') : this._e() // this._e() generates a comment node
Copy the code

That is, v-if only reduces the time of each traversal, but does not reduce the number of traversals. Also, the vue. js style guide explicitly states not to use v-if and V-for on the same element, as this may cause unnecessary rendering.

We can instead iterate over the computed properties of a visible node:

<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>
Copy the code

The optimized performance time is as follows:

First Rendering: 194.7890625ms Expanded change: 204.01904296875msCopy the code

You can look at the improved example (Demo2) to see the performance cost of the component, which is much better than before the optimization.

Two-way binding

In the previous example, we “bidirectional bind” expanded keys with.sync, which is really syntactic sugar for prop and custom events. This makes it easy for the parent component of the Tree to synchronize the expansion status updates.

However, when using the Tree component without passing expanded keys, the node cannot be expanded or collapsed, even if you don’t care about the operation of expanding or collapsing. Here the expanded keys are a side effect of the outside world.

<! -- Cannot expand/collapse node -->
<tree :data="data"></tree>
Copy the code

There are also performance issues where expanding or collapsing a node triggers the side effect of the parent component to update Expanded keys. The Tree component’s status depends on expanded-keys, and the this.getStatus method is called to get the new status. Even a change in the state of a single node causes the state of all nodes to be recalculated.

Consider status as the internal state of a Tree component, and change status directly when you expand or collapse a node. The default expansion node default-expanded-keys is also defined. Status relies on default-expanded-keys only during initialization.

export default {
  props: {
    data: Array.// Expand the node by default
    defaultExpandedKeys: {
      type: Array.default: (a)= > [],
    },
  },
  data() {
    return {
      status: null.// Status indicates the local state
    };
  },
  computed: {
    nodes() {
      return this.getNodes(this.data); }},watch: {
    nodes: {
      // Status is recalculated when nodes changes
      handler() {
        this.status = this.getStatus(this.nodes);
      },
      // Initialize status
      immediate: true,},// Recalculate status when defaultExpandedKeys changes
    defaultExpandedKeys() {
      this.status = this.getStatus(this.nodes); }},methods: {
    getNodes(data, level = 0, parent = null) {
      // ...
    },
    getStatus(nodes) {
      // ...
    },
    // When the node is expanded or collapsed, change status directly and notify the parent component
    changeExpanded(key) {
      console.time('expanded change');

      const node = this.nodes.find(n= > n.key === key); // Find the node
      const newExpanded = !this.status[key].expanded; // New expanded state
      
      // Update status by recursing to the descendants of the node
      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);

      // Trigger the node expansion state change event
      this.$emit('expanded-change', node, newExpanded, this.nodes.filter(n= > this.status[n.key].expanded));

      this.$nextTick((a)= > {
        console.timeEnd('expanded change');
      });
    },
  },
  beforeCreate() {
    console.time('first rendering');
  },
  mounted() {
    console.timeEnd('first rendering'); }};Copy the code

When the Tree component is used, the node can be expanded or collapsed without default-expanded-keys.

<! -- Nodes can be expanded or collapsed -->
<tree :data="data"></tree>

<! -- Configure the default expanded node -->
<tree
  :data="data"
  :default-expanded-keys="[' 1 ', '1-1]"
  @expanded-change="handleExpandedChange"
>
</tree>
Copy the code

The optimized performance time is as follows:

First Rendering: 91.48193359375ms Expanded change: 20.4287109375msCopy the code

You can use the improved example (Demo3) to see the performance cost of components.

Frozen data

At this point, the Tree component’s performance issues are not obvious. To further expand the performance problem, look for optimization space. We increased the number of nodes to 10,000.

// Generate 10000 nodes
this.getRandomData(4.1000)
Copy the code

Here, we intentionally made a change that might have performance issues. Although this is not necessary, it will help us understand the issues we are going to cover.

Change the compute attribute Nodes to get the value of Nodes in the Data watcher.

export default {
  // ...
  watch: {
    data: {
      handler() {
        this.nodes = this.getNodes(this.data);
        this.status = this.getStatus(this.nodes);
      },
      immediate: true,},// ...
  },
  // ...
};
Copy the code

This change has no impact on the functionality of the implementation, so what about performance?

First Rendering: 490.119140625ms Expanded change: 183.94189453125msCopy the code

Use the Performance tool to try to find Performance bottlenecks.

We found that there was a long proxySetter after the getNodes method was called. This is Vue adding a response to the Nodes attribute, allowing Vue to track changes to dependencies. GetStatus in the same way.

When you pass a normal JavaScript Object to the Data option of a Vue instance, Vue will walk through all the properties of the Object and use object.defineProperty to turn them into getters/setters.

The more complex the object and the deeper the hierarchy, the longer this process takes. When we have 1w nodes, the proxySetter takes a very long time.

There is a problem. Instead of modifying a specific attribute of Nodes, we will recalculate it every time the data changes. Therefore, the response added here for Nodes is useless. So how do we get rid of this unwanted proxySetter? One way is to change Nodes back to compute attributes, which generally have no assignment behavior. Another way is to freeze the data.

Using object.freeze () to freeze the data prevents modification of existing properties and means that the responding system can no longer track the changes.

this.nodes = Object.freeze(this.getNodes(this.data));
Copy the code

The Performance tool shows that there is no proxySetter after getNodes.

The performance indicators are as follows, and the improvement for the first render is considerable.

First Rendering: 312.22998046875ms Expanded change: 179.59326171875msCopy the code

You can use the improved example (Demo4) to see the performance cost of components.

Can we do the same for tracking status? The answer is no, because we need to update the attribute value (changeExpanded) in status. Therefore, this optimization only applies when its properties are not updated, only the entire object’s data is updated. And the more complex the structure, the deeper the level of data, the more obvious the optimization effect.

alternative

As we can see, both the rendering of nodes and the calculation of data in the example have a large number of loops or recursions. In addition to the Vue optimization mentioned above, we can also optimize for this kind of large data problem in terms of reducing the time spent per loop and reducing the number of cycles.

For example, you can use dictionaries to optimize data lookups.

// Generate a Map object with defaultExpandedKeys
const expandedKeysMap = this.defaultExpandedKeys.reduce((map, key) = > {
  map[key] = true;
  return map;
}, {});

/ / to find
if (expandedKeysMap[key]) {
  // do something
}
Copy the code

DefaultExpandedKeys. Includes event complexity is O (n), expandedKeysMap [key] the time complexity is O (1).

For more information about optimizing Vue application performance, see Vue Application Performance Optimization Guide.

The value of doing so

Application performance is very important to the improvement of user experience and is often overlooked. If an app that works well on one device crashes the user’s browser on another poorly configured device, it’s a bad experience. Or maybe your app works fine with regular data, but takes a long time with large data volumes, and you may be missing out on users.

conclusion

Performance optimization is a perennial topic, and there is no one-size fits all solution to all performance problems. Performance tuning can go on and on, but the deeper the problem goes, the less obvious the performance bottleneck becomes, and the more difficult the tuning becomes.

The example in this article is unique, but it provides a guide to the performance optimization methodology.

  1. Determine the metrics that measure runtime performance
  2. Determine optimization objectives, such as achieving 1W+ data in seconds
  3. Use the tool (Chrome Performance) to analyze Performance issues
  4. Prioritize the bottleneck
  5. Repeat 3 or 4 steps until you achieve your goal

Scan to pay attention to thunder front public number