PWAの機能(Service Worker、起動可能、通知など)も徐々に追加しているWebアプリ(AngularJSを使用して構築)があります。私たちのウェブアプリが持っている機能の1つは、オフラインでウェブフォームに記入する機能です。現時点では、オフライン時にIndexedDBにデータを保存し、オンラインになったらそのデータをサーバーにプッシュするようユーザーに促します(「このフォームはデバイスに保存されます。オンラインに戻ったので、保存する必要がありますクラウドに...」)。これはいつか自動的に行われますが、現時点では必要ありません。
これらのWebフォームに機能を追加します。これにより、ユーザーは、おそらくフォーム全体のいくつかのポイントで、ファイル(画像、ドキュメント)をフォームに添付できるようになります。
私の質問はこれです-ServiceWorkerがファイルのアップロードを処理する方法はありますか?どういうわけか-おそらく-オフライン時にアップロードするファイルへのパスを保存し、接続が復元されたらそのファイルをプッシュアップしますか?これらのデバイスでその「パス」にアクセスできるので、これはモバイルデバイスで機能しますか?任意のヘルプ、アドバイスまたは参照をいただければ幸いです。
ユーザーが<input type="file">
要素を介してファイルを選択すると、選択したファイルをfileInput.files
を介して取得できます。これにより、FileList
オブジェクトが得られ、その中の各アイテムは、選択したファイルを表すFile
オブジェクトになります。 FileList
とFile
は、HTML5の 構造化クローンアルゴリズム でサポートされています。
IndexedDBストアにアイテムを追加すると、格納されている値の構造化クローンが作成されます。 FileList
およびFile
オブジェクトは構造化クローンアルゴリズムでサポートされているため、これらのオブジェクトをIndexedDBに直接格納できることを意味します。
ユーザーが再びオンラインになったときにこれらのファイルのアップロードを実行するには、ServiceWorkerのバックグラウンド同期機能を使用できます。これが 紹介記事 その方法についてです。そのための他のリソースもたくさんあります。
バックグラウンド同期コードの実行後に添付ファイルをリクエストに含めることができるようにするには、 FormData
を使用できます。 FormData
sを使用すると、バックエンドに送信されるリクエストにFile
オブジェクトを追加でき、ServiceWorkerコンテキスト内から利用できます。
ファイルのアップロード/削除およびほとんどすべてを処理する1つの方法は、オフライン要求中に行われたすべての変更を追跡することです。 2つの配列を含むsync
オブジェクトを作成できます。1つはアップロードが必要な保留中のファイル用で、もう1つはオンラインに戻ったときに削除する必要がある削除済みファイル用です。
Service Worker fetch
イベントの処理で、フェッチが失敗した場合は、ファイルリストのリクエスト、サーバーにファイルをアップロードするリクエスト、サーバーからファイルを削除するリクエストを処理する必要があります。これらのリクエストがない場合は、デフォルトのキャッシュから一致するものを返します。
GET
/uploads
)およびsync
オブジェクト。 concat
ファイルを含むデフォルトのリストファイルをpending
し、deleted
ファイルを削除すると、サーバーが返すように、JSON結果を含む新しい応答オブジェクトが返されます。PUT
sync
pending
ファイルをキャッシュから取得します。ファイルが存在しない場合は、そのファイルの新しいキャッシュエントリを作成し、リクエストのmimeタイプとblob
を使用して、新しいResponse
オブジェクトを作成します。デフォルトのキャッシュに保存されます。DELETE
を削除しますpending
配列から削除します。それ以外の場合は、まだdeleted
配列にない場合は、追加します。最後に、リスト、ファイル、同期オブジェクトキャッシュを更新します。(インラインコメントをお読みください)
const cacheName = 'pwasndbx';
const syncCacheName = 'pwasndbx-sync';
const pendingName = '__pending';
const syncName = '__sync';
const filesToCache = [
'/',
'/uploads',
'/styles.css',
'/main.js',
'/utils.js',
'/favicon.ico',
'/manifest.json',
];
/* Start the service worker and cache all of the app's content */
self.addEventListener('install', function(e) {
console.log('SW:install');
e.waitUntil(Promise.all([
caches.open(cacheName).then(async function(cache) {
let cacheAdds = [];
try {
// Get all the files from the uploads listing
const res = await fetch('/uploads');
const { data = [] } = await res.json();
const files = data.map(f => `/uploads/${f}`);
// Cache all uploads files urls
cacheAdds.Push(cache.addAll(files));
} catch(err) {
console.warn('PWA:install:fetch(uploads):err', err);
}
// Also add our static files to the cache
cacheAdds.Push(cache.addAll(filesToCache));
return Promise.all(cacheAdds);
}),
// Create the sync cache object
caches.open(syncCacheName).then(cache => cache.put(syncName, jsonResponse({
pending: [], // For storing the penging files that later will be synced
deleted: [] // For storing the files that later will be deleted on sync
}))),
])
);
});
self.addEventListener('fetch', function(event) {
// Clone request so we can consume data later
const request = event.request.clone();
const { method, url, headers } = event.request;
event.respondWith(
fetch(event.request).catch(async function(err) {
const { headers, method, url } = event.request;
// A custom header that we set to indicate the requests come from our syncing method
// so we won't try to fetch anything from cache, we need syncing to be done on the server
const xSyncing = headers.get('X-Syncing');
if(xSyncing && xSyncing.length) {
return caches.match(event.request);
}
switch(method) {
case 'GET':
// Handle listing data for /uploads and return JSON response
break;
case 'PUT':
// Handle upload to cache and return success response
break;
case 'DELETE':
// Handle delete from cache and return success response
break;
}
// If we meet no specific criteria, then lookup to the cache
return caches.match(event.request);
})
);
});
function jsonResponse(data, status = 200) {
return new Response(data && JSON.stringify(data), {
status,
headers: {'Content-Type': 'application/json'}
});
}
GET
if(url.match(/\/uploads\/?$/)) { // Failed to get the uploads listing
// Get the uploads data from cache
const uploadsRes = await caches.match(event.request);
let { data: files = [] } = await uploadsRes.json();
// Get the sync data from cache
const syncRes = await caches.match(new Request(syncName), { cacheName: syncCacheName });
const sync = await syncRes.json();
// Return the files from uploads + pending files from sync - deleted files from sync
const data = files.concat(sync.pending).filter(f => sync.deleted.indexOf(f) < 0);
// Return a JSON response with the updated data
return jsonResponse({
success: true,
data
});
}
PUT
// Get our custom headers
const filename = headers.get('X-Filename');
const mimetype = headers.get('X-Mimetype');
if(filename && mimetype) {
// Get the uploads data from cache
const uploadsRes = await caches.match('/uploads', { cacheName });
let { data: files = [] } = await uploadsRes.json();
// Get the sync data from cache
const syncRes = await caches.match(new Request(syncName), { cacheName: syncCacheName });
const sync = await syncRes.json();
// If the file exists in the uploads or in the pendings, then return a 409 Conflict response
if(files.indexOf(filename) >= 0 || sync.pending.indexOf(filename) >= 0) {
return jsonResponse({ success: false }, 409);
}
caches.open(cacheName).then(async (cache) => {
// Write the file to the cache using the response we cloned at the beggining
const data = await request.blob();
cache.put(`/uploads/${filename}`, new Response(data, {
headers: { 'Content-Type': mimetype }
}));
// Write the updated files data to the uploads cache
cache.put('/uploads', jsonResponse({ success: true, data: files }));
});
// Add the file to the sync pending data and update the sync cache object
sync.pending.Push(filename);
caches.open(syncCacheName).then(cache => cache.put(new Request(syncName), jsonResponse(sync)));
// Return a success response with fromSw set to tru so we know this response came from service worker
return jsonResponse({ success: true, fromSw: true });
}
DELETE
の削除// Get our custom headers
const filename = headers.get('X-Filename');
if(filename) {
// Get the uploads data from cache
const uploadsRes = await caches.match('/uploads', { cacheName });
let { data: files = [] } = await uploadsRes.json();
// Get the sync data from cache
const syncRes = await caches.match(new Request(syncName), { cacheName: syncCacheName });
const sync = await syncRes.json();
// Check if the file is already pending or deleted
const pendingIndex = sync.pending.indexOf(filename);
const uploadsIndex = files.indexOf(filename);
if(pendingIndex >= 0) {
// If it's pending, then remove it from pending sync data
sync.pending.splice(pendingIndex, 1);
} else if(sync.deleted.indexOf(filename) < 0) {
// If it's not in pending and not already in sync for deleting,
// then add it for delete when we'll sync with the server
sync.deleted.Push(filename);
}
// Update the sync cache
caches.open(syncCacheName).then(cache => cache.put(new Request(syncName), jsonResponse(sync)));
// If the file is in the uplods data
if(uploadsIndex >= 0) {
// Updates the uploads data
files.splice(uploadsIndex, 1);
caches.open(cacheName).then(async (cache) => {
// Remove the file from the cache
cache.delete(`/uploads/${filename}`);
// Update the uploads data cache
cache.put('/uploads', jsonResponse({ success: true, data: files }));
});
}
// Return a JSON success response
return jsonResponse({ success: true });
}
// Get the sync data from cache
const syncRes = await caches.match(new Request(syncName), { cacheName: syncCacheName });
const sync = await syncRes.json();
// If the are pending files send them to the server
if(sync.pending && sync.pending.length) {
sync.pending.forEach(async (file) => {
const url = `/uploads/${file}`;
const fileRes = await caches.match(url);
const data = await fileRes.blob();
fetch(url, {
method: 'PUT',
headers: {
'X-Filename': file,
'X-Syncing': 'syncing' // Tell SW fetch that we are synching so to ignore this fetch
},
body: data
}).catch(err => console.log('sync:pending:PUT:err', file, err));
});
}
// If the are deleted files send delete request to the server
if(sync.deleted && sync.deleted.length) {
sync.deleted.forEach(async (file) => {
const url = `/uploads/${file}`;
fetch(url, {
method: 'DELETE',
headers: {
'X-Filename': file,
'X-Syncing': 'syncing' // Tell SW fetch that we are synching so to ignore this fetch
}
}).catch(err => console.log('sync:deleted:DELETE:err', file, err));
});
}
// Update and reset the sync cache object
caches.open(syncCacheName).then(cache => cache.put(syncName, jsonResponse({
pending: [],
deleted: []
})));
これらすべてを実装するPWAの例を作成しました。これを見つけて、テストすることができます ここ 。 ChromeとFirefoxを使用し、モバイルデバイスでFirefox Androidを使用してテストしました。
アプリケーションの完全なソースコード(express
サーバーを含む)は、次のGithubリポジトリにあります: https://github.com/ clytras/pwa-sandbox 。
Cache APIは、サーバーからWebページのコンテンツをキャッシュするために、要求(キーとして)と応答(値として)を格納するように設計されています。ここでは、サーバーへの将来のディスパッチのためにユーザー入力をキャッシュすることについて話します。言い換えれば、キャッシュではなくメッセージブローカーを実装しようとしていますが、それは現在、によって処理されているものではありません。 Service Worker仕様( ソース )。
あなたはこのコードを試すことによってそれを理解することができます:
HTML:
_<button id="get">GET</button>
<button id="post">POST</button>
<button id="put">PUT</button>
<button id="patch">PATCH</button>
_
JavaScript:
_if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js', { scope: '/' }).then(function (reg) {
console.log('Registration succeeded. Scope is ' + reg.scope);
}).catch(function (error) {
console.log('Registration failed with ' + error);
});
};
document.getElementById('get').addEventListener('click', async function () {
console.log('Response: ', await fetch('50x.html'));
});
document.getElementById('post').addEventListener('click', async function () {
console.log('Response: ', await fetch('50x.html', { method: 'POST' }));
});
document.getElementById('put').addEventListener('click', async function () {
console.log('Response: ', await fetch('50x.html', { method: 'PUT' }));
});
document.getElementById('patch').addEventListener('click', async function () {
console.log('Response: ', await fetch('50x.html', { method: 'PATCH' }));
});
_
サービスワーカー:
_self.addEventListener('fetch', function (event) {
var response;
event.respondWith(fetch(event.request).then(function (r) {
response = r;
caches.open('v1').then(function (cache) {
cache.put(event.request, response);
}).catch(e => console.error(e));
return response.clone();
}));
});
_
スローするもの:
TypeError:リクエストメソッド「POST」はサポートされていません
TypeError:リクエストメソッド「PUT」はサポートされていません
TypeError:リクエストメソッド「PATCH」はサポートされていません
Cache APIは使用できないため、 Googleガイドライン に従うと、IndexedDBは進行中のリクエストのデータストアとして最適なソリューションです。次に、メッセージブローカーの実装は開発者の責任であり、すべてのユースケースをカバーする独自の汎用実装はありません。解決策を決定する多くのパラメータがあります:
window.navigator.onLine
_?特定のタイムアウト?その他?self.addEventListener('online', ...)
? _navigator.connection
_?これは、単一のStackOverflowの回答に対して非常に広範囲です。
そうは言っても、これが最小限の実用的な解決策です:
HTML:
_<input id="file" type="file">
<button id="sync">SYNC</button>
<button id="get">GET</button>
_
JavaScript:
_if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js', { scope: '/' }).then(function (reg) {
console.log('Registration succeeded. Scope is ' + reg.scope);
}).catch(function (error) {
console.log('Registration failed with ' + error);
});
};
document.getElementById('get').addEventListener('click', async function () {
fetch('api');
});
document.getElementById('file').addEventListener('change', function () {
fetch('api', { method: 'PUT', body: document.getElementById('file').files[0] });
});
document.getElementById('sync').addEventListener('click', async function () {
navigator.serviceWorker.controller.postMessage('sync');
});
_
サービスワーカー:
_self.importScripts('https://unpkg.com/[email protected]/build/iife/index-min.js');
const { openDB, deleteDB, wrap, unwrap } = idb;
const dbPromise = openDB('put-store', 1, {
upgrade(db) {
db.createObjectStore('put');
},
});
const idbKeyval = {
async get(key) {
return (await dbPromise).get('put', key);
},
async set(key, val) {
return (await dbPromise).put('put', val, key);
},
async delete(key) {
return (await dbPromise).delete('put', key);
},
async clear() {
return (await dbPromise).clear('put');
},
async keys() {
return (await dbPromise).getAllKeys('put');
},
};
self.addEventListener('fetch', function (event) {
if (event.request.method === 'PUT') {
let body;
event.respondWith(event.request.blob().then(file => {
// Retrieve the body then clone the request, to avoid "body already used" errors
body = file;
return fetch(new Request(event.request.url, { method: event.request.method, body }));
}).then(response => handleResult(response, event, body)).catch(() => handleResult(null, event, body)));
} else if (event.request.method === 'GET') {
event.respondWith(fetch(event.request).then(response => {
return response.ok ? response : caches.match(event.request);
}).catch(() => caches.match(event.request)));
}
});
async function handleResult(response, event, body) {
const getRequest = new Request(event.request.url, { method: 'GET' });
const cache = await caches.open('v1');
await idbKeyval.set(event.request.method + '.' + event.request.url, { url: event.request.url, method: event.request.method, body });
const returnResponse = response && response.ok ? response : new Response(body);
cache.put(getRequest, returnResponse.clone());
return returnResponse;
}
// Function to call when the network is supposed to be available
async function sync() {
const keys = await idbKeyval.keys();
for (const key of keys) {
try {
const { url, method, body } = await idbKeyval.get(key);
const response = await fetch(url, { method, body });
if (response && response.ok)
await idbKeyval.delete(key);
}
catch (e) {
console.warn(`An error occurred while trying to sync the request: ${key}`, e);
}
}
}
self.addEventListener('message', sync);
_
ソリューションに関するいくつかの言葉:将来のGET要求のためにPUT要求をキャッシュすることができ、将来の同期のためにPUT要求をIndexedDBデータベースに格納することもできます。キーについては、Angularの TransferHttpCacheInterceptor に触発されました。これにより、サーバー側のレンダリングされたページでバックエンドリクエストをシリアル化して、ブラウザーでレンダリングされたページで使用できるようになります。キーとして_<verb>.<url>
_を使用します。これは、リクエストが同じ動詞とURLを持つ別のリクエストをオーバーライドすると想定しています。
このソリューションでは、バックエンドがPUT要求の応答として_204 No content
_を返すのではなく、本体にエンティティを含む_200
_を返すことも想定しています。