Last active
June 30, 2023 01:14
-
-
Save artulloss/439bdca7255411dbdaaef1148ba485d2 to your computer and use it in GitHub Desktop.
Make some of your links load content via AJAX instead of a full page load. Good for sections of a website without JavaScript events on them.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Partial SPA | |
* Make some of your links load content via AJAX instead of a full page load | |
*/ | |
function partialSPA(config) { | |
const { | |
selectorWithContentAndLinks = "body", // Selector of the element containing the links and the content to replace | |
selectorToReplace = "body", // Selector of the element to replace | |
routes = [/./], // Routes to replace | |
reservedPaths = [], // Paths that should not be replaced | |
onComplete = null, // Function to run after the page is loaded | |
cache = false, // Set to localStorage, sessionStorage, RAMStorage to cache pages | |
refetchAnyways = false, // Refetch the page even if it's in the cache, but will compare the fetched page with the cached page and only replace if they're different | |
} = config || {}; | |
const eventListenerMap = new WeakMap(); | |
let currentFetch = null; | |
let controller = new AbortController(); | |
const initSPA = () => { | |
const sectionWithContentAndLinks = document.querySelector( | |
selectorWithContentAndLinks | |
); | |
const linkElements = sectionWithContentAndLinks.querySelectorAll("a"); | |
if (!linkElements) return; // No links to replace | |
linkElements.forEach((linkElement) => { | |
const url = new URL(linkElement.href); | |
// Only replace links to the same domain | |
if (url.origin === window.location.origin) { | |
/** | |
* Partial SPA | |
* Make some of your links load content via AJAX instead of a full page load | |
*/ | |
export default function partialSPA(config) { | |
const { | |
selectorWithContentAndLinks = "body", // Selector of the element containing the links and the content to replace | |
selectorToReplace = "body", // Selector of the element to replace | |
routes = [/./], // Routes to replace | |
reservedPaths = [], // Paths that should not be replaced | |
onComplete = null, // Function to run after the page is loaded | |
cache = false, // Set to localStorage, sessionStorage, RAMStorage to cache pages | |
refetchAnyways = false, // Refetch the page even if it's in the cache, but will compare the fetched page with the cached page and only replace if they're different | |
} = config || {}; | |
const eventListenerMap = new WeakMap(); | |
let lastUrl = ""; | |
let currentFetch = null; | |
let controller = new AbortController(); | |
const initSPA = () => { | |
const sectionWithContentAndLinks = document.querySelector( | |
selectorWithContentAndLinks | |
); | |
const linkElements = | |
sectionWithContentAndLinks.querySelectorAll("a"); | |
if (!linkElements) return; // No links to replace | |
linkElements.forEach((linkElement) => { | |
const url = new URL(linkElement.href); | |
// Only replace links to the same domain | |
if (url.origin === window.location.origin) { | |
function handleClick(event) { | |
if (reservedPaths.includes(url.pathname)) return; // Skip if it's a reserved path | |
if (routes.every((route) => !route.test(url.pathname))) | |
return; // Skip if it's not a route we want to replace | |
event.preventDefault(); | |
if (url.pathname === window.location.pathname) return; // Skip if we're already on the page | |
// Cancel any previous fetches | |
if (currentFetch && lastUrl && lastUrl !== url.toString()) { | |
console.log("Aborting previous fetch for:", lastUrl); | |
currentFetch = null; | |
controller.abort(); | |
controller = new AbortController(); | |
} | |
lastUrl = url.toString(); | |
const switchPage = (data, fetched) => { | |
const el = document.createElement("html"); | |
el.innerHTML = data; | |
const section = el.querySelector(selectorToReplace); | |
if (cache) { | |
if (fetched) { | |
// Compare the fetched section with the cached section | |
const cachedPage = cache.getItem(url.toString()); | |
if (cachedPage === section.outerHTML) { | |
console.log( | |
"Page is the same as the cached page. Not replacing." | |
); | |
onComplete && onComplete(event.target); | |
return; | |
} else { | |
// Store the cached section | |
const el = document.createElement("html"); | |
el.innerHTML = data; | |
cache.setItem(url.toString(), section.outerHTML); // Store only the section we want to replace | |
} | |
} | |
document.querySelector(selectorToReplace).innerHTML = | |
section.innerHTML; | |
} else { | |
document.querySelector(selectorToReplace).innerHTML = | |
section.innerHTML; | |
} | |
window.history.pushState( | |
{ | |
section: section.innerHTML, | |
}, | |
"", | |
url | |
); | |
fetched && onComplete && onComplete(event.target); | |
initSPA(); | |
}; | |
if (cache) { | |
const data = cache.getItem(url.toString()); | |
if (data) { | |
switchPage(data, !refetchAnyways); | |
if (!refetchAnyways) return; | |
} | |
} | |
currentFetch = fetch(url, { signal: controller.signal }); | |
currentFetch | |
.then((response) => { | |
return response.text(); | |
}) | |
.then((data) => { | |
switchPage(data, true); | |
currentFetch = null; | |
}) | |
.catch((error) => { | |
currentFetch = null; | |
if (error.name === "AbortError") return; // We expect this error when we abort the fetch | |
console.error(error); | |
}); | |
} | |
if (eventListenerMap.has(linkElement)) { | |
linkElement.removeEventListener( | |
"click", | |
eventListenerMap.get(linkElement) | |
); | |
} | |
linkElement.addEventListener("click", handleClick); | |
eventListenerMap.set(linkElement, handleClick); | |
} | |
}); | |
}; | |
initSPA(); | |
window.addEventListener("popstate", (event) => { | |
if (event.state) { | |
document.querySelector(selectorToReplace).innerHTML = | |
event.state.section; | |
initSPA(); | |
onComplete && onComplete(); | |
} | |
}); | |
if (cache) { | |
// Cache the initial page | |
cache.setItem( | |
window.location.toString(), | |
document.querySelector(selectorToReplace).outerHTML | |
); | |
} else if (refetchAnyways) { | |
console.warn( | |
"partialSPA: refetchAnyways should only be true when using a cache." | |
); | |
} | |
} | |
// Not persisted | |
const RAMStorage = { | |
map: new Map(), | |
getItem: (key) => { | |
return RAMStorage.map.get(key); | |
}, | |
setItem: (key, value) => { | |
RAMStorage.map.set(key, value); | |
}, | |
}; | |
function handleClick(event) { | |
if (reservedPaths.includes(url.pathname)) return; // Skip if it's a reserved path | |
if (routes.every((route) => !route.test(url.pathname))) return; // Skip if it's not a route we want to replace | |
event.preventDefault(); | |
if (url.pathname === window.location.pathname) return; // Skip if we're already on the page | |
// Cancel any previous fetches | |
if (currentFetch && lastUrl && lastUrl !== url.toString()) { | |
console.log("Aborting previous fetch for:", lastUrl); | |
currentFetch = null; | |
controller.abort(); | |
controller = new AbortController(); | |
} | |
lastUrl = url.toString(); | |
const switchPage = (data, fetched) => { | |
const el = document.createElement("html"); | |
el.innerHTML = data; | |
const section = el.querySelector(selectorToReplace); | |
if (cache) { | |
if (fetched) { | |
// Compare the fetched section with the cached section | |
const cachedPage = cache.getItem(url.toString()); | |
if (cachedPage === section.outerHTML) { | |
console.log( | |
"Page is the same as the cached page. Not replacing." | |
); | |
onComplete && onComplete(event.target); | |
return; | |
} else { | |
// Store the cached section | |
const el = document.createElement("html"); | |
el.innerHTML = data; | |
cache.setItem(url.toString(), section.outerHTML); // Store only the section we want to replace | |
} | |
} | |
document.querySelector(selectorToReplace).innerHTML = | |
section.innerHTML; | |
} else { | |
document.querySelector(selectorToReplace).innerHTML = | |
section.innerHTML; | |
} | |
window.history.pushState( | |
{ | |
section: section.innerHTML, | |
}, | |
"", | |
url | |
); | |
fetched && onComplete && onComplete(event.target); | |
initSPA(); | |
}; | |
if (cache) { | |
const data = cache.getItem(url.toString()); | |
if (data) { | |
switchPage(data, !refetchAnyways); | |
if (!refetchAnyways) return; | |
} | |
} | |
currentFetch = fetch(url, { signal: controller.signal }); | |
currentFetch | |
.then((response) => { | |
return response.text(); | |
}) | |
.then((data) => { | |
switchPage(data, true); | |
currentFetch = null; | |
}) | |
.catch((error) => { | |
currentFetch = null; | |
if (error.name === "AbortError") return; // We expect this error when we abort the fetch | |
console.error(error); | |
}); | |
} | |
if (eventListenerMap.has(linkElement)) { | |
linkElement.removeEventListener( | |
"click", | |
eventListenerMap.get(linkElement) | |
); | |
} | |
linkElement.addEventListener("click", handleClick); | |
eventListenerMap.set(linkElement, handleClick); | |
} | |
}); | |
}; | |
initSPA(); | |
window.addEventListener("popstate", (event) => { | |
if (event.state) { | |
document.querySelector(selectorToReplace).innerHTML = event.state.section; | |
initSPA(); | |
onComplete && onComplete(); | |
} | |
}); | |
if (cache) { | |
// Cache the initial page | |
cache.setItem( | |
window.location.toString(), | |
document.querySelector(selectorToReplace).outerHTML | |
); | |
} else if (refetchAnyways) { | |
console.warn( | |
"partialSPA: refetchAnyways should only be true when using a cache." | |
); | |
} | |
} | |
// Not persisted | |
const RAMStorage = { | |
map: new Map(), | |
getItem: (key) => { | |
return RAMStorage.map.get(key); | |
}, | |
setItem: (key, value) => { | |
RAMStorage.map.set(key, value); | |
}, | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment