Skip to content

perf(hydration): avoid observer if element is in viewport#11639

Merged
yyx990803 merged 10 commits intovuejs:minorfrom
GalacticHypernova:patch-2
Sep 18, 2024
Merged

perf(hydration): avoid observer if element is in viewport#11639
yyx990803 merged 10 commits intovuejs:minorfrom
GalacticHypernova:patch-2

Conversation

@GalacticHypernova
Copy link
Copy Markdown
Contributor

@GalacticHypernova GalacticHypernova commented Aug 17, 2024

This PR optimizes the intersection based hydration by avoiding the observer overhead if the element is visible initially, ensuring it is immediately hydrated, as opposed to waiting for the observer to run its callback

The approach is originally taken from https://github.com/nuxt/nuxt/pull/26468/files#diff-8e89e3233ee017161774c7e5df6b9a18cc926d5a1b4081e7fcbf9651109ce45d by @huang-julien

@github-actions
Copy link
Copy Markdown

github-actions bot commented Aug 17, 2024

Size Report

Bundles

File Size Gzip Brotli
runtime-dom.global.prod.js 99 kB (+211 B) 37.5 kB (+87 B) 33.8 kB (+74 B)
vue.global.prod.js 157 kB (+211 B) 57.3 kB (+102 B) 51 kB (+77 B)

Usages

Name Size Gzip Brotli
createApp 54.2 kB 21 kB 19.1 kB
createSSRApp 58.1 kB 22.6 kB 20.6 kB
defineCustomElement 58.8 kB 22.5 kB 20.5 kB
overall 67.8 kB 26 kB 23.7 kB

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Aug 17, 2024

Open in Stackblitz

@vue/compiler-core

pnpm add https://pkg.pr.new/@vue/compiler-core@11639

@vue/compiler-dom

pnpm add https://pkg.pr.new/@vue/compiler-dom@11639

@vue/compiler-ssr

pnpm add https://pkg.pr.new/@vue/compiler-ssr@11639

@vue/compiler-sfc

pnpm add https://pkg.pr.new/@vue/compiler-sfc@11639

@vue/reactivity

pnpm add https://pkg.pr.new/@vue/reactivity@11639

@vue/runtime-core

pnpm add https://pkg.pr.new/@vue/runtime-core@11639

@vue/runtime-dom

pnpm add https://pkg.pr.new/@vue/runtime-dom@11639

@vue/server-renderer

pnpm add https://pkg.pr.new/@vue/server-renderer@11639

@vue/shared

pnpm add https://pkg.pr.new/@vue/shared@11639

@vue/compat

pnpm add https://pkg.pr.new/@vue/compat@11639

vue

pnpm add https://pkg.pr.new/vue@11639

commit: 72c9bfc

@GalacticHypernova GalacticHypernova marked this pull request as ready for review August 17, 2024 10:53
Copy link
Copy Markdown
Member

@yyx990803 yyx990803 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this optimization is really necessary, since the amount components with lazy hydration will unlikely be a number that would lead to noticeable performance issues.

There's also no clear data showing the difference between the cost of ob.observe() vs. manually checking whether something is in viewport via getBoundingClientRect(). My intuition is that the difference is negligible.

Side note: the implementation has an issue where the return doesn't actually break the forEach look so it will call the hydrate multiple times - but again, I don't think this optimization is even needed in the first place.

@yyx990803 yyx990803 closed this Aug 19, 2024
@GalacticHypernova
Copy link
Copy Markdown
Contributor Author

GalacticHypernova commented Aug 19, 2024

Hey @yyx990803 , thank you for the review!

I decided to benchmark both behaviors and it seems like there is an average of ~95% performance improvement when using bounding rect, which is far from negligible:
image

I tested this in regular environments in the browser, below is a the benchmark code (if you'd like me to send the full test webpage, I will gladly do so):

const target = document.getElementById('target');

function benchmarkIntersectionObserver() {
    const start = performance.now();
    const observer = new IntersectionObserver(entries => {
        if (entries[0].isIntersecting) {
            const duration = performance.now() - start;
            console.log(`IntersectionObserver time: ${duration}ms`);
            observer.disconnect();
        }
    });

    observer.observe(target);
}

function benchmarkGetBoundingClientRect() {
    const start = performance.now();
    const {
        top,
        left,
        bottom,
        right
    } = target.getBoundingClientRect()
    const {
        innerHeight,
        innerWidth
    } = window
    if (
        ((top > 0 && top < innerHeight) || (bottom > 0 && bottom < innerHeight)) &&
        ((left > 0 && left < innerWidth) || (right > 0 && right < innerWidth))
    ) { // Given the test is for an in-viewport element, this is guaranteed to evaluate to true and can therefore be tested reliably

        const duration = performance.now() - start;
        console.log(`Bounding rect time: ${duration}ms`);
    }
}

function runBenchmark() {
    const intersectionObserverTime = benchmarkIntersectionObserver();
    const boundingClientRectTime = benchmarkGetBoundingClientRect();
}

window.addEventListener('load', () => {
    runBenchmark();
});

From dozens of benchmarks, bounding rect consistently takes around 0.1ms, whereas the intersection observer consistently takes multiple milliseconds, sometimes even reaching 10 and higher, as seen in this screenshot:
image

In terms of real world impact, it's not impossible for some websites to have numerous elements to cause a performance degradation, even if not quite noticeable for users (but still for crawlers, Lighthouse, and the likes). For example, having such intersection-based element for ads, content requiring scrolling, footer, links, etc, can easily reach even 10 elements in the same page, which can be a difference of around ~60ms load time if the observer is consistently on the faster end, and ~105ms load time if it's on the slower end.

That calculation doesn't take into account developers who decide to make every element in the page intersection based, which despite being a bad practice because of the inherent performance decrease for above-the-fold content, shouldn't mean that the performance should be overly impacted, in which case the difference could even be a few hundreds milliseconds, depending on the complexity of the page, which would then be really noticable

As for the error in implementation, I will gladly fix it. I haven't fully checked how the forEach operates (which I guess is my bad, and I apologize for it) so it's possible to refactor this into a boolean flag or a different method, if you deem it necessary.

@GalacticHypernova
Copy link
Copy Markdown
Contributor Author

Hey @yyx990803 , apologies for the second mention. I just wanted to confirm that the lack of response to my follow-up means you still don't think this is necessary despite the major performance difference, and not that you haven't seen my follow-up? If you think a difference of 12ms is not enough to warrant an optimization, I'm perfectly fine with it. I'm just wondering whether or not you've seen my benchmarks.

Thanks in advance!

@yyx990803 yyx990803 reopened this Sep 12, 2024
if (elementIsVisibleInViewport(el)) {
hydrate()
ob.disconnect()
return () => {}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After looking at the benchmark I think this is worthwhile, but the implementation needs some adjustments.

Right now there is no way to break out of the forEach loop, returning an empty function here doesn't do anything. We need to refactor forEach so that the loop breaks if the callback returns false.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I'll look into refactoring the function, thank you!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the proposed fix functional?

@yyx990803
Copy link
Copy Markdown
Member

Merged into minor by mistake - cherry-picked into main as e075dfa

@GalacticHypernova
Copy link
Copy Markdown
Contributor Author

GalacticHypernova commented Oct 9, 2024

Sorry for the late response, gotten caught up with personal matters. Thanks for the help!

@GalacticHypernova GalacticHypernova deleted the patch-2 branch October 9, 2024 07:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants