PWA Night Conf: ScrapboxでのServiceWorkerとCacheの活用

PWA Night Conf: ScrapboxでのServiceWorkerとCacheの活用

1周年おめでとうございます🎊

こんにちは
daiiz / @daizplus daiiz


Scrapboxを作っています
shokai masui rakusai progfay yutaro takeru tiro

最近はHelpfeelも作っています

同人誌を書きました


Scrapbox
チームのための新しい共有ノート


Scrapbox
Wikiシステム
複数人でリアルタイムに共同編集できるノート
編集内容はSocket.IOでやりとりしている
/shokai/Scrapboxの開発 - React & Websocketで作るリアルタイムWiki shokai
たくさんの小さなページをリンクで繋いで思考するツール
社内のScrapboxは 15,000 ページを超えていた
プレゼンもできる
フルJavaScript実装のSPA

Scrapbox PWA
Scrapboxでの取り組み
キャッシュ対象を徐々に増やしながらコツコツと作り込んでいく
整ってきた💪

Scrapbox PWA
個人的には daiiz
読む
そもそもWorkerとは
試す
実際に書いて挙動に納得していく作業大事
小さいデモを書いてみる
#ServiceWorker にメモがある

Scrapbox PWA
モバイル
ネイティブアプリのようにさくさく動く
起動~記事閲覧が高速
オフラインでの記事閲覧も可能
新機能をすばやくユーザーに届けられる

デスクトップ
独立したウィンドウで表示できる
Google ChromeのDesktop PWAという機能
app manifest で display: standalone を指定

Agenda
基本的な話
ServiceWorker
CacheStorage
Scrapboxでの事例


基本的な話
ServiceWorker
CacheStorage


ServiceWorker
プログラム可能なネットワークプロキシ

今回扱うテーマ
FetchEventのハンドリング
UIスレッドからのpostMessage

ServiceWorker導入後
UIスレッド ↔ ServiceWorker ↔ Network

FetchEventのハンドリング
UIスレッドで発生したHTTPリクエストを横取りできる
リクエストのURLのpathnameなどの条件ごとに切り分けて、好きな処理を行える
Responseを作ってUIスレッドに返却できる
キャッシュ由来のレスポンスに任意のヘッダを付けたり
一から組み立てたり
serviceworker.js
Copied!
self.addEventListener('fetch', event => {
event.respondWith(async function () {
// ...
}())
})
つまり fetchEvent.respondWith() の書き方をマスターすれば完璧

Responseを一から組み立てて返す例
(Scrapboxとは関係ないです)
XMLに対してServiceWorkerでXSLT変換してSVG画像として返却する


CacheStorage
UIスレッド、ServiceWorkerの両方から参照できる
Key Value Store
key: Request objectかURL文字列
value: Response object
Cache生成
const cahce = caches.open(cacheKey)
responseを保存
await cache.put(request, response)
CacheStorage全体からresponseを取得
const response = await caches.match(request)


CacheStorage: Scrapboxでの構成
リソースの種類に応じて、4つのcacheに分けて保存
cacheNameの例
静的リソース
assets-20200129-035507
UGC画像
image-2020-01-29
APIデータ
api-2020-01-29
prefetch

キャッシュパターン
3パターンの選択肢
Network
Network first
Cache first



1. Network
これまで通り、いきなりネットワークからデータを取得
ServiceWorkerをインストールしていない状況と同じ
serviceworker.js
Copied!
self.addEventListener('fetch', event => {
return
})
cacheを参照せず、すべてをnetworkから返す
ハンドルしたくないリクエストは直ちにreturnすればいい

if (req.method !== GET) return
POSTリクエストなど
if (new URL(req.url).pathname === "/login") return
オフラインでは機能しようがないリクエスト


ServiceWorker専用のエンドポイントも作れる
Android向けGyazoでの例

OSの共有メニューから画像をアップロードできる

serviceworker.js
Copied!
self.addEventListener('fetch', event => {
const { method, url } = event.request
if (method !== 'POST') return
if (new URL(url).pathname === '/serviceworker-upload') {
event.respondWith((async () => {
// POSTリクエストを発行する
})())
}
})

manifest.json
Copied!
{
...
"share_target": {
"action": "/serviceworker-upload",
"method": "POST",
"enctype": "multipart/form-data",
"params": { ... }
}
}


2. Network first
まずはnetworkからの取得を試みる
だめならcacheから探して返す
serviceworker.js
Copied!
self.addEventListener('fetch', event => {
event.respondWith(async function () {
const req = event.request
try {
// まずはnetworkから取得できるか試みる
return fetch(req.clone())
} catch (err) {
// 失敗したらCacheStorageから探す
return caches.match(req)
}
}())
})

