news 2026/9/14 18:04:17

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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.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;
  • fileAtPathstatexists等大多数fsDriver方法都能感知虚拟文件:fileAtPath优先返回virtualFiles_中的Filestat对虚拟文件返回lastModifiedsizeexists直接命中内存 Map。

这意味着虚拟文件对上层业务完全透明——调用方无需区分"这是持久文件还是内存文件"。

2.4 本地目录挂载(mountExternalDirectory)

在支持 File System Access API 的浏览器中(2024 年时该 API 支持范围仍有限),用户可以通过showDirectoryPicker选择本地目录,并交给fsDriver.mountExternalDirectory(handle, id, mode)挂载:

  1. 权限确认:Worker 侧先调用handle.requestPermission({ mode }),其中mode'read' | 'readwrite'AccessMode类型定义于 worker 源码 L16);未获授权则抛出 "Missing read-write access..." 错误。
  2. 生成挂载路径:以/external/为前缀拼接随机 UUID,例如/external/<uuid>,见 worker 源码 L515-L527。
  3. 句柄持久化:文件系统句柄可被浏览器序列化,因此写入indexedDB中的fs-storage数据库(对象仓库external-handles,以id为主键并建立path索引),以便页面刷新后恢复访问。选择indexedDB的原因在源码注释中写得很清楚:localStorage只能存字符串,而 SQLite 的自定义存储几乎不可能容纳文件系统句柄,见 worker 源码 L67-L92。
  4. 恢复时的权限校验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-origin
  • Cross-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-originCross-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)判断并重定向:

  1. 判断请求是否为 Web App 主页(mainPagePathswaitingForClientPath,即just-one-client.html);
  2. 若是,则拦截请求;
  3. 遍历所有受控客户端(clients.matchAll({ includeUncontrolled: true })),检查是否已存在打开的主页客户端,同时排除"当前请求自身导致的刷新"(通过比较event.clientId/event.resultingClientId与已有 client 的 id);
  4. 若已存在运行中的实例,则返回 302 重定向到just-one-client.html错误页;反之若访问的是等待页而当前无实例占用,则重定向回主页面。

相关的辅助页面(如just-one-client.htmlclosed.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-Typetext/html开头(覆盖以目录 URL 请求index.html的场景);
  • 缓存写入名为v1的 Cache 存储。

读取侧(fetch处理器主流程,L166-L195)为:

  1. 先执行单实例重定向判断;
  2. 打开v1缓存,尝试网络请求(响应经过withExtraResponseHeaders注入跨源隔离头),成功且命中缓存规则则写入缓存;
  3. 同源主页请求失败(!response.ok)时,回退到缓存中的响应;
  4. 网络异常(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:基于沙箱化 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通过postMessageinjectJs通道注入并在 iframe 内eval执行;
  • 外层组件通过window.addEventListener('message')接收 iframe 内发来的消息并回调onMessage

6.2 高层 IPC:RemoteMessenger 与消息双向通道

除了底层的postMessageExtendedWebView还支持高层通信:RNToWebViewMessengerWebViewToRNMessenger是一对RemoteMessenger,可以把方法直接暴露为 JavaScript 对象供 WebView 内外互相调用。相关实现位于 packages/app-mobile/utils/ipc(RNToWebViewMessenger.ts等),应用侧典型用法如 useWebViewSetup.ts 中建立渲染器与宿主之间的双向通信。

文档强调:即便有高层 API,底层消息协议依然完全可用,便于与不依赖 Messenger 的既有代码对接。

6.3 底层消息:两种方向

WebView 内部 → 宿主(onMessage:为兼容react-native-webviewExtendedWebView在 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,NoteBodyViewerfsDriver读取资源文件后调用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.tsshareImage.web.ts并存,按平台分别被选中。

策略二:空 mock 替换。如果某个不兼容库仅被"已知在 Web 上不可达的代码"所import,可以在 webpack 配置中把该库替换为空实现(对应 webpack.config.ts 的resolve.fallback配置区域),从而避免打包时报错。

此外,webpack 配置还通过fallback为 Node.js 核心模块提供浏览器端 polyfill(urleventstimerspathstreamcrypto),这是 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 18:04:13

从TF树到图优化:多机器人协同定位与hyperframes实践

去年年底做多机协同巡检&#xff0c;两台AGV在走廊里擦肩而过时&#xff0c;它们各自估算出的相对位置差了接近半米。这个数字本身还能忍&#xff0c;真正让我头疼的是&#xff0c;当我想在ROS的TF树里给这两台车加上“互相看到的约束”&#xff0c;让全局优化去修正这个误差时…

作者头像 李华
网站建设 2026/9/14 18:02:45

柔性直流输电系统稳定性分析与阻抗控制方法

1. 项目概述 "基于阻抗模型的柔性直流输电系统稳定性分析与控制方法研究"这个标题乍看专业性强&#xff0c;但它实际上指向了电力电子领域一个极具现实意义的技术方向——如何确保柔性直流输电系统在大规模新能源接入背景下的稳定运行。我在电力系统稳定性分析领域深…

作者头像 李华
网站建设 2026/9/14 18:02:32

React Native鸿蒙版SortList组件开发与优化指南

1. React Native鸿蒙版SortList组件深度解析 在跨平台移动应用开发领域&#xff0c;React Native与鸿蒙生态的结合为开发者带来了全新的可能性。SortList作为React Native生态中的高级列表组件&#xff0c;在鸿蒙平台上的实现不仅保留了原生平台的流畅交互体验&#xff0c;还针…

作者头像 李华
网站建设 2026/9/14 18:00:12

Flutter+OpenHarmony实现MV播放功能的技术实践

1. MV播放功能整体设计思路 在音乐播放器App中实现MV播放功能&#xff0c;需要从技术架构和用户体验两个维度进行整体规划。与单纯的音频播放相比&#xff0c;MV播放涉及更复杂的媒体处理和UI交互。 1.1 技术架构选型 在Flutter for OpenHarmony环境下&#xff0c;MV播放的核…

作者头像 李华
网站建设 2026/9/14 17:59:32

C++与Matlab图像处理及人脸识别技术对比

1. 项目概述&#xff1a;跨平台图像处理与识别技术实践这个项目本质上是一次跨越编程语言边界的图像处理技术探索&#xff0c;核心在于比较C和Matlab两种技术栈在图像处理与人脸识别领域的实现差异。作为一名在计算机视觉领域工作多年的工程师&#xff0c;我经常需要面对这样的…

作者头像 李华