Kiwix PWA 如何允许用户存储来自互联网的 GB 级数据以供离线使用

Geoffrey Kantaris
Geoffrey Kantaris
Stéphane Coillet-Matillon
Stéphane Coillet-Matillon

人们聚在一起,围着笔记本电脑站在一张简单的桌子上,桌上放着一把塑料椅子。背景看起来像是某个发展中国家/地区的学校。

本案例研究探讨了非营利组织 Kiwix 如何使用 Progressive Web 应用技术和 File System Access API,使用户能够下载 并存储大型互联网存档以供离线使用。了解 处理原始私有文件系统 (OPFS) 的代码实现, Kiwix PWA 中的全新浏览器功能可增强文件管理、 让用户无需请求权限即可访问归档内容。文章 探讨了这一新领域面临的挑战,并强调了未来 文件系统

Kiwix 简介

在网络诞生 30 多年后, 全球三分之一的人口仍在等待可靠的互联网接入 (根据国际电信联盟准则)。故事就是这里 是否结束?当然不是。来自瑞士的 Kiwix 团队 建立了一个由开源应用和内容组成的生态系统, 旨在为无法访问互联网或无法访问互联网的人们提供知识。 他们的想法是,如果无法轻松访问互联网,那么在有网络连接的地方和时间,有人可以为您下载关键资源,并将其存储在本地,以供日后离线使用。例如,许多重要网站 维基百科、古腾堡项目、Stack Exchange 甚至是 TED 演讲现在都可以 并将其转换为高度压缩的存档(称为 ZIM 文件),并可即时读取 使用 Kiwix 浏览器。

ZIM 归档使用高效的 Zstandard (ZSTD) 压缩(旧版本使用 XZ),主要 用于存储 HTML、JavaScript 和 CSS,而图片通常 压缩的 WebP 格式。每个 ZIM 还包含网址和标题索引。压缩是关键,因为整个英文版维基百科(640 万篇文章以及图片)在转换为 ZIM 格式后被压缩到了 97 GB,这听起来似乎很多,但当您意识到人类所有知识的总和现在可以装在一款中档 Android 手机上时,就会觉得这并不多。还提供了许多较小的资源,包括 维基百科,例如数学、医学等。

Kiwix 提供了一系列原生应用, 桌面设备 (Windows/Linux/macOS) 和移动设备 (iOS/Android) 的使用情况。此案例 将重点介绍渐进式 Web 应用 (PWA), 一款通用、简单易用的解决方案,适用于配备现代浏览器的任何设备。

我们将了解在开发符合以下特征的通用 Web 应用时所面临的挑战: 需要让用户能够快速访问完全离线的大型内容归档,而一些 现代 JavaScript API,尤其是 File System Access API源私有文件系统、 ,为这些挑战提供令人兴奋的创新解决方案。

一款可离线使用的 Web 应用?

Kiwix 用户具有多种不同的需求,而 Kiwix 对用户访问内容所用的设备和操作系统几乎没有控制权。其中有些设备可能运行缓慢或已过时 尤其是在世界低收入地区。Kiwix 会尽量涵盖 组织还认识到,通过使用类似应用, 在任意设备上使用最通用的软件: 网络浏览器。灵感来自于 阿特伍德定律, 声明,任何能用 JavaScript 编写的应用 最终会采用 JavaScript 编写,一些 Kiwix 开发者在大约 10 年前将 将 Kiwix 软件从 C++ 移植到 JavaScript。

这个端口的首个版本叫做 Kiwix HTML5,目前已经不存在 Firefox 操作系统和浏览器扩展程序。其核心是(现在是)C++ 解压缩引擎(XZ 和 ZSTD)编译到中间 JavaScript ASM.js 语言,后来又成为 Wasm 或 WebAssembly, 使用 Emscripten 编译器。后来更名为 Kiwix JS,但浏览器扩展程序仍然 积极开发。

Kiwix JS 离线浏览器

输入 渐进式 Web 应用 (PWA)。在发挥这项技术的潜力后,Kiwix 开发者构建了 Kiwix JS 的专用 PWA 版本,并开始添加 操作系统集成,让 应用提供类似于本机应用的功能,尤其是在离线区域 使用、安装、文件处理和文件系统访问。

