Joplin Web App 架构解析:基于 react-native-web 与 OPFS 的跨平台笔记应用

Joplin Web App 架构解析:基于 react-native-web 与 OPFS 的跨平台笔记应用 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.tswebpack 的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 SystemOPFS。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()truncatewriteclose在不支持该接口的浏览器上回退到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 转发到 WorkerWorker 侧把内容存入内存中的virtualFiles_MapMapstring, File见 worker 源码 L511-L513fileAtPath、stat、exists等大多数fsDriver方法都能感知虚拟文件fileAtPath优先返回virtualFiles_中的Filestat对虚拟文件返回lastModified与sizeexists直接命中内存 Map。这意味着虚拟文件对上层业务完全透明——调用方无需区分这是持久文件还是内存文件。2.4 本地目录挂载mountExternalDirectory在支持 File System Access API 的浏览器中2024 年时该 API 支持范围仍有限用户可以通过showDirectoryPicker选择本地目录并交给fsDriver.mountExternalDirectory(handle, id, mode)挂载权限确认Worker 侧先调用handle.requestPermission({ mode })其中mode为read | readwriteAccessMode类型定义于 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 Isolation3.1 sqlite-wasm 与 SharedArrayBufferWeb App 使用sqlite.org/sqlite-wasm在浏览器中运行 SQLite。该库正常工作的前提很可能是依赖SharedArrayBuffer而SharedArrayBuffer只有在页面开启跨源隔离Cross-Origin Isolation时才可用。跨源隔离由两组 HTTP 响应头开启Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp或降级方案credentialless3.2 GitHub Pages 的限制与 ServiceWorker 变通截至 2024 年 7 月官方 Web App 部署在 GitHub Pages 上而 GitHub Pages不支持自定义上述响应头。Joplin 的解法是把跨源隔离头放到ServiceWorker里注入。serviceWorker.ts 是 coi-serviceworker 项目MIT 许可的深度改造 fork其改造点包括新增单实例重定向、离线缓存支持、始终注册 ServiceWorker 等。核心逻辑位于withExtraResponseHeadersL122-L143const 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 拦截所有请求通过handleRedirectsL90-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事件中同时承担缓存职责cacheResponseL145-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 客户端同域的静态资源请求跨域请求不会被缓存。六、WebViewiframe 化与 IPC 协议6.1 ExtendedWebView 的平台差异Joplin 在所有平台上渲染本地 HTML 时统一使用ExtendedWebView组件其实现随平台切换Android / iOS基于react-native-webviewJest 测试基于 JSDOM 的 mockExtendedWebView/index.jest.tsxWeb基于沙箱化 iframeExtendedWebView/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 高层 IPCRemoteMessenger 与消息双向通道除了底层的postMessageExtendedWebView还支持高层通信RNToWebViewMessenger与WebViewToRNMessenger是一对RemoteMessenger可以把方法直接暴露为 JavaScript 对象供 WebView 内外互相调用。相关实现位于 packages/app-mobile/utils/ipcRNToWebViewMessenger.ts等应用侧典型用法如 useWebViewSetup.ts 中建立渲染器与宿主之间的双向通信。文档强调即便有高层 API底层消息协议依然完全可用便于与不依赖 Messenger 的既有代码对接。6.3 底层消息两种方向WebView 内部 → 宿主onMessage为兼容react-native-webviewExtendedWebView在 WebView 内部暴露全局对象ReactNativeWebViewReactNativeWebView.postMessage(message) // 触发宿主侧 onMessage宿主 → WebView 内部window.onmessage通过webviewRef.postMessage发送的消息在 WebView 内由全局message事件接收。文档给出了完整示例// ...within some component const webViewRef useRefWebViewControl(); return ( ExtendedWebView webviewInstanceIdtest-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 的contentWindowiframe 内的监听器收到后提取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 L183NoteBodyViewer从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.tsWeb 构建会导入xxx.web.ts而其他平台导入xxx.ts。示例shareImage.ts与shareImage.web.ts并存按平台分别被选中。策略二空 mock 替换。如果某个不兼容库仅被已知在 Web 上不可达的代码所import可以在 webpack 配置中把该库替换为空实现对应 webpack.config.ts 的resolve.fallback配置区域从而避免打包时报错。此外webpack 配置还通过fallback为 Node.js 核心模块提供浏览器端 polyfillurl、events、timers、path、stream、crypto这是 React Native 生态代码在浏览器中得以运行的基础设施之一。九、小结Joplin Web App 的技术方案可以概括为一条主线与四项关键设计一条主线复用packages/app-mobile的全部业务代码通过react-native-web 平台扩展名选择机制适配 Web文件系统fsDriver.webfs-driver-rn.web.ts把 OPFS 封装为类 Node 的 fs 接口虚拟文件与外部目录挂载共同构成三层文件来源全部文件操作收敛到共享 Workerfs-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),仅供参考