离线时如何使用Service Worker处理文件上传

25
我们有一个使用AngularJS构建的Web应用程序,正在逐步向其中添加PWA“功能”(服务工作者、可启动、通知等)。我们Web应用程序的其中一个功能是能够在离线状态下完成Web表单。目前,我们在离线时将数据存储在IndexedDB中,并鼓励用户在上线后将该数据推送到服务器(“此表单已保存到您的设备。现在您已经联网,请将它保存到云端...”)。我们将在某个时候自动执行此操作,但目前不必要。
我们正在为这些Web表单添加一个新功能,即用户可以将文件(图像、文档)附加到表单中,并且可能需要在表单的几个点上进行此操作。
我的问题是:服务工作者是否有一种方法来处理文件上传?当离线时,是否有一种方式——也许是存储要上传的文件路径——并在连接恢复后将该文件上传?这是否适用于移动设备,因为我们是否可以在这些设备上访问该“路径”?如有任何帮助、建议或参考资料,将不胜感激。

1
你可能想要查看这个文档,了解如何处理大部分的请求,如果你正在开发离线优先的应用。其他模式将基于传入请求的例外情况。此外,如果你对离线存储有兴趣,可以查看这个文档 - abielita
4个回答

14

处理文件上传/删除以及几乎所有其他问题的一种方法是通过跟踪离线请求期间所做的所有更改。我们可以创建一个名为sync的对象,其中包含两个数组,一个用于需要上传的待处理文件,另一个用于在联机时需要删除的文件。

简而言之

通过跟踪离线请求期间所做的所有更改来处理文件上传/删除以及几乎所有其他问题。

关键阶段


  1. Service Worker安装


    • 除了静态数据,我们还确保获取动态数据作为上传文件的主要列表(在示例中,/uploads GET返回JSON格式的文件数据)。

      Service Worker Install

  2. Service Worker获取


    • 处理服务工作者的fetch事件,如果获取失败,则需要处理请求文件列表、上传文件到服务器的请求以及从服务器删除文件的请求。如果没有这些请求,则从默认缓存中返回匹配项。

      • 列表GET
        我们获取列表(在我们的情况下是/uploads)和sync对象的缓存对象。我们使用concat将默认的列表文件与pending文件合并,并删除deleted文件,然后返回一个新的响应对象,其中包含JSON格式的结果,就像服务器返回的那样。
      • 上传PUT
        我们从缓存中获取已缓存的列表文件和syncpending文件。如果文件不存在,则为该文件创建一个新的缓存条目,并使用请求中的mime类型和blob创建一个新的Response对象,该对象将保存到默认缓存中。
      • 删除DELETE
        我们检查已上传的文件,如果该文件存在,则从列表数组和缓存文件中删除该条目。如果文件正在等待处理,则只需从pending数组中删除该条目;否则,如果它尚未在deleted数组中,则添加它。最后更新列表、文件和同步对象缓存。

      Service Worker Fetch

  3. 同步


    • 当触发online事件时,我们尝试与服务器同步。我们读取sync缓存。

      • 如果有待处理的文件,则从缓存中获取每个文件的Response对象,并向服务器发送一个PUT fetch请求。
      • 如果有已删除的文件,则向服务器发送每个文件的DELETE fetch请求。
      • 最后,重置sync缓存对象。

      Synching to server

代码实现


(Please read the inline comments)

Service Worker Install

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
    }))),
  ])
  );
});

Service Worker Fetch

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'}
  });
}

Service Worker Fetch Listing 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
  });
}

Service Worker Fetch Uloading 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 });
}

Service Worker Fetch Deleting 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 });
}

Synching

// 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: []
})));

Example PWA


I have created a PWA example that implements all these, which you can find and test here. I have tested it using Chrome and Firefox and using Firefox Android on a mobile device.

You can find the full source code of the application (including an express server) in this Github repository: https://github.com/clytras/pwa-sandbox.


谢谢你的解决方案!非常感谢你的努力。我将实际授予赏金给你的答案,并为另一个答案开始另一个奖励,因为我认为它们都提供了一些很好的起点。有一个问题想问你...你使用过后台同步API吗?它很容易添加到你的示例中,但我对它的限制很好奇。在我的情况下,我将上传相当大的文件(20MB至100MB),我想知道时间限制等等。 - Brad
@Brad 我已经检查了 sync API,它没有任何支持通过同步注册传递自定义数据的功能。有 fetch-sync 可以使用,你也可以查看这个 Q/A Pass custom data to service worker sync?。相同的逻辑可以使用不同的方法,也许直接将文件放入缓存,然后为每个请求注册一个同步事件。 - Christos Lytras
对于大文件大小,不能保证文件会被上传,因此在doSync的逻辑中可能需要进行更改,只有在fetch promise解析时才从pending数组中删除文件,而不是在其被拒绝时删除;deleted数组同理。当然,必须在某个初始点调用doSync以检查是否存在来自某些失败的同步请求的待处理文件,即使在开始时进行了sync注册也可以完成这一操作。 - Christos Lytras

13
当用户通过<input type="file">元素选择文件时,我们可以通过fileInput.files获取所选文件。 这给我们一个FileList对象,其中每个条目都是代表所选文件的File对象。 FileListFile是HTML5的结构化克隆算法支持的对象类型之一。
在向IndexedDB存储添加项目时,它会创建正在存储值的结构化克隆。由于FileListFile对象受到结构化克隆算法的支持,这意味着我们可以直接将这些对象存储在IndexedDB中。
为了在用户再次联机时执行这些文件上传操作,可以使用服务工作者的后台同步功能。以下是关于如何实现此功能的介绍性文章。还有很多其他相关资源可供参考。
为了能够在后台同步代码运行时将文件附件包含在请求中,可以使用FormDataFormData允许将File对象添加到将发送到后端的请求中,并且可以在服务工作者上下文中使用。

1
使用它可以上传和离线存储多少照片(或最大尺寸)? - John
1
但我想要的行为是,当文件等待上传时,对URL的常规获取将从服务工作器缓存中获取文件,以便使用可能已上传或未上传的文件的客户端代码对其是否实际上已上传都不关心。 - user9315861

5

Cache API旨在以请求为键和响应为值的形式存储内容,以便为网页缓存来自服务器的内容。在这里,我们谈论的是缓存用户输入以便将来发送到服务器。换句话说,我们不是在尝试实现缓存,而是在实现消息代理,而这目前不是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();
    }));
});

抛出以下错误:

类型错误:不支持请求方法“POST”

类型错误:不支持请求方法“PUT”

类型错误:不支持请求方法“PATCH”

由于无法使用缓存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', function () {
  fetch('api');
});

document.getElementById('file').addEventListener('change', function () {
  fetch('api', { method: 'PUT', body: document.getElementById('file').files[0] });
});

document.getElementById('sync').addEventListener('click', function () {
  navigator.serviceWorker.controller.postMessage('sync');
});

服务工作者:

self.importScripts('https://unpkg.com/idb@5.0.1/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);

关于解决方案的说明:它允许将PUT请求进行缓存以供未来的GET请求使用,同时还将PUT请求存储到IndexedDB数据库中以供未来同步使用。关于键(key),我受到了Angular的TransferHttpCacheInterceptor的启发,该拦截器允许在服务器端呈现的页面上对后端请求进行序列化,以供浏览器呈现的页面使用。它使用<verb>.<url>作为键。这意味着一个请求将覆盖具有相同动词和URL的另一个请求。
此解决方案还假设后端不会返回204无内容,而是返回带有实体主体的200响应。

1
非常感谢您的实验和解释。然而,我仍然对缓存API感到困惑。我们不能像这样做吗:cache.put(event.request.url, new Response(event.request.body))?换句话说,将请求体(文件)缓存起来,以便通过创建一个新的响应来为未来的GET请求提供服务。至于您提到的其他问题,您是正确的,但我认为它们不必在回答这个问题的目的上进行讨论。示例代码可以尽可能简单地工作。 - Brad
@ChristosLytras 实际上,这些错误与我的后端无关,而与缓存实现有关:https://chromium.googlesource.com/chromium/src/+/781c15c373baee26d5447ff9157370411f61b18f/third_party/blink/renderer/modules/cache_storage/cache.cc#712 在您的代码中,您没有缓存“PUT”和“DELETE”请求,而是缓存“GET”请求。 - Guerric P
@Brad,你太好了。我很高兴它有帮助,这是一个非常有趣的主题! - Guerric P
@GuerricP 我们在这里讨论。fetch API 支持所有方法这一事实不是我的观点,而是 fetch 的工作方式。我的观点是,你根本不需要使用 IDBDatabase 来处理 GETDELETEPATCH,只需使用 Cache API 即可完成。 - Christos Lytras
让我们来讨论一下,但在发布之前请先验证您的声明。将请求作为 cache.put 的第一个参数传递时,它们被设置为 GET 请求,因为目的是在此之后使用 caches.match 并提供相应的响应。所以,是的,您的代码可以工作,因为您为每种 HTTP 方法创建一个缓存,并使用自己的代码解释缓存中的内容,但这是以一种并非意图使用缓存 API 的方式进行的。 - Guerric P
显示剩余5条评论

0

最近我也遇到了这个问题。以下是我正在做的存储在索引数据库中并在离线时返回响应的方法。

const storeFileAndReturnResponse = async function (request, urlSearchParams) {
  let requestClone = request.clone();

  let formData = await requestClone.formData();

  let tableStore = "fileUploads";

  let fileList = [];
  let formDataToStore = [];
  //Use formData.entries to iterate collection - this assumes you used input type= file
  for (const pair of formData.entries()) {
    let fileObjectUploaded = pair[1];
    //content holds the arrayBuffer (blob) of the uploaded file
    formDataToStore.push({
      key: pair[0],
      value: fileObjectUploaded,
      content: await fileObjectUploaded.arrayBuffer(),
    });

    let fileName = fileObjectUploaded.name;
    fileList.push({
      fileName: fileName,
    });
  }

  let payloadToStore = {
    parentId: parentId,
    fileList: fileList,
    formDataKeyValue: formDataToStore,
  };
  (await idbContext).put(tableStore, payloadToStore);

  return {
    UploadedFileList: fileList,
  };
};

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接