Reputation: 326
Consider this:
<table class="v-gridView" style="width:100%;">
<template v-for="(row, idx) in rows">
<tr :key="row.key" :class="rowClass(row, idx)" :style="rowStyle(row)">
<td v-for="col in cols" :key="col.field" :class="cellClass(row, col)" :style="cellStyle(row, col)" @click="cellClick(row, col)">
{{formatField(row, col)}}
</td>
</tr>
</template>
</table>
And script:
let vm = new Vue({
el: '#vmPage',
data: {
cols: [
{ name: 'Col1', field: 'a', ... },
{ name: 'Col2', field: 'b', ... },
{ name: 'Col3', field: 'c', ... },
],
rows: [
{ key: 1, a: 'A', b: 'B', c: 'C', ... },
{ key: 2, a: 'D', b: 'E', c: 'F', ... },
{ key: 3, a: 'G', b: 'H', c: 'I', ... },
]
},
methods: {
rowClass(row, idx) {
console.log('rowClass');
// complex processing here
},
rowStyle(row) {
console.log('rowStyle');
// complex processing here
},
cellClass(row, col) {
console.log('cellClass');
// complex processing here
},
cellStyle(row, col) {
console.log('cellStyle');
// complex processing here
},
cellClick(row, col) {
row[col.field] = 'new value';
},
formatField(row, col) {
console.log('formatField');
const val = row[col.field];
// complex val processing here
return val;
}
}
})
Now imagine this with a larger row set (300+ records) and more involved javascript processing in place of every one of those "console.log()" statements. In the actual code this takes 1200ms to render the entire table which is fine on initial render but not OK when only tiny parts of the row set are changed frequently. For example if I change a single property on a single row in the rows array, vue decides to call absolutely every method on every row and every cell even though there are no dependencies that would warrant that. Shrinking the data set is not an option that the customer will go for since this was possible with great performance even with 3000+ records before we moved to Vue from jQuery and this performs poorly even on small row counts.
Right now my ugly solution is to keep the vue data (rows array) immutable, while making changes to a clone of that data from which I then make selective updates to table cells with jQuery. Performs well but the code is more complicated than it needs to be and there must be a better way.
Update: Here is what I have now with given solution. The component template broken down into two components table and row:
<template id="vt-gridview">
<table v-if="rows" ref="table" :class="['v-gridView', {rowLines: rowLines}]">
<tr v-if="rows.length === 0">
<td>
<div style="padding:4px;" class="noRec">{{empty}}</div>
</td>
</tr>
<template v-else>
<tr v-if="showHeader">
<th v-if="colShow(col)" v-for="col in cols" :style="headStyle(col)" :title="col.hTitle" @click="doSort(col)">
<slot v-if="col.hSlot" :name="col.hSlot" :col="col"></slot>
<template v-else>
{{col.name}}<span v-if="sort" v-show="sort.exp==col.sort" style="margin-left:3px;">{{sortGliph}}</span>
</template>
</th>
</tr>
<v-gridview-tr v-for="(row, idx) in rows" :key="row[rowKeyProp]"
:row="row" :idx="idx" :cols="cols" :col-key-prop="colKeyProp" :cols-hidden="colsHidden" :alt-rows="altRows" :sel-row="selRow" @row-selected="rowSelected">
<template v-for="(index, name) in $scopedSlots" v-slot:[name]="data">
<slot :name="name" v-bind="data"></slot>
</template>
</v-gridview-tr>
</template>
</table>
</template>
<template id="vt-gridview-tr">
<tr :class="rowClass" :style="rowStyle" @click="rowClick">
<td v-if="colShow(col)" v-for="col in cols" :key="col[colKeyProp]" :class="cellClass(col)" :style="cellStyle(col)" :title="row[col.bTitle]" @click="cellClick(col)">
<slot v-if="col.bSlot" :name="col.bSlot" :row-idx="idx" :row="row" :col="col"></slot>
<div v-else-if="col.html" v-html="formatField(col)"></div>
<template v-else>
{{formatField(col)}}
</template>
</td>
</tr>
</template>
The component JS:
Vue.component('v-gridview', {
template: '#vt-gridview',
props: {
rowKeyProp: {
type: String,
default: 'ID'
},
colKeyProp: {
type: String,
default: 'field'
},
showHeader: {
default: true
},
cols: {
type: Array,
required: true
},
colsHidden: Array,
empty: String,
altRows: {
default: true
},
rowLines: {
default: false
},
rows: Array,
rowClassFn: Function,
cellStyleFn: Function,
sort: Object,
selRow: Object
},
created() {
},
computed: {
sortGliph() {
return this.sort.ord == 'ASC' ? '▲' : '▼';
}
},
methods: {
colShow(col) {
return this.colsHidden ? !this.colsHidden.includes(col.field) : true;
},
colSortable(col) {
return this.sort && col.sort
},
doSort(col) {
if (!this.colSortable(col)) return;
const sort = {
exp: col.sort,
ord: this.sort.exp != col.sort ? 'ASC' : this.sort.ord == 'ASC' ? 'DESC' : 'ASC'
}
this.$emit('update:sort', sort); // sync to parent
this.$emit('sort-chng', sort);
},
headStyle(col) {
return [col.hStyle, { cursor: this.colSortable(col) ? 'pointer' : '' }];
},
rowSelected(row) {
this.$emit('update:sel-row', row);
console.log('%crowSelected', 'color: red');
}
}
});
Vue.component('v-gridview-tr', {
template: '#vt-gridview-tr',
props: {
row: Object,
idx: Number,
colKeyProp: String,
cols: Array,
colsHidden: Array,
altRows: Boolean,
rowClassFn: Function,
cellStyleFn: Function,
selRow: Object
},
data() {
return {
selected: false
};
},
watch: {
'selRow': {
immediate: true,
handler(val) {
this.selected = val === this.row;
}
}
},
computed: {
rowClass() {
const cls = {
alt: (this.altRows) ? this.idx % 2 === 1 : false,
selected: this.selected === undefined ? false : this.selected
}
if (this.rowClassFn)
return this.rowClassFn({ cls, row: this.row, idx: this.idx });
else
return cls;
},
rowStyle() {
return { cursor: this.selected === undefined ? '' : 'pointer' };
}
},
methods: {
colShow(col) {
return this.colsHidden ? !this.colsHidden.includes(col.field) : true;
},
rowClick() {
if (this.selected === undefined) return;
this.$emit('row-selected', this.row);
},
cellClass(col) {
console.log('%ccellClass', 'color: yellow');
},
cellStyle(col) {
const style = {
//cursor: this.cellSelectable(col) ? 'pointer' : '', // base style
...col.bStyle // override/add col styles
};
if (this.cellStyleFn)
return this.cellStyleFn({ style, row: this.row, col }); // override/add callback given styles
else
return style;
},
cellClick(col) {
},
formatField(col) {
console.log('%cformatField', 'color: lightblue');
const val = this.row[col.field];
if (val === undefined) {
console.error(`[GridView error]: "${col.field}" field not found in data row.`);
return;
}
if (col.format)
return $.toStr(val, col.format);
else
return val;
}
}
});
And the use of the component from the page:
<v-gridview v-bind="trans" :sort.sync="trans.sort" :sel-row.sync="trans.selRow" style="width:100%;">
<template v-slot:trans="{row}">
{{ row.TransID }} <span v-show="row.dirty" style="color:var(--error-color);">✽</span>
</template>
<template v-slot:actclear="{row}">
<input type='text' class='cellInput' :value="row.ClearedDate" @focusout="commitInput(row, 'ClearedDate')" />
</template>
</v-gridview>
JS:
data: {
trans: {
cols: [
{ name: 'Tran.#', field: 'TransID', bSlot: 'trans', hStyle: { 'text-align': 'left' } },
{ name: 'Date', field: 'TransDate', hStyle: { 'text-align': 'left' } },
{ name: 'Cleared', field: 'ClearedDate', bSlot: 'actclear', hStyle: { 'text-align': 'left', width: '75px' }, bStyle: { padding: 0 } },
{ name: 'Amount', field: 'Amount', format: 'money', hStyle: { 'text-align': 'right' }, bStyle: { 'text-align': 'right' } }
],
rows: [
{ TransID: 1, TransDate: '01/01/20', ClearedDate: null, Amount: 35 },
{ TransID: 2, TransDate: '01/02/20', ClearedDate: null, Amount: 40 },
{ TransID: 3, TransDate: '01/03/20', ClearedDate: '02/02/20', Amount: 45.56 },
],
sort: null,
selRow: null
}
},
methods: {
commitInput(row, field) {
if (row[field] == event.target.value) return;
row[field] = event.target.value;
this.$set(row, 'dirty', true);
},
}
Although updating the input only changes data in a single row, all rows are updated. Same with selecting row. Problem still there even when removing all the row selection code.
Upvotes: 0
Views: 1432
Reputation: 29092
There's a lot to cover here...
A component's template will be compiled down to a render
function.
When Vue needs to render a component it will call that render
function. The render
function is not directly responsible for rendering anything. Instead it returns a tree of VNodes that describe what should be rendered.
Those VNodes come in two types. Some represent DOM elements, while others represent child components.
Before calling the render
function, Vue will start tracking. Any reactive data that is read while the render
function is running will be added to a list of dependencies. Vue will stop the tracker when the render
function finishes.
Vue does not directly track how the data is used. All it records is what data was read when render
ran. Trying to keep track at a more granular level would be very complicated and in most cases would not make much difference to performance. In fact, the complexity of the tracking process would likely have a detrimental impact on performance in most cases. However, as I'll come to later, you can give Vue a nudge in the right direction so that it does know which dependencies are used where.
For web development more generally, the biggest performance bottleneck is manipulating the DOM. That is typically much, much slower than doing anything else. By carefully batching updates and only making the minimum of changes the impact can be reduced to keep everything fast. There are various ways this can be done but Vue uses a virtual DOM, much like several other frameworks.
When a component is updated, Vue will run the render
function to generate a new tree of VNodes. It will then use a diffing algorithm to compare the old VNodes to the new VNodes and use that to figure out what the minimal set of changes are to update the DOM.
Generating the VNodes and diffing them isn't free. It's much cheaper than regenerating all of the DOM from scratch each time, but it still takes time. Vue 3 includes a lot of improvements to speed up both the generation and diffing of VNodes. Most of those improvements target specific cases that are not relevant to the code in the question.
Ultimately, frameworks like Vue cannot compete on performance with hand-crafted JavaScript. A general purpose framework can never be faster that code written to do one specific task.
In practice, Vue is usually fast enough for real use cases and the benefits come from maintainable code. Writing hand-crafted JavaScript may be theoretically faster but beyond a certain point it becomes too difficult to write code that way, leading to problems with both correctness and performance.
I don't want to give the impression that Vue isn't fast. It is. But it's always going to struggle to compete with hand-crafted JavaScript for an example like the one in the question.
That said, it should be possible to make the code in the question much faster.
As I mentioned earlier, Vue doesn't track exactly how reactive data is used within the render
function. However, if you split your component up into smaller components then that does allow a finer granularity of tracking.
Each render
function is tracked individually, so if data is only used in one child component then only that component will re-render when that data changes. That's a significant reduction in the number of VNodes that need to be created and diffed. It should be lightning fast.
If the parent re-renders that won't necessarily cause the child components to re-render. It might, if the props have changed, but if everything has stayed the same then the children won't re-render.
Having child components will also allow you to take advantage of computed properties. That can save some calculation overhead. Usually this is a minimal saving but it depends on how much work is involved in calculating the value.
So I suggest changing your template to something like this:
<table class="v-gridView" style="width:100%;">
<my-table-row
v-for="(row, idx) in rows"
:row="row"
:index="index"
:key="row.key"
/>
</table>
If you're using in-DOM templates then you'll need to make some adjustments to stop the browser from trashing it but the idea's still the same.
You'd then introduce a component called my-table-row
that does all the work for a particular row.
Written this way, the parent component has a dependency on the rows
array and the objects within in but it doesn't have a dependency on the individual properties of those objects. Instead, that's now moved into the child components. So if one property of one object changes it will only need to render that one child.
You can take this even further by splitting up the rows into individual cell components.
Update: Selections.
Yeah, selections are tricky when you have 3000 rows.
Multi-select would probably be easier to implement here because we could use a single object where the property keys are the row ids and the values are true
or false
for whether the row is selected. Written carefully the rows would end up with a dependency on just their specific property so nothing else needs to render.
Of course, we could implement single-select the same way. It would be fast but it feels like a wasteful use of data structures.
The first thing I would try is having a selected
prop on each row component that is just a boolean. Something like this:
<table class="v-gridView" style="width:100%;">
<my-table-row
v-for="(row, idx) in rows"
:row="row"
:index="index"
:selected="row === selRow"
:key="row.key"
/>
</table>
When selRow
changes it will re-render the parent table component but most of the child rows won't be touched. Yes, there is a lot of unnecessary work being done here, but it might be fast enough that we don't have to start taking extreme measures.
But with 3000 rows, extreme measures may be necessary.
I should add that with 3000 rows you would usually use some form of lazy rendering to only show the visible rows rather than the whole lot at once. But let's continue assuming that isn't an option.
So what if rendering the parent component is still too slow, even when its children don't need to re-render?
Well, here's one idea...
I should say upfront that what I'm about to describe is pretty extreme and I have never needed to do anything this complicated to squeeze out performance in a real Vue application.
The selRow
could be wrapped in an object, e.g.:
data () {
return {
selRow: {
value: null // update this value when the selection changes
}
}
}
with:
<table class="v-gridView" style="width:100%;">
<my-table-row
v-for="(row, idx) in rows"
:row="row"
:index="index"
:selected="selRow"
:key="row.key"
/>
</table>
When the value
changes it won't trigger the parent to re-render because it doesn't have a dependency on that property.
But now we need to ensure that the child rows don't all re-render.
One way we could do that it to use a watch
on selected.value
to update an internal data
property with a true
or false
value. That value would then be used within the row's template.
While the watch
would be called 3000 times that would likely be very quick as it would hardly be doing anything. Only components where that internal property changed would then re-render.
This all sounds like the classic state synchronisation watch
anti-pattern. The 'best practice' would be to use a computed
property. However, that won't help to prevent the rows from all rendering. It doesn't matter whether a computed property's value has changed, if the upstream dependencies change then the render
function is triggered. We need to use a separate data
property to ensure that doesn't happen.
So the row component code for that would be something like this (in Vue 2):
data () {
return {
rowSelected: false
}
},
watch: {
'selected.value': {
immediate: true,
handler (selRow) {
this.rowSelected = selRow === this.row
}
}
}
It is a convoluted way to do things but it should be plenty fast.
As a final note: in extreme performance scenarios there is nothing wrong with using direct DOM manipulation as an escape hatch. You have to be really careful to do it right but it can be done.
Further update: Speeding up the JSFiddle example
I understand that your real use case is going to be much more complicated than the example but I'm going to have to limit my answer to the code you provided. We'll be here forever if I try to cover all the hypothetical cases you might be hitting.
This is increasingly drifting out of scope for a Stack Overflow question. This would arguably be a better fit for the Vue Forums.
That said, here are my findings from looking at your latest example.
Firstly, the console logging does not show what you think it shows. The logging shows that only one row is being updated, or two rows in the case of selection changes. The method cellClass
is called once per cell, so 4 times per row. Likewise formatField
is called twice per row.
I ramped it up to 10000 rows and switched the logging to something a little more helpful:
https://jsfiddle.net/skirtle/tk6mL4hq/1/
The exact timing will depend on your browser and hardware but 10000 rows was enough for me to get an obvious delay for selections and editing.
Thankfully, the slots aren't actually causing any problems here.
The logging I added gives a hint about what the problem is. Even though only one row is updating it is also updating both the root and parent components.
I don't think updating the root is a problem but the parent component is going to be doing a lot of extra work looping through 10000 rows.
So to speed it up we need to avoid triggering that parent render.
The first problem is using $set
to add a new property. As Vue 2 can't track missing properties it has to assume the worst when they're added subsequently. Vue 3 uses proxies so it doesn't have this problem.
So to fix that we can pre-populate the dirty
field in the row data. Using $set
for a property that already exists isn't necessary but it also isn't really doing any harm so I've left that line unchanged:
https://jsfiddle.net/skirtle/tk6mL4hq/3/
You won't see any improvement for selections but the editing is dramatically faster. You'll also notice that the root and parent components aren't being updated, just the relevant row.
Moving on to selections, this can be sped up by using the wrapper object trick I outlined earlier.
The wrapper object hides the change from the root and parent components. They just see the wrapper. Only the rows read the inner value so only they will be impacted by the change:
https://jsfiddle.net/skirtle/tk6mL4hq/4/
For 10000 rows the DOM is going to be huge and I suspect much of the remaining performance drain is down to that. Maybe there is more that can be done (e.g. production build of Vue) but for me it's plenty fast enough. If I reduce it to 3000 rows it updates faster than my eyes can see.
I know very little about your application but I would add that a large DOM is likely to impact performance no matter what tools you use. The browser's layout calculations in particular. Even changes that aren't related to your table can take a long time if your layouts aren't sufficiently isolated. That is a massive topic all by itself and not directly related to Vue so I'm not going to dwell on it, just to say that it is one reason why applications use lazy rendering rather than showing thousands of rows all at once.
Upvotes: 2
Reputation: 1
you can use the watcher by changing the register to a clone and use it.
Upvotes: -1