Turbolinksにもプリロード機能が欲しくなったので自作しました。
もしかしたらもうある?
InstantClickを目指します(使えば良いのに
環境
Turbolinks 5.2.0
Typescript 3.4.2
dir src/turbolinks
preload.tsの作成
import { Location } from "./location"
import { dispatch } from "./util"
import { HttpRequest } from "./http_request"
import { Snapshot } from "./snapshot"
import { Controller } from "./controller";
import { SnapshotRenderer } from "./snapshot_renderer";
interface cacheObject {
url: URL
html: string
date: {
start: Date
end: Date
}
}
interface HTMLElementEvent<T extends HTMLElement> extends Event {
target: T;
}
export class PreloadCache {
readonly referrer?: Location
readonly controller: Controller
size: number
cache: Array<cacheObject>
now: Snapshot|null
constructor(size: number, controller: Controller) {
this.cache = []
this.size = size
this.now = null
this.controller = controller
}
get(url: URL): HTMLElement {
return this.getHTMLElement(this.getDocument(url))
}
getDocument(url: URL): Document {
return this.documentFromHTML(this.raw(url).html)
}
raw(url: URL): cacheObject {
let index = this.getIndex(url)
if (this.exist(url)) {
return this.cache[index]
} else {
return {
url: url,
html: "",
date: {
start: new Date,
end: new Date
}
}
}
}
exist(url: URL) {
return this.getIndex(url) !== -1
}
indexs(url: URL): Array<any> {
return this.cache.map(function (e, _i, s): [cacheObject, number] {
return [e, s.indexOf(e)]
}).filter(function (e) {
return e[0].url.pathname === url.pathname
}).map(function (e) {
return e[0]
});
}
getIndex(url: URL) {
return this.cache.map(function(e) { return e.url.pathname }).indexOf(url.pathname)
}
toHref(url: URL) {
return `${url.origin}${url.pathname}${url.search}`
}
update() {
let _this = this
this.cache = this.urls().map(function (e) {
return _this.getLast(e)
})
}
urls() {
let _this = this
let hrefs = this.cache.map(function(e) {
return _this.toHref(e.url)
})
return this.cache.map(function(e) {
return e.url
}).filter(function (e, i) {
return hrefs.indexOf(_this.toHref(e)) === i;
});
}
getLast(url: URL) {
return this.cache.map(function (e) {
return {
elm: e,
pathname: e.url.pathname,
date: e.date.start
}
}).filter(function (e) {
return e.pathname === url.pathname
}).sort(function (a, b) {
return b.date.getTime() - a.date.getTime()
})[0].elm
}
snapshot(url: URL) {
return Snapshot.wrap(this.get(url))
}
new(url: URL): XMLHttpRequest {
let xhr = new XMLHttpRequest
let _this = this
const referrer = this.referrer ? this.referrer.absoluteURL : ""
const timeout = HttpRequest.timeout * 1000
let start = new Date
xhr.timeout = timeout
xhr.onreadystatechange = function() {
switch (xhr.readyState) {
case 0:
break
case 1:
case 2:
case 3:
dispatch("turbolinks:preload-loading", {
data: {
url: url,
xhr: xhr
}})
break
case 4:
if (xhr.status == 200 || xhr.status == 304) {
_this.cache.push({
url: url,
html: xhr.responseText,
date: {
start: start,
end: new Date
}
})
_this.update()
}
dispatch("turbolinks:preload-end", {
data: {
url: url,
xhr: xhr
}})
break
}
}
xhr.open('GET', url.href, true)
dispatch("turbolinks:preload-start", {
data: {
url: url,
xhr: xhr
}})
xhr.setRequestHeader("Accept", "text/html, application/xhtml+xml")
xhr.setRequestHeader("Turbolinks-Referrer", referrer)
//サーバー側で判定するため 任意
xhr.setRequestHeader("Turbolinks-Mode", "pleload")
xhr.send()
start = new Date
return xhr
}
add(url: URL, str: string) {
this.cache.push({
url: url,
html: str,
date: {
start: new Date,
end: new Date
}
})
this.update()
}
start() {
let _this = this
this.add(new URL(location.href), document.documentElement.outerHTML)
Array.from(document.querySelectorAll("a[data-turbolinks-preload][href]")).forEach(function(e) {
_this.new(new URL(Object(e)))
})
}
render(url: URL) {
if(!this.exist(url)) return
this._render(this.snapshot(url), false)
}
_render(snapshot: Snapshot, isPreview: boolean) {
new SnapshotRenderer(
this.controller.view.delegate,
Snapshot.fromHTMLElement(window.document.documentElement),
snapshot,
isPreview
).render(this.emptyFn())
this.start()
}
getnow() {
return this.now
}
getnowRaw(url: URL) {
return this.getnow() || this.snapshot(url)
}
mergeHead(url: URL, elm: string | HTMLHtmlElement | Snapshot | HTMLElement) {
new SnapshotRenderer(
this.controller.view.delegate,
this.getnowRaw(url),
Snapshot.wrap(elm),
false
).mergeHead()
}
documentFromHTML(html: string) {
return new DOMParser().parseFromString(html, "text/html")
}
Equal(url: URL, str: string) {
return this.get(url).isEqualNode(this.getHTMLElement(this.documentFromHTML(str)))
}
EqualIgnoreHead(url: URL, str: string) {
return this.ignoreHead(this.getDocument(url)).isEqualNode(this.ignoreHead(this.documentFromHTML(str)))
}
applyHead(url: URL, str: string) {
if(!this.documentFromHTML(str).head.isEqualNode(this.getDocument(url).head)) {
this.mergeHead(url, this.get(url))
}
}
apply(url: URL, str: string) {
if (!this.EqualIgnoreHead(url, str)) {
this._render(Snapshot.fromHTMLString(str), false)
} else if(!this.Equal(url, str)) {
this.applyHead(url, str)
}
}
emptyFn() {
return () => {}
}
ignoreHead(elm: Document): Document {
this.getHTMLElement(elm).removeChild(elm.head)
return elm
}
getHTMLElement(elm: Document): HTMLElement {
return elm.documentElement
}
loadResponseHelper(url: URL, str: string) {
this.apply(url, str)
this.add(url, str)
}
hover(e: HTMLElementEvent<HTMLAnchorElement>) {
let eTarget = e.target.closest("a")
if(eTarget !== null) {
this.new(new URL(eTarget.href))
}
}
hoverEvent = {
handleEvent: this.hover.bind(this),
}
}
長いな、qiitaに乗っけるものじゃないですね。
Eventのコンパイルエラーで苦労しました。
どこのページで解決したんだっけ。。。忘れてしまった。。。
既存ファイルの追記・書き換え
browser_adapter.ts
visitStartedメソッドの最後に追記
this.controller.preloadCache.render(new URL(visit.location.absoluteURL))
controller.ts
- import文を追記
- supportedの条件を追加 (最後の条件の行の最後に
&&
を追加するのを忘れるとエラーになる) - 変数の定義を追加
- startメソッドの
this.startHistory
の後に追記 - startメソッドの
addEventListener
の後に追記
5
はaタグにホバーした時にpreloadしたければ書きます。
// 1
import { PreloadCache } from "./preload"
// 2
Object.keys &&
window.URL &&
Node.prototype.isEqualNode &&
Array.prototype.map &&
Array.prototype.filter
// 3
preloadCache = new PreloadCache(10, this)
// 4
this.preloadCache.start()
// 5
addEventListener("mouseover", this.preloadCache.hoverEvent, true)
http_request.ts
サーバー側で判定するためです。 任意
createXHR関数のxhr.setRequestHeader
関数実行の後に追記
xhr.setRequestHeader("Turbolinks-Mode", "visit")
snapshot.ts
document.documentElement
の型がHTMLHtmlElement
ではなくHTMLElement
であることが原因のエラーの対処。
document.children
をArray.from
で配列にしてfilter
でhtml要素だけ抜き出すとかすればもしかしたらできるかもしれませんが未検証
- wrapメソッド・fromHTMLElementメソッドの引数を修正する
- wrapメソッドは
value
、fromHTMLElementはhtmlElement
の型にHTMLElement
を追加する
static wrap(value: Snapshot | string | HTMLHtmlElement | HTMLElement)
static fromHTMLElement(htmlElement: HTMLHtmlElement | HTMLElement)
snapshot_renderer.ts
removeCurrentHeadProvisionalElements
関数のdocument.head.removeChild
実行部分を以下のように変更
document.head.removeChild(Array.from(document.head.children).filter(function(e) {
return element.isEqualNode(e)
})[0])
visit.ts
このファイルを一番いじります。
- なぜか
snapshot.ts
をimportする部分を削除しないとエラーになったので削除する -
isPreview
変数定義の次の行に追記 -
loadResponse
関数内のthis.render
実行部分の中のelse
文の中を置き換え
// 1 削除
import { Snapshot } from "./snapshot"
// 2
if (this.controller.preloadCache.exist(new URL(this.location.absoluteURL))) {
this.adapter.visitRendered(this)
if (!isPreview) this.complete()
return
}
// 3 置き換え
// before
this.controller.render({ snapshot: Snapshot.fromHTMLString(response) }, this.performScroll)
// after
this.controller.preloadCache.loadResponseHelper(new URL(this.location.absoluteURL), response)
this.performScroll()
あとがき
いやあ1つ機能を追加するって大変なんですね、疲れました。
長いpreload.tsはgistでもよかったかも。