generated at
ScrapboxでのServiceWorkerとCacheの活用

photo by kanata


こんにちは
daiizですdaiiz
京都から来ました
Notaという会社でScrapboxを作っています


Scrapbox
Wikiみたいなノートアプリ
複数人での同時編集できる
文中リンクで繋げて思考する

フルJavaScript実装のSingle Page Application
サーバーサイド
クライアントサイド


2018/11
東京Node学園祭2018での発表資料
SPAにServiceWorkerを導入する話
ユーザーによってコンテンツが頻繁に更新されるウェブサービスでのキャッシュパターンの考察


2019/4
ネイティブアプリのようにさくさく動く
起動~記事閲覧まで、ネイティブアプリのようにさくさく高速に動く
新機能をすばやくユーザーに届けることが可能に
環境問わず同じようなサービス体験が可能に

これらを実現している技術について話します


デモ
本日説明する技術でどんなことができるのか?
Scrapboxの機能で見てみる

ネイティブアプリのような見た目で起動できる
画面の表示がはやい
ページ遷移がはやい
オフラインでもページを読める
右下に「Offline mode」と表示される
Offline modeで表示される内容が古くない


各機能と本日のトピック
ネイティブアプリのような見た目で起動できる
Desktop PWA
manifest.jsonでの display: standalone 指定
画面の表示がはやい
静的リソースをキャッシュに保存 (assets cache)
キャッシュファーストでのCacheの活用
キャッシュから取得したAPIデータで一旦画面を作る
ページ遷移がはやい
マウスホバーでのPrefetch
オフラインでもページを読める
ネットワークファーストでのCacheの活用

これらの裏ではServiceWorkerCacheStorageを激しく活用している


発表の流れ
基本事項の説明
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 になる
実戦投入する前に https://github.com/nota/sw_skelton で調査していた by rakusai


UIスレッド ↔ ServiceWorker
特に意識ことは必要はない
普段どおりHTTPリクエストを発行するだけで良い
aタグをクリック
postMessageで通信する方法もある


ServiceWorker ↔ Network, CacheStorage
ServiceWorkerには色んなイベントが飛んでくる
アプリで必要な機能に関するイベントハンドラを実装していく
後にFetchEventやMessageEventのハンドリングを考える


FetchEvent
UIスレッドでリクエストが発生すると、ServiceWorkerに飛んでくるイベント
このイベントをハンドリングすることでいろいろできる
respondWith() 内でレスポンスをつくってUIスレッドに返す
すべてをネットワークから返す例
serviceworker.js
self.addEventListener('fetch', event => { return })
ネットワークファーストで返す例
オフラインならCacheStorageから返す
serviceworker.js
self.addEventListener('fetch', event => { event.respondWith(async function () { const req = event.request try { return fetch(req.clone()) } catch (err) { return caches.match(req) } }()) })
キャッシュファーストで返す例
まずはCacheStorageからの返却を試みる
serviceworker.js
self.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にインストールメニューが現れる
manifest.json display: standalone または display: minimal-ui を指定する
何かしらのFetchEventハンドラを書いておく
Dockに追加できる
単独のウィンドウで起動できる
タイトルバーの背景色も変更できる


CacheStorage
Key Value Store
key: RequestInfo: Request object / URL文字列
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から取得し基礎を表示する
assets cacheという仕組みを独自実装
初期画面構築に必要なHTML, CSS, JS, Fontsなどを保存しておく
UIスレッドでのfetchが落ち着いたら、更新を試みる
assetsのホワイトリスト
CDNから読み込むリソースも含まれる
ここで指定されたurlsをcache.addAll(urls)で保存する
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.js
async 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.js
let 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.js
const 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とUIスレッド間でpostMessageによってやりとりする
リンクホバーのタイミングで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.js
self.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を更新する

Periodic synchronizationで実現したいところだが、現状では実装してるブラウザが存在しない
js
navigator.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 }) });
実装されたら使いたい daiiz


その他の工夫


各デバイスに適した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