离线优先 PWA 非常轻巧,因此非常适合情境 因为移动网络是断断续续或费用高昂的。背后的技术 这是 Service Worker API 以及相关的 Cache API,供所有用户使用 构建基于 Kiwix JS 的应用。这些 API 允许应用充当服务器 拦截 提取请求 将用户重定向到 (JS) 后端从 ZIM 存档中提取和构建响应。

海量存储空间,无处不在

鉴于 ZIM 归档文件的大小,存储和访问这些文件(尤其是在移动设备上)可能是 Kiwix 开发者的最大难题。很多 Kiwix 最终用户会在连接到互联网时在应用内下载内容,以供日后观看 离线使用。其他用户使用 Torrent 内容在 PC 上下载,然后转移到 移动设备或平板电脑,还有一些通过 U 盘或便携式设备交换内容 在移动互联网不稳定或费用高昂的地区使用硬盘。所有这些方式 需要支持从用户可访问的任意位置访问内容 由 Kiwix JS 和 Kiwix PWA 开发。

最初让 Kiwix JS 能够读取大量归档、 数百 GB ( 的 ZIM 存档大小为 166 GB!)即使在低内存设备上, File API。任何浏览器(包括非常旧的浏览器)都支持此 API,因此它可用作通用回退,以便在浏览器不支持较新 API 时使用。就像 就像在 HTML 中定义 input 元素一样简单,像 Kiwix 一样:

<input
  type="file"
  accept="application/octet-stream,.zim,.zimaa,.zimab,.zimac, ..."
  value="Select folder with ZIM files"
  id="archiveFilesLegacy"
  multiple
/>

选择后,输入元素将包含 File 对象,这些对象本质上是 引用存储中底层数据的元数据。从技术角度来讲,Kiwix's 面向对象的后端,使用纯客户端 JavaScript 编写, 根据需要拆分大型归档的各个部分。如果这些 Slices 需要 后端将其传递到 Wasm 解压缩器, 切片(如果请求),直到解压缩完整的 blob(通常是文章或 资源)。也就是说,无需将大型归档内容 内存。

File API 是通用的,但有一个缺点, (与原生应用相比)更加笨拙和过时: 使用文件选择器进行归档,或者 拖放功能 每次应用启动时,系统都会将一个文件导入应用,因为使用 因此无法在一个会话之间保留访问权限。

与许多开发者一样,为了减轻这种糟糕的用户体验,Kiwix JS 开发者最初 ElectronJS 是一个 令人惊叹的框架,它提供了强大的功能,包括对 使用 Node API 构建文件系统。不过,这种方法也存在一些众所周知的缺点:

  • 它只能在桌面操作系统上运行。
  • 文件又大又厚 (70MB–100MB)。

由于每个 Electron 应用都包含 Chromium 的完整副本,因此与最小化且捆绑的 PWA 的仅 5.1 MB 相比,Electron 应用的大小相差甚远!

那么,Kiwix 是否可以通过某种方法为 PWA 用户改善这种情况?

File System Access API 就能派上用场

2019 年左右,Kiwix 意识到一个崭露头角的 API, 在 Chrome 78 中试用,然后调用 Native File System API。它承诺能够获取文件或文件夹的文件句柄,并将其存储在 IndexedDB 数据库中。至关重要的是,此句柄会在应用会话之间保持不变 不会强制在重新启动应用时再次选择相关文件或文件夹(不过 他们必须快速给出权限提示)。到达目的地时 我们已将其更名为 File System Access API,该 API 的核心是 部分被 WHATWG 标准化为 File System API (FSA)。

那么, 文件系统访问权限 部分工作?需要注意的几个要点:

  • 它是一个异步 API(Web Worker 中的专用函数除外)。
  • 文件或目录选择器必须以编程方式启动, 捕获用户手势(点击或点按界面元素)。
  • 如果用户需要再次授予访问之前选择的文件的权限(在 新会话),那么也需要用户手势。实际上,浏览器会拒绝 在用户手势发起时,显示权限提示。

代码相对简单,除了必须使用复杂的 IndexedDB API 来存储文件和目录句柄。好消息是 可为您处理大量繁杂工作的库,例如 browser-fs-access: 在 Kiwix JS 团队,我们决定直接与 API 合作, 记录。

打开文件和目录选择器

打开文件选择器的效果如下所示(此处使用了 Promise,但如果您更喜欢 async/await 糖衣,请参阅 Chrome for Developers 教程):

return window
  .showOpenFilePicker({ multiple: false })
  .then(function (fileHandles) {
    return processFileHandle(fileHandles[0]);
  })
  .catch(function (err) {
    // This is normal if app is launching
    console.warn(
      'User cancelled, or cannot access fs without user gesture',
      err,
    );
  });

请注意,为简单起见,此代码仅处理第一个 文件(并禁止选择多个文件)。如果您想允许选择 多个文件具有 { multiple: true },只需将所有 promise 封装起来即可 处理 Promise.all().then(...) 语句中的每个句柄,例如:

let promisesForFiles = fileHandles.map(function (fileHandle) {
    return processFileHandle(fileHandle);
});
return Promise.all(promisesForFiles).then(function (arrayOfFiles) {
    // Do something with the files array
    console.log(arrayOfFiles);
}).catch(function (err) {
    // Handle any errors that occurred during processing
    console.error('Error processing file handles!', err);
)};

但是,选择多个文件无疑是让用户 请选择包含这些文件的目录,而不是 特别是考虑到 Kiwix 用户往往会将所有 ZIM 文件整理到 同一目录中。用于启动目录选择器的代码几乎如下所示: 除使用 window.showDirectoryPicker.then(function (dirHandle) { … });

处理文件或目录句柄

获得句柄后,您需要对其进行处理,因此函数 processFileHandle 可能如下所示:

function processFileHandle(fileHandle) {
  // Serialize fileHandle to indexedDB
  serializeFSHandletoIdxDB('pickedFSHandle', fileHandle, function (val) {
    console.debug('IndexedDB responded with ' + val);
  });
  return fileHandle.getFile().then(function (file) {
    // Do something with the file
    return file;
  });
}

请注意,您必须提供存储文件句柄的函数, 没有便捷方法来实现此操作,除非您使用抽象库。Kiwix 的 其实现过程可在 cache.js、 但如果它仅用于存储和检索 文件或文件夹句柄。

处理目录稍微复杂一些,因为您必须对 使用异步 entries.next() 通过选定目录中的条目来查找 或文件类型实现这一目标的方法有很多 以下是 Kiwix PWA 中使用的代码,大致如下所示:

let iterableEntryList = dirHandle.entries();
return iterateAsyncDirEntries(iterableEntryList, []).then(function (entryList) {
  // Do something with the entry list
  return entryList;
});

/**
 * Iterates FileSystemDirectoryHandle iterator and adds entries to an array
 * @param {Iterator} entries An asynchronous iterator of entries
 * @param {Array} archives An array to which to add the entries (may be empty)
 * @return {Promise<Array>} A Promise for an array of entries in the directory
 */
function iterateAsyncDirEntries(entries, archives) {
  return entries
    .next()
    .then(function (result) {
      if (!result.done) {
        let entry = result.value[1];
        // Filter for the files you want
        if (/\.zim(\w\w)?$/i.test(entry.name)) {
          archives.push(entry);
        }
        return iterateAsyncDirEntryArray(entries, archives);
      } else {
        // We've processed all the entries
        if (!archives.length) {
          console.warn('No archives found in the picked directory!');
        }
        return archives;
      }
    })
    .catch(function (err) {
      console.error('There was an error processing the directory!', err);
    });
}

请注意,对于 entryList 中的每个条目,您稍后需要获取相应文件 entry.getFile().then(function (file) { … })(如果需要使用的话),或者 在const file = await entry.getFile() async function

我们还能进一步吗?

由用户发起的授予权限请求 手势会给应用后续启动带来一小段阻力 文件和文件夹(重新打开),但操作起来要比强制打开灵活得多 重新选择一个文件。Chromium 开发者目前 完成代码 为已安装的 PWA 提供永久性权限的权限。这是 这也是许多 PWA 开发者所呼吁的,并且 符合预期。

但如果我们不必等待呢?Kiwix 开发者最近发现, 来消除所有权限提示 功能(受 Chromium 和 Firefox 支持) (受 Safari 部分支持,但仍 缺少 FileSystemWritableFileStream)。 这项新功能是源私有文件系统