ブラウザの接続状況を返すAPIも存在するが
navigator.onLine
Scrapboxでは、実際にリクエストを発行して判断している
true でも実質オフラインの可能性があるため
WiFiにログインしていないなど


3. Cache first
初手として、cacheから探して返すことを試みる
cacheになければnetworkから取得する
serviceworker.js
Copied!
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())
}())
})

Resposeを組み立ててnetworkから取得する場合
横取りしたリクエストの req.credentials , req.redirect , req.mode を引き継ぐのを忘れない
serviceweorker.js
Copied!
const options = Object.create(null)
if (req.mode !== 'navigate') options.mode = req.mode
if (req.credentials) options.credentials = req.credentials
if (req.redirect) options.redirect = req.redirect
return fetch(req, options)
req.mode === "navigate"
ブラウザのアドレスバーにURLを直接入力されたときのリクエスト
これは引き継がなくていい


Quiz!! Scrapboxでの事例
Wikiアプリケーションを構成する様々なデータに対して、どのキャッシュパターンを使うと良い?
Network
Network first
Cache first


Q1. 静的リソース
app assets
SPAとして共通して使うHTML
CSS, アイコン画像、フォントなど
どんな画面でも必ず必要となるリソース

ページの土台や
エラーメッセージ画面


A1. Cache first
ネットワークの状況に依らずに保存したレスポンスを優先して返す
寿命が長いコンテンツに向いているキャッシュパターン

assets-cacheの作成
キャッシュすべきassetsのURLのリスト
アプリで使うリソースは既知なので全て列挙できる
assets-versionを与えて管理している

cache.addAll() で追加する
引数に与えたリソースがすべてcache追加に成功したことを保証できる
cache.add() で個別に取得すると?
どれか取得失敗したときにバージョンの整合性が崩れる


Q2. UGCの画像
Scrapobox記事にはユーザによって色々な画像が貼られる
当然ながら予め列挙することはできない

古来のキャッシュ方法
Cache-Control: max-age= に大きい値を与えるなど
運良く残っているかもしれない

オフライン時も画像を表示したいので明示的にキャッシュする


A2. Network first
オフライン用途を想定しているため
逐次保存する

CacehStorageには、異なるoriginの画像も保存可能
ただしプログラムからは読めない
opaqueなレスポンスとして扱われる
リクエストの成否を把握できない
js
Copied!
res.ok == false
res.status == 0
res.body == null
cache.addAll() での取得対象にしないよう注意
fetch APIのオプションで CORS mode を指定すれば読める

画像リクエストの判定方法
Request.destination - Web APIs | MDNを確認すればいい
request.destination === "image"
imgタグで要求されたリクエストであることがわかる
他にも "audio" "video" なども把握できる

容量が許す限りキャッシュしていく


動画は直ちにfallback to networkしている
request.destination === "video" なリクエストの扱い

2019/2 ごろ
macOS Safari 12
videoタグで読み込まれたmp4動画が再生できない問題が生じた
serviceworker.js
Copied!
self.addEventListener('fetch', event => {
event.respondWith((async function () {
// キャッシュを探して...
// ...
// 最終的にnetworkから取得
return fetch(req.clone())
})())
})

respondWithでハンドルする前にreturnすることで解決
serviceworker.js
Copied!
self.addEventListener('fetch', event => {
if (req.destination === 'video') return
event.respondWith((async function () {
// ...
})())
})
respondWith内でfetchする際にRange Headerが欠落することが原因か?

Q3. ページ
APIデータ
ページ本文 & 関連ページ

A3. Network first
Wikiなので超頻繁に内容が更新されている
保持しているキャッシュが古すぎるかもしれないのでcacheを優先しない方が良い
オフライン用途を想定してレスポンスを逐次保存していく
X-Serviceworker-Cache: true を付けてから cache.put() する
UIスレッドで使う


Prefetch もやっている
リンクにマウスホバーされたときにpostMessageでServiceWorkerにfetchを要請
取得したデータをCacheStorageに入れていく
10秒くらい経過したらcacheを消す
低速ネットワーク環境な場合は一時的にオフにする
Promise.race()を用いて数秒のtimeoutと競う

UIスレッド
js
Copied!
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
Copied!
self.addEventListener('message', event => {
event.waitUntil(async function () {
const { urls } = event.data
// 各urlをfetchして cache.put() する
Promise.all(urls.map(async url => {
const response = await fetch(url)
// cache "prefetch" と "api" を更新する
})
event.ports[0].postMessage({ title: 'prefetch' })
}())
})


ブラウザバック時に Cache first で表示をしたい
まだ検討段階
複数ページを遷移しながら編集することが多いので効果を期待できる
鮮度も大きくは失われていない


Q4. ページリスト
APIデータ

A4. Cache first
CacheStorageから探し出し、仮画面を構成する
レスポンスヘッダ X-Serviceworker-Cache で判断できる
仮画面をしている間にネットワークから最新データを取得する
キャッシュを更新して再描画

Slow 3G 回線でのシミュレートした様子
CacheStorageから取得したAPIデータでページリストを仮表示したあと、
サーバーから最新のデータを取得して、
リストの先頭に「PWA Night」のページが浮上してきた。


最も新しいcacheを探す
Scrapboxの場合
cache keyを日付にしているため、複数のcacheに目的のresponseが格納されていることがある
cacheを日付の降順で開き、探していく
sw.js
Copied!
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
}


Q5. 検索結果
APIデータ
search paramsに検索クエリを与えたURL
GET /api/pages/daiiz/search/query?q=PWA

A5. Network first
search params も含めたURLをキーとして保存している
オフライン時も検索キーワードごとにキャッシュされたレスポンスを取得可能

search paramsを無視して探すことも可能
cache.match(url, {ignoreSearch: true})
同様に、Methodを無視するオプションもある

キャッシュの更新と削除
Wikiアプリケーションでの方針を考える
キャッシュの寿命設定
キャッシュの新鮮化

キャッシュの寿命設定: UGC画像
予め決めた容量を超えない範囲で保存していく
quotaを参考にして、余裕を持って削除する
メインはAPIデータなので、CacheStorageの容量を逼迫しないよう注意する
オリジンに割り当てられた容量と使用量の見積もりを取得可能
const { quota, usage } = await navigator.storage.estimate()

キャッシュの寿命設定: APIデータ
Wikiアプリの特性上、古すぎるページデータを保持していても仕方がない
一週間が経過したものから順次削除している

いきなりcache objectを削除するとquotaが即時反映されないことがある
結構な量のリクエストを破棄したはずだが空き容量の値が戻らない、という現象
WorkBoxのコードも読んだが、削除の仕方を間違えているわけではなさそう

requestを一個ずつ消すとquotaに即反映された
多くのアイテムが含まれるキャッシュを削除する場合に有用な技
serviceworker.js
Copied!
const cache = await caches.open('images')
const reqs = await cache.keys()
// requestを1個ずつ削除する
for (const req of reqs) {
await cache.delete(req.url)
}
// 仕上げにcache objectを削除
caches.delete("images")


APIデータのキャッシュの新鮮化
オフライン時に表示するページをなるべく最新にしたい
ページを編集しているそばから手元のキャッシュを更新する
ServiceWorkerでsetIntervalを仕掛けて実現している
更新したいページをキューイングして、ページデータを定期的にfetchする

response.ok を確認してから保存する
status 200番台のときだけ true になっている
失敗のレスポンスを保存してしまうと、cacheからエラー画面が構築されてしまう

「status 200 だがキャッシュしたくない」ケースがあった
response.ok だけ見るのでは不十分だった
Scrapboxではページの本文が空の場合も、関連ページが存在すれば、有益なコンテンツとみなして 200 を返している
しかし本文がない状態がキャッシュされるのは不都合
ある条件で、cmd-shift-t でタブを復元したときに永遠に空ページで復元されてしまうバグ

サーバからのレスポンスヘッダに Cache-Control: no-store を付けて、ServiceWorkerでも保存前に確認するよう修正
一般的な規格に従った


オフラインモード
最近見たページをオフラインでも閲覧できる機能
蓄積した手元のキャッシュを素にして、閲覧専用の画面を構築する
ページリスト画面はCache firstで表示するだけ

ページ画面はNetwork firstのfallbackとしてCacheを表示すればOK

デモ


環境ごとに適切な見せ方を考える
画面を素早く表示することだけでなく
各デバイスでの体験が損なわれていないか気を付けている

モバイル
再読み込みボタンを自前で設置する
メニューアイテムは指で押すことを意識した高さになっているか?
など

Desktop PWA
アドレスバーや戻るボタンなどが表示されない
URLコピーボタンや戻るボタンを用意する

window.open(url) で新規にDesktop PWAウィンドウを開きたい
ページを切り出す機能
第三引数に "noopener" "noreferrer" 以外を与えるとmenu_barなどが非表示になり、独立したウィンドウで開けるようだ
Chromium: local_dom_window.ccを軽く読む


まとめ
ServiceWorker と CacheStorage の活用
Scrapboxでのキャッシュパターンの紹介

Powered by Helpfeel