LoginSignup
1
0

More than 5 years have passed since last update.

Turbolinksにpreload機能を追加する

Posted at

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メソッドの最後に追記

browser_adapter.ts
this.controller.preloadCache.render(new URL(visit.location.absoluteURL))

controller.ts

  1. import文を追記
  2. supportedの条件を追加 (最後の条件の行の最後に&&を追加するのを忘れるとエラーになる)
  3. 変数の定義を追加
  4. startメソッドのthis.startHistoryの後に追記
  5. startメソッドのaddEventListenerの後に追記

5はaタグにホバーした時にpreloadしたければ書きます。

controller.ts
// 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関数実行の後に追記

http_request.ts
xhr.setRequestHeader("Turbolinks-Mode", "visit")

snapshot.ts

document.documentElementの型がHTMLHtmlElementではなくHTMLElementであることが原因のエラーの対処。
document.childrenArray.fromで配列にしてfilterでhtml要素だけ抜き出すとかすればもしかしたらできるかもしれませんが未検証

  • wrapメソッド・fromHTMLElementメソッドの引数を修正する
  • wrapメソッドはvalue、fromHTMLElementはhtmlElementの型にHTMLElementを追加する
snapshot.ts
static wrap(value: Snapshot | string | HTMLHtmlElement | HTMLElement)

static fromHTMLElement(htmlElement: HTMLHtmlElement | HTMLElement)

snapshot_renderer.ts

removeCurrentHeadProvisionalElements関数のdocument.head.removeChild実行部分を以下のように変更

snapshot_renderer.ts
document.head.removeChild(Array.from(document.head.children).filter(function(e) {
  return element.isEqualNode(e)
})[0])

visit.ts

このファイルを一番いじります。

  1. なぜかsnapshot.tsをimportする部分を削除しないとエラーになったので削除する
  2. isPreview変数定義の次の行に追記
  3. loadResponse関数内のthis.render実行部分の中のelse文の中を置き換え
visit.ts
// 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でもよかったかも。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0