全面采用原生文件系统:Origin 私有文件系统

通过 源私有文件系统 (OPFS) 仍是 Kiwix PWA 中的一项实验性功能, 团队非常高兴能够鼓励 因为它在很大程度上弥合了原生应用和 。其主要优势如下:

  • 访问 OPFS 中的归档无需权限提示即可访问,即使 。用户可以从上一个会话中中断的地方继续阅读文章和浏览归档内容,完全不会遇到任何阻碍。
  • 它提供了对存储在其中文件的高度优化的访问权限:在 Android 上,我们 速度提升到原来的 5 到 10 倍。

在 Android 中使用 File API 进行标准文件访问速度非常慢,尤其是当大型归档文件存储在 microSD 卡上而不是设备存储空间中时(Kiwix 用户通常会遇到这种情况)。这一切都将随着这个新 API 而改变。虽然大多数用户无法在 OPFS 中存储 97 GB 的文件, 设备存储,而不是 microSD 卡存储),则非常适合用于存储小到 中等大小的归档文件。您想要 最完整的医学百科全书 WikiProject Medicine 吗?没问题,1.7 GB 的大小很容易放入 OPFS 中!(提示:在应用内库中,依次选择其他mdwiki_en_all_maxi。)

OPFS 的运作方式

OPFS 是浏览器提供的文件系统,每个来源都有单独的文件系统,可以视为类似于 Android 上的应用级存储空间。文件可以 从用户可见的文件系统导入 OPFS 中导入,也可以 (该 API 还允许在 OPFS 中创建文件)。 进入 OPFS 后,这些数据会与设备的其余部分隔离。在基于 Chromium 的桌面浏览器中,还可以将文件从 OPFS 导出回可供用户查看的文件系统。

如需使用 OPFS,第一步是使用 navigator.storage.getDirectory() 请求对其的访问权限(再次提醒,如果您更希望查看使用 await 的代码,请参阅源私有文件系统):

return navigator.storage
  .getDirectory()
  .then(function (handle) {
    return processDirHandle(handle);
  })
  .catch(function (err) {
    console.warn('Unable to get the OPFS directory entry', err);
  });

您从中获得的标识名是 window.showDirectoryPicker()为您提供的 FileSystemDirectoryHandle 也就是说,您可以重复使用处理该任务的代码(以及 幸运的是,无需将其存储在 indexedDB 中,只需在 )。假设 OPFS 中已经有一些文件, 然后使用之前显示的函数 iterateAsyncDirEntries() 您可以执行如下操作:

return navigator.storage.getDirectory().then(function (dirHandle) {
  let entries = dirHandle.entries();
  return iterateAsyncDirEntries(entries, [])
    .then(function (archiveList) {
      return archiveList;
    })
    .catch(function (err) {
      console.error('Unable to iterate OPFS entries', err);
    });
});

请注意,对于任何要处理的条目,您仍需使用 getFile() 替换为 archiveList 数组中的 。

将文件导入 OPFS

那么,首先要如何将文件导入 OPFS 呢?别着急!首先, 您需要估算必须使用的存储空间 确保用户不会尝试放入 97 GB 的文件。

获取估算配额很简单: navigator.storage.estimate().then(function (estimate) { … });。有点难 正在研究如何向用户显示此内容在 Kiwix 应用中,我们选择了 复选框旁边会显示应用内小面板 OPFS:

显示已用存储空间百分比和剩余可用存储空间(以 GB 为单位)的面板。

该面板是使用 estimate.quotaestimate.usage, 例如:

let OPFSQuota; // Global variable, so we don't have to keep checking it
return navigator.storage.estimate().then(function (estimate) {
  const percent = ((estimate.usage / estimate.quota) * 100).toFixed(2);
  OPFSQuota = estimate.quota - estimate.usage;
  document.getElementById('OPFSQuota').innerHTML =
    '<b>OPFS storage quota:</b><br />Used:&nbsp;<b>' +
    percent +
    '%</b>; ' +
    'Remaining:&nbsp;<b>' +
    (OPFSQuota / 1024 / 1024 / 1024).toFixed(2) +
    '&nbsp;GB</b>';
});

