ScrapboxでのServiceWorkerとCacheの活用
https://gyazo.com/84be328845073bf7de062f7ba5aa3bf4
photo by kanata.icon
こんにちは
daiizですdaiiz.icon
Notaという会社でScrapboxを作っています Scrapbox
Wikiみたいなノートアプリ
https://gyazo.com/6650f305b46ff9a2683fd11988e12cf3 https://gyazo.com/ed58dad9ca677c8ab54867fe08cf6817
複数人での同時編集できる
文中リンクで繋げて思考する
フルJavaScript実装のSingle Page Application
サーバーサイド
クライアントサイド
2018/11
ユーザーによってコンテンツが頻繁に更新されるウェブサービスでのキャッシュパターンの考察
2019/4
https://gyazo.com/413958f720f63e79d2424ec147feebf4
ネイティブアプリのようにさくさく動く
起動~記事閲覧まで、ネイティブアプリのようにさくさく高速に動く
新機能をすばやくユーザーに届けることが可能に
環境問わず同じようなサービス体験が可能に
これらを実現している技術について話します
デモ
本日説明する技術でどんなことができるのか?
Scrapboxの機能で見てみる
ネイティブアプリのような見た目で起動できる
画面の表示がはやい
ページ遷移がはやい
右下に「Offline mode」と表示される
https://gyazo.com/6875646132da661aab19654be0c795e0
Offline modeで表示される内容が古くない
各機能と本日のトピック
ネイティブアプリのような見た目で起動できる
Desktop PWA
manifest.jsonでのdisplay: standalone指定
画面の表示がはやい
静的リソースをキャッシュに保存 (assets cache)
キャッシュファーストでのCacheの活用
キャッシュから取得したAPIデータで一旦画面を作る
ページ遷移がはやい
マウスホバーでのPrefetch
オフラインでもページを読める
ネットワークファーストでのCacheの活用
発表の流れ
基本事項の説明
Scrapboxでの各機能の実現方法の紹介
基本事項
ServiceWorker
FetchEvent
Desktop PWA
CacheStorage
ServiceWorker
プログラム可能なネットワークプロキシ
オフラインで動作させるために必要な機能を提供してくれる
ネットワークリクエストへの介入や処理機能
レスポンスをプログラムから操作できるキャッシュ機能
Responseにheaderを加えたり
レスポンスをイチから組み立てたりもできる
ServiceWorkerのインストール
インストールの仕方
code:js
// Window
const registration = await navigator.serviceWorker.register('/sw.js', {scope: '/'})
ServiceWorker自身の更新
ブラウザがbyte単位で変更がないかを確認して、自動で更新してくれる
client ↔ server の通信が、client ↔ ServiceWorker ↔ server になる
UIスレッド ↔ ServiceWorker
https://gyazo.com/a02919ec041cf1c05bb3187469f251d5
特に意識ことは必要はない
普段どおりHTTPリクエストを発行するだけで良い
aタグをクリック
postMessageで通信する方法もある
ServiceWorker ↔ Network, CacheStorage
https://gyazo.com/1ac40646474b698a91d7c3cde2c7d441
ServiceWorkerには色んなイベントが飛んでくる
アプリで必要な機能に関するイベントハンドラを実装していく
後にFetchEventやMessageEventのハンドリングを考える
FetchEvent
UIスレッドでリクエストが発生すると、ServiceWorkerに飛んでくるイベント
このイベントをハンドリングすることでいろいろできる
respondWith()内でレスポンスをつくってUIスレッドに返す
すべてをネットワークから返す例
code:serviceworker.js
self.addEventListener('fetch', event => {
return
})
ネットワークファーストで返す例
オフラインならCacheStorageから返す
code: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からの返却を試みる
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())
}())
})
Desktop PWA
以下の条件を満たすとChromeにインストールメニューが現れる
何かしらのFetchEventハンドラを書いておく
https://gyazo.com/6cfc2c649ff3c9980d8d84c1dbf81ed0
Dockに追加できる
https://gyazo.com/487170a86ae07a5cd3fcf8846067f2fc/raw
単独のウィンドウで起動できる
https://gyazo.com/e37e9336717b6c40d23b6f383b0fef8b/raw
タイトルバーの背景色も変更できる
CacheStorage
Key Value Store
value: Response object
https://gyazo.com/2ffa075373e7d871b0d489dcb923ea89/raw
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 と呼んでおく
https://gyazo.com/0a817c5159dffb2efb9995ade91bb2e1
一番新しいcacheを探すには?
cacheを日付 (cache key) の降順で開き、探していく必要がある
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
}
cache.match()のoptionsに{ignoreSearch: true}をセットするとsearch queryを無視して取得できる
cacheをputする際ににURLを正規化せずに済む
https://gyazo.com/163c4ff27535cd5df8d70b66b9da2022/thumb/542.png
3. サーバーからAPIデータを取得
ServiceWorkerでfetchEventを処理する
responseをUIスレッドに返却する
Cacheを更新する
responseをUIスレッドに返却する
ネットワークファースト
code: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保存時に付けるほうが、取得時に付けるよりも回数が少なく済む
https://gyazo.com/65dd3c3f457b3d4ef6bf7a722e4a72fb
assets cacheと同様に日時をcache keyにしている
古くなったものがわかりやすい
https://gyazo.com/162d65083b56d462c47558031cbaae3f
画像もキャッシュする
same originでない画像もCacehStorageに保存できる
request.destination === 'image'
img.src由来などの画像リクエストを判定できる
予め決めた容量を超えない範囲で保存していく
quotaを参考にしつつ、適切な値を決めておく
const { quota, usage } = await navigator.storage.estimate()
現在のオリジンに割り当てられた容量と使用量の見積もりを取得できる
容量を超えそうになったら削除
用意している画像用のcache objectをまるごと削除すると、quotaに即反映されないことがある
cache内のアイテムの削除が不完全なのか、quotaの反映が遅れているだけなのかは不明
cache内のrequestを一個ずつ消すと確実
code: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が走る
https://gyazo.com/88366ea574cc459ebda5aa3e17f038d4
Slow 3G 回線でのシミュレート
CacheStorageから取得したAPIデータでページリストを仮表示した後、サーバーから最新のデータを取得し、リストの先頭に「PWA Night」のページが浮上してきた様子
response headerを読むと、データがキャッシュ由来かどうか分かる
X-Serviceworker-Cache: true あり
readyState: FALLBACK_CACHE
なし
readyState: FROM_REMOTE
https://gyazo.com/d8a13b05e313d092375a2e66e70cc309
Offline mode
ここまでの流れでOffline modeに必要な準備は揃っている
画面表示工程 Step. 4 での、readyState: FALLBACK_CACHEの状態
networkからの取得に失敗してcacheにfallbackしている
https://gyazo.com/7c9ff9ac3e104b742cffc7e21b8bc4f0
編集機能をdisableにするだけでOK
マウスホバーでのPrefetch
https://gyazo.com/cec09588ff9af551617ebb122d9a7373
リンクホバーのタイミングでServiceWorkerにprefetchを要請する
code: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}
})
}
ServiceWorkerではMessageEventをハンドル
code: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.ports0.postMessage({title: 'prefetch'}) }())
})
リンククリック時にキャッシュから返せて高速になる
ページ編集中
CacheStorage内のページデータをどうやって更新するか?
自分が編集したり、他のメンバーによって編集されたりと、ページは刻々と更新されていく
Offline modeで閲覧するページをなるべく最新のものにしたい
いま表示しているページデータをときどき再取得する
ServiceWorkerでsetIntervalを仕掛けている
更新したいページをキューに追加していき、定期的にfetchしてCacheを更新する
code: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.icon
その他の工夫
各デバイスに適したUI
Drawer menu
指でタッチすることを考えた高さのmenu item
https://gyazo.com/adb84bec5f2f7b99f1d568f5616db44d
Desktop PWAでのHistory backボタン
manifest.jsonでdisplay-mode: standaloneを指定していると
アドレスバーや戻るボタンなどが表示されない
アプリ内に代わりの自前のボタンを置くとよい
https://gyazo.com/e9f4e79980f0d9c2566fb772317f9c93/raw
standalone modeで表示されていることの判定
JS
window.matchMedia('(display-mode: standalone)').matches
https://gyazo.com/1f40fdecfb09f20641b74d12c19c6588/raw
CSS
media queryで判定できる
@media (display-mode: standalone) { }
Android版でのReload, Shareボタン
Reloadボタン
モバイルでは Cmd+R とかできないので用意しておくと安心
Web Share API
各種SNSに共有するためのネイティブUIを使える
code:js
// Window
const onClick = () => {
return navigator.share({
title: document.title,
url: location.href
})
}
https://gyazo.com/d730c80653f2aad3f9330993a94a9802 https://gyazo.com/9f0fbbe03b5d7bcbcc8382d254188a6d
まとめ
Cacheを活用して素早くコンテンツを表示
マウスホバーでPrefetchして遷移前にページデータを取得
環境に応じた適切なUI