本案例研究探讨了非营利组织 Kiwix 如何使用渐进式 Web 应用技术和文件系统访问 API,让用户能够下载和存储大型互联网归档文件以供离线使用。了解处理 Origin Private File System (OPFS) 的代码的技术实现。OPFS 是 Kiwix PWA 中新增的浏览器功能,可增强文件管理功能,让用户无需看到权限提示即可更好地访问归档文件。本文探讨了该新文件系统的挑战,并重点介绍了未来可能的发展方向。
Kiwix 简介
据国际电信联盟 (ITU) 统计,在万维网诞生 30 多年后,全球仍有三分之一的人口仍在等待可靠的互联网连接。故事到此结束了吗?当然不是。瑞士非营利组织 Kiwix 的团队开发了一个由开源应用和内容组成的生态系统,旨在为无法或难以访问互联网的用户提供知识。他们的想法是,如果您无法轻松访问互联网,那么有人可以在有网络连接的地方和时间为您下载关键资源,并将其存储在本地,以供日后离线使用。许多重要的网站(例如维基百科、Gutenberg 计划、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 和 Origin Private File System)如何为这些挑战提供创新且令人兴奋的解决方案。
可离线使用的 Web 应用?
Kiwix 用户具有多种不同的需求,而 Kiwix 对用户访问内容所用的设备和操作系统几乎没有控制权。其中一些设备可能速度缓慢或已过时,尤其是在世界低收入地区。虽然 Kiwix 会尝试涵盖尽可能多的用例,但该组织还意识到,通过使用适用于任何设备的最通用的软件(即网络浏览器),他们可以覆盖更多用户。因此,受 Atwood 法则的启发(该法则指出“任何可用 JavaScript 编写的应用最终都会用 JavaScript 编写”),大约 10 年前,一些 Kiwix 开发者开始着手将 Kiwix 软件从 C++ 移植到 JavaScript。
此移植版本的第一个版本名为 Kiwix HTML5,适用于已停用的 Firefox OS 和浏览器扩展程序。其核心是(现在仍然是)使用 Emscripten 编译器将 C++ 解压缩引擎 (XZ 和 ZSTD) 编译为 ASM.js 中间 JavaScript 语言,后来又编译为 Wasm 或 WebAssembly。这些浏览器扩展程序后来更名为 Kiwix JS,并且仍在积极开发中。
进入渐进式 Web 应用 (PWA)。意识到这种技术的潜力后,Kiwix 开发者构建了 Kiwix JS 的专用 PWA 版本,并着手添加操作系统集成,以便应用提供类似原生功能,尤其是在离线使用、安装、文件处理和文件系统访问方面。
注重离线功能的 PWA 非常轻量,因此非常适合移动互联网连接不稳定或费用昂贵的环境。这背后的技术是 Service Worker API 和相关的 Cache API,所有基于 Kiwix JS 的应用都使用这两项技术。借助这些 API,应用可以充当服务器,拦截正在查看的主要文档或文章中的提取请求,并将其重定向到 (JS) 后端,以从 ZIM 归档中提取和构建响应。
随处存储
鉴于 ZIM 归档文件的大小,存储和访问这些文件(尤其是在移动设备上)可能是 Kiwix 开发者的最大难题。许多 Kiwix 最终用户会在有互联网连接时在应用内下载内容,以供日后离线使用。其他用户使用种子文件在 PC 上进行下载,然后再传输到移动设备或平板电脑,还有一些用户在移动互联网信号不稳定或价格昂贵的地区通过 USB 随身碟或便携式硬盘交换内容。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 的面向对象后端完全使用客户端 JavaScript 编写,可根据需要读取大型归档文件的小部分内容。如果需要解压缩这些 slice,后端会将它们传递给 Wasm 解压缩程序,并在有请求时获取更多 slice,直到解压缩整个 blob(通常是文章或素材资源)。这意味着,您无需将大型归档文件完全读入内存。
虽然 File API 是通用的,但它有一个缺点,即与原生应用相比,Kiwix JS 应用看起来笨重且过时:它要求用户每次启动应用时都使用文件选择器选择归档文件,或者将文件拖放到应用中,因为使用此 API 无法将访问权限从一个会话保留到下一个会话。
为了缓解这种糟糕的用户体验,与许多开发者一样,Kiwix JS 开发者最初采用了 Electron 方案。ElectronJS 是一个出色的框架,提供强大的功能,包括使用 Node API 完全访问文件系统。不过,它也存在一些众所周知的缺点:
- 它仅在桌面操作系统上运行。
- 文件较大且较重(70 MB-100 MB)。
由于每个 Electron 应用都包含 Chromium 的完整副本,因此与仅 5.1 MB 的缩减版捆绑 PWA 相比,Electron 应用的大小相差甚远!
那么,Kiwix 能否为 PWA 用户改善这种情况?
File System Access API 大显身手
2019 年左右,Kiwix 发现了一种新兴的 API,该 API 当时在 Chrome 78 中接受源起试用,名为原生文件系统 API。它承诺能够获取文件或文件夹的文件句柄,并将其存储在 IndexedDB 数据库中。重要的是,此句柄会在应用会话之间保留,因此用户在重新启动应用时不必再次选择文件或文件夹(但他们必须回答快速权限提示)。在正式发布时,该 API 已重命名为 File System Access API,核心部分已由 WHATWG 标准化为 File System API (FSA)。
那么,API 的 File System Access 部分如何运作?请注意以下几点重要事项:
- 它是一个异步 API(Web Worker 中的专用函数除外)。
- 必须通过捕获用户手势(点击或点按界面元素)以编程方式启动文件或目录选择器。
- 如需用户再次授予访问之前选择的文件的权限(在新会话中),还需要用户手势 - 事实上,如果未由用户手势发起,浏览器将拒绝显示权限提示。
除了必须使用笨重的 IndexedDB API 来存储文件和目录句柄之外,代码相对简单。好消息是,有几个库可以为您完成大量繁重工作,例如 browser-fs-access。在 Kiwix JS 中,我们决定直接使用 API,这些 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;
});
}
请注意,您必须提供用于存储文件句柄的函数,除非您使用抽象库,否则没有便捷的方法。您可以在文件 cache.js
中查看 Kiwix 对此的实现,但如果它仅用于存储和检索文件或文件夹句柄,则可以大大简化。
处理目录稍微复杂一些,因为您必须使用异步 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) { … })
获取相应文件,或者在 async function
中使用 const file = await entry.getFile()
执行等效操作,才能使用该文件。
可以继续吗?
在应用的后续启动中,要求用户通过用户手势授予权限会增加文件和文件夹的(重新)打开操作的一点摩擦,但与强制用户重新选择文件相比,这种方式仍然更加流畅。Chromium 开发者目前正在最终确定代码,以允许为已安装的 PWA 提供永久性权限。这是许多 PWA 开发者一直呼吁的功能,我们也非常期待。
但如果我们不必等待呢?Kiwix 开发者最近发现,现在可以通过使用 File Access API 的一项全新功能来消除所有权限提示,该功能同时受 Chromium 和 Firefox 浏览器支持(Safari 也部分支持,但仍缺少 FileSystemWritableFileStream
)。这项新功能就是源私有文件系统。
全面采用原生文件系统:Origin 私有文件系统
源私有文件系统 (OPFS) 在 Kiwix PWA 中仍是一项实验性功能,但团队非常乐意鼓励用户试用它,因为它在很大程度上缩小了原生应用和 Web 应用之间的差距。主要优势如下:
- 用户无需任何权限提示即可访问 OPFS 中的归档文件,即使在启动时也是如此。用户可以从上一个会话中中断的地方继续阅读文章和浏览归档内容,完全不会遇到任何阻碍。
- 它提供对其中存储的文件的高度优化的访问:在 Android 上,速度提升了 5 到 10 倍。
在 Android 中使用 File API 进行标准文件访问速度非常慢,尤其是当大型归档文件存储在 microSD 卡上而不是设备存储空间中时(Kiwix 用户通常会遇到这种情况)。这一切都将随着这个新 API 而改变。虽然大多数用户无法在 OPFS(它会占用设备存储空间,而非 microSD 卡存储空间)中存储 97 GB 的文件,但它非常适合存储小型到中型归档文件。您想要查看 WikiProject Medicine 中最完整的医学百科全书?没问题,1.7 GB 的大小很容易放入 OPFS 中!(提示:在应用内库中,依次选择其他 → mdwiki_en_all_maxi。)
OPFS 的运作方式
OPFS 是浏览器提供的文件系统,每个源都有单独的 OPFS,可视为类似于 Android 上的应用级存储空间。文件可以从可见的文件系统导入到 OPFS,也可以直接下载到 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);
});
});
别忘了,您仍然需要对 archiveList
数组中要处理的任何条目使用 getFile()
。
将文件导入 OPFS
那么,您首先要如何将文件导入 OPFS 中?别着急!首先,您需要估算所需的存储空间量,并确保用户不会尝试放入无法容纳的 97 GB 文件。
获取估算配额非常简单:navigator.storage.estimate().then(function (estimate) { … });
。稍微难一些的是,确定如何向用户显示此内容。在 Kiwix 应用中,我们选择在复选框旁边显示一个小小的应用内面板,以便用户试用 OPFS:
该面板是使用 estimate.quota
和 estimate.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: <b>' +
percent +
'%</b>; ' +
'Remaining: <b>' +
(OPFSQuota / 1024 / 1024 / 1024).toFixed(2) +
' GB</b>';
});
如您所见,还有一个按钮,可让用户从可见的文件系统将文件添加到 OPFS。好消息是,您只需使用 File API 即可获取要导入的所需 File 对象(或对象)。事实上,请务必不要使用 window.showOpenFilePicker()
,因为 Firefox 不支持此方法,而 OPFS 则绝对支持。
上图中显示的添加文件按钮不是旧版文件选择器,但点击或轻触该按钮时,系统会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!)
});
}
});
由于在某些操作系统(例如 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
管道传输到 OPFS 内新文件的写入器。
在这种情况下,Kiwix 能够统计通过 ReadableStream
传输的字节数,从而向用户提供进度指示器,并警告用户不要在下载期间退出应用。此代码过于复杂,无法在此处显示,但由于我们的应用是开源软件,因此如果您有兴趣执行类似操作,可以查看源代码。Kiwix 界面如下所示(下方显示的进度值不同,是因为它仅在百分比发生变化时更新横幅,但会更频繁地更新下载进度面板):
由于下载操作可能需要很长时间,因此 Kiwix 允许用户在下载期间自由使用应用,但会确保始终显示横幅,以提醒用户在下载操作完成之前不要关闭应用。
在应用内实现迷你文件管理器
此时,Kiwix PWA 开发者意识到,仅能将文件添加到 OPFS 是不够的。该应用还需要向用户提供一种从此存储区域中删除不再需要的文件的方法,并且最好还能将锁定在 OPFS 中的所有文件导出回可供用户查看的文件系统。因此,我们必须在应用中实现一个迷你文件管理系统。
在此向适用于 Chrome 的出色的 OPFS Explorer 扩展程序致敬(它也适用于 Edge)。它会在开发者工具中添加一个标签页,让您能够准确查看 OPFS 中的内容,还能删除异常或失败的文件。这对检查代码是否正常运行、监控下载行为以及一般清理开发实验非常有用。
文件导出取决于是否能够获取 Kiwix 要将导出的文件保存到其中的所选文件或目录的文件句柄,因此此操作仅在可以使用 window.showSaveFilePicker()
方法的情况下才有效。如果 Kiwix 文件小于几 GB,我们将能够在内存中构建一个 Blob,为其指定一个网址,然后将其下载到用户可见的文件系统。很抱歉,对于如此庞大的归档文件,我们无法执行此操作。如果受支持,导出非常简单:与将文件保存到 OPFS 的操作几乎完全相同(获取要保存的文件的句柄,使用 window.showSaveFilePicker()
让用户选择要将其保存到的位置,然后对 saveHandle
使用 createWriteable()
)。您可以在代码库中查看代码。
所有浏览器都支持文件删除,只需使用简单的 dirHandle.removeEntry('filename')
即可实现。对于 Kiwix,我们更倾向于像上面那样迭代 OPFS 条目,以便先检查所选文件是否存在,然后再请求确认,但这对所有人来说都不是必需的。再次提醒您,如果感兴趣,可以查看我们的代码。
我们决定不使用提供这些选项的按钮来填充 Kiwix 界面,而是直接在归档列表下方放置小图标。点按其中一个图标会更改归档列表的颜色,以便用户直观地了解自己要执行的操作。然后,用户点击或点按其中一个归档,系统就会执行相应的操作(导出或删除)(在确认后)。
最后,下面的屏幕录制演示视频介绍了上述所有文件管理功能:将文件添加到 OPFS、直接将文件下载到 OPFS、删除文件以及导出到用户可见的文件系统。
开发者的工作永无止境
OPFS 是面向 PWA 开发者的一项重大创新,它提供了非常强大的文件管理功能,大大缩小了原生应用与 Web 应用之间的差距。但开发者是一群可怜的人,他们永远不会完全满意!OPFS 几乎完美无缺,但还不够完美... 很高兴主要功能在 Chromium 和 Firefox 浏览器中都适用,并且在 Android 和桌面设备上都实现了。我们希望很快也能在 Safari 和 iOS 中实现完整功能集。仍存在以下问题:
- Firefox 目前对 OPFS 配额设置了 10GB 的上限,无论底层磁盘空间有多大。虽然对于大多数 PWA 作者来说,这可能已经足够,但对于 Kiwix 来说,这限制太多了。幸运的是,Chromium 浏览器的限制要宽松得多。
- 由于未实现
window.showSaveFilePicker()
,因此目前无法将大型文件从 OPFS 导出到移动浏览器或桌面版 Firefox 上的用户可见文件系统。在这些浏览器中,大型文件会被有效地困在 OPFS 中。这违背了 Kiwix 的理念,即开放获取内容,以及用户之间能够共享归档文件,尤其是在互联网连接不稳定或费用较高的地方。 - 用户无法控制 OPFS 虚拟文件系统将使用哪种存储空间。在移动设备上,这一点尤为重要,因为用户的 microSD 卡上可能有大量空间,但设备存储空间却非常小。
总的来说,这些都是小问题,但对于 PWA 中的文件访问而言,这是一个巨大的进步。Kiwix PWA 团队非常感谢 Chromium 开发者和倡导者,他们首次提出并设计了文件系统访问 API,并在浏览器供应商之间就源私有文件系统的重要性达成共识,为此付出了艰辛的努力。对于 Kiwix JS PWA,它解决了过去困扰该应用的许多用户体验问题,并帮助我们为所有人提升了 Kiwix 内容的无障碍使用体验。请试用 Kiwix PWA,并告诉开发者您的想法!
如需查看一些关于 PWA 功能的实用资源,请访问以下网站:
- Project Fugu API 展示版:一系列 Web 应用,展示了缩小原生应用与 PWA 之间差距的功能。
- PWA 目前能做什么:展示 PWA 目前能实现的功能。