英文:
Table keeps jumping back to top after assigning custom scroll position
问题
I'm using Vue 3 with a Vuetify table and whenever I modify data I have to fetch everything again. If you modify a cell inside row 123 and column 456 reconstructing the whole grid is annoying because the table scrollbars jump back to the start. I think a good solution for this would be to
- store the current scroll position
- perform the write action
- reassign the stored scroll position
( any better suggestions are highly appreciated )
As a sidenote: Since Vuetify requires a fixed table height for fixed table headers I'm, calculating the height dynamically ( table should fill the rest of the page ).
I created the following example ( Playground link )
英文:
I'm using Vue 3 with a Vuetify table and whenever I modify data I have to fetch everything again. If you modify a cell inside row 123 and column 456 reconstructing the whole grid is annoying because the table scrollbars jump back to the start. I think a good solution for this would be to
- store the current scroll position
- perform the write action
- reassign the stored scroll position
( any better suggestions are highly appreciated )
As a sidenote: Since Vuetify requires a fixed table height for fixed table headers I'm, calculating the height dynamically ( table should fill the rest of the page ).
I created the following example ( Playground link )
<script setup lang="ts">
import { ref, nextTick, onMounted, watch } from "vue";
const mainContainerComponent = ref<VMain>();
const tableComponent = ref<VTable>();
const tableHeight: Ref<number | undefined> = ref(undefined);
const tableMatrix = ref([[]]);
onMounted(async () => {
// we only want to scroll inside the table
document.documentElement.classList.add("overflow-y-hidden");
await loadData();
});
watch(tableMatrix, async () => {
// Unset table height and wait for it to rerender
tableHeight.value = undefined;
await nextTick();
if (!tableComponent.value) {
return;
}
const mainContainerComponentRectangle = mainContainerComponent.value.$el.getBoundingClientRect();
const tableRectangle = tableComponent.value.$el.getBoundingClientRect();
const topOffset = tableRectangle.top;
const bottomOffset = mainContainerComponentRectangle.bottom - tableRectangle.bottom;
tableHeight.value = window.innerHeight - bottomOffset - topOffset;
});
async function loadData() {
// destroy table
tableMatrix.value = [];
await nextTick();
// fetch data
const fetchedData = new Array(Math.floor(Math.random() * 300) + 50).fill("data");
// calculate table matrix
tableMatrix.value = [...fetchedData.map(x => [x])];
}
async function performWriteAction() {
// send modify request here
const { scrollLeft, scrollTop } = getTableScrollPosition();
await loadData();
// wait for the DOM to finish
await nextTick();
// try to restore the previous scroll position
setTableScrollPosition(scrollLeft, scrollTop);
}
function getTableDOMElement() {
return tableComponent.value?.$el.querySelector(".v-table__wrapper");
}
function getTableScrollPosition() {
const { scrollLeft, scrollTop } = getTableDOMElement();
console.log(`current scroll position => x: ${scrollLeft} | y: ${scrollTop}`);
return { scrollLeft, scrollTop };
}
function setTableScrollPosition(scrollLeft: number, scrollTop: number) {
const tableElement = getTableDOMElement();
console.log(`scroll to => x: ${scrollLeft} | y: ${scrollTop}`);
tableElement.scrollLeft = scrollLeft;
tableElement.scrollTop = scrollTop;
}
</script>
<template>
<v-app>
<v-main ref="mainContainerComponent">
<v-container>
<v-btn @click="performWriteAction">Modify data</v-btn>
</v-container>
<v-table
ref="tableComponent"
density="compact"
fixed-header
:height="tableHeight"
>
<thead>
<tr>
<th>Col</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in tableMatrix" :key="rowIndex">
<template v-for="(cell, columnIndex) in row" :key="columnIndex">
<td>{{ rowIndex }}</td>
</template>
</tr>
</tbody>
</v-table>
</v-main>
</v-app>
</template>
The problem with this code is that the table always jumps back to the start. If you scroll down to the center of the table and modify some data the scroll position is still wrong.
Do you have any ideas what's wrong or missing?
答案1
得分: 2
Seems like it takes another rendering cycle, not sure why. Adding another nextTick()
fixes the scroll.
Here is the updated playground
I think I found why it happens, first, let's look at the order of events (this is without a second nextTick
), when inserting a number of log statements in the [playground](https://play.vuetifyjs.com/#eNqVV+1u2zYUfRVOKBB7laV23TDAddxmafcBNOiQBtuPKGhlibK1UqQqUXYMz+/eQ1KyKEtuUgSIpcvLc7/OvSJvd85FnnvrijpTZ1ZGRZpLUlJZ5YSFfHkeOLIMnHnA0ywXhSQ7UtDEJZzey5s0+uwSwa9ExSWNXbIJZbQie5IUIiOBA8zAeUlIwAMeCV5KkoUpvxRc4ocWlwKInHJJzhXm7J8riOej8ctGW4YLRntaN0raU/uTpsuVnJJrqPAqW9CC/E8qHtMEpuK52Tw6
英文:
Seems like it takes another rendering cycle, not sure why. Adding another nextTick()
fixes the scroll:
await nextTick();
await nextTick();
setTableScrollPosition(scrollLeft, scrollTop);
Here is the updated playground
I think I found why it happens, first, let's look at the order of events (this is without a second nextTick
), when inserting a number of log statements in the playground, I get:
function | action |
---|---|
performWriteAction | awaiting loadData() |
loadData | clearing tableMatrix (triggers first watch), waiting |
watch 1 | unset tableHeight , waiting |
render | table is empty, no height |
loadData | continue, filling tableMatrix (triggers second watch) |
watch 1 | continuing, updating tableHeight (for empty table) |
watch 2 | unset tableHeight , waiting |
render | table has data, no height |
performWriteAction | waiting for update |
watch 2 | continuing, updating tableHeight (for filled table) |
performWriteAction | continue, setting scroll !!! starts before render !!! |
render | table has data, height is set |
setTableScrollPosition | scroll back |
The important lines are the ones after the penultimate render:
function | action |
---|---|
render | table has data, no height |
performWriteAction | waiting for update |
watch 2 | continuing, updating tableHeight (for filled table) |
performWriteAction | continue, setting scroll !!! starts before render !!! |
There you can see that performWriteAction()
calls await nextTick()
immediately after a render, when there are no pending state updates.
Looking at the implementation of nextTick()
, we can see that if there are no pending updates (in the currentFlushPromise
), a stored Promise.resolve()
is used:
const resolvedPromise = Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null
export function nextTick<T = void>(
this: T,
fn?: (this: T) => void
): Promise<void> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
So basically, since there is nothing to wait for, nextTick()
resolves immediately. Only the await
forces to wait until the next computation cycle, allowing the watcher is run in between. But any changes made by the watcher do not affect the preceding nextTick()
. You might as well write await Promise.resolve()
directly.
Interestingly, it works when you pass a callback to nextTick()
:
async function performWriteAction() {
const { scrollLeft, scrollTop } = getTableScrollPosition();
await loadData();
await nextTick(() => console.log('plz wait'));
setTableScrollPosition(scrollLeft, scrollTop);
}
Note that the callback still runs before the render, but looking at the implementation above, it adds another .then()
, which again forces to wait for another computation cycle, which is enough to get the render update in between (it is the same effect as the two await
statements in my initial answer).
With all this, I am going to say that there is no real bug in your code, except that nextTick()
behaves differently than expected (and maybe that it is not clear anymore what waits for what).
The clean solution would be to only run nextTick()
when there are actually pending changes. You could just reset the tableHeight
before waiting:
await loadData();
tableHeight.value = null
await nextTick();
setTableScrollPosition(scrollLeft, scrollTop);
}
Or, in general, it looks like you are using nextTick
a lot because you want to do CSS changes with JS. If you instead find a way to set table height through CSS (which should be possible), you would get rid of all the problems and a lot of fragile code, where parallel tasks have to wait for each other.
The most feasible solution though is probably to eliminate the watcher. You know when tableHeight
has to be updated, and that is when the tableMatrix
data changes. So you could just set it along the call to the load method:
async function performWriteAction() {
const { scrollLeft, scrollTop } = getTableScrollPosition();
tableHeight.value = null;
await loadData();
updateTableHeight();
setTableScrollPosition(scrollLeft, scrollTop);
}
Since it is particularly the clearing and then setting of tableMatrix
and tableHeight
, this would eliminate the issue and make your code much more clear and maintainable.
But that approach would not have led to your interesting question, which I hope this answers conclusively.
答案2
得分: 1
你可以通过添加几行CSS代码来取消固定表格高度设置。这样,你不仅可以解决滚动跳跃问题,还可以清理你的代码。
CSS 代码:
main {
height: 100vh;
display: flex;
flex-direction: column;
}
.v-container {
flex: none;
}
.v-table {
flex: auto;
display: flex;
flex-direction: column;
min-height: 0;
}
英文:
You can opt out of the rigid table height set by adding a few lines of css. This way, you will not only get rid of the scroll jump problem, but also clean up your code.
CSS code:
main {
height: 100vh;
display: flex;
flex-direction: column;
}
.v-container {
flex: none;
}
.v-table {
flex: auto;
display: flex;
flex-direction: column;
min-height: 0;
}
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论