Turbolinksにpreload機能を追加する

Turbolinksにもプリロード機能が欲しくなったので自作しました。
もしかしたらもうある？
InstantClickを目指します(使えば良いのに

# 環境
```
Turbolinks 5.2.0
Typescript 3.4.2
dir        src/turbolinks
```

# preload.tsの作成
```typescript
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メソッドの最後に追記

```typescript: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したければ書きます。

```typescript: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`関数実行の後に追記

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

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

- wrapメソッド・fromHTMLElementメソッドの引数を修正する
- wrapメソッドは`value`、fromHTMLElementは`htmlElement`の型に`HTMLElement`を追加する

```typescript:snapshot.ts
static wrap(value: Snapshot | string | HTMLHtmlElement | HTMLElement)

static fromHTMLElement(htmlElement: HTMLHtmlElement | HTMLElement)
```

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

```typescript: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`文の中を置き換え

```typescript: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でもよかったかも。