如您所见,还有一个按钮 用户可见的文件系统。好消息是,您只需使用 File API 即可获取要导入的所需 File 对象(或对象)。事实上 切勿使用 window.showOpenFilePicker(),因为此方法 而 Firefox 则不支持 OPFS

您在上方屏幕截图中看到的 Add file(s) 按钮不是旧版 但它会click()隐藏旧版选择器 (<input type="file" multiple … /> 元素)。通过 然后,应用仅捕获隐藏文件输入的 change 事件, 文件的大小;如果文件太大,超出配额,则会拒绝。如果一切顺利,请询问用户是否要添加这些内容:

archiveFilesLegacy.addEventListener('change', function (files) {
  const filesArray = Array.from(files.target.files);
  // Abort if user didn't select any files
  if (filesArray.length === 0) return;
  // Calculate the size of the picked files
  let filesSize = 0;
  filesArray.forEach(function (file) {
    filesSize += file.size;
  });
  // Check the size of the files does not exceed the quota
  if (filesSize > OPFSQuota) {
    // Oh no, files are too big! Tell user...
    console.log('Files would exceed the OPFS quota!');
  } else {
    // Ask user if they're sure... if user said yes...
    return importOPFSEntries(filesArray)
      .then(function () {
        // Tell user we successfully imported the archives
      })
      .catch(function (err) {
        // Tell user there was an error (error catching is important!)
      });
  }
});

询问用户是否要将 .zim 文件列表添加到源专用文件系统的对话框。

由于在某些操作系统(例如 Android)上,导入归档文件的速度并不快,因此在导入归档文件时,Kiwix 还会显示横幅和一个小旋转图标。团队不知道如何添加进度 此操作的指示标志:如果你搞定了,请拍下明信片上的答案吧!

那么,Kiwix 是如何实现 importOPFSEntries() 函数的?这涉及使用 fileHandle.createWriteable() 方法,该方法可有效地将每个文件流式传输到 OPFS。所有繁重工作都由浏览器处理。(Kiwix 在此处使用 Promise 是出于对我们的 但必须说的是,在本例中,await 会生成一个更简单的 语法,避免“毁灭性金字塔”的发生。)

function importOPFSEntries(files) {
  // Get a handle on the OPFS directory
  return navigator.storage
    .getDirectory()
    .then(function (dir) {
      // Collect the promises for each file that we want to write
      let promises = files.map(function (file) {
        // Create the file and get a writeable handle on it
        return dir
          .getFileHandle(file.name, { create: true })
          .then(function (fileHandle) {
            // Get a writer for the file
            return fileHandle.createWritable().then(function (writer) {
              // Show a banner / spinner, then write the file
              return writer
                .write(file)
                .then(function () {
                  // Finished with this writer
                  return writer.close();
                })
                .catch(function (err) {
                  console.error('There was an error writing to the OPFS!', err);
                });
            });
          })
          .catch(function (err) {
            console.error('Unable to get file handle from OPFS!', err);
          });
      });
      // Return a promise that resolves when all the files have been written
      return Promise.all(promises);
    })
    .catch(function (err) {
      console.error('Unable to get a handle on the OPFS directory!', err);
    });
}

将文件流直接下载到 OPFS

它的一个变体是直接从互联网上流式传输文件 进入 OPFS,或者进入具有目录句柄(即 是使用 window.showDirectoryPicker() 选择的目录)。它使用与 但它会构建一个 Response,其中包含一个 ReadableStream,以及一个控制器(用于将从遥控器读取的字节加入队列) 文件。然后,生成的 Response.body 会 通过管道传送到新文件的写入者 内部 IP 地址

在这种情况下,Kiwix 能够统计通过 ReadableStream 传输的字节数,从而向用户提供进度指示器,并警告用户不要在下载期间退出应用。代码有点复杂 但由于我们的应用是 FOSS 应用,您可以 查看源代码 。Kiwix 界面如下所示: (下面显示的不同的进度值是因为它仅更新 显示百分比变化时显示的横幅,但会更新下载进度面板 频率):

Kiwix 界面,底部有一个栏,警告用户不要退出应用,并显示 .zim 归档文件的下载进度。

由于下载操作可能会很长,Kiwix 允许用户使用 应用在操作期间自由运行,但要确保始终显示横幅,因此 系统会提醒用户不要关闭应用,直到下载操作完成 。

在应用内实现迷你文件管理器

此时,Kiwix PWA 开发者意识到,仅仅能够 将文件添加到 OPFS。应用还需要为用户提供删除内容的方式 从该存储区域提取不再需要的文件,理想情况下,您还可以 将 OPFS 中锁定的所有文件重新导入用户可见的文件系统中。 因此,有必要实施一个小型文件系统

在此向适用于 Chrome 的出色的 OPFS Explorer 扩展程序致敬(它也适用于 Edge)。它在开发者工具中增加了一个标签 具体是什么在 OPFS 中,同时删除流氓或失败文件。时间是 在检查代码是否正常运行、监控代码行为、 下载,并通常清理开发实验。

文件导出功能取决于能否获取所选文件的文件句柄 或 Kiwix 用于保存导出文件的目录, 适用于可使用 window.showSaveFilePicker() 方法的上下文。如果 Kiwix 文件小于数 GB,我们可以构建一个 blob 为其提供一个网址,然后将其下载到用户可见的文件系统中。 遗憾的是,如此大的存档无法实现。如果支持 导出非常简单:反过来,这与 将文件导入 OPFS(获取要保存的文件的句柄,让用户选择一个 使用 window.showSaveFilePicker() 将其保存到某个位置,然后使用 createWriteable()(在 saveHandle 上)。您可以 查看代码 存储库中

所有浏览器都支持文件删除,只需使用简单的 dirHandle.removeEntry('filename') 即可实现。对于 Kiwix,我们更希望 像上面那样迭代 OPFS 条目,以便检查 所选文件已存在并请求确认,但可能不存在 每个人都需要。同样, 检查代码 如果感兴趣。

他们决定不要使用提供这些选项的按钮,让 Kiwix 界面变得杂乱, 而是将小图标直接放在归档列表的下方点按其中一个 这些图标会改变存档列表的颜色,作为一种视觉线索, 以及他们将要采取的行动然后,用户点击或点按 并且执行相应的操作(导出或删除) (确认后)。

询问用户是否要删除 .zim 文件的对话框。

最后,以下是一个抓屏演示,介绍了讨论过的所有文件管理功能 如上所示 - 将文件添加到 OPFS,直接将文件下载到其中; 删除文件,然后导出到用户可见的文件系统。

开发者的工作永无止境

OPFS 是面向 PWA 开发者的一项重大创新,它提供了强大的文件管理功能,大大缩小了原生应用与 Web 应用之间的差距。但开发者是一群可怜的人,他们永远不会完全满意!OPFS 近乎完美,但还不够...很棒, 主要功能在 Chromium 和 Firefox 浏览器中都能运行,而且它们 在 Android 和桌面设备上实现。我们希望全套功能 很快也会在 Safari 和 iOS 中实现。仍然存在以下问题:

  • Firefox 目前对 OPFS 配额设置了 10GB 的上限,无论底层磁盘空间有多大。虽然对于大多数 PWA 作者来说,这可能已经足够了,但对于 Kiwix 来说,这限制太多了。幸运的是,Chromium 浏览器 更加慷慨
  • 目前无法将大型文件从 OPFS 导出到移动浏览器或桌面版 Firefox 上的用户可见文件系统,因为未实现 window.showSaveFilePicker()。在这些浏览器中,large 文件实际上被困在 OPFS 中。这违背了 Kiwix 的理念 开放的内容访问权限,以及可在用户之间分享归档文件的功能 特别是在互联网连接时断时续或费用高昂的情况下。
  • 用户无法控制 OPFS 虚拟文件系统将使用哪种存储空间。这一问题在移动设备上尤其容易出现 microSD 卡上可能有较大空间, 设备存储空间

但总的来说,这些都是一些细微的小缺陷 支持在 PWA 中访问文件。Kiwix PWA 团队非常感谢 Chromium 最先提出和设计了文件系统访问技术的 并在 Google Cloud 上通过努力使浏览器供应商达成共识的 来源私有文件系统的重要性对于 Kiwix JS PWA,它具有 解决了过去阻碍应用发展的许多用户体验问题, 可帮助我们提升 Kiwix 内容的无障碍性, 所有人。请试用 Kiwix PWA,并 告诉开发者 你会怎么想!

如需有关 PWA 功能的一些实用资源,请访问以下网站: