Increasing performance of v-data-table with custom cells and async data loading

Viewed 943

I'm creating a page with v-data-table. Some content of this table is loading at mounted stage, but the data for one column should be loaded line-by-line in the background by async API calls after rendering the whole table. Table rows should also be colored based on data returned from API call.

I've already developed this page, but stuck into one issue - when the table contains composite cells that was redefined by item slot (by example, a cell with icons, tooltips or spans), table row update time significantly increases.

According to business logic, the page may contain a large amount of rows, but I can't use v-data-table pagination to reduce entries count at one page.

The question is - how can I update row (in fact, just its color and one cell value) with a little performance loss as possible?

There is a Codepen with this problem. The way of loading data into the page is completely preserved in this Codepen, but API calls was replaced by promises with fixed timeout.

The problem still exists in Codepen. By default all the requests for 100 items have passed in 12-13 seconds (there's a counter at the bottom of the page). When I comment out last td, they're passed just in 7-8 seconds. When I comment out another one td (second from the end), they're passed in 6 seconds. When I increase items count to 1000, row update time is also increases.

new Vue({
  el: '#app',
  vuetify: new Vuetify(),
  data () {
    return {
      headers: [
        {
          text: 'Dessert (100g serving)',
          value: 'name',
        },
        { text: 'Second name', value: 'secondName' },
        { text: 'Fat (g)', value: 'fat' },
        { text: 'Carbs (g)', value: 'carbs' },
        { text: 'Protein (g)', value: 'protein' },
        { text: 'Max value', value: 'maxValue' },
        { text: 'Actions', value: 'id' },
      ],
      desserts: [],
      timerStart: null,
      loadingTime: null,
    }
  },
  created() {
    this.generateDesserts();
  },
  mounted() {
    this.countMaxValues(this.desserts).then(() => {
      this.loadingTime = (Date.now() - this.timerStart) / 1000;
    });
  },
  methods: {
    generateDesserts() {
      let dessertNames = [
        'Frozen Yogurt  ',
        'Ice cream sandwich ',
        'Eclair',
        'Cupcake',
        'Gingerbread',
        'Jelly bean',
        'Lollipop',
        'Honeycomb',
        'Donut',
        'KitKat',
        null
      ];
      for (let i = 0; i < 100; i++) {
        let dessert = {
          id: i,
          name: dessertNames[Math.floor(Math.random() * dessertNames.length)],
          secondName: dessertNames[8 + Math.floor(Math.random() * (dessertNames.length - 8))],
          fat: Math.random() * 100,
          carbs: Math.floor(Math.random() * 100),
          protein: Math.random() * 10
        };
        this.desserts.push(dessert);
      }
    },
    async countMaxValues(array) {
      this.timerStart = Date.now();
      for (const item of array) {
        await this.countMaxValue(item).catch(() => {
          //Even when one request throws error we should not stop others
        })
      }
    },
    async countMaxValue(item) {
      await new Promise(resolve => setTimeout(resolve, 50)).then(() => {
        let maxVal = Math.random() * 100;
        item.maxValue = maxVal < 20 ? null : maxVal;
        this.desserts.splice(item.id, 1, item);
      });
    }
  }
})

 
<div id="app">
  <v-app id="inspire">
    <v-data-table
            :headers="headers"
            :items="desserts"
            :footer-props='{
                itemsPerPageOptions: [-1],
                prevIcon: null,
                nextIcon: null,
            }'
    >
        <template v-slot:item="props">
            <tr :style="{
                        background: (props.item.maxValue !== null && (props.item.carbs < props.item.maxValue))
                            ? '#ffcdd2'
                            : (
                                (props.item.maxValue !== null && (props.item.carbs > props.item.maxValue)
                                    ? '#ffee58'
                                    : (
                                        props.item.maxValue === null ? '#ef5350' : 'transparent'
                                    )
                                )
                              )}">
                <td>{{ props.item.name || '—' }}</td>
                <td>{{ props.item.secondName || '—' }}</td>
                <td>{{ props.item.fat }}</td>
                <td>{{ props.item.carbs }}</td>
                <td>{{ props.item.protein }}</td>
                <td>
                    <span>
                        {{ props.item.maxValue || '—' }}
                    </span>
                    <v-btn v-if="props.item.name && props.item.maxValue" icon>
                        <v-icon small>mdi-refresh</v-icon>
                    </v-btn>
                </td>
                <td class="justify-center text-center" style="min-width: 100px">
                    <v-tooltip bottom v-if="props.item.name && props.item.secondName">
                        <template v-slot:activator="{ on }">
                            <v-icon v-on="on"
                                    class="mr-2"
                                    small
                            >
                                format_list_numbered_rtl
                            </v-icon>
                        </template>
                        <span>Some action tooltip</span>
                    </v-tooltip>
                    <v-tooltip bottom v-if="props.item.name && props.item.secondName">
                        <template v-slot:activator="{ on }">
                            <v-icon v-on="on"
                                    class="mr-2"
                                    small
                            >
                                edit
                            </v-icon>
                        </template>
                        <span>Edit action tooltip</span>
                    </v-tooltip>
                    <v-tooltip bottom v-if="props.item.name === 'KitKat'">
                        <template v-slot:activator="{ on }">
                            <v-icon v-on="on"
                                    small
                            >
                                delete
                            </v-icon>
                        </template>
                        <span>Delete action tooltip</span>
                    </v-tooltip>
                </td>
            </tr>
        </template>
    </v-data-table>
    <p>{{ "Page loading time (sec): " + (loadingTime || '...') }}</p>
  </v-app>
</div>

1 Answers

It seems Vue can update DOM more efficient if it is wrap in component (Sorry, I don't know in detail why).

This is your original code in JSFiddle. It will use around 12-13 seconds.

Then I create a component which wrap your entire tr:

const Tr = {
  props: {
    item: Object
  },
    template: `
    <tr>
      ... // change props.item to item
    </tr>
  `
}

new Vue({
  el: '#app',
  vuetify: new Vuetify(),
  components: {
    'tr-component': Tr // register Tr component
  },
  ...

  async countMaxValue(item) {
    await new Promise(resolve => setTimeout(resolve, 50)).then(() => {
      let maxVal = Math.random() * 100;
      // update entire object instead of one property since we send it as object to Tr
      let newItem = {
        ...item,
        maxValue: maxVal < 20 ? null : maxVal
      }
      this.desserts.splice(newItem.id, 1, newItem);
    });
  }
})

And your html will looks like:

<v-data-table
  :headers="headers"
  :items="desserts"
  :footer-props='{
    itemsPerPageOptions: [-1],
    prevIcon: null,
    nextIcon: null,
  }'>
  <template v-slot:item="props">
    <tr-component :item='props.item'/>
  </template>
</v-data-table>

The result will use around 6-7 seconds which is only 1-2 seconds to update DOM.

Or if you find out that your function trigger very fast (In your example use 50ms which is too fast in my opinion) you could try throttle it to less update DOM.

...
methods: {
  async countMaxValue(item) {
    await new Promise(resolve => setTimeout(resolve, 50)).then(() => {
      let maxVal = Math.random() * 100;
      let newItem = {
        ...item,
        maxValue: maxVal < 20 ? null : maxVal
      }
      this.changes.push(newItem) // keep newItem to change later
      this.applyChanges() // try to apply changes if it already schedule it will do nothing
    });
  },
  applyChanges () {
    if (this.timeoutId) return
    this.timeoutId = setTimeout(() => {
      while (this.changes.length) {
        let item = this.changes.pop()
        this.desserts.splice(item.id, 1, item)
      }
      this.timeoutId = null
    }, 1500)
  }
}

The result will use around 5-6 seconds but as you can see it's not immediately update.

Or you may try to call your API in parallel such as 10 requests, you could reduce from to wait 100 * 50 ms to around 10 * 50 ms (mathematically).

I hope this help.

Related