PWA Night Conf: ScrapboxでのServiceWorkerとCacheの活用
https://gyazo.com/affe424475102777ec44f65b1d8815ba
1周年おめでとうございます🎊
こんにちは
https://gyazo.com/932e69f1562d49b5795fb42b4c9c875c
shokai.icon masui.icon rakusai.icon progfay.icon yutaro.icon takeru.icon tiro.icon
同人誌を書きました
Scrapbox
チームのための新しい共有ノート
https://gyazo.com/454c60b13f8a783990fa223137cb754f
Scrapbox
https://gyazo.com/6650f305b46ff9a2683fd11988e12cf3 https://gyazo.com/ed58dad9ca677c8ab54867fe08cf6817
複数人でリアルタイムに共同編集できるノート
たくさんの小さなページをリンクで繋いで思考するツール
社内のScrapboxは 15,000 ページを超えていた
プレゼンもできる
フルJavaScript実装のSPA
Scrapbox PWA
Scrapboxでの取り組み
キャッシュ対象を徐々に増やしながらコツコツと作り込んでいく
整ってきた💪
https://gyazo.com/413958f720f63e79d2424ec147feebf4
Scrapbox PWA
個人的には daiiz.icon
読む
試す
実際に書いて挙動に納得していく作業大事
小さいデモを書いてみる
Scrapbox PWA
モバイル
ネイティブアプリのようにさくさく動く
起動~記事閲覧が高速
オフラインでの記事閲覧も可能
新機能をすばやくユーザーに届けられる
デスクトップ
独立したウィンドウで表示できる
app manifest でdisplay: standaloneを指定
Agenda
基本的な話
ServiceWorker
CacheStorage
Scrapboxでの事例
基本的な話
ServiceWorker
CacheStorage
ServiceWorker
プログラム可能なネットワークプロキシ
今回扱うテーマ
FetchEventのハンドリング
UIスレッドからのpostMessage
ServiceWorker導入後
UIスレッド ↔ ServiceWorker ↔ Network
https://gyazo.com/56ea425ad45437eb2bdb1093a5f065a1
FetchEventのハンドリング
UIスレッドで発生したHTTPリクエストを横取りできる
リクエストのURLのpathnameなどの条件ごとに切り分けて、好きな処理を行える
Responseを作ってUIスレッドに返却できる
キャッシュ由来のレスポンスに任意のヘッダを付けたり
一から組み立てたり
code:serviceworker.js
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
https://gyazo.com/162d65083b56d462c47558031cbaae3f
キャッシュパターン
3パターンの選択肢
Network
Network first
Cache first
1. Network
これまで通り、いきなりネットワークからデータを取得
ServiceWorkerをインストールしていない状況と同じ
code:serviceworker.js
self.addEventListener('fetch', event => {
return
})
cacheを参照せず、すべてをnetworkから返す
ハンドルしたくないリクエストは直ちにreturnすればいい
例
if (req.method !== GET) return
POSTリクエストなど
if (new URL(req.url).pathname === "/login") return
オフラインでは機能しようがないリクエスト
ServiceWorker専用のエンドポイントも作れる
OSの共有メニューから画像をアップロードできる
https://gyazo.com/32bb42338ff69aa98ecdb969d8c3f9e8 https://gyazo.com/7d62c56bc52186bc53517c1dcf70186a
code:serviceworker.js
self.addEventListener('fetch', event => {
const { method, url } = event.request
if (method !== 'POST') return
if (new URL(url).pathname === '/serviceworker-upload') {
event.respondWith((async () => {
// POSTリクエストを発行する
})())
}
})
code:manifest.json
{
...
"share_target": {
"action": "/serviceworker-upload",
"method": "POST",
"enctype": "multipart/form-data",
"params": { ... }
}
}
2. Network first
まずはnetworkからの取得を試みる
だめならcacheから探して返す
code:serviceworker.js
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から取得する
code: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())
}())
})
Resposeを組み立ててnetworkから取得する場合
横取りしたリクエストの req.credentials, req.redirect, req.mode を引き継ぐのを忘れない
code:serviceweorker.js
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, アイコン画像、フォントなど
どんな画面でも必ず必要となるリソース
ページの土台や
https://gyazo.com/ef6a4be5c7f75225f2c9cecfa9ae771f
エラーメッセージ画面
https://gyazo.com/3d600eaba8a31a6154effa99380dfb88
A1. Cache first
ネットワークの状況に依らずに保存したレスポンスを優先して返す
寿命が長いコンテンツに向いているキャッシュパターン
https://gyazo.com/7b630b4f518e1e486d3121f12a490e03
assets-cacheの作成
キャッシュすべきassetsのURLのリスト
アプリで使うリソースは既知なので全て列挙できる
https://gyazo.com/f8baeb356df4bf0cad6bd3513a99cff4
assets-versionを与えて管理している
cache.addAll()で追加する
引数に与えたリソースがすべてcache追加に成功したことを保証できる
cache.add()で個別に取得すると?
どれか取得失敗したときにバージョンの整合性が崩れる
Q2. UGCの画像
Scrapobox記事にはユーザによって色々な画像が貼られる
当然ながら予め列挙することはできない
https://gyazo.com/9da0db670cb25213a7c910a238e251c7
古来のキャッシュ方法
Cache-Control: max-age= に大きい値を与えるなど
運良く残っているかもしれない
オフライン時も画像を表示したいので明示的にキャッシュする
A2. Network first
オフライン用途を想定しているため
逐次保存する
CacehStorageには、異なるoriginの画像も保存可能
ただしプログラムからは読めない
リクエストの成否を把握できない
code:js
res.ok == false
res.status == 0
res.body == null
cache.addAll() での取得対象にしないよう注意
fetch APIのオプションで CORS mode を指定すれば読める 画像リクエストの判定方法
request.destination === "image"
imgタグで要求されたリクエストであることがわかる
他にも"audio"や"video"なども把握できる
容量が許す限りキャッシュしていく
動画は直ちにfallback to networkしている
request.destination === "video"なリクエストの扱い
2019/2 ごろ
macOS Safari 12
videoタグで読み込まれたmp4動画が再生できない問題が生じた
code:serviceworker.js
self.addEventListener('fetch', event => {
event.respondWith((async function () {
// キャッシュを探して...
// ...
// 最終的にnetworkから取得
return fetch(req.clone())
})())
})
respondWithでハンドルする前にreturnすることで解決
code:serviceworker.js
self.addEventListener('fetch', event => {
if (req.destination === 'video') return
event.respondWith((async function () {
// ...
})())
})
respondWith内でfetchする際にRange Headerが欠落することが原因か?
Q3. ページ
APIデータ
ページ本文 & 関連ページ
https://gyazo.com/05522824ecb1e26df48a38ead5d085cf https://gyazo.com/f6c7de02b8c214016a1e831e95b9b2c2
A3. Network first
Wikiなので超頻繁に内容が更新されている
保持しているキャッシュが古すぎるかもしれないのでcacheを優先しない方が良い
https://gyazo.com/f64a42f8b107adae90cb04517fc6be72
オフライン用途を想定してレスポンスを逐次保存していく
X-Serviceworker-Cache: trueを付けてから cache.put() する
UIスレッドで使う
Prefetch もやっている
取得したデータをCacheStorageに入れていく
10秒くらい経過したらcacheを消す
低速ネットワーク環境な場合は一時的にオフにする
UIスレッド
code:js
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 }
})
}
ServiceWorker
MessageEventをハンドリング
code:serviceworker.js
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.ports0.postMessage({ title: 'prefetch' }) }())
})
ブラウザバック時に Cache first で表示をしたい
まだ検討段階
複数ページを遷移しながら編集することが多いので効果を期待できる
鮮度も大きくは失われていない
Q4. ページリスト
APIデータ
https://gyazo.com/c02e15c0c675d929031cbfc178e2080b
A4. Cache first
CacheStorageから探し出し、仮画面を構成する
レスポンスヘッダX-Serviceworker-Cacheで判断できる
仮画面をしている間にネットワークから最新データを取得する
https://gyazo.com/d8a13b05e313d092375a2e66e70cc309
キャッシュを更新して再描画
Slow 3G 回線でのシミュレートした様子
https://gyazo.com/88366ea574cc459ebda5aa3e17f038d4
CacheStorageから取得したAPIデータでページリストを仮表示したあと、
サーバーから最新のデータを取得して、
リストの先頭に「PWA Night」のページが浮上してきた。
最も新しいcacheを探す
Scrapboxの場合
cache keyを日付にしているため、複数のcacheに目的のresponseが格納されていることがある
cacheを日付の降順で開き、探していく
code: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
}
https://gyazo.com/163c4ff27535cd5df8d70b66b9da2022/thumb/542.png
Q5. 検索結果
APIデータ
search paramsに検索クエリを与えたURL
GET /api/pages/daiiz/search/query?q=PWA
https://gyazo.com/684de4346f2ba5031caf2222f0eae1b1
A5. Network first
search params も含めたURLをキーとして保存している
オフライン時も検索キーワードごとにキャッシュされたレスポンスを取得可能
https://gyazo.com/b0d5915ac2c8038eeb77a414a472b824
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に即反映された
多くのアイテムが含まれるキャッシュを削除する場合に有用な技
code:serviceworker.js
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 を返している
https://gyazo.com/ac189baaa8617385b49fd886fd736c0d
しかし本文がない状態がキャッシュされるのは不都合
ある条件で、cmd-shift-t でタブを復元したときに永遠に空ページで復元されてしまうバグ
サーバからのレスポンスヘッダに Cache-Control: no-store を付けて、ServiceWorkerでも保存前に確認するよう修正
一般的な規格に従った
オフラインモード
最近見たページをオフラインでも閲覧できる機能
蓄積した手元のキャッシュを素にして、閲覧専用の画面を構築する
ページリスト画面はCache firstで表示するだけ
ページ画面はNetwork firstのfallbackとしてCacheを表示すればOK
https://gyazo.com/7c9ff9ac3e104b742cffc7e21b8bc4f0
デモ
環境ごとに適切な見せ方を考える
画面を素早く表示することだけでなく
各デバイスでの体験が損なわれていないか気を付けている
モバイル
再読み込みボタンを自前で設置する
メニューアイテムは指で押すことを意識した高さになっているか?
https://gyazo.com/adb84bec5f2f7b99f1d568f5616db44d
など
Desktop PWA
アドレスバーや戻るボタンなどが表示されない
URLコピーボタンや戻るボタンを用意する
https://gyazo.com/e9f4e79980f0d9c2566fb772317f9c93/raw
window.open(url)で新規にDesktop PWAウィンドウを開きたい
ページを切り出す機能
https://gyazo.com/f4ef01ee32acb0d117e3ad63162cb29b
第三引数に "noopener"、"noreferrer" 以外を与えるとmenu_barなどが非表示になり、独立したウィンドウで開けるようだ
まとめ
ServiceWorker と CacheStorage の活用
Scrapboxでのキャッシュパターンの紹介