1
\$\begingroup\$

Problem

The scrolling looks smooth on Windows, but very laggy on Linux (Webkit webview and Webkit browsers).

Any thoughts on what could be optimized or what's obviously broken?

Demo playground:

https://stackblitz.com/edit/sigma-scrollkit-demo?file=README.md

Code

Full source code

The main logic is happening in this component:

<script setup lang="ts">
import uniqueId from '@/utils/uniqueId';
import {
  ref,
  computed,
  nextTick,
  watch,
  onMounted,
  onBeforeUnmount
} from 'vue';
import type {Ref} from 'vue';
import type {VirtualEntryItem, VirtualEntry} from 'types/shared';

type ElementResizeCallback = (entry: ResizeObserverEntry) => void;

interface ScrollEmitValue {
  event: UIEvent;
  scrollOffsetY: number;
  scrollDelta: number;
  scrollDirection: 'up' | 'down';
  scrollSpeed: number;
}

interface Props {
  virtualEntries: VirtualEntry[];
  scrollerId: string;
  layoutType: 'list' | 'grid';
  minColumnWidth?: number;
  bufferItemCount?: number;
  calcExtraInfo?: boolean;
  topOffsetTrigger?: number;
  bottomOffsetTrigger?: number;
}

interface Emits {
  (
    event: 'viewport-mounted',
    value: { viewport: Ref<HTMLElement | null>; selector: string }
  ): void;
  (event: 'scroll', value: ScrollEmitValue): void;
  (event: 'scrolling', value: boolean): void;
  (event: 'is-scrollable', value: boolean): void;
  (event: 'top-reached', value: boolean): void;
  (event: 'bottom-reached', value: boolean): void;
  (event: 'top-offset-trigger', value: boolean): void;
  (event: 'bottom-offset-trigger', value: boolean): void;
}

const props = withDefaults(defineProps<Props>(), {
  virtualEntries: () => [],
  bufferItemCount: 2,
  minColumnWidth: 200,
  calcExtraInfo: false,
  topOffsetTrigger: 0,
  bottomOffsetTrigger: 0
});

const emit = defineEmits<Emits>();

defineExpose({scrollTop, scrollToItem, scrollToIndex});

const virtualScrollerSelector = ref<string>('');
const transformedVirtualEntries = ref<VirtualEntry[]>(props.virtualEntries);
const maxColumns = ref<number>(1);
const rootElementRef = ref<HTMLElement | null>(null);
const rootElementHeight = ref<number>(500);
const rootElementWidth = ref<number>(500);
const rootElementRenderedHeight = ref<number>(1000);
const itemHeights = ref<number[]>([]);
const renderedItemHeights = ref<number[]>([]);
const totalItemHeight = ref<number>(0);
const scrolling = ref<boolean>(false);
const scrollStopTimeout = ref<ReturnType<typeof setTimeout> | null>(null);
const scrollOffsetY = ref<number>(0);
const scrollPreviousOffsetY = ref<number>(0);
const scrollDirection = ref<'up' | 'down'>('down');
const scrollDelta = ref<number>(0);
const scrollSpeed = ref<number>(0);
const scrollSpeedHistorySize = ref<number>(10);
const scrollSpeedHistory = ref<number[]>([]);

onMounted(() => {
  init();
  window.addEventListener('resize', windowResizeHandler);
});

onBeforeUnmount(() => {
  window.removeEventListener('resize', windowResizeHandler);
});

const renderedItems = computed(() =>
  transformedVirtualEntries.value.slice(
    scrolledItemsCount.value,
    scrolledItemsCount.value + renderedItemsCount.value
  )
);

const isTopReached = computed(() => scrollOffsetY.value === 0);

const isBottomReached = computed(
  () =>
    Math.ceil(scrollOffsetY.value + rootElementHeight.value) >=
    totalItemHeight.value
);

const isTopTriggerReached = computed(
  () =>
    Math.ceil(scrollOffsetY.value + rootElementHeight.value) >=
    props.topOffsetTrigger
);

const isBottomTriggerReached = computed(
  () =>
    Math.ceil(scrollOffsetY.value + rootElementHeight.value) >=
    totalItemHeight.value - props.bottomOffsetTrigger
);

const viewportOffsetY = computed(() =>
  itemHeights.value
    .slice(0, scrolledItemsCount.value)
    .reduce((accumulator, current) => accumulator + (current || 0), 0)
);

const virtualScrollerStyle = computed(() => ({
  height: `${totalItemHeight.value}px`
}));

const viewportStyle = computed(() => ({
  transform: `translateY(${viewportOffsetY.value}px)`
}));

const scrolledItemsCount = computed(() => {
  let itemHeightSum = 0;
  let counter = 0;
  itemHeights.value.forEach(height => {
    if (itemHeightSum <= scrollOffsetY.value) {
      itemHeightSum += height;
      counter += 1;
    }
  });
  return counter - 1;
});

const renderedItemsCount = computed(() => {
  let itemHeightSum = 0;
  let itemsNeededToFitRootContainer = 0;
  itemHeights.value.forEach(height => {
    if (itemHeightSum < rootElementHeight.value) {
      itemHeightSum += height;
      itemsNeededToFitRootContainer += 1;
    }
  });
  return itemsNeededToFitRootContainer + props.bufferItemCount;
});

watch(
  () => props.virtualEntries,
  () => {
    update();
    updateLazy();
    nextTick(() => {
      updateLazy();
    });
  },
  {
    deep: true,
    immediate: true
  }
);

watch(isTopReached, newValue => {
  emit('top-reached', newValue);
});

watch(isBottomReached, newValue => {
  emit('bottom-reached', newValue);
});

watch(isTopTriggerReached, newValue => {
  emit('top-offset-trigger', newValue);
});

watch(isBottomTriggerReached, newValue => {
  emit('bottom-offset-trigger', newValue);
});

function init() {
  virtualScrollerSelector.value = `.sigma-scrollkit[scroller-index="${props.scrollerId}"]`;

  update();
  updateLazy();
  nextTick(() => {
    emit('viewport-mounted', {
      viewport: rootElementRef,
      selector: virtualScrollerSelector.value
    });
    initRootElementResizeObserver();
  });
}

function setVirtualScrollerSelector() {
  virtualScrollerSelector.value = `.sigma-scrollkit[scroller-index="${props.scrollerId}"]`;
}

function transformItems() {
  transformedVirtualEntries.value = entriesToRows();
}

function entriesToRows(): VirtualEntry[] {
  if (props.virtualEntries.length === 0) {
    return [];
  }

  let newRows: VirtualEntry[] = [];
  if (props.layoutType === 'grid') {
    maxColumns.value = Math.floor(
      rootElementWidth.value / props.minColumnWidth
    );
  } else {
    maxColumns.value = 1;
  }
  props.virtualEntries.forEach(row => {
    const newItems = chunkArray(row.items, maxColumns.value);
    newItems.forEach(chunk => {
      row.id = uniqueId();
      newRows.push({
        ...row,
        items: chunk
      });
    });
  });
  return newRows;
}

function chunkArray<T>(array: T[], chunkSize: number): T[][] {
  if (chunkSize <= 0) {
    chunkSize = 1;
  }

  const result: T[][] = [];

  for (let i = 0; i < array.length; i += chunkSize) {
    const chunk = array.slice(i, i + chunkSize);
    result.push(chunk);
  }

  return result;
}

function update() {
  if (!rootElementRef.value || transformedVirtualEntries.value.length === 0) {
    return;
  }

  scrollOffsetY.value = rootElementRef.value.scrollTop;

  nextTick(() => {
    setRenderedItemHeights();
  });
}

function updateLazy() {
  setVirtualScrollerSelector();
  setRootElementRenderedHeight();
  setRootElementHeight();
  transformItems();
  setItemHeights();
  emit('is-scrollable', isScrollable());
}

function initRootElementResizeObserver() {
  const element = getVirtualScrollerElement()?.parentElement;
  if (!element) {
    return;
  }
  observeRootElementResize(element, rootElementResizeHandler);
}

function observeRootElementResize(
  element: HTMLElement,
  callback: ElementResizeCallback
) {
  const observer = new ResizeObserver(entries => {
    for (const entry of entries) {
      if (entry.target === element) {
        callback(entry);
      }
    }
  });
  observer.observe(element);
  return observer;
}

function rootElementResizeHandler() {
  update();
  updateLazy();
}

function windowResizeHandler() {
  update();
  updateLazy();
}

function isScrollable() {
  if (!rootElementRef.value) {
    return false;
  }
  return rootElementRef.value.scrollHeight > rootElementRef.value.clientHeight;
}

function scrollHandler(event: UIEvent) {
  handleScrollStart();
  handleScrollStop();
  setScrollStatus(rootElementRef.value);
  update();
  emit('scroll', {
    event: event satisfies UIEvent,
    scrollOffsetY: scrollOffsetY.value,
    scrollDelta: scrollDelta.value,
    scrollDirection: scrollDirection.value,
    scrollSpeed: scrollSpeed.value
  });
}

function handleScrollStart() {
  scrolling.value = true;
  emit('scrolling', true);
}

function handleScrollStop() {
  if (scrollStopTimeout.value) {
    clearTimeout(scrollStopTimeout.value);
  }
  scrollStopTimeout.value = setTimeout(() => {
    scrolling.value = false;
    emit('scrolling', false);
  }, 100);
}

function setScrollStatus(element: HTMLElement | null) {
  scrollPreviousOffsetY.value = scrollOffsetY.value;
  scrollOffsetY.value = element?.scrollTop || 0;
  scrollDelta.value = scrollOffsetY.value - scrollPreviousOffsetY.value;
  scrollDirection.value = scrollDelta.value > 0 ? 'down' : 'up';

  if (props.calcExtraInfo) {
    recordScrollSpeedHistory(scrollDelta.value);
    const speedSum = Math.abs(
      scrollSpeedHistory.value.reduce(
        (accumulator, current) => accumulator + current,
        0
      )
    );
    scrollSpeed.value = speedSum / scrollSpeedHistory.value.length;
  }
}

function recordScrollSpeedHistory(_scrollSpeed: number) {
  if (scrollSpeedHistory.value.length > scrollSpeedHistorySize.value) {
    scrollSpeedHistory.value.splice(0, 1);
  }
  scrollSpeedHistory.value.push(_scrollSpeed);
}

function setRootElementHeight() {
  const virtualScrollerParentElement = getVirtualScrollerElement()
    ?.parentElement satisfies HTMLElement | undefined | null;

  if (virtualScrollerParentElement) {
    rootElementHeight.value = virtualScrollerParentElement.offsetHeight;
    rootElementWidth.value = virtualScrollerParentElement.offsetWidth;
  }
}

function setRootElementRenderedHeight() {
  const virtualScrollerElement = getVirtualScrollerElement();

  if (virtualScrollerElement) {
    rootElementRenderedHeight.value = virtualScrollerElement.offsetHeight;
  }
}

function getVirtualScrollerElement() {
  return document.querySelector(
    virtualScrollerSelector.value
  ) satisfies HTMLElement | null;
}

function setRenderedItemHeights() {
  renderedItemHeights.value = renderedItems.value.map(
    item => item.height + item.rowGap
  );
}

function setItemHeights() {
  itemHeights.value = transformedVirtualEntries.value.map(
    item => item.height + item.rowGap
  );
  totalItemHeight.value = itemHeights.value.reduce(
    (accumulator, current) => accumulator + (current || 0),
    0
  );
}

function scrollTop() {
  rootElementRef?.value?.scrollTo?.({top: 0, behavior: 'smooth'});
}

function scrollToItem(params: { key: string; value: unknown }) {
  const targetItemIndex = transformedVirtualEntries.value.findIndex(
    (virtualEntry: VirtualEntry) =>
      !!virtualEntry.items.find(
        (item: VirtualEntryItem) => item[params.key] === params.value
      )
  );
  if (targetItemIndex !== -1) {
    scrollToIndex(targetItemIndex);
  }
}

function scrollToIndex(index: number) {
  if (rootElementRef.value) {
    rootElementRef.value.scrollTop = getIndexOffsetY(index);
  }
}

function getIndexOffsetY(index: number) {
  return transformedVirtualEntries.value
    .slice(0, index)
    .reduce(
      (accumulator, current) =>
        accumulator + (current.height + current.rowGap || 0),
      0
    );
}

setVirtualScrollerSelector();
</script>

<template>
  <div ref="rootElementRef"
class="sigma-scrollkit"
:scroller-index="props.scrollerId"
@scroll="scrollHandler">
    <div class="sigma-scrollkit__viewport-container"
:style="virtualScrollerStyle">
      <div class="sigma-scrollkit__viewport"
:style="viewportStyle">
        <slot name="viewport"
:rendered-items="renderedItems"
:scrolling="scrolling"
:max-columns="maxColumns" />
      </div>
    </div>
  </div>
</template>

<style>
.sigma-scrollkit {
  height: 100%;
  overflow-x: hidden;
  overflow-y: auto;
}
</style>
\$\endgroup\$

2 Answers 2

1
\$\begingroup\$

Found the problem - the filter: blur() rule was causing the issue, when I added it to the cards in the virtual list.

And the Webkit doesn't use hardware acceleration, until you force it with CSS.

\$\endgroup\$
1
\$\begingroup\$

General review points

The code is quite readable as indentation is consistent and long lines are broken up. Most of it uses functional techniques to iterate through arrays, and it makes good use of type-safe comparisons. There aren't many things I would change except for a few suggestions mentioned in the next section.

The main logic is happening in this component:

That link points to a file in the repository at path src/components/VirtualScroller.vue yet the link under heading Demo playground: points to a stackblitz demo which does not appear to have a file at that path. I would have verified my suggestions below and compared output if the demo had the VirtualScroller.vue file.

Suggestions

Bearing in mind you already posted a self-answer mentioning the source of the problem was found, below are some tips.

Consider using requestAnimationFrame() instead of setTimeout()

There is a call to setTimeout() in handleScrollStop(). One could consider using requestAnimationFrame() instead though it may require additional calculations and logic to achieve the same timing. For more information on this matter, refer to this blog post.

Analogous to clearTimeout(), cancelAnimationFrame() can be used to cancel an animation frame request previously scheduled through a call to requestAnimationFrame().

Extra closure for nextTick callback can be removed

While it likely would have little impact on performance, this construct for the callback of nextTick can be simpler:

nextTick(() => {
  updateLazy();
});

Since there is just one statement in the callback, updateLazy can be the callback:

nextTick(updateLazy);

There is also this code at the end of update():

nextTick(() => {
  setRenderedItemHeights();
});

It can be simplified to:

nextTick(setRenderedItemHeights);

Postfix increment/decrement operators could be used

In the foreach() callback inside the callback passed to computed() before being assigned to scrolledItemsCount is

  counter += 1;

It could be simplified to this:

  counter++;

Similarly, in the foreach() callback inside the callback passed to computed() before being assigned to renderedItemsCount is this assignment operation:

itemsNeededToFitRootContainer += 1;

It could be simplified to this:

itemsNeededToFitRootContainer++;
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.