本案例研究探讨了非营利组织 Kiwix 如何使用渐进式 Web 应用技术和文件系统访问 API,让用户能够下载和存储大型互联网归档文件以供离线使用。了解处理 Origin Private File System (OPFS) 的代码的技术实现。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 试图涵盖尽可能多的用例,但该组织还意识到,只要在任何设备上都使用最通用的软件(即网络浏览器),您就可以覆盖更多用户。因此,受 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 允许应用充当服务器,拦截正在查看的主文档或文章中的 Fetch 请求,并将其重定向到 (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 对文件系统的完整访问权限。不过,它也存在一些众所周知的缺点:
- 它仅在桌面操作系统上运行。
- 文件又大又厚 (70MB–100MB)。
由于每个应用都包含 Chromium 的完整副本,因此 Electron 应用的大小,与极小的捆绑式 PWA 相比,只有 5.1 MB 非常不利!
那么,Kiwix 能否通过某种方式改善 PWA 用户的体验?
File System Access API 大显身手
在 2019 年左右,Kiwix 意识到了一个新兴的 API 正在进行 Chrome 78 中的源试用,该 API 称为 Native File System 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。
打开文件和目录选择器
打开文件选择器的效果如下所示(此处使用了 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 是浏览器提供的文件系统,每个来源都有单独的文件系统,可以视为类似于 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
传输的字节数,从而向用户提供进度指示器,并警告用户不要在下载期间退出应用。此处的代码有点复杂,但由于我们的应用是 FOSS 应用,因此如果您想执行类似的操作,可以查看源代码。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 是面向 PWA 开发者的一项重大创新,它提供了强大的文件管理功能,大大缩小了原生应用与 Web 应用之间的差距。但开发者是一群可怜的人,他们永远不会完全满意!OPFS 几乎完美无缺,但还不够完美... 很高兴主要功能在 Chromium 和 Firefox 浏览器中都适用,并且在 Android 和桌面设备上都实现了。我们希望很快也能在 Safari 和 iOS 中实现完整功能集。仍然存在以下问题:
- Firefox 目前对 OPFS 配额施加了 10GB 的上限,而无论底层磁盘空间有多少。虽然对于大多数 PWA 作者来说,这对于 Kiwix 来说可能足够大,但这非常严格。幸运的是,Chromium 浏览器的限制要宽松得多。
- 目前无法在移动浏览器或桌面版 Firefox 上将大型文件从 OPFS 导出到用户可见的文件系统,因为未实现
window.showSaveFilePicker()
。在这些浏览器中,大型文件会被有效地困在 OPFS 中。这违背了 Kiwix 的理念,即开放获取内容,以及用户之间能够共享归档文件,尤其是在互联网连接不稳定或费用较高的地方。 - 用户无法控制 OPFS 虚拟文件系统将使用哪种存储空间。在移动设备上,这一点尤为重要,因为用户的 microSD 卡上可能有大量空间,但设备存储空间却非常小。
总的来说,这些都是小问题,对于 PWA 中的文件访问而言,这是一个巨大的进步。Kiwix PWA 团队非常感谢最先提出和设计 File System Access API 的 Chromium 开发者和倡导者,也非常感谢浏览器供应商就源私有文件系统的重要性达成共识的辛勤工作。对于 Kiwix JS PWA,它解决了过去困扰该应用的许多用户体验问题,并帮助我们为所有人提升了 Kiwix 内容的无障碍使用体验。请试用 Kiwix PWA,并告诉开发者您的想法!
如需有关 PWA 功能的一些实用资源,请访问以下网站:
- Project Fugu API 展示版:一系列 Web 应用,展示了缩小原生应用与 PWA 之间差距的功能。
- PWA 目前能实现什么:展示 PWA 目前无法实现的功能。