ScrapboxでのServiceWorkerとCacheの活用
PWA Night vol.6での発表資料です。
photo by 
こんにちは
daiizです
京都から来ました
Notaという会社でScrapboxを作っています
Scrapbox
Wikiみたいなノートアプリ
複数人での同時編集できる
文中リンクで繋げて思考する
フルJavaScript実装のSingle Page Application
サーバーサイド
クライアントサイド
/shokai/Scrapboxの開発 - React & Websocketで作るリアルタイムWiki by 
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の活用
これらの裏ではServiceWorkerとCacheStorageを激しく活用している
発表の流れ
基本事項の説明
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 
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
ページ編集中
その他の工夫
画面が表示されるまで
ページ画面以外 (ページリストなど) での流れ
静的リソースをCache Storageから取得し基礎を表示する
CacheStorageから最新のAPIデータを取得して、仮画面を表示
サーバーからAPIデータを取得
画面を再描画して最新状態にする
ページ画面では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
})
});
実装されたら使いたい 
その他の工夫
各デバイスに適した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