Joplin Web App 架构解析:基于 react-native-web 与 OPFS 的跨平台笔记应用
【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin
导读
Joplin 的 Web App 是移动端应用在浏览器中的移植版本,它借助react-native-web将 React Native 组件编译为网页应用,并在此基础上解决了浏览器环境的文件系统、数据库、单实例约束与离线缓存等一系列技术难题。本文围绕 readme/dev/spec/web_app.md 展开,从文件系统抽象、跨域隔离、单实例锁、离线支持、WebView IPC 到不兼容库的处理策略,完整梳理该 Web App 的底层实现,并辅以仓库源码(如 fs-driver-rn.web.ts、serviceWorker.ts)作为实现证据,帮助读者理解"同一个 Joplin 代码库如何运行在浏览器中"。
一、整体架构:Web App 与移动端的关系
Joplin Web App 不是一套独立重写的应用,而是移动端应用(app-mobile)面向 Web 目标的编译产物。其核心手段是使用react-native-web:同一份packages/app-mobile下的 React Native 组件代码,通过平台扩展名机制(.web.ts/.web.tsx优先于.ts/.tsx)选择 Web 专属实现,从而在浏览器中复用移动端几乎全部业务逻辑。
构建入口在 packages/app-mobile/web/webpack.config.ts 中定义:
- 入口文件为
index.web.ts; - webpack 的
resolve.extensions依次尝试.web.js、.js、.web.ts、.ts、.web.tsx、.tsx等扩展名,确保 Web 专属文件优先被选中; devServer监听 8088 端口,并预先写入跨域隔离所需的 HTTP 响应头(详见下文"跨域隔离"一节)。
关于构建与运行方式,参考 readme/dev/BUILD.md:
| 命令 | 用途 |
|---|---|
yarn serve-web | 在 8088 端口启动开发服务器,源码改动后整页自动刷新 |
yarn serve-web-hot-reload | 启动带热重载的开发服务器 |
yarn web | 构建发布版本,产物输出到packages/app-mobile/web/dist |
二、文件系统:OPFS、Worker 与三层数据来源
2.1 fsDriver 与 OPFS
在 Web 上,shim.fsDriver由 FsDriverWeb 实现,它封装了 Origin Private File System(OPFS)。OPFS 是浏览器提供的、与源(origin)绑定的私有持久化文件系统,Joplin 在其中创建名为joplin-web的根目录用于存放应用数据。
关键背景(截至 2024 年 7 月):部分主流浏览器(如 Safari)只提供 OPFS 的同步版本接口(如createSyncAccessHandle),而同步接口只能在 Web Worker 中访问。因此 Joplin 的文件操作全部经由一个 Worker 转发。
App logic <--> fsDriver.web <--> Worker <--> OPFS and virtual files从上图可以看到:应用逻辑层(App logic)只与fsDriver.web交互;fsDriver.web通过 WorkerMessenger 与 Worker 通信;真正的文件读写发生在 Worker 内的 OPFS 与虚拟文件之上。
2.2 Worker 中的实现细节
fs-driver-rn.web.worker.ts 是 Worker 侧的核心实现,其WorkerApi类承担全部文件操作:
- 根目录初始化:构造时通过
navigator.storage.getDirectory()获取 OPFS 根,再getDirectoryHandle('joplin-web', { create: true })建立应用专属根目录,失败时最多重试两次(每次间隔 1 秒),见 worker 源码 L153-L174。 - 同步访问句柄优先:
writeFile优先尝试createSyncAccessHandle()(truncate+write+close),在不支持该接口的浏览器上回退到createWritable(),见 worker 源码 L302-L323。 - 保留字文件名处理:Worker 内部把以
tmp结尾的文件名改写成_tmp存储、读取时再还原(removeReservedWords/restoreReservedWords),避免与浏览器保留语义冲突。 - 路径模型:
/app/对应应用数据目录、/cache/对应缓存目录(tarExtract/tarCreate均以/cache/为工作目录),外部目录统一挂载在虚拟的/external/下。
此外,FsDriverWeb通过模块级单例getWorkerMessenger()保证所有 fsDriver 实例共享同一个 Worker——这一点对虚拟文件的正确性至关重要,见 fs-driver-rn.web.ts L43-L64。
2.3 虚拟文件(Virtual files)
有些临时文件没必要写入持久化存储(例如渲染过程中的中间产物)。为此fsDriver.web提供了createReadOnlyVirtualFile(path, content)方法:
- 应用侧调用 FsDriverWeb.createReadOnlyVirtualFile,经由 Messenger 转发到 Worker;
- Worker 侧把内容存入内存中的
virtualFiles_Map(Map<string, File>),见 worker 源码 L511-L513; fileAtPath、stat、exists等大多数fsDriver方法都能感知虚拟文件:fileAtPath优先返回virtualFiles_中的File,stat对虚拟文件返回lastModified与size,exists直接命中内存 Map。
这意味着虚拟文件对上层业务完全透明——调用方无需区分"这是持久文件还是内存文件"。
2.4 本地目录挂载(mountExternalDirectory)
在支持 File System Access API 的浏览器中(2024 年时该 API 支持范围仍有限),用户可以通过showDirectoryPicker选择本地目录,并交给fsDriver.mountExternalDirectory(handle, id, mode)挂载:
- 权限确认:Worker 侧先调用
handle.requestPermission({ mode }),其中mode为'read' | 'readwrite'(AccessMode类型定义于 worker 源码 L16);未获授权则抛出 "Missing read-write access..." 错误。 - 生成挂载路径:以
/external/为前缀拼接随机 UUID,例如/external/<uuid>,见 worker 源码 L515-L527。 - 句柄持久化:文件系统句柄可被浏览器序列化,因此写入
indexedDB中的fs-storage数据库(对象仓库external-handles,以id为主键并建立path索引),以便页面刷新后恢复访问。选择indexedDB的原因在源码注释中写得很清楚:localStorage只能存字符串,而 SQLite 的自定义存储几乎不可能容纳文件系统句柄,见 worker 源码 L67-L92。 - 恢复时的权限校验:
getExternalHandle_从indexedDB读回句柄后,会调用queryPermission/requestPermission再次确认权限(部分浏览器不支持这两个方法,此时视为不可用),见 worker 源码 L176-L208。
从源码结构可以推断,/external/目录本身是虚拟的——pathToDirectoryHandle_对/external/直接返回null,只有其下的具体子目录才与真实句柄关联。
三、数据库与跨源隔离(Cross-Origin Isolation)
3.1 sqlite-wasm 与 SharedArrayBuffer
Web App 使用@sqlite.org/sqlite-wasm在浏览器中运行 SQLite。该库正常工作的前提很可能是依赖SharedArrayBuffer,而SharedArrayBuffer只有在页面开启跨源隔离(Cross-Origin Isolation)时才可用。
跨源隔离由两组 HTTP 响应头开启:
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp(或降级方案credentialless)
3.2 GitHub Pages 的限制与 ServiceWorker 变通
截至 2024 年 7 月,官方 Web App 部署在 GitHub Pages 上,而 GitHub Pages不支持自定义上述响应头。Joplin 的解法是:把跨源隔离头放到ServiceWorker里注入。
serviceWorker.ts 是 coi-serviceworker 项目(MIT 许可)的深度改造 fork,其改造点包括:新增单实例重定向、离线缓存支持、始终注册 ServiceWorker 等。核心逻辑位于withExtraResponseHeaders(L122-L143):
const withExtraResponseHeaders = (response: Response) => { if (response.status !== 0 && needsExtraHeaders) { const newHeaders = new Headers(response.headers); newHeaders.set('Cross-Origin-Embedder-Policy', coepCredentialless ? 'credentialless' : 'require-corp', ); if (!coepCredentialless) { newHeaders.set('Cross-Origin-Resource-Policy', 'cross-origin'); } newHeaders.set('Cross-Origin-Opener-Policy', 'same-origin'); // 对 101/204/205/304 等无 body 响应做特殊处理,避免构造 Response 时抛错 const body = (response.status === 101 || response.status === 204 || response.status === 205 || response.status === 304) ? null : response.body; response = new Response(body, { status: response.status, statusText: response.statusText, headers: newHeaders, }); } return response; };ServiceWorker 脚本还实现了COEP 降级(degrade)机制:当页面虽然由 ServiceWorker 控制但仍未处于crossOriginIsolated状态时,先向 Worker 发送coepCredentialless消息把Cross-Origin-Embedder-Policy降为credentialless并整页刷新重试,见 L228-L253。开发模式下,webpack.config.ts 的devServer.headers也直接配置了Cross-Origin-Opener-Policy: same-origin与Cross-Origin-Embedder-Policy: require-corp。
3.3 ServiceWorker 注册的前提
浏览器侧逻辑(serviceWorker.ts 的else分支)在注册前做了若干检查:需要安全上下文(isSecureContext)、需要navigator.serviceWorker可用(Firefox 隐私模式等环境不可用)、注册后若 ServiceWorker 已 active 但尚未控制页面则刷新以接管,见 L261-L286。
四、单实例锁:防止多标签页数据损坏
Web App 的数据基于本地 SQLite 与 OPFS,如果多个标签页同时打开并写入,可能因状态不同步而损坏数据。因此当前实现只允许同一时刻打开一个应用实例,且通过两级机制双重保障。
4.1 第一级:ServiceWorker 重定向
ServiceWorker 拦截所有请求,通过handleRedirects(L90-L120)判断并重定向:
- 判断请求是否为 Web App 主页(
mainPagePaths与waitingForClientPath,即just-one-client.html); - 若是,则拦截请求;
- 遍历所有受控客户端(
clients.matchAll({ includeUncontrolled: true })),检查是否已存在打开的主页客户端,同时排除"当前请求自身导致的刷新"(通过比较event.clientId/event.resultingClientId与已有 client 的 id); - 若已存在运行中的实例,则返回 302 重定向到
just-one-client.html错误页;反之若访问的是等待页而当前无实例占用,则重定向回主页面。
相关的辅助页面(如just-one-client.html、closed.html)与 ServiceWorker 注册逻辑同处于 packages/app-mobile/web 目录。
4.2 第二级:BroadcastChannel 兜底
如果 ServiceWorker 注册失败(但服务器已启用跨源隔离),则可能绕过第一级锁。为此 Web App 还实现了第二级锁:通过BroadcastChannel与其他已打开的 Web App 实例通信来探测占用情况。文档同时指出了该兜底方案的两个固有局限:
- 即使原始 ServiceWorker 注册失败,该检查仍可能成功;
- 如果其他应用位于不同标签页且长时间未活跃,该检查可能误报"只有一个实例在运行"。
4.3 ServiceWorker 的消息协议
ServiceWorker 支持若干自定义消息(L56-L77):
| 消息类型 | 行为 |
|---|---|
deregister | 注销 ServiceWorker 并刷新所有受控客户端 |
coepCredentialless | 设置 COEP 是否使用credentialless模式 |
closeAllJoplinWebTabs | 将所有 Joplin 主页标签页导航到closed.html |
五、离线支持
ServiceWorker 在fetch事件中同时承担缓存职责(cacheResponse,L145-L164):
- 仅缓存
GET请求、响应ok、且与 Web 客户端同源的请求; - 命中条件为路径匹配
.js|.css|.wasm|.json|.ttf|.html|.png扩展名,或响应Content-Type以text/html开头(覆盖以目录 URL 请求index.html的场景); - 缓存写入名为
v1的 Cache 存储。
读取侧(fetch处理器主流程,L166-L195)为:
- 先执行单实例重定向判断;
- 打开
v1缓存,尝试网络请求(响应经过withExtraResponseHeaders注入跨源隔离头),成功且命中缓存规则则写入缓存; - 同源主页请求失败(
!response.ok)时,回退到缓存中的响应; - 网络异常(
catch分支)时直接cache.match(request)返回缓存副本。
这就是"首次在线访问时缓存、后续断网时由缓存兜底"的离线运行机制。需要说明:离线支持仅针对与 Web 客户端同域的静态资源请求,跨域请求不会被缓存。
六、WebView:iframe 化与 IPC 协议
6.1 ExtendedWebView 的平台差异
Joplin 在所有平台上渲染本地 HTML 时统一使用ExtendedWebView组件,其实现随平台切换:
- Android / iOS:基于
react-native-webview; - Jest 测试:基于 JSDOM 的 mock(
ExtendedWebView/index.jest.tsx); - Web:基于沙箱化 iframe(
ExtendedWebView/index.web.tsx)——截至 2024 年 7 月,react-native-webview尚不支持 Web 目标。
index.web.tsx的实现要点(源码 L78-L163):
- 通过
makeSandboxedIframe创建 iframe,权限字符串为allow-scripts allow-modals allow-popups allow-popups-to-escape-sandbox(后者用于让target="_blank"的 PDF 预览等链接可以弹出新页); - 向 iframe 注入
<base target="_blank">,让链接默认在新窗口打开; - 注入脚本在 iframe 内部定义
window.ReactNativeWebView.postMessage(向父窗口转发消息)并监听来自父窗口的message事件,把event.data.postMessage重新派发为origin === 'react-native'的message事件——从而模拟 react-native-webview 的消息语义; injectedJavaScript通过postMessage的injectJs通道注入并在 iframe 内eval执行;- 外层组件通过
window.addEventListener('message')接收 iframe 内发来的消息并回调onMessage。
6.2 高层 IPC:RemoteMessenger 与消息双向通道
除了底层的postMessage,ExtendedWebView还支持高层通信:RNToWebViewMessenger与WebViewToRNMessenger是一对RemoteMessenger,可以把方法直接暴露为 JavaScript 对象供 WebView 内外互相调用。相关实现位于 packages/app-mobile/utils/ipc(RNToWebViewMessenger.ts等),应用侧典型用法如 useWebViewSetup.ts 中建立渲染器与宿主之间的双向通信。
文档强调:即便有高层 API,底层消息协议依然完全可用,便于与不依赖 Messenger 的既有代码对接。
6.3 底层消息:两种方向
WebView 内部 → 宿主(onMessage):为兼容react-native-webview,ExtendedWebView在 WebView 内部暴露全局对象ReactNativeWebView:
ReactNativeWebView.postMessage(message) // 触发宿主侧 onMessage宿主 → WebView 内部(window.onmessage):通过webviewRef.postMessage发送的消息,在 WebView 内由全局message事件接收。文档给出了完整示例:
// ...within some component const webViewRef = useRef<WebViewControl>(); return ( <ExtendedWebView webviewInstanceId='test-webview' html={'some html here'} injectedJavaScript={` window.addEventListener('message', event => { if (event.origin === 'react-native') { const messageData = event.data; // ...use event.data... } }); `} ref={webViewRef} onLoadEnd={() => webViewRef.current.postMessage('test')} /> )结合 index.web.tsx 的源码可知:webviewRef.current.postMessage(message)实际上以{ postMessage: message }的形式postMessage给 iframe 的contentWindow;iframe 内的监听器收到后提取postMessage字段并重新派发为带origin: 'react-native'的message事件,因此示例中event.origin === 'react-native'的判断成立。
七、Note viewer:资源经虚拟文件系统流入 WebView
与 Android / iOS 一样,笔记正文渲染由NoteBodyViewer通过ExtendedWebView完成(见 packages/app-mobile/components/NoteBodyViewer)。不同之处在于:Web App 的文件都位于虚拟文件系统(Worker + OPFS)中,普通 URL 无法直接引用这些文件,因此需要额外的"搬运"环节。
文档用流程图描述了资源的流转路径:
Attached resource IDs --> Load from fsDriver --> setResourceFile(id, file) | v Convert to blob URL | v store in resourcePathOverrides | v Renderer(按需替换资源路径)结合仓库源码(Renderer.ts)可以验证这一流程的实现:
setResourceFile(id, file: Blob)内部执行this.resourcePathOverrides_[id] = URL.createObjectURL(file),即把资源文件转为blob URL存入覆盖表;- 渲染时若资源 id 命中
resourcePathOverrides_,则用 blob URL 替换原始资源路径,供 iframe 内直接加载; - 调用侧位于 useWebViewSetup.ts L183,
NoteBodyViewer从fsDriver读取资源文件后调用renderer.setResourceFile,随后触发重渲染。
插件资源(如渲染数学公式所用的 CSS 与字体)也走类似的加载流程。
八、与 react-native-web 不兼容的库:两种处理策略
部分 npm 库无法在react-native-web下运行。Joplin 采用两种策略,二者可组合使用:
策略一:平台专属文件(.web.ts扩展名)。把依赖不兼容库的代码限制在仅 Android / iOS 使用的文件中;若该功能在 Web 上也有需求,则创建一个.web.ts版本提供 Web 专属实现。由于 webpack 的resolve.extensions优先解析.web.ts(见 webpack.config.ts),Web 构建会导入xxx.web.ts,而其他平台导入xxx.ts。示例:shareImage.ts与shareImage.web.ts并存,按平台分别被选中。
策略二:空 mock 替换。如果某个不兼容库仅被"已知在 Web 上不可达的代码"所import,可以在 webpack 配置中把该库替换为空实现(对应 webpack.config.ts 的resolve.fallback配置区域),从而避免打包时报错。
此外,webpack 配置还通过fallback为 Node.js 核心模块提供浏览器端 polyfill(url、events、timers、path、stream、crypto),这是 React Native 生态代码在浏览器中得以运行的基础设施之一。
九、小结
Joplin Web App 的技术方案可以概括为一条主线与四项关键设计:
- 一条主线:复用
packages/app-mobile的全部业务代码,通过react-native-web+ 平台扩展名选择机制适配 Web; - 文件系统:
fsDriver.web(fs-driver-rn.web.ts)把 OPFS 封装为类 Node 的 fs 接口,虚拟文件与外部目录挂载共同构成三层文件来源,全部文件操作收敛到共享 Worker(fs-driver-rn.web.worker.ts); - 运行环境:借助改造自 coi-serviceworker 的 serviceWorker.ts 注入 COOP/COEP 头以获得 sqlite-wasm 所需的跨源隔离,同时复用该 ServiceWorker 实现单实例锁与离线缓存;
- 渲染通道:
ExtendedWebView在 Web 上以沙箱 iframe 模拟react-native-webview语义,RNToWebViewMessenger/WebViewToRNMessenger提供高层 IPC,笔记资源以 blob URL 形式注入渲染器; - 兼容策略:平台专属文件与空 mock 双管齐下,化解第三方库与
react-native-web的冲突。
理解这些设计,不仅可以解释"为什么 Joplin Web App 能跑在浏览器里",也为在类似场景下把移动端应用移植到 Web、或为自家应用设计"OPFS + Worker + ServiceWorker"架构提供了可直接借鉴的参考。
【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考