diff options
| author | BigCoke233 <hi@guhub.cn> | 2026-01-17 00:00:27 +0800 |
|---|---|---|
| committer | BigCoke233 <hi@guhub.cn> | 2026-01-17 00:00:27 +0800 |
| commit | e52bd5a9c99c5e769f039e794572e028619ba53e (patch) | |
| tree | 11d7e1ba16f33b8dbe994aa510fad110161dbd0a | |
| parent | bcd509c540632b6524a8dd87a4613e64ed0402f6 (diff) | |
perf: use instantpage.js to improve page speed
| -rw-r--r-- | assets/js/instantpage.js | 452 | ||||
| -rw-r--r-- | layouts/_partials/scripts.html | 9 |
2 files changed, 461 insertions, 0 deletions
diff --git a/assets/js/instantpage.js b/assets/js/instantpage.js new file mode 100644 index 00000000..60f1cb17 --- /dev/null +++ b/assets/js/instantpage.js @@ -0,0 +1,452 @@ +/*! instant.page v5.2.0 - (C) 2019-2025 Alexandre Dieulot - https://instant.page/license */ + +let _chromiumMajorVersionInUserAgent = null + , _speculationRulesType + , _allowQueryString + , _allowExternalLinks + , _useWhitelist + , _delayOnHover = 65 + , _lastTouchstartEvent + , _mouseoverTimer + , _preloadedList = new Set() + +init() + +function init() { + const supportChecksRelList = document.createElement('link').relList + + const supportsPrefetch = supportChecksRelList.supports('prefetch') + if (!supportsPrefetch) { + return + } + + const chromium100Check = 'throwIfAborted' in AbortSignal.prototype // Chromium 100+, Safari 15.4+, Firefox 97+ + const firefox115AndSafari17_0Check = supportChecksRelList.supports('modulepreload') // Firefox 115+, Safari 17.0+, Chromium 66+ + const safari15_4AndFirefox116Check = Intl.PluralRules && 'selectRange' in Intl.PluralRules.prototype // Safari 15.4+, Firefox 116+, Chromium 106+ + const firefox115AndSafari15_4Check = firefox115AndSafari17_0Check || safari15_4AndFirefox116Check + const isBrowserSupported = chromium100Check && firefox115AndSafari15_4Check + if (!isBrowserSupported) { + return + } + // In order to lessen maintenance and unnoticed bugs we only support: + // - Chromium ⩾ 100 — UC Browser 14 + // - Gecko as in Firefox ⩾ 115 — last version supported on Windows 7 + // - WebKit as in Safari ⩾ 15.4 — last major WebKit version supported on iPhone 6s & 7 + // + // WebKit doesn’t support prefetch anyway, but instant.page might + // eventually drop this requirement by providing an option for + // fetch()-based preloading. + // + // Additionally, instant.page should not cause JavaScript errors in: + // - Chromium ⩾ 61 + // - Gecko as in Firefox ⩾ 60 + // - WebKit as in Safari ⩾ 10.1 (iOS ⩾ 10.3 and macOS ⩾ 10.10) + // Browser engines older than that don’t support <script type=module> + // and thus don’t load instant.page at all. + + const handleVaryAcceptHeader = 'instantVaryAccept' in document.body.dataset || 'Shopify' in window + // The `Vary: Accept` header when received in Chromium 79–109 makes prefetches + // unusable, as Chromium used to send a different `Accept` header. + // It’s applied on all Shopify sites by default, as Shopify is very popular + // and is the main source of this problem. + // `window.Shopify` only exists on “classic” Shopify sites. Those using + // Hydrogen (Remix SPA) aren’t concerned. + + const chromiumUserAgentIndex = navigator.userAgent.indexOf('Chrome/') + if (chromiumUserAgentIndex > -1) { + _chromiumMajorVersionInUserAgent = parseInt(navigator.userAgent.substring(chromiumUserAgentIndex + 'Chrome/'.length)) + } + // The user agent client hints API is a theoretically more reliable way to + // get Chromium’s version… but it’s not available in Samsung Internet 20. + // It also requires a secure context, which would make debugging harder, + // and is only available in recent Chromium versions. + // In practice, Chromium browsers never shy from announcing "Chrome" in + // their regular user agent string, as that maximizes their compatibility. + + if (handleVaryAcceptHeader && _chromiumMajorVersionInUserAgent && _chromiumMajorVersionInUserAgent < 110) { + return + } + + _speculationRulesType = 'none' + if (HTMLScriptElement.supports && HTMLScriptElement.supports('speculationrules')) { + const speculationRulesConfig = document.body.dataset.instantSpecrules + if (speculationRulesConfig == 'prerender') { + _speculationRulesType = 'prerender' + } else if (speculationRulesConfig != 'no') { + _speculationRulesType = 'prefetch' + } + } + + const useMousedownShortcut = 'instantMousedownShortcut' in document.body.dataset + _allowQueryString = 'instantAllowQueryString' in document.body.dataset + _allowExternalLinks = 'instantAllowExternalLinks' in document.body.dataset + _useWhitelist = 'instantWhitelist' in document.body.dataset + + let preloadOnMousedown = false + let preloadOnlyOnMousedown = false + let preloadWhenVisible = false + if ('instantIntensity' in document.body.dataset) { + const intensityParameter = document.body.dataset.instantIntensity + + if (intensityParameter == 'mousedown' && !useMousedownShortcut) { + preloadOnMousedown = true + } + + if (intensityParameter == 'mousedown-only' && !useMousedownShortcut) { + preloadOnMousedown = true + preloadOnlyOnMousedown = true + } + + if (intensityParameter == 'viewport') { + const isOnSmallScreen = document.documentElement.clientWidth * document.documentElement.clientHeight < 450000 + // Smartphones are the most likely to have a slow connection, and + // their small screen size limits the number of links (and thus + // server load). + // + // Foldable phones (being expensive as of 2023), tablets and PCs + // generally have a decent connection, and a big screen displaying + // more links that would put more load on the server. + // + // iPhone 14 Pro Max (want): 430×932 = 400 760 + // Samsung Galaxy S22 Ultra with display size set to 80% (want): + // 450×965 = 434 250 + // Small tablet (don’t want): 600×960 = 576 000 + // Those number are virtual screen size, the viewport (used for + // the check above) will be smaller with the browser’s interface. + + const isNavigatorConnectionSaveDataEnabled = navigator.connection && navigator.connection.saveData + const isNavigatorConnectionLike2g = navigator.connection && navigator.connection.effectiveType && navigator.connection.effectiveType.includes('2g') + const isNavigatorConnectionAdequate = !isNavigatorConnectionSaveDataEnabled && !isNavigatorConnectionLike2g + + if (isOnSmallScreen && isNavigatorConnectionAdequate) { + preloadWhenVisible = true + } + } + + if (intensityParameter == 'viewport-all') { + preloadWhenVisible = true + } + + const intensityAsInteger = parseInt(intensityParameter) + if (!isNaN(intensityAsInteger)) { + _delayOnHover = intensityAsInteger + } + } + + const eventListenersOptions = { + capture: true, + passive: true, + } + + if (preloadOnlyOnMousedown) { + document.addEventListener('touchstart', touchstartEmptyListener, eventListenersOptions) + } + else { + document.addEventListener('touchstart', touchstartListener, eventListenersOptions) + } + + if (!preloadOnMousedown) { + document.addEventListener('mouseover', mouseoverListener, eventListenersOptions) + } + + if (preloadOnMousedown) { + document.addEventListener('mousedown', mousedownListener, eventListenersOptions) + } + if (useMousedownShortcut) { + document.addEventListener('mousedown', mousedownShortcutListener, eventListenersOptions) + } + + if (preloadWhenVisible) { + let requestIdleCallbackOrFallback = window.requestIdleCallback + // Safari has no support as of 16.3: https://webkit.org/b/164193 + if (!requestIdleCallbackOrFallback) { + requestIdleCallbackOrFallback = (callback) => { + callback() + // A smarter fallback like setTimeout is not used because devices that + // may eventually be eligible to a Safari version supporting prefetch + // will be very powerful. + // The weakest devices that could be eligible are the 2017 iPad and + // the 2016 MacBook. + } + } + + requestIdleCallbackOrFallback(function observeIntersection() { + const intersectionObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const anchorElement = entry.target + intersectionObserver.unobserve(anchorElement) + preload(anchorElement.href) + } + }) + }) + + document.querySelectorAll('a').forEach((anchorElement) => { + if (isPreloadable(anchorElement)) { + intersectionObserver.observe(anchorElement) + } + }) + }, { + timeout: 1500, + }) + } +} + +function touchstartListener(event) { + _lastTouchstartEvent = event + + const anchorElement = event.target.closest('a') + + if (!isPreloadable(anchorElement)) { + return + } + + preload(anchorElement.href, 'high') +} + +function touchstartEmptyListener(event) { + _lastTouchstartEvent = event +} + +function mouseoverListener(event) { + if (isEventLikelyTriggeredByTouch(event)) { + // This avoids uselessly adding a mouseout event listener and setting a timer. + return + } + + if (!('closest' in event.target)) { + return + // Without this check sometimes an error “event.target.closest is not a function” is thrown, for unknown reasons + // That error denotes that `event.target` isn’t undefined. My best guess is that it’s the Document. + // + // Details could be gleaned from throwing such an error: + //throw new TypeError(`instant.page non-element event target: timeStamp=${~~event.timeStamp}, type=${event.type}, typeof=${typeof event.target}, nodeType=${event.target.nodeType}, nodeName=${event.target.nodeName}, viewport=${innerWidth}x${innerHeight}, coords=${event.clientX}x${event.clientY}, scroll=${scrollX}x${scrollY}`) + } + const anchorElement = event.target.closest('a') + + if (!isPreloadable(anchorElement)) { + return + } + + anchorElement.addEventListener('mouseout', mouseoutListener, {passive: true}) + + _mouseoverTimer = setTimeout(() => { + preload(anchorElement.href, 'high') + _mouseoverTimer = null + }, _delayOnHover) +} + +function mousedownListener(event) { + if (isEventLikelyTriggeredByTouch(event)) { + // When preloading only on mousedown, not touch, we need to stop there + // because touches send compatibility mouse events including mousedown. + // + // (When preloading on touchstart, instructions below this block would + // have no effect.) + return + } + + const anchorElement = event.target.closest('a') + + if (!isPreloadable(anchorElement)) { + return + } + + preload(anchorElement.href, 'high') +} + +function mouseoutListener(event) { + if (event.relatedTarget && event.target.closest('a') == event.relatedTarget.closest('a')) { + return + } + + if (_mouseoverTimer) { + clearTimeout(_mouseoverTimer) + _mouseoverTimer = null + } +} + +function mousedownShortcutListener(event) { + if (isEventLikelyTriggeredByTouch(event)) { + // Due to a high potential for complications with this mousedown shortcut + // combined with other parties’ JavaScript code, we don’t want it to run + // at all on touch devices, even though mousedown and click are triggered + // at almost the same time on touch. + return + } + + const anchorElement = event.target.closest('a') + + if (event.which > 1 || event.metaKey || event.ctrlKey) { + return + } + + if (!anchorElement) { + return + } + + anchorElement.addEventListener('click', function (event) { + if (event.detail == 1337) { + return + } + + event.preventDefault() + }, {capture: true, passive: false, once: true}) + + const customEvent = new MouseEvent('click', {view: window, bubbles: true, cancelable: false, detail: 1337}) + anchorElement.dispatchEvent(customEvent) +} + +function isEventLikelyTriggeredByTouch(event) { + // Touch devices fire “mouseover” and “mousedown” (and other) events after + // a touch for compatibility reasons. + // This function checks if it’s likely that we’re dealing with such an event. + + if (!_lastTouchstartEvent || !event) { + return false + } + + if (event.target != _lastTouchstartEvent.target) { + return false + } + + const now = event.timeStamp + // Chromium (tested Chrome 95 and 122 on Android) sometimes uses the same + // event.timeStamp value in touchstart, mouseover, and mousedown. + // Testable in test/extras/delay-not-considered-touch.html + // This is okay for our purpose: two equivalent timestamps will be less + // than the max duration, which means they’re related events. + // TODO: fill/find Chromium bug + const durationBetweenLastTouchstartAndNow = now - _lastTouchstartEvent.timeStamp + + const MAX_DURATION_TO_BE_CONSIDERED_TRIGGERED_BY_TOUCHSTART = 2500 + // How long after a touchstart event can a simulated mouseover/mousedown event fire? + // /test/extras/delay-not-considered-touch.html tries to answer that question. + // I saw up to 1450 ms on an overwhelmed Samsung Galaxy S2. + // On the other hand, how soon can an unrelated mouseover event happen after an unrelated touchstart? + // Meaning the user taps a link, then grabs their pointing device and clicks another/the same link. + // That scenario could occur if a user taps a link, thinks it hasn’t worked, and thus fall back to their pointing device. + // I do that in about 1200 ms on a Chromebook. In which case this function returns a false positive. + // False positives are okay, as this function is only used to decide to abort handling mouseover/mousedown/mousedownShortcut. + // False negatives could lead to unforeseen state, particularly in mousedownShortcutListener. + + return durationBetweenLastTouchstartAndNow < MAX_DURATION_TO_BE_CONSIDERED_TRIGGERED_BY_TOUCHSTART + + // TODO: Investigate if pointer events could be used. + // https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/pointerType + + // TODO: Investigate if InputDeviceCapabilities could be used to make it + // less hacky on Chromium browsers. + // https://developer.mozilla.org/en-US/docs/Web/API/InputDeviceCapabilities_API + // https://wicg.github.io/input-device-capabilities/ + // Needs careful reading of the spec and tests (notably, what happens with a + // mouse connected to an Android or iOS smartphone?) to make sure it’s solid. + // Also need to judge if WebKit could implement it differently, as they + // don’t mind doing when a spec gives room to interpretation. + // It seems to work well on Chrome on ChromeOS. + + // TODO: Consider using event screen position as another heuristic. +} + +function isPreloadable(anchorElement) { + if (!anchorElement || !anchorElement.href) { + return + } + + if (_useWhitelist && !('instant' in anchorElement.dataset)) { + return + } + + if (anchorElement.origin != location.origin) { + let allowed = _allowExternalLinks || 'instant' in anchorElement.dataset + if (!allowed || !_chromiumMajorVersionInUserAgent) { + // Chromium-only: see comment on “restrictive prefetch” and “cross-site speculation rules prefetch” + return + } + } + + if (!['http:', 'https:'].includes(anchorElement.protocol)) { + return + } + + if (anchorElement.protocol == 'http:' && location.protocol == 'https:') { + return + } + + if (!_allowQueryString && anchorElement.search && !('instant' in anchorElement.dataset)) { + return + } + + if (anchorElement.hash && anchorElement.pathname + anchorElement.search == location.pathname + location.search) { + return + } + + if ('noInstant' in anchorElement.dataset) { + return + } + + return true +} + +function preload(url, fetchPriority = 'auto') { + if (_preloadedList.has(url)) { + return + } + + if (_speculationRulesType != 'none') { + preloadUsingSpeculationRules(url) + } else { + preloadUsingLinkElement(url, fetchPriority) + } + + _preloadedList.add(url) +} + +function preloadUsingSpeculationRules(url) { + const scriptElement = document.createElement('script') + scriptElement.type = 'speculationrules' + + scriptElement.textContent = JSON.stringify({ + [_speculationRulesType]: [{ + source: 'list', + urls: [url] + }] + }) + + // When using speculation rules, cross-site prefetch is supported, but will + // only work if the user has no cookies for the destination site. The + // prefetch will not be sent, if the user does have such cookies. + + document.head.appendChild(scriptElement) +} + +function preloadUsingLinkElement(url, fetchPriority = 'auto') { + const linkElement = document.createElement('link') + linkElement.rel = 'prefetch' + linkElement.href = url + + linkElement.fetchPriority = fetchPriority + // By default, a prefetch is loaded with a low priority. + // When there’s a fair chance that this prefetch is going to be used in the + // near term (= after a touch/mouse event), giving it a high priority helps + // make the page load faster in case there are other resources loading. + // Prioritizing it implicitly means deprioritizing every other resource + // that’s loading on the page. Due to HTML documents usually being much + // smaller than other resources (notably images and JavaScript), and + // prefetches happening once the initial page is sufficiently loaded, + // this theft of bandwidth should rarely be detrimental. + + linkElement.as = 'document' + // as=document is Chromium-only and allows cross-origin prefetches to be + // usable for navigation. They call it “restrictive prefetch” and intend + // to remove it: https://crbug.com/1352371 + // + // This document from the Chrome team dated 2022-08-10 + // https://docs.google.com/document/d/1x232KJUIwIf-k08vpNfV85sVCRHkAxldfuIA5KOqi6M + // claims (I haven’t tested) that data- and battery-saver modes as well as + // the setting to disable preloading do not disable restrictive prefetch, + // unlike regular prefetch. That’s good for prefetching on a touch/mouse + // event, but might be bad when prefetching every link in the viewport. + + document.head.appendChild(linkElement) +} diff --git a/layouts/_partials/scripts.html b/layouts/_partials/scripts.html index b1e9aa2e..878ac01e 100644 --- a/layouts/_partials/scripts.html +++ b/layouts/_partials/scripts.html @@ -1,8 +1,17 @@ {{ with resources.Get "js/tooltip.js" }} +<!-- tooltip by Eltrac --> <script>{{ .Content | safeJS }}</script> {{ end }} +{{ if not .IsHome }} {{ with resources.Get "js/inject-footnotes.js" }} +<!-- inject footnotes by Eltrac --> +<script>{{ .Content | safeJS }}</script> +{{ end }} +{{ end }} + +{{ with resources.Get "js/instantpage.js" }} +<!-- instantpage.js --> <script>{{ .Content | safeJS }}</script> {{ end }} |
