Skip to content

Commit da4852a

Browse files
committed
feat: prefetch in viewport inbound page chunks
1 parent 7a90c4f commit da4852a

File tree

5 files changed

+139
-35
lines changed

5 files changed

+139
-35
lines changed

src/client/app/components/Content.ts

+5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { h } from 'vue'
22
import { useRoute } from '../router'
3+
import { usePrefetch } from '../composables/preFetch'
34

45
export const Content = {
56
setup() {
67
const route = useRoute()
8+
if (!__DEV__) {
9+
// in prod mode, enable intersectionObserver based pre-fetch.
10+
usePrefetch()
11+
}
712
return () => (route.contentComponent ? h(route.contentComponent) : null)
813
}
914
}
+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Customized pre-fetch for page chunks based on
2+
// https://github.com/GoogleChromeLabs/quicklink
3+
4+
import { onMounted, onUnmounted, onUpdated } from 'vue'
5+
import { inBrowser, pathToFile } from '../utils'
6+
7+
const hasFetched = new Set<string>()
8+
const createLink = () => document.createElement('link')
9+
10+
const viaDOM = (url: string) => {
11+
const link = createLink()
12+
link.rel = `prefetch`
13+
link.href = url
14+
document.head.appendChild(link)
15+
}
16+
17+
const viaXHR = (url: string) => {
18+
const req = new XMLHttpRequest()
19+
req.open('GET', url, (req.withCredentials = true))
20+
req.send()
21+
}
22+
23+
let link
24+
const doFetch: (url: string) => void =
25+
inBrowser &&
26+
(link = createLink()) &&
27+
link.relList &&
28+
link.relList.supports &&
29+
link.relList.supports('prefetch')
30+
? viaDOM
31+
: viaXHR
32+
33+
export function usePrefetch() {
34+
if (!inBrowser) {
35+
return
36+
}
37+
38+
if (!window.IntersectionObserver) {
39+
return
40+
}
41+
42+
let conn
43+
if (
44+
(conn = (navigator as any).connection) &&
45+
(conn.saveData || /2g/.test(conn.effectiveType))
46+
) {
47+
// Don't prefetch if using 2G or if Save-Data is enabled.
48+
return
49+
}
50+
51+
const rIC = (window as any).requestIdleCallback || setTimeout
52+
let observer: IntersectionObserver | null = null
53+
54+
const observeLinks = () => {
55+
if (observer) {
56+
observer.disconnect()
57+
}
58+
59+
observer = new IntersectionObserver((entries) => {
60+
entries.forEach((entry) => {
61+
if (entry.isIntersecting) {
62+
const link = entry.target as HTMLAnchorElement
63+
observer!.unobserve(link)
64+
const { pathname } = link
65+
if (!hasFetched.has(pathname)) {
66+
hasFetched.add(pathname)
67+
const pageChunkPath = pathToFile(pathname)
68+
doFetch(pageChunkPath)
69+
}
70+
}
71+
})
72+
})
73+
74+
rIC(() => {
75+
document.querySelectorAll('.vitepress-content a').forEach((link) => {
76+
if ((link as HTMLAnchorElement).hostname === location.hostname) {
77+
observer!.observe(link)
78+
}
79+
})
80+
})
81+
}
82+
83+
onMounted(observeLinks)
84+
onUpdated(observeLinks)
85+
86+
onUnmounted(() => {
87+
observer && observer.disconnect()
88+
})
89+
}

src/client/app/index.ts

+7-29
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { Content } from './components/Content'
77
import Debug from './components/Debug.vue'
88
import Theme from '/@theme/index'
99
import { hot } from 'vite/hmr'
10-
11-
const inBrowser = typeof window !== 'undefined'
10+
import { inBrowser, pathToFile } from './utils'
1211

1312
const NotFound = Theme.NotFound || (() => '404 Not Found')
1413

@@ -38,38 +37,17 @@ export function createApp() {
3837
let initialPath: string
3938

4039
const router = createRouter((route) => {
41-
let pagePath = route.path.replace(/\.html$/, '')
42-
if (pagePath.endsWith('/')) {
43-
pagePath += 'index'
44-
}
40+
let pagePath = pathToFile(route.path)
4541

4642
if (isInitialPageLoad) {
4743
initialPath = pagePath
4844
}
4945

50-
if (__DEV__) {
51-
// awlays force re-fetch content in dev
52-
pagePath += `.md?t=${Date.now()}`
53-
} else {
54-
// in production, each .md file is built into a .md.js file following
55-
// the path conversion scheme.
56-
// /foo/bar.html -> ./foo_bar.md
57-
58-
if (inBrowser) {
59-
pagePath = pagePath.slice(__BASE__.length).replace(/\//g, '_') + '.md'
60-
// client production build needs to account for page hash, which is
61-
// injected directly in the page's html
62-
const pageHash = __VP_HASH_MAP__[pagePath]
63-
// use lean build if this is the initial page load or navigating back
64-
// to the initial loaded path (the static vnodes already adopted the
65-
// static content on that load so no need to re-fetch the page)
66-
const ext =
67-
isInitialPageLoad || initialPath === pagePath ? 'lean.js' : 'js'
68-
pagePath = `${__BASE__}_assets/${pagePath}.${pageHash}.${ext}`
69-
} else {
70-
// ssr build uses much simpler name mapping
71-
pagePath = `./${pagePath.slice(1).replace(/\//g, '_')}.md.js`
72-
}
46+
// use lean build if this is the initial page load or navigating back
47+
// to the initial loaded path (the static vnodes already adopted the
48+
// static content on that load so no need to re-fetch the page)
49+
if (isInitialPageLoad || initialPath === pagePath) {
50+
pagePath = pagePath.replace(/\.js$/, '.lean.js')
7351
}
7452

7553
if (inBrowser) {

src/client/app/router.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -88,17 +88,17 @@ export function createRouter(
8888
(e) => {
8989
const link = (e.target as Element).closest('a')
9090
if (link) {
91-
const { href, target } = link
92-
const targetUrl = new URL(href)
91+
const { href, protocol, hostname, pathname, hash, target } = link
9392
const currentUrl = window.location
93+
// only intercept inbound links
9494
if (
9595
target !== `_blank` &&
96-
targetUrl.protocol === currentUrl.protocol &&
97-
targetUrl.hostname === currentUrl.hostname
96+
protocol === currentUrl.protocol &&
97+
hostname === currentUrl.hostname
9898
) {
99-
if (targetUrl.pathname === currentUrl.pathname) {
99+
if (pathname === currentUrl.pathname) {
100100
// smooth scroll bewteen hash anchors in the same page
101-
if (targetUrl.hash !== currentUrl.hash) {
101+
if (hash !== currentUrl.hash) {
102102
e.preventDefault()
103103
window.scrollTo({
104104
left: 0,

src/client/app/utils.ts

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export const inBrowser = typeof window !== 'undefined'
2+
3+
/**
4+
* Converts a url path to the corresponding js chunk filename.
5+
*/
6+
export function pathToFile(path: string): string {
7+
let pagePath = path.replace(/\.html$/, '')
8+
if (pagePath.endsWith('/')) {
9+
pagePath += 'index'
10+
}
11+
12+
if (__DEV__) {
13+
// awlays force re-fetch content in dev
14+
pagePath += `.md?t=${Date.now()}`
15+
} else {
16+
// in production, each .md file is built into a .md.js file following
17+
// the path conversion scheme.
18+
// /foo/bar.html -> ./foo_bar.md
19+
if (inBrowser) {
20+
pagePath = pagePath.slice(__BASE__.length).replace(/\//g, '_') + '.md'
21+
// client production build needs to account for page hash, which is
22+
// injected directly in the page's html
23+
const pageHash = __VP_HASH_MAP__[pagePath]
24+
pagePath = `${__BASE__}_assets/${pagePath}.${pageHash}.js`
25+
} else {
26+
// ssr build uses much simpler name mapping
27+
pagePath = `./${pagePath.slice(1).replace(/\//g, '_')}.md.js`
28+
}
29+
}
30+
31+
return pagePath
32+
}

0 commit comments

Comments
 (0)