UNPKG

instantclick

Version:

dramatically speeds up your website

983 lines (831 loc) 29.8 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global.InstantClick = factory()); }(this, (function () { 'use strict'; /* InstantClick 3.1.0 | (C) 2014-2017 Alexandre Dieulot | http://instantclick.io/license */ var instantclick; var InstantClick = instantclick = function(document, location, $userAgent) { // Internal variables var $currentLocationWithoutHash , $urlToPreload , $preloadTimer , $lastTouchTimestamp , $hasBeenInitialized , $touchEndedWithoutClickTimer , $lastUsedTimeoutId = 0 // Preloading-related variables , $history = {} , $xhr , $url = false , $title = false , $isContentTypeNotHTML , $areTrackedElementsDifferent , $body = false , $lastDisplayTimestamp = 0 , $isPreloading = false , $isWaitingForCompletion = false , $gotANetworkError = false , $trackedElementsData = [] // Variables defined by public functions , $preloadOnMousedown , $delayBeforePreload = 65 , $eventsCallbacks = { preload: [], receive: [], wait: [], change: [], restore: [], exit: [] } , $timers = {} , $currentPageXhrs = [] , $windowEventListeners = {} , $delegatedEvents = {}; ////////// POLYFILL ////////// // Needed for `addEvent` if (!Element.prototype.matches) { Element.prototype.matches = Element.prototype.webkitMatchesSelector || Element.prototype.msMatchesSelector || function (selector) { var this$1 = this; var matches = document.querySelectorAll(selector); for (var i = 0; i < matches.length; i++) { if (matches[i] == this$1) { return true } } return false }; } ////////// HELPERS ////////// function removeHash(url) { var index = url.indexOf('#'); if (index == -1) { return url } return url.substr(0, index) } function getParentLinkElement(element) { while (element && element.nodeName != 'A') { element = element.parentNode; } // `element` will be null if no link element is found return element } function isBlacklisted(element) { do { if (!element.hasAttribute) { // Parent of <html> break } if (element.hasAttribute('data-instant')) { return false } if (element.hasAttribute('data-no-instant')) { return true } } while (element = element.parentNode) return false } function isPreloadable(linkElement) { var domain = location.protocol + '//' + location.host; if (linkElement.target // target="_blank" etc. || linkElement.hasAttribute('download') || linkElement.href.indexOf(domain + '/') != 0 // Another domain, or no href attribute || (linkElement.href.indexOf('#') > -1 && removeHash(linkElement.href) == $currentLocationWithoutHash) // Anchor || isBlacklisted(linkElement) ) { return false } return true } function triggerPageEvent(eventType) { var argumentsToApply = Array.prototype.slice.call(arguments, 1) , returnValue = false; for (var i = 0; i < $eventsCallbacks[eventType].length; i++) { if (eventType == 'receive') { var altered = $eventsCallbacks[eventType][i].apply(window, argumentsToApply); if (altered) { // Update arguments for the next iteration of the loop. if ('body' in altered) { argumentsToApply[1] = altered.body; } if ('title' in altered) { argumentsToApply[2] = altered.title; } returnValue = altered; } } else { $eventsCallbacks[eventType][i].apply(window, argumentsToApply); } } return returnValue } function changePage(title, body, urlToPush, scrollPosition) { abortCurrentPageXhrs(); document.documentElement.replaceChild(body, document.body); // We cannot just use `document.body = doc.body`, it causes Safari (tested // 5.1, 6.0 and Mobile 7.0) to execute script tags directly. document.title = title; if (urlToPush) { addOrRemoveWindowEventListeners('remove'); if (urlToPush != location.href) { history.pushState(null, null, urlToPush); if ($userAgent.indexOf(' CriOS/') > -1) { // Chrome for iOS: // // 1. Removes title in tab on pushState, so it needs to be set after. // // 2. Will not set the title if it's identical after trimming, so we // add a non-breaking space. if (document.title == title) { document.title = title + String.fromCharCode(160); } else { document.title = title; } } } var hashIndex = urlToPush.indexOf('#') , offsetElement = hashIndex > -1 && document.getElementById(urlToPush.substr(hashIndex + 1)) , offset = 0; if (offsetElement) { while (offsetElement.offsetParent) { offset += offsetElement.offsetTop; offsetElement = offsetElement.offsetParent; } } if ('requestAnimationFrame' in window) { // Safari on macOS doesn't immediately visually change the page on // `document.documentElement.replaceChild`, so if `scrollTo` is called // without `requestAnimationFrame` it often scrolls before the page // is displayed. requestAnimationFrame(function() { scrollTo(0, offset); }); } else { scrollTo(0, offset); // Safari on macOS scrolls before the page is visually changed, but // adding `requestAnimationFrame` doesn't fix it in this case. } clearCurrentPageTimeouts(); $currentLocationWithoutHash = removeHash(urlToPush); if ($currentLocationWithoutHash in $windowEventListeners) { $windowEventListeners[$currentLocationWithoutHash] = []; } $timers[$currentLocationWithoutHash] = {}; applyScriptElements(function(element) { return !element.hasAttribute('data-instant-track') }); triggerPageEvent('change', false); } else { // On popstate, browsers scroll by themselves, but at least Firefox // scrolls BEFORE popstate is fired and thus before we can replace the // page. If the page before popstate is too short the user won't be // scrolled at the right position as a result. We need to scroll again. scrollTo(0, scrollPosition); // iOS's gesture to go back by swiping from the left edge of the screen // will start a preloading if the user touches a link, it needs to be // cancelled otherwise the page behind the touched link will be // displayed. $xhr.abort(); setPreloadingAsHalted(); applyScriptElements(function(element) { return element.hasAttribute('data-instant-restore') }); restoreTimers(); triggerPageEvent('restore'); } } function setPreloadingAsHalted() { $isPreloading = false; $isWaitingForCompletion = false; } function removeNoscriptTags(html) { // Must be done on text, not on a node's innerHTML, otherwise strange // things happen with implicitly closed elements (see the Noscript test). return html.replace(/<noscript[\s\S]+?<\/noscript>/gi, '') } function abortCurrentPageXhrs() { for (var i = 0; i < $currentPageXhrs.length; i++) { if (typeof $currentPageXhrs[i] == 'object' && 'abort' in $currentPageXhrs[i]) { $currentPageXhrs[i].instantclickAbort = true; $currentPageXhrs[i].abort(); } } $currentPageXhrs = []; } function clearCurrentPageTimeouts() { for (var i in $timers[$currentLocationWithoutHash]) { var timeout = $timers[$currentLocationWithoutHash][i]; window.clearTimeout(timeout.realId); timeout.delayLeft = timeout.delay - +new Date + timeout.timestamp; } } function restoreTimers() { for (var i in $timers[$currentLocationWithoutHash]) { if (!('delayLeft' in $timers[$currentLocationWithoutHash][i])) { continue } var args = [ $timers[$currentLocationWithoutHash][i].callback, $timers[$currentLocationWithoutHash][i].delayLeft ]; for (var j = 0; j < $timers[$currentLocationWithoutHash][i].params.length; j++) { args.push($timers[$currentLocationWithoutHash][i].params[j]); } addTimer(args, $timers[$currentLocationWithoutHash][i].isRepeating, $timers[$currentLocationWithoutHash][i].delay); delete $timers[$currentLocationWithoutHash][i]; } } function handleTouchendWithoutClick() { $xhr.abort(); setPreloadingAsHalted(); } function addOrRemoveWindowEventListeners(addOrRemove) { if ($currentLocationWithoutHash in $windowEventListeners) { for (var i = 0; i < $windowEventListeners[$currentLocationWithoutHash].length; i++) { window[addOrRemove + 'EventListener'].apply(window, $windowEventListeners[$currentLocationWithoutHash][i]); } } } function applyScriptElements(condition) { var scriptElementsInDOM = document.body.getElementsByTagName('script') , scriptElementsToCopy = [] , originalElement , copyElement , parentNode , nextSibling , i; // `scriptElementsInDOM` will change during the copy of scripts if // a script add or delete script elements, so we need to put script // elements in an array to loop through them correctly. for (i = 0; i < scriptElementsInDOM.length; i++) { scriptElementsToCopy.push(scriptElementsInDOM[i]); } for (i = 0; i < scriptElementsToCopy.length; i++) { originalElement = scriptElementsToCopy[i]; if (!originalElement) { // Might have disappeared, see previous comment continue } if (!condition(originalElement)) { continue } copyElement = document.createElement('script'); for (var j = 0; j < originalElement.attributes.length; j++) { copyElement.setAttribute(originalElement.attributes[j].name, originalElement.attributes[j].value); } copyElement.textContent = originalElement.textContent; parentNode = originalElement.parentNode; nextSibling = originalElement.nextSibling; parentNode.removeChild(originalElement); parentNode.insertBefore(copyElement, nextSibling); } } function addTrackedElements() { var trackedElements = document.querySelectorAll('[data-instant-track]') , element , elementData; for (var i = 0; i < trackedElements.length; i++) { element = trackedElements[i]; elementData = element.getAttribute('href') || element.getAttribute('src') || element.textContent; // We can't use just `element.href` and `element.src` because we can't // retrieve `href`s and `src`s from the Ajax response. $trackedElementsData.push(elementData); } } function addTimer(args, isRepeating, realDelay) { var callback = args[0] , delay = args[1] , params = [].slice.call(args, 2) , timestamp = +new Date; $lastUsedTimeoutId++; var id = $lastUsedTimeoutId; var callbackModified; if (isRepeating) { callbackModified = function(args2) { callback(args2); delete $timers[$currentLocationWithoutHash][id]; args[0] = callback; args[1] = delay; addTimer(args, true); }; } else { callbackModified = function(args2) { callback(args2); delete $timers[$currentLocationWithoutHash][id]; }; } args[0] = callbackModified; if (realDelay != undefined) { timestamp += delay - realDelay; delay = realDelay; } var realId = window.setTimeout.apply(window, args); $timers[$currentLocationWithoutHash][id] = { realId: realId, timestamp: timestamp, callback: callback, delay: delay, params: params, isRepeating: isRepeating }; return -id } ////////// EVENT LISTENERS ////////// function mousedownListener(event) { var linkElement = getParentLinkElement(event.target); if (!linkElement || !isPreloadable(linkElement)) { return } preload(linkElement.href); } function mouseoverListener(event) { if ($lastTouchTimestamp > (+new Date - 500)) { // On a touch device, if the content of the page change on mouseover // click is never fired and the user will need to tap a second time. // https://developer.apple.com/library/content/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html#//apple_ref/doc/uid/TP40006511-SW4 // // Content change could happen in the `preload` event, so we stop there. return } if (+new Date - $lastDisplayTimestamp < 100) { // After a page is displayed, if the user's cursor happens to be above // a link a mouseover event will be in most browsers triggered // automatically, and in other browsers it will be triggered when the // user moves his mouse by 1px. // // Here are the behaviors I noticed, all on Windows: // - Safari 5.1: auto-triggers after 0 ms // - IE 11: auto-triggers after 30-80 ms (depends on page's size?) // - Firefox: auto-triggers after 10 ms // - Opera 18: auto-triggers after 10 ms // // - Chrome: triggers when cursor moved // - Opera 12.16: triggers when cursor moved // // To remedy to this, we do nothing if the last display occurred less // than 100 ms ago. return } var linkElement = getParentLinkElement(event.target); if (!linkElement) { return } if (linkElement == getParentLinkElement(event.relatedTarget)) { // Happens when mouseout-ing and mouseover-ing child elements of the same link element return } if (!isPreloadable(linkElement)) { return } linkElement.addEventListener('mouseout', mouseoutListener); if (!$isWaitingForCompletion) { $urlToPreload = linkElement.href; $preloadTimer = setTimeout(preload, $delayBeforePreload); } } function touchstartListener(event) { $lastTouchTimestamp = +new Date; var linkElement = getParentLinkElement(event.target); if (!linkElement || !isPreloadable(linkElement)) { return } if ($touchEndedWithoutClickTimer) { clearTimeout($touchEndedWithoutClickTimer); $touchEndedWithoutClickTimer = false; } linkElement.addEventListener('touchend', touchendAndTouchcancelListener); linkElement.addEventListener('touchcancel', touchendAndTouchcancelListener); preload(linkElement.href); } function clickListenerPrelude() { // Makes clickListener be fired after everyone else, so that we can respect // event.preventDefault. document.addEventListener('click', clickListener); } function clickListener(event) { document.removeEventListener('click', clickListener); if ($touchEndedWithoutClickTimer) { clearTimeout($touchEndedWithoutClickTimer); $touchEndedWithoutClickTimer = false; } if (event.defaultPrevented) { return } var linkElement = getParentLinkElement(event.target); if (!linkElement || !isPreloadable(linkElement)) { return } // Check if it's opening in a new tab if (event.button != 0 // Chrome < 55 fires a click event when the middle mouse button is pressed || event.metaKey || event.ctrlKey) { return } event.preventDefault(); display(linkElement.href); } function mouseoutListener(event) { if (getParentLinkElement(event.target) == getParentLinkElement(event.relatedTarget)) { // Happens when mouseout-ing and mouseover-ing child elements of the same link element, // we don't want to stop preloading then. return } if ($preloadTimer) { clearTimeout($preloadTimer); $preloadTimer = false; return } if (!$isPreloading || $isWaitingForCompletion) { return } $xhr.abort(); setPreloadingAsHalted(); } function touchendAndTouchcancelListener(event) { if (!$isPreloading || $isWaitingForCompletion) { return } $touchEndedWithoutClickTimer = setTimeout(handleTouchendWithoutClick, 500); } function readystatechangeListener() { if ($xhr.readyState == 2) { // headers received var contentType = $xhr.getResponseHeader('Content-Type'); if (!contentType || !/^text\/html/i.test(contentType)) { $isContentTypeNotHTML = true; } } if ($xhr.readyState < 4) { return } if ($xhr.status == 0) { // Request error/timeout/abort $gotANetworkError = true; if ($isWaitingForCompletion) { triggerPageEvent('exit', $url, 'network error'); location.href = $url; } return } if ($isContentTypeNotHTML) { if ($isWaitingForCompletion) { triggerPageEvent('exit', $url, 'non-html content-type'); location.href = $url; } return } var doc = document.implementation.createHTMLDocument(''); doc.documentElement.innerHTML = removeNoscriptTags($xhr.responseText); $title = doc.title; $body = doc.body; var alteredOnReceive = triggerPageEvent('receive', $url, $body, $title); if (alteredOnReceive) { if ('body' in alteredOnReceive) { $body = alteredOnReceive.body; } if ('title' in alteredOnReceive) { $title = alteredOnReceive.title; } } var urlWithoutHash = removeHash($url); $history[urlWithoutHash] = { body: $body, title: $title, scrollPosition: urlWithoutHash in $history ? $history[urlWithoutHash].scrollPosition : 0 }; var trackedElements = doc.querySelectorAll('[data-instant-track]') , element , elementData; if (trackedElements.length != $trackedElementsData.length) { $areTrackedElementsDifferent = true; } else { for (var i = 0; i < trackedElements.length; i++) { element = trackedElements[i]; elementData = element.getAttribute('href') || element.getAttribute('src') || element.textContent; if ($trackedElementsData.indexOf(elementData) == -1) { $areTrackedElementsDifferent = true; } } } if ($isWaitingForCompletion) { $isWaitingForCompletion = false; display($url); } } function popstateListener() { var loc = removeHash(location.href); if (loc == $currentLocationWithoutHash) { return } if ($isWaitingForCompletion) { setPreloadingAsHalted(); $xhr.abort(); } if (!(loc in $history)) { triggerPageEvent('exit', location.href, 'not in history'); if (loc == location.href) { // no location.hash location.href = location.href; // Reloads the page while using cache for scripts, styles and images, // unlike `location.reload()` } else { // When there's a hash, `location.href = location.href` won't reload // the page (but will trigger a popstate event, thus causing an infinite // loop), so we need to call `location.reload()` location.reload(); } return } $history[$currentLocationWithoutHash].scrollPosition = pageYOffset; clearCurrentPageTimeouts(); addOrRemoveWindowEventListeners('remove'); $currentLocationWithoutHash = loc; changePage($history[loc].title, $history[loc].body, false, $history[loc].scrollPosition); addOrRemoveWindowEventListeners('add'); } ////////// MAIN FUNCTIONS ////////// function preload(url) { if ($preloadTimer) { clearTimeout($preloadTimer); $preloadTimer = false; } if (!url) { url = $urlToPreload; } if ($isPreloading && (url == $url || $isWaitingForCompletion)) { return } $isPreloading = true; $isWaitingForCompletion = false; $url = url; $body = false; $isContentTypeNotHTML = false; $gotANetworkError = false; $areTrackedElementsDifferent = false; triggerPageEvent('preload'); $xhr.open('GET', url); $xhr.timeout = 90000; // Must be set after `open()` with IE $xhr.send(); } function display(url) { $lastDisplayTimestamp = +new Date; if ($preloadTimer || !$isPreloading) { // $preloadTimer: // Happens when there's a delay before preloading and that delay // hasn't expired (preloading didn't kick in). // // !$isPreloading: // A link has been clicked, and preloading hasn't been initiated. // It happens with touch devices when a user taps *near* the link, // causing `touchstart` not to be fired. Safari/Chrome will trigger // `mouseover`, `mousedown`, `click` (and others), but when that happens // we do nothing in `mouseover` as it may cause `click` not to fire (see // comment in `mouseoverListener`). // // It also happens when a user uses his keyboard to navigate (with Tab // and Return), and possibly in other non-mainstream ways to navigate // a website. if ($preloadTimer && $url && $url != url) { // Happens when the user clicks on a link before preloading // kicks in while another link is already preloading. triggerPageEvent('exit', url, 'click occured while preloading planned'); location.href = url; return } preload(url); triggerPageEvent('wait'); $isWaitingForCompletion = true; // Must be set *after* calling `preload` return } if ($isWaitingForCompletion) { // The user clicked on a link while a page to display was preloading. // Either on the same link or on another link. If it's the same link // something might have gone wrong (or he could have double clicked, we // don't handle that case), so we send him to the page without pjax. // If it's another link, it hasn't been preloaded, so we redirect the // user to it. triggerPageEvent('exit', url, 'clicked on a link while waiting for another page to display'); location.href = url; return } if ($isContentTypeNotHTML) { triggerPageEvent('exit', $url, 'non-html content-type'); location.href = $url; return } if ($gotANetworkError) { triggerPageEvent('exit', $url, 'network error'); location.href = $url; return } if ($areTrackedElementsDifferent) { triggerPageEvent('exit', $url, 'different assets'); location.href = $url; return } if (!$body) { triggerPageEvent('wait'); $isWaitingForCompletion = true; return } $history[$currentLocationWithoutHash].scrollPosition = pageYOffset; setPreloadingAsHalted(); changePage($title, $body, $url); } ////////// PUBLIC VARIABLE AND FUNCTIONS ////////// var supported = false; if ('pushState' in history && location.protocol != "file:") { supported = true; var indexOfAndroid = $userAgent.indexOf('Android '); if (indexOfAndroid > -1) { // The stock browser in Android 4.0.3 through 4.3.1 supports pushState, // though it doesn't update the address bar. // // More problematic is that it has a bug on `popstate` when coming back // from a page not displayed through InstantClick: `location.href` is // undefined and `location.reload()` doesn't work. // // Android < 4.4 is therefore blacklisted, unless it's a browser known // not to have that latter bug. var androidVersion = parseFloat($userAgent.substr(indexOfAndroid + 'Android '.length)); if (androidVersion < 4.4) { supported = false; if (androidVersion >= 4) { var whitelistedBrowsersUserAgentsOnAndroid4 = [ / Chrome\//, // Chrome, Opera, Puffin, QQ, Yandex / UCBrowser\//, / Firefox\//, / Windows Phone / ]; for (var i = 0; i < whitelistedBrowsersUserAgentsOnAndroid4.length; i++) { if (whitelistedBrowsersUserAgentsOnAndroid4[i].test($userAgent)) { supported = true; break } } } } } } function init(preloadingMode) { if (!supported) { triggerPageEvent('change', true); return } if ($hasBeenInitialized) { return } $hasBeenInitialized = true; if (preloadingMode == 'mousedown') { $preloadOnMousedown = true; } else if (typeof preloadingMode == 'number') { $delayBeforePreload = preloadingMode; } $currentLocationWithoutHash = removeHash(location.href); $timers[$currentLocationWithoutHash] = {}; $history[$currentLocationWithoutHash] = { body: document.body, title: document.title, scrollPosition: pageYOffset }; if (document.readyState == 'loading') { document.addEventListener('DOMContentLoaded', addTrackedElements); } else { addTrackedElements(); } $xhr = new XMLHttpRequest(); $xhr.addEventListener('readystatechange', readystatechangeListener); document.addEventListener('touchstart', touchstartListener, true); if ($preloadOnMousedown) { document.addEventListener('mousedown', mousedownListener, true); } else { document.addEventListener('mouseover', mouseoverListener, true); } document.addEventListener('click', clickListenerPrelude, true); addEventListener('popstate', popstateListener); } function on(eventType, callback) { $eventsCallbacks[eventType].push(callback); if (eventType == 'change') { callback(!$lastDisplayTimestamp); } } function setTimeout() { return addTimer(arguments, false) } function setInterval() { return addTimer(arguments, true) } function clearTimeout(id) { id = -id; for (var loc in $timers) { if (id in $timers[loc]) { window.clearTimeout($timers[loc][id].realId); delete $timers[loc][id]; } } } function xhr(xhr) { $currentPageXhrs.push(xhr); } function addPageEvent() { if (!($currentLocationWithoutHash in $windowEventListeners)) { $windowEventListeners[$currentLocationWithoutHash] = []; } $windowEventListeners[$currentLocationWithoutHash].push(arguments); addEventListener.apply(window, arguments); } function removePageEvent() { var arguments$1 = arguments; if (!($currentLocationWithoutHash in $windowEventListeners)) { return } firstLoop: for (var i = 0; i < $windowEventListeners[$currentLocationWithoutHash].length; i++) { if (arguments$1.length != $windowEventListeners[$currentLocationWithoutHash][i].length) { continue } for (var j = 0; j < $windowEventListeners[$currentLocationWithoutHash][i].length; j++) { if (arguments$1[j] != $windowEventListeners[$currentLocationWithoutHash][i][j]) { continue firstLoop } } $windowEventListeners[$currentLocationWithoutHash].splice(i, 1); } } function addEvent(selector, type, listener) { if (!(type in $delegatedEvents)) { $delegatedEvents[type] = {}; document.addEventListener(type, function(event) { var element = event.target; event.originalStopPropagation = event.stopPropagation; event.stopPropagation = function() { this.isPropagationStopped = true; this.originalStopPropagation(); }; while (element && element.nodeType == 1) { for (var selector in $delegatedEvents[type]) { if (element.matches(selector)) { for (var i = 0; i < $delegatedEvents[type][selector].length; i++) { $delegatedEvents[type][selector][i].call(element, event); } if (event.isPropagationStopped) { return } break } } element = element.parentNode; } }, false); // Third parameter isn't optional in Firefox < 6 if (type == 'click' && /iP(?:hone|ad|od)/.test($userAgent)) { // Force Mobile Safari to trigger the click event on document by adding a pointer cursor to body var styleElement = document.createElement('style'); styleElement.setAttribute('instantclick-mobile-safari-cursor', ''); // So that this style element doesn't surprise developers in the browser DOM inspector. styleElement.textContent = 'body { cursor: pointer !important; }'; document.head.appendChild(styleElement); } } if (!(selector in $delegatedEvents[type])) { $delegatedEvents[type][selector] = []; } // Run removeEvent beforehand so that it can't be added twice removeEvent(selector, type, listener); $delegatedEvents[type][selector].push(listener); } function removeEvent(selector, type, listener) { var index = $delegatedEvents[type][selector].indexOf(listener); if (index > -1) { $delegatedEvents[type][selector].splice(index, 1); } } //////////////////// return { supported: supported, init: init, on: on, setTimeout: setTimeout, setInterval: setInterval, clearTimeout: clearTimeout, xhr: xhr, addPageEvent: addPageEvent, removePageEvent: removePageEvent, addEvent: addEvent, removeEvent: removeEvent } }(document, location, navigator.userAgent); return InstantClick; })));