Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 10 commits into from
Sep 18, 2024

Conversation

GalacticHypernova
Copy link
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

Copy link

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

Copy link

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
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
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
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
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
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
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 yyx990803 merged commit b9f84da into vuejs:minor Sep 18, 2024
13 checks passed
@yyx990803
Copy link
Member

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

@GalacticHypernova
Copy link
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