2021-03-01 15:06:11 +08:00

149 lines
5.6 KiB
JavaScript

import { reactive, inject, markRaw, nextTick, readonly } from 'vue';
export const RouterSymbol = Symbol();
// we are just using URL to parse the pathname and hash - the base doesn't
// matter and is only passed to support same-host hrefs.
const fakeHost = `http://a.com`;
const getDefaultRoute = () => ({
path: '/',
component: null,
// this will be set upon initial page load, which is before
// the app is mounted, so it's guaranteed to be avaiable in
// components
data: null
});
export function createRouter(loadPageModule, fallbackComponent) {
const route = reactive(getDefaultRoute());
const inBrowser = typeof window !== 'undefined';
function go(href = inBrowser ? location.href : '/') {
// ensure correct deep link so page refresh lands on correct files.
const url = new URL(href, fakeHost);
if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) {
url.pathname += '.html';
href = url.pathname + url.search + url.hash;
}
if (inBrowser) {
// save scroll position before changing url
history.replaceState({ scrollPosition: window.scrollY }, document.title);
history.pushState(null, '', href);
}
return loadPage(href);
}
let latestPendingPath = null;
async function loadPage(href, scrollPosition = 0) {
const targetLoc = new URL(href, fakeHost);
const pendingPath = (latestPendingPath = targetLoc.pathname);
try {
let page = loadPageModule(pendingPath);
// only await if it returns a Promise - this allows sync resolution
// on initial render in SSR.
if ('then' in page && typeof page.then === 'function') {
page = await page;
}
if (latestPendingPath === pendingPath) {
latestPendingPath = null;
const { default: comp, __pageData } = page;
if (!comp) {
throw new Error(`Invalid route component: ${comp}`);
}
route.path = pendingPath;
route.component = markRaw(comp);
route.data = readonly(JSON.parse(__pageData));
if (inBrowser) {
nextTick(() => {
if (targetLoc.hash && !scrollPosition) {
const target = document.querySelector(decodeURIComponent(targetLoc.hash));
if (target) {
scrollTo(target, targetLoc.hash);
return;
}
}
window.scrollTo(0, scrollPosition);
});
}
}
}
catch (err) {
if (!err.message.match(/fetch/)) {
console.error(err);
}
if (latestPendingPath === pendingPath) {
latestPendingPath = null;
route.path = pendingPath;
route.component = fallbackComponent ? markRaw(fallbackComponent) : null;
}
}
}
if (inBrowser) {
window.addEventListener('click', (e) => {
const link = e.target.closest('a');
if (link) {
const { href, protocol, hostname, pathname, hash, target } = link;
const currentUrl = window.location;
// only intercept inbound links
if (!e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
target !== `_blank` &&
protocol === currentUrl.protocol &&
hostname === currentUrl.hostname) {
e.preventDefault();
if (pathname === currentUrl.pathname) {
// scroll between hash anchors in the same page
if (hash && hash !== currentUrl.hash) {
history.pushState(null, '', hash);
// use smooth scroll when clicking on header anchor links
scrollTo(link, hash, link.classList.contains('header-anchor'));
}
}
else {
go(href);
}
}
}
}, { capture: true });
window.addEventListener('popstate', (e) => {
loadPage(location.href, (e.state && e.state.scrollPosition) || 0);
});
window.addEventListener('hashchange', (e) => {
e.preventDefault();
});
}
return {
route,
go
};
}
export function useRouter() {
const router = inject(RouterSymbol);
if (!router) {
throw new Error('useRouter() is called without provider.');
}
// @ts-ignore
return router;
}
export function useRoute() {
return useRouter().route;
}
function scrollTo(el, hash, smooth = false) {
const pageOffset = document.querySelector('.navbar')
.offsetHeight;
const target = el.classList.contains('.header-anchor')
? el
: document.querySelector(decodeURIComponent(hash));
if (target) {
const targetTop = target.offsetTop - pageOffset - 15;
// only smooth scroll if distance is smaller than screen height.
if (!smooth || Math.abs(targetTop - window.scrollY) > window.innerHeight) {
window.scrollTo(0, targetTop);
}
else {
window.scrollTo({
left: 0,
top: targetTop,
behavior: 'smooth'
});
}
}
}