ScrapboxでのServiceWorkerとCacheの活用
photo by
こんにちは
daiizです
Notaという会社でScrapboxを作っています
Scrapbox
Wikiみたいなノートアプリ
複数人での同時編集できる
文中リンクで繋げて思考する
フルJavaScript実装のSingle Page Application
サーバーサイド
クライアントサイド
2018/11
ユーザーによってコンテンツが頻繁に更新されるウェブサービスでのキャッシュパターンの考察
2019/4
ネイティブアプリのようにさくさく動く
起動~記事閲覧まで、ネイティブアプリのようにさくさく高速に動く
新機能をすばやくユーザーに届けることが可能に
環境問わず同じようなサービス体験が可能に
これらを実現している技術について話します
デモ
本日説明する技術でどんなことができるのか?
Scrapboxの機能で見てみる
ネイティブアプリのような見た目で起動できる
画面の表示がはやい
ページ遷移がはやい
右下に「Offline mode」と表示される
Offline modeで表示される内容が古くない
各機能と本日のトピック
ネイティブアプリのような見た目で起動できる
Desktop PWA
manifest.jsonでの display: standalone
指定
画面の表示がはやい
静的リソースをキャッシュに保存 (assets cache)
キャッシュファーストでのCacheの活用
キャッシュから取得したAPIデータで一旦画面を作る
ページ遷移がはやい
マウスホバーでのPrefetch
オフラインでもページを読める
ネットワークファーストでのCacheの活用
発表の流れ
基本事項の説明
Scrapboxでの各機能の実現方法の紹介
基本事項
ServiceWorker
FetchEvent
Desktop PWA
CacheStorage
ServiceWorker
プログラム可能なネットワークプロキシ
オフラインで動作させるために必要な機能を提供してくれる
ネットワークリクエストへの介入や処理機能
レスポンスをプログラムから操作できるキャッシュ機能
Responseにheaderを加えたり
レスポンスをイチから組み立てたりもできる
ServiceWorkerのインストール
インストールの仕方
js // Window
const registration = await navigator.serviceWorker.register('/sw.js', {scope: '/'})
ServiceWorker自身の更新
ブラウザがbyte単位で変更がないかを確認して、自動で更新してくれる
client ↔ server の通信が、client ↔ ServiceWorker ↔ server になる
UIスレッド ↔ ServiceWorker
特に意識ことは必要はない
普段どおりHTTPリクエストを発行するだけで良い
aタグをクリック
postMessageで通信する方法もある
ServiceWorker ↔ Network, CacheStorage
ServiceWorkerには色んなイベントが飛んでくる
アプリで必要な機能に関するイベントハンドラを実装していく
後にFetchEventやMessageEventのハンドリングを考える
FetchEvent
UIスレッドでリクエストが発生すると、ServiceWorkerに飛んでくるイベント
このイベントをハンドリングすることでいろいろできる
respondWith()
内でレスポンスをつくってUIスレッドに返す
すべてをネットワークから返す例
ネットワークファーストで返す例
オフラインならCacheStorageから返す
serviceworker.jsself.addEventListener('fetch', event => {
event.respondWith(async function () {
const req = event.request
try {
return fetch(req.clone())
} catch (err) {
return caches.match(req)
}
}())
})
キャッシュファーストで返す例
まずはCacheStorageからの返却を試みる
serviceworker.jsself.addEventListener('fetch', event => {
event.respondWith(async function () {
const req = event.request
const res = await caches.match(req)
if (res) return res
return fetch(req.clone())
}())
})
Desktop PWA
以下の条件を満たすとChromeにインストールメニューが現れる
何かしらのFetchEventハンドラを書いておく
Dockに追加できる
単独のウィンドウで起動できる
タイトルバーの背景色も変更できる
CacheStorage
Key Value Store
value: Response object
UIスレッド、ServiceWorkerの両方から参照できる
従来の「何を一時保存して、いつ使われるかはブラウザ任せ」のキャッシュとは異なり、開発者がコントロールできる
Cache生成
cache keyを指定して const cahce = caches.open(cacheKey)
でCache objectを作成
responseを保存
await cache.put(request, response)
CacheStorage全体からresponseを取得
const response = await caches.match(request)
Scrapboxの例で見る
画面が表示されるまで
画像のキャッシュ
Offline mode
マウスホバーでのPrefetch
ページ編集中
その他の工夫
画面が表示されるまで
ページ画面以外 (ページリストなど) での流れ
1. 静的リソースをCache Storageから取得し基礎を表示する
2. CacheStorageから最新のAPIデータを取得して、仮画面を表示
3. サーバーからAPIデータを取得
4. 画面を再描画して最新状態にする
ページ画面ではStep. 2を飛ばす
1. 静的リソースをCache Storageから取得し基礎を表示する
初期画面構築に必要なHTML, CSS, JS, Fontsなどを保存しておく
UIスレッドでのfetchが落ち着いたら、更新を試みる
assetsのホワイトリスト
CDNから読み込むリソースも含まれる
js build時にnpm scriptsのtaskで生成する
serverとclientでリストを共用できる為、アプリとassets.jsonの乖離が起きない
日時をcache keyとしている
assets-20181109-103746
古いものかどうかを文字列の比較で判断できる
キャッシュファーストでassets cacheから返す
2. CacheStorageから直近のAPIデータを取得して、仮画面を表示
最後に取得したAPIデータに基づいて画面を復元する
サーバーからの待ち時間にコンテンツをいち早く見せるのが目的
CacheStorageに目的のAPIレスポンスが存在すればそれを使う
このステップはUIスレッドで行える
UIスレッドからもCacheStorageにアクセスできる為
リストアしたデータをrenderしつつ、ネットワークリクエストを発行する
このときのアプリの状態を RESTORE_CACHE
と呼んでおく
一番新しいcacheを探すには?
cacheを日付 (cache key) の降順で開き、探していく必要がある
sw.jsasync function findLatestCache (req) {
const cacheNames = await caches.keys()
for (const date of cacheNames.sort().reverse()) {
const cache = await caches.open(date)
const res = await cache.match(req, {ignoreSearch: true})
if (res) return res
}
return null
}
cache.match()
のoptionsに {ignoreSearch: true}
をセットするとsearch queryを無視して取得できる
cacheをputする際ににURLを正規化せずに済む
3. サーバーからAPIデータを取得
ServiceWorkerでfetchEventを処理する
responseをUIスレッドに返却する
Cacheを更新する
responseをUIスレッドに返却する
ネットワークファースト
serviceworker.jslet res
try {
// まずはnetworkから取得できるか試みる
res = await fetch(req.clone())
} catch (err) {
// 失敗したらCacheStorageから探す
return findLatestApiCache(req)
}
// キャッシュを更新
updateApiCache(req, res.clone())
return res
Cacheを更新する
response headerに X-Serviceworker-Cache: true
を付けて cache.put() する
次のステップで、UIスレッドにて、responseがどこ由来か判定するのに使える
Cache保存時に付けるほうが、取得時に付けるよりも回数が少なく済む
assets cacheと同様に日時をcache keyにしている
古くなったものがわかりやすい
画像もキャッシュする
same originでない画像もCacehStorageに保存できる
request.destination === 'image'
img.src由来などの画像リクエストを判定できる
予め決めた容量を超えない範囲で保存していく
quotaを参考にしつつ、適切な値を決めておく
const { quota, usage } = await navigator.storage.estimate()
現在のオリジンに割り当てられた容量と使用量の見積もりを取得できる
容量を超えそうになったら削除
用意している画像用のcache objectをまるごと削除すると、quotaに即反映されないことがある
cache内のアイテムの削除が不完全なのか、quotaの反映が遅れているだけなのかは不明
cache内のrequestを一個ずつ消すと確実
serviceworker.jsconst cache = await caches.open('images')
const reqs = await cache.keys()
// requestを1個ずつ削除すると、削除後にQuotaに即反映される
for (const req of reqs) {
await cache.delete(req.url)
}
4. 画面を再描画
最新のデータに従って再度React renderが走る
Slow 3G 回線でのシミュレート
CacheStorageから取得したAPIデータでページリストを仮表示した後、サーバーから最新のデータを取得し、リストの先頭に「PWA Night」のページが浮上してきた様子
response headerを読むと、データがキャッシュ由来かどうか分かる
X-Serviceworker-Cache: true
あり
readyState: FALLBACK_CACHE
なし
readyState: FROM_REMOTE
Offline mode
ここまでの流れでOffline modeに必要な準備は揃っている
画面表示工程 Step. 4 での、readyState: FALLBACK_CACHE
の状態
networkからの取得に失敗してcacheにfallbackしている
編集機能をdisableにするだけでOK
マウスホバーでのPrefetch
リンクホバーのタイミングでServiceWorkerにprefetchを要請する
js // Window
async function prefetch (urls) {
const {controller} = await navigator.serviceWorker
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = event => { resolve(event.data) }
controller.postMessage({
title: 'prefetch',
body: {urls}
}, [channel.port2])
})
}
ServiceWorkerではMessageEventをハンドル
serviceworker.jsself.addEventListener('message', event => {
event.waitUntil(async function () {
const {urls} = event.data
// ここで各urlをfetchしてcacheに追加する
// await fetch(new Request(url, {credentials: 'same-origin'}))
event.ports[0].postMessage({title: 'prefetch'})
}())
})
リンククリック時にキャッシュから返せて高速になる
ページ編集中
CacheStorage内のページデータをどうやって更新するか?
自分が編集したり、他のメンバーによって編集されたりと、ページは刻々と更新されていく
Offline modeで閲覧するページをなるべく最新のものにしたい
いま表示しているページデータをときどき再取得する
ServiceWorkerでsetIntervalを仕掛けている
更新したいページをキューに追加していき、定期的にfetchしてCacheを更新する
jsnavigator.serviceWorker.ready.then(function(registration) {
registration.periodicSync.register({
tag: 'get-latest-news',
minPeriod: 12 * 60 * 60 * 1000,
powerState: 'avoid-draining',
networkState: 'avoid-cellular'
}).then(function(periodicSyncReg) {
// success
}, function() {
// failure
})
});
実装されたら使いたい
その他の工夫
各デバイスに適したUI
Drawer menu
指でタッチすることを考えた高さのmenu item
Desktop PWAでのHistory backボタン
manifest.jsonで display-mode: standalone
を指定していると
アドレスバーや戻るボタンなどが表示されない
アプリ内に代わりの自前のボタンを置くとよい
standalone modeで表示されていることの判定
JS
window.matchMedia('(display-mode: standalone)').matches
CSS
media queryで判定できる
@media (display-mode: standalone) { }
Android版でのReload, Shareボタン
Reloadボタン
モバイルでは Cmd+R とかできないので用意しておくと安心
Web Share API
各種SNSに共有するためのネイティブUIを使える
js// Window
const onClick = () => {
return navigator.share({
title: document.title,
url: location.href
})
}
まとめ
Cacheを活用して素早くコンテンツを表示
マウスホバーでPrefetchして遷移前にページデータを取得
環境に応じた適切なUI