[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
として検知できます)。
スクロール位置の取得とスクロールさせるタイミングを把握する
スクロール位置を記録するのはそのページから離脱して他のページに遷移するタイミングです。
スクロールさせるのはページに遷移した (正確にはコンテンツが十分に render
された) タイミングです。
スクロール位置の決定方法を把握する
history.pushState
では遷移先を基準に任意のデータを保持し、「もどる」「すすむ」でその遷移先にもどってきた時に参照できます。そこで、history.pushState
であたらしいページに遷移する度にその遷移先に一意なあたらしい id を発行します。
その id をもとに遷移素のスクロール位置を保持し、遷移先のスクロール位置を復元・スクロールします。
クリックでの遷移と「もどる」「すすむ」でのスクロール位置の区別
クリックなどであたらしいページに 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>
)
}
実際
Router
の onChange
をページ遷移完了タイミングとしてスクロールさせていますが、実用においてはなんらかの非同期処理でコンテンツが取得されるのを待ってからスクロールさせることになります。
その場合は onChange
を Router
から呼びださせるのではなく、コンテンツデータの fetch 完了を検知できる何某かからよびだすことになるでしょう。
まとめ
最初は常に window.scrollTo(0, 0)
をしていたのですが、自分で使っていてこれは使いづらいと思ったので、流れで DIY しました。業務など一定の質を求められる局面では、ライブラリを検索する手間を惜しんでノリで DIY するようなことはせず、既存のライブラリを使うほうがよいでしょう。
サンプルリポジトリ
https://github.com/mmmpa/preact-router-scroll