diff --git a/src/client/app/components/Content.ts b/src/client/app/components/Content.ts index 637a932a9407..59ecd10d1fbf 100644 --- a/src/client/app/components/Content.ts +++ b/src/client/app/components/Content.ts @@ -1,9 +1,14 @@ import { h } from 'vue' import { useRoute } from '../router' +import { usePrefetch } from '../composables/preFetch' export const Content = { setup() { const route = useRoute() + if (!__DEV__) { + // in prod mode, enable intersectionObserver based pre-fetch. + usePrefetch() + } return () => (route.contentComponent ? h(route.contentComponent) : null) } } diff --git a/src/client/app/composables/preFetch.ts b/src/client/app/composables/preFetch.ts new file mode 100644 index 000000000000..d8584b1c5c7a --- /dev/null +++ b/src/client/app/composables/preFetch.ts @@ -0,0 +1,89 @@ +// Customized pre-fetch for page chunks based on +// https://github.com/GoogleChromeLabs/quicklink + +import { onMounted, onUnmounted, onUpdated } from 'vue' +import { inBrowser, pathToFile } from '../utils' + +const hasFetched = new Set() +const createLink = () => document.createElement('link') + +const viaDOM = (url: string) => { + const link = createLink() + link.rel = `prefetch` + link.href = url + document.head.appendChild(link) +} + +const viaXHR = (url: string) => { + const req = new XMLHttpRequest() + req.open('GET', url, (req.withCredentials = true)) + req.send() +} + +let link +const doFetch: (url: string) => void = + inBrowser && + (link = createLink()) && + link.relList && + link.relList.supports && + link.relList.supports('prefetch') + ? viaDOM + : viaXHR + +export function usePrefetch() { + if (!inBrowser) { + return + } + + if (!window.IntersectionObserver) { + return + } + + let conn + if ( + (conn = (navigator as any).connection) && + (conn.saveData || /2g/.test(conn.effectiveType)) + ) { + // Don't prefetch if using 2G or if Save-Data is enabled. + return + } + + const rIC = (window as any).requestIdleCallback || setTimeout + let observer: IntersectionObserver | null = null + + const observeLinks = () => { + if (observer) { + observer.disconnect() + } + + observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const link = entry.target as HTMLAnchorElement + observer!.unobserve(link) + const { pathname } = link + if (!hasFetched.has(pathname)) { + hasFetched.add(pathname) + const pageChunkPath = pathToFile(pathname) + doFetch(pageChunkPath) + } + } + }) + }) + + rIC(() => { + document.querySelectorAll('.vitepress-content a').forEach((link) => { + if ((link as HTMLAnchorElement).hostname === location.hostname) { + observer!.observe(link) + } + }) + }) + } + + onMounted(observeLinks) + onUpdated(observeLinks) + + onUnmounted(() => { + observer && observer.disconnect() + }) +} diff --git a/src/client/app/index.ts b/src/client/app/index.ts index 04b69aeb2518..883d09bb32c3 100644 --- a/src/client/app/index.ts +++ b/src/client/app/index.ts @@ -7,8 +7,7 @@ import { Content } from './components/Content' import Debug from './components/Debug.vue' import Theme from '/@theme/index' import { hot } from 'vite/hmr' - -const inBrowser = typeof window !== 'undefined' +import { inBrowser, pathToFile } from './utils' const NotFound = Theme.NotFound || (() => '404 Not Found') @@ -38,38 +37,17 @@ export function createApp() { let initialPath: string const router = createRouter((route) => { - let pagePath = route.path.replace(/\.html$/, '') - if (pagePath.endsWith('/')) { - pagePath += 'index' - } + let pagePath = pathToFile(route.path) if (isInitialPageLoad) { initialPath = pagePath } - if (__DEV__) { - // awlays force re-fetch content in dev - pagePath += `.md?t=${Date.now()}` - } else { - // in production, each .md file is built into a .md.js file following - // the path conversion scheme. - // /foo/bar.html -> ./foo_bar.md - - if (inBrowser) { - pagePath = pagePath.slice(__BASE__.length).replace(/\//g, '_') + '.md' - // client production build needs to account for page hash, which is - // injected directly in the page's html - const pageHash = __VP_HASH_MAP__[pagePath] - // use lean build if this is the initial page load or navigating back - // to the initial loaded path (the static vnodes already adopted the - // static content on that load so no need to re-fetch the page) - const ext = - isInitialPageLoad || initialPath === pagePath ? 'lean.js' : 'js' - pagePath = `${__BASE__}_assets/${pagePath}.${pageHash}.${ext}` - } else { - // ssr build uses much simpler name mapping - pagePath = `./${pagePath.slice(1).replace(/\//g, '_')}.md.js` - } + // use lean build if this is the initial page load or navigating back + // to the initial loaded path (the static vnodes already adopted the + // static content on that load so no need to re-fetch the page) + if (isInitialPageLoad || initialPath === pagePath) { + pagePath = pagePath.replace(/\.js$/, '.lean.js') } if (inBrowser) { diff --git a/src/client/app/router.ts b/src/client/app/router.ts index d192876026c5..35bbb67fe6ca 100644 --- a/src/client/app/router.ts +++ b/src/client/app/router.ts @@ -88,17 +88,17 @@ export function createRouter( (e) => { const link = (e.target as Element).closest('a') if (link) { - const { href, target } = link - const targetUrl = new URL(href) + const { href, protocol, hostname, pathname, hash, target } = link const currentUrl = window.location + // only intercept inbound links if ( target !== `_blank` && - targetUrl.protocol === currentUrl.protocol && - targetUrl.hostname === currentUrl.hostname + protocol === currentUrl.protocol && + hostname === currentUrl.hostname ) { - if (targetUrl.pathname === currentUrl.pathname) { + if (pathname === currentUrl.pathname) { // smooth scroll bewteen hash anchors in the same page - if (targetUrl.hash !== currentUrl.hash) { + if (hash !== currentUrl.hash) { e.preventDefault() window.scrollTo({ left: 0, diff --git a/src/client/app/utils.ts b/src/client/app/utils.ts new file mode 100644 index 000000000000..f2955a373eca --- /dev/null +++ b/src/client/app/utils.ts @@ -0,0 +1,32 @@ +export const inBrowser = typeof window !== 'undefined' + +/** + * Converts a url path to the corresponding js chunk filename. + */ +export function pathToFile(path: string): string { + let pagePath = path.replace(/\.html$/, '') + if (pagePath.endsWith('/')) { + pagePath += 'index' + } + + if (__DEV__) { + // awlays force re-fetch content in dev + pagePath += `.md?t=${Date.now()}` + } else { + // in production, each .md file is built into a .md.js file following + // the path conversion scheme. + // /foo/bar.html -> ./foo_bar.md + if (inBrowser) { + pagePath = pagePath.slice(__BASE__.length).replace(/\//g, '_') + '.md' + // client production build needs to account for page hash, which is + // injected directly in the page's html + const pageHash = __VP_HASH_MAP__[pagePath] + pagePath = `${__BASE__}_assets/${pagePath}.${pageHash}.js` + } else { + // ssr build uses much simpler name mapping + pagePath = `./${pagePath.slice(1).replace(/\//g, '_')}.md.js` + } + } + + return pagePath +}