\

[DIY] preact-router で遷移時のスクロール位置をちゃんとする。

最近ブログを preact + preact-router で SPA としてつくりなおしました。

ところで SPA では通常のリンクによる遷移を用いません。

表示内容を変更させるだけなら問題はないのですが、リロード時には同じ画面が表示されることが望まれる場合が多々あります。そこで history.pushState を用いた遷移を伴わない url 変更を行い、その url をもとに各種 router でコンポーネントを切り替えることにより表示内容を実現しています。

SPA 画面遷移でよくある問題

ところで、history.pushState は単なる値の変更なので、通常のリンクや「もどる」「すすむ」でのスクロール位置のリセットおよび復元が行われないという問題があります。

単純に表示内容変更時に window.scrollTo(0, 0) することにより一定のは得られますが、「もどる」「すすむ」の際にもゼロ位置に戻されてしまうため、いささか UX が損なわれる結果となります。

幸い preact-router には customHistory といってこの history.pushState などの処理回りを差しかえられる機能があります。そこで今回はスクロール位置を復元させる仕組みをもつ customHistory を DIY しました。

DIY

画面遷移を把握する

url の書きかえには history.pushState の他に history.replaceState もあります。この 2 つが SPA でリンクをクリックした時に用いられます (以降は history.pushState のみ書きます)。また自動的なリダイレクトなどもこれらを用います。

クリックおよびリダイレクト以外の画面遷移では「もどる」「すすむ」があります (「もどる」「すすむ」は PopStateEvent として検知できます)。

image.png

スクロール位置の取得とスクロールさせるタイミングを把握する

スクロール位置を記録するのはそのページから離脱して他のページに遷移するタイミングです。

スクロールさせるのはページに遷移した (正確にはコンテンツが十分に render された) タイミングです。

image.png

スクロール位置の決定方法を把握する

history.pushState では遷移先を基準に任意のデータを保持し、「もどる」「すすむ」でその遷移先にもどってきた時に参照できます。そこで、history.pushState であたらしいページに遷移する度にその遷移先に一意なあたらしい id を発行します。

image.png

その id をもとに遷移素のスクロール位置を保持し、遷移先のスクロール位置を復元・スクロールします。

image.png

クリックでの遷移と「もどる」「すすむ」でのスクロール位置の区別

クリックなどであたらしいページに history.pushState で遷移した場合、常にあたらしい id が発行されるので pos[n] は必ず空です。そこでゼロ位置にスクロールします。

「もどる」「すすむ」で遷移した場合、必ず一度は訪れ (そして離脱し) ているので pos[n] にはスクロール位置が保持されています。そこでその保持されている位置にスクロールさせます。

コード

以上を踏まえ、CustomHistory を作成しました。

import { CustomHistory as PreactCustomHistory, RouterOnChangeArgs } from 'preact-router'

type StoredScrollPosition = { x: number, y: number }
type ScrollPositionStore = { [key: number]: StoredScrollPosition }
type RoutingListener = (l: Location) => any

const defaultScrollPosition: StoredScrollPosition = { x: 0, y: 0 }

class CustomHistory implements PreactCustomHistory {
  idNow = 0
  currentId = 0
  scrollPositions: ScrollPositionStore = {}
  location: Location = window.location

  constructor () {
    window.addEventListener('popstate', this.onPopState)
  }

  listen (f: RoutingListener): () => void {
    const listener = (): void => f(location)
    window.addEventListener('popstate', listener)
    return () => window.removeEventListener('popstate', listener)
  }

  store () {
    const { scrollX: x, scrollY: y } = window
    this.scrollPositions[this.currentId] = { x, y }
  }

  onPopState = ({ state: { id } }: PopStateEvent): void => {
    this.store()
    this.currentId = id
  }

  work (type, url) {
    this.store()

    this.idNow += 1
    this.currentId = this.idNow
    window.history[`${type}State`]({ id: this.idNow }, `page${this.idNow}`, url)
  }

  push (url) {
    this.work('push', url)
  }

  replace (url) {
    this.work('replace', url)
  }

  onChange = ({ current }: RouterOnChangeArgs): void => {
    if (!current) {
      return
    }
    const { x, y } = this.scrollPositions[this.currentId] || defaultScrollPosition
    setTimeout(() => window.scrollTo(x, y))
  }
}

export default function createCustomHistory (): { history: CustomHistory, onChange: (args: RouterOnChangeArgs) => void } {
  const history = new CustomHistory()
  return { history, onChange: history.onChange }
}
export function createRouter (): VNode<any> {
  return (
    <Router {...createRouter(createCustomHistory())}>
      <Route default component={PageA} />
      <Route path='/b' component={PageB} />
      <Route path='/c' component={PageC} />
      <Route path='/d' component={PageD} />
    </Router>
  )
}

実際

RouteronChange をページ遷移完了タイミングとしてスクロールさせていますが、実用においてはなんらかの非同期処理でコンテンツが取得されるのを待ってからスクロールさせることになります。

その場合は onChangeRouter から呼びださせるのではなく、コンテンツデータの fetch 完了を検知できる何某かからよびだすことになるでしょう。

まとめ

最初は常に window.scrollTo(0, 0) をしていたのですが、自分で使っていてこれは使いづらいと思ったので、流れで DIY しました。業務など一定の質を求められる局面では、ライブラリを検索する手間を惜しんでノリで DIY するようなことはせず、既存のライブラリを使うほうがよいでしょう。

サンプルリポジトリ

https://github.com/mmmpa/preact-router-scroll