Joplin 技术解析:离线优先笔记应用的架构、同步机制与开发工作流
Joplin 是一款免费、开源的笔记与待办(to-do)应用,支持在 Windows、Linux、macOS、Android 和 iOS 上运行,所有应用共享同一套后端逻辑。本文以项目根目录 README.md 为主线,梳理其核心能力——Markdown 笔记、离线优先(offline first)、端到端加密(E2EE)多端同步、全文搜索、插件与主题扩展——并结合仓库源码,剖析其 monorepo 包结构、同步目标(Sync Target)抽象层与导入导出机制,最后给出构建、测试与贡献的开发工作流。
一、项目定位:面向隐私的跨平台笔记应用
README.md 对 Joplin 的定义非常明确:
- 免费开源的笔记与待办应用,可以管理大量组织在笔记本(notebook)中的笔记;
- 笔记以 Markdown 格式存储,支持搜索、复制、打标签和修改,既可以应用内编辑,也可以用自己的文本编辑器修改(详见 Markdown 指南);
- 离线优先:数据始终保存在用户的手机或电脑上,无论是否有网络连接,笔记都可访问;
- 笔记可以端到端加密同步到多种云服务(Nextcloud、Dropbox、OneDrive、Joplin Cloud 等),详见 同步文档 与 E2EE 文档;
- 全平台提供全文搜索;应用可通过插件和主题定制,用户也可以自行开发;
- 提供 Web Clipper(浏览器剪藏工具,支持 Firefox 与 Chrome),用于保存网页和截图,文档见 clipper.md;
- 支持从 Evernote 导入:包括格式化内容(转换为 Markdown)、资源(图片、附件)以及完整元数据(地理位置、更新时间、创建时间等),也支持导入纯 Markdown 文件,详见 导入导出文档。
二、Monorepo 架构:共享后端 + 多端应用包
从 package.json 的 workspaces: ["packages/*"] 与 lerna.json("version": "independent","useWorkspaces": true)可以看出,Joplin 采用 Yarn 4 workspaces + Lerna 独立版本管理的 monorepo 结构。贡献指南也明确说明:“所有应用共享同一后端(数据库、同步、设置、模型、业务逻辑等),因此在一个应用中修改后端时,要确保其他应用仍然工作”(见 readme/dev/index.md)。
packages/ 下的包可大致分为四层:
| 层次 | 包 | 职责 |
|---|---|---|
| 共享后端 | packages/lib |
数据库、模型(models/ 目录含 57 个文件)、同步器(Synchronizer.ts)、文件 API、设置、业务逻辑、本地化等 |
| 桌面端 | packages/app-desktop |
Electron 应用,含 GUI(gui/)、命令(commands/)、服务(services/) |
| 移动端 | packages/app-mobile |
React Native 应用,含 Android/iOS 原生工程与 components/ |
| 剪藏器 | packages/app-clipper |
浏览器扩展,含 manifest.json、content scripts 与 service worker |
| 终端端 | packages/app-cli |
命令行客户端 |
| 渲染与编辑 | packages/editor(CodeMirror/ProseMirror 封装)、packages/renderer(MdToHtml 渲染链) |
编辑器与 Markdown 渲染 |
| 服务端 | packages/server、packages/transcribe |
自托管服务器与转写服务 |
| 工具链 | packages/tools、packages/generator-joplin、packages/default-plugins |
构建、发布、插件脚手架等 |
几个可以从源码结构看出来的设计细节:
- packages/lib/initLib.ts 要求每个“应用”包在启动时调用
initLib(globalLogger),让 lib 与宿主应用共享同一个 Logger 实例——这正是“同一后端被多个应用复用”的具体体现; - packages/lib/ 下的
database-driver-better-sqlite.ts、database-driver-node.ts、fs-driver-node.ts、fs-driver-dummy.ts等驱动文件,体现了一种驱动化的抽象:同一套模型层可以通过不同数据库/文件驱动在 Electron、Node CLI、React Native 等环境中运行; - CLAUDE.md 等开发约定要求 SQL 查询只允许出现在
packages/lib/models中,进一步印证模型层是数据访问的唯一入口。
三、Markdown:CommonMark 基线 + 可开关插件
readme/apps/markdown.md 说明 Joplin 遵循 CommonMark 规范,并通过插件扩展功能;桌面与移动端都能同时展示 Markdown 原文与渲染后的富文本。
Joplin 扩展语法(Extras):
- 笔记间链接:在 URL 中写入目标笔记 ID,如
Link to my note。桌面端可拖拽笔记到其他笔记内生成链接,或右键“Copy Markdown link”;移动端在笔记右上菜单中选择“Copy Markdown link”; - 数学公式:KaTeX 语法,行内用
$EXPRESSION$,块级用$$ ... $$;启用数学公式后 mhchem 化学方程式插件自动生效; - 图表:Mermaid 语法放入
```mermaid代码块即可渲染(Mermaid 图形始终渲染在白色背景上,避免主题色冲突); - 乐谱:ABC notation 放入
```abc代码块渲染为乐谱。全局选项在配置界面的 Markdown 区以 JSON5 对象设置(如{ foregroundColor: "#ff0000", scale: 2 });单曲谱可用---分隔的头部覆盖全局选项(如{tablature: [{instrument: 'violin'}]}); - 复选框:
- [ ] Milk/- [x] Rice,可在桌面与移动端直接勾选; - HTML 支持:当 Markdown 表达力不足(如删除线)时可直接写 HTML,例如
This is <s>strikethrough</s> mixed with **Markdown**。
可开关的 Markdown 插件(需在配置界面的 Markdown 区启用/禁用):
| 插件 | 语法示例 | 默认启用 |
|---|---|---|
| Soft breaks | 将换行渲染为软换行(Joplin 默认是硬换行 <br>) |
否 |
| Typographer | (c) → © 等排版替换 |
否 |
| Linkify | 自动识别 URL 并转为可点击链接 | 是 |
| KaTeX | $math$ / $$math$$ |
是 |
| Fountain | ```fountain 代码块,剧本写作标记语言 |
否 |
| Mermaid | ```mermaid 代码块 |
是 |
| Mark | ==marked== → <mark>marked</mark> |
是 |
| Footnote | Simple inline footnote ^[I'm inline!] |
是 |
| TOC | ${toc}、[[toc]] 等插入目录 |
是 |
| Sub / Sup | X~1~ / X^2^ |
否 |
| Deflist / Abbr / Emoji / Insert / Multitable | 见 markdown.md 完整表格 | 否 |
文档同时提醒:这些插件功能不属于 CommonMark 规范,在其他 Markdown 阅读器中不保证可用——这是“隐私优先、数据自有”理念下对格式兼容性的诚实说明。渲染侧的实现位于 packages/renderer/(如 MdToHtml.ts、MarkupToHtml.ts),编辑侧位于 packages/editor/(CodeMirror 与 ProseMirror 封装)。
四、同步体系:无硬依赖的 Sync Target 抽象层
readme/apps/sync/index.md 阐明了同步层的设计目标:不绑定任何特定公司或服务(无论是 Evernote、Google 还是 Microsoft)。同步过程的大部分逻辑运行在抽象层,对外部服务(Nextcloud、Dropbox 等)的访问通过轻量驱动完成——驱动只需提供类文件系统的接口(读取、写入、删除、列出条目),因此新增服务、切换服务都很简单。
当前支持的目标:Joplin Cloud、Nextcloud、S3、WebDAV、Dropbox、OneDrive 或本地文件系统。配置完成后,应用在运行时会后台自动同步(本地内容变更后自动触发后台同步),也可以点击“Synchronise”手动同步。若安装了终端客户端,可在终端执行 joplin sync,用于在界面外同步,例如用 cron 每 30 分钟同步一次:
*/30 * * * * /path/to/joplin sync
4.1 源码中的同步目标注册机制
packages/lib/SyncTargetRegistry.ts 是这一抽象的核心:一个静态注册表,键为目标 id,值为继承自 BaseSyncTarget 的类。SyncTargetInfo 接口暴露了每个目标的元数据:
export interface SyncTargetInfo {
id: number;
name: string;
label: string;
supportsSelfHosted: boolean; // 是否支持自托管
supportsConfigCheck: boolean; // 是否支持配置校验
supportsRecursiveLinkedNotes: boolean;
supportsShare: boolean;
description: string;
classRef: typeof BaseSyncTarget;
}
每个具体目标(如 SyncTargetWebDAV.ts、SyncTargetDropbox.ts、SyncTargetOneDrive.ts、SyncTargetNextcloud.ts 等)以静态方法声明自身元信息。以 WebDAV 为例:
id()返回6,targetName()返回'webdav',requiresPassword()返回true,supportsConfigCheck()返回true;- 描述文案为:“WebDAV 协议允许用户在服务器上创建、修改和移动文档。许多服务器兼容 WebDAV,包括 SeaFile、Nginx 或 Apache”;
newFileApi_()将path、username、password、ignoreTlsErrors等配置项装配为 WebDavApi,再包装成FileApiDriverWebDav并交给统一的 FileApi(fileApi.setSyncTargetId(syncTargetId));checkConfig()通过fileApi.stat('')探测目标目录是否可达,失败时返回ok: false及错误信息——这就是配置界面“检查连接”按钮背后的调用链。
SyncTargetRegistry.optionsOrder() 返回 ['0', '10', '7', '3'](注释标明依次是 None、Joplin Cloud、Dropbox、OneDrive),用于配置界面中目标下拉框的排序;isJoplinServerOrCloud() 则把 joplinServer、joplinCloud、joplinServerSaml 三类 id 归为一组处理。从源码结构看,注册表以数字 id 为键(addClass 以 id() 注册),而设置界面展示时通过 nameToId / idToName 在名称与 id 之间转换——这意味着新增一个同步目标,只需实现 BaseSyncTarget 子类并注册,其余同步逻辑(增量、冲突处理、E2EE)完全复用。
真正的同步算法集中在 packages/lib/Synchronizer.ts,配合 BaseSyncTarget 定义的抽象接口运行;文件读写则被进一步下沉到 file-api-driver-*(local、memory、dropbox、onedrive、webdav、amazon-s3、joplinServer)与 fs-driver-* 系列驱动中。
4.2 端到端加密(E2EE)
readme/apps/sync/e2ee.md 说明 Joplin 在所有应用上支持 E2EE:只有数据拥有者能读取笔记、笔记本、标签和资源,运营商、网络提供商乃至 Joplin 开发者都无法解密。文档同时提醒:E2EE 存在加解密的额外开销,应评估是否真的需要。
启用步骤(关键点全部保留):
- 由于 Joplin 的分布式特性,E2EE 必须先在单一设备上手动启用(这会创建由密码保护的加密主密钥 Master Key),再同步到其余设备。建议在桌面端或终端端先启用——它们通常运行在性能更强的设备上,加密初始数据更快;
- 在 Configuration screen 的 Encryption 区点击“Enable encryption”;
- 输入 Master Key 密码。注意:出于安全原因密码不可找回;
- 等待一次完整同步完成,使所有笔记以密文发送到同步目标。数据量大时可能耗时很长(可过夜执行),看似卡住时不要取消;
- 打开下一台设备,点击“Synchronise”,设备会收到主密钥,输入密码后 E2EE 即在该设备自动启用;完成后再次同步;
- 对每台设备重复步骤 5。不要在多台设备上并行手动启用加密,否则可能产生多个加密密钥(Joplin 支持多密钥,但通常不是期望结果)。
全部设备同步完成并启用 E2EE 后,加解密基本透明;偶尔可能看到未解密的条目,它们最终会在后台解密。禁用 E2EE 的流程与启用对称:逐台设备禁用并等待同步完成。更底层的技术描述见 readme/dev/spec/e2ee/index.md。
五、导入与导出:ENEX、Markdown、OneNote 与 JEX
readme/apps/import_export.md 是数据迁移的权威文档:
从 Evernote 导入(ENEX):可导入完整笔记本、笔记、标签、资源与元数据(作者、地理位置等)。两点已知差异:
- 识别数据(OCR):Evernote 图片附带的识别文本不会保留;若已在 Joplin 中启用 OCR,该数据会以 Joplin 兼容格式重建;
- 颜色/字号/字体:Evernote 的 HTML 在导入时转为 Markdown,对纯文本和基础格式(粗体、斜体、列表、链接)是近乎无损的,表格也会转为 Markdown 表格;但颜色、字号、字体不保留。文本本身无论如何都会完整导入;若必须保留这些格式,Joplin 支持将 ENEX 按 HTML 导入。
- 笔记间链接:大部分保留。ENEX 格式缺少定位链接目标的完整信息(Evernote 使用 ID 但 ID 未关联目标笔记),Joplin 按笔记标题猜测,多标题重名或标题不一致时可能失败,失败则保留原 Evernote 链接。
操作步骤:先在 Evernote 中将笔记本导出为 ENEX 文件。桌面端走 File > Import > ENEX 并选择文件,笔记会导入一个新建的独立笔记本;终端端在 命令行模式 下执行 import /path/to/file.enex,笔记导入以文件名命名的新笔记本。两种方式都支持单文件或包含多个 ENEX 文件的目录:导入单文件时创建同名笔记本导入全部笔记;导入目录时为每个文件创建一个笔记本。实现位于 packages/lib/import-enex.ts 及配套的 import-enex-html-gen.ts、import-enex-md-gen.ts。
从 Markdown 导入:桌面端 File > Import > MD - Markdown (file) 导入单个文件到当前选中的笔记本;MD - Markdown (directory) 导入整个目录,目录结构映射为“笔记本 > 子笔记本 > 笔记”。终端端:import --format md /path/to/file.md 或 import --format md /path/to/directory/。
从 OneNote 导入:
- OneNote Online:要求 Joplin ≥ v3.5.1,笔记本需存放在 OneDrive;从 OneNote Web 进入 OneDrive 下载笔记本文件夹(ZIP,可能较大,2-4 GB 以上的笔记本可能下载不完整),再用桌面端 File > Import > ZIP - OneNote Notebook 导入;
- OneNote Windows 桌面版:要求 Joplin ≥ v3.5.5;在 OneNote 桌面版 File > Export 中选择“Section → OneNote 2010-2016 Section(
*.one/*.onex)”,或“Notebook → OneNote Package(*.onepkg)”(*.onepkg仅支持在 Windows 上运行 Joplin 时导入),再同样通过 ZIP 导入入口导入。OneNote 解析由独立的 Rust 包 packages/onenote-converter/ 承担(含parser/、renderer/等子模块)。
导出:Joplin 支持导出 JEX(Joplin Export file)格式——一个可包含多篇笔记、笔记本等的 tar 文件,无损保留笔记与地理位置、更新时间、标签等元数据,便于备份并可重新导入 Joplin;另有 raw 格式,与 JEX 相同但数据保存为目录、每个条目一个文件。此外还支持 HTML、PDF 等格式的导出,可针对单篇笔记、整个笔记本或全部内容。
六、构建、测试与开发工作流
package.json 声明了环境要求:"node": ">=22.12"、"yarn": "4.12.0"(packageManager 为 yarn@4.16.0)。关键脚本与 readme/dev/index.md 对应:
# 构建(工作区按拓扑顺序并行构建,随后编译 TypeScript)
yarn buildParallel
# 运行全部工作区的测试(并行、每包 2 job)
yarn test
# 进入单个包运行其测试,例如 packages/lib 下:
yarn test
# 只运行某个测试文件 / 某个用例
yarn test markdownUtils
yarn test markdownUtils --filter="should handle conflict"
贡献指南中的测试要点:
- 测试框架为 Jest;新测试文件以
.test.ts命名并放在同一目录; - 多数测试工具函数位于
@joplin/lib/testing/test-utils,可参考 packages/lib/models/Note.test.ts 了解带数据库支持与同步器支持的测试搭建方式; - React Hooks 测试使用
@testing-library/react-hooks,示例见 useLayoutItemSizes.test.ts; - 若确实无法写单测,需提供手动测试计划(至少 5 个测试步骤,覆盖边界输入)以及“相关功能未被破坏”的验证步骤。
其他工程细节:
- package.json 中
postinstall为husky && gulp build,即安装后自动执行 git hooks 安装与 Gulp 构建;resolutions中对 react-native、pdfjs-dist 等多个依赖打了patch:补丁,保证跨端行为一致; - CLAUDE.md 总结了编码约定:Tab 缩进、单引号、避免
any、优先//注释、复制大段代码需注明出处、新增 TypeScript 文件后运行yarn updateIgnored、用yarn tsc编译 /yarn tsc --noEmit类型检查、桌面端样式使用 RSCSS + SCSS(不用 styled-components); - 贡献流程要求:PR 解决已讨论并被接受的具体问题;超过 50 行的改动应先论坛讨论;一个 PR 只解决一个问题;自动化批量修改(拼写、样式等)的 PR 一般不接受;所有贡献者需签署个人贡献者协议(readme/cla.md)。
七、透明度与社区
README.md 提供了 Warrant Canary 签名密钥,用于公开承诺未收到政府取数请求:
- 指纹:
F820 F830 6DD0 05A1 02D1 8CD5 946A E9FA 5915 EF53 - 公钥文件:Assets/keys/joplin-canary-signing-key.asc
社区方面,README 列出了论坛(支持讨论、用户帮助、新功能讨论与 beta 版本发布)、Patreon、Bluesky、Mastodon、YouTube、Discord、LinkedIn 与 Lemmy 社区等渠道;开发文档入口为 readme/dev/index.md,完整帮助文档见 readme/index.md。README 末尾还内嵌了自动生成的贡献者名单(CONTRIBUTORS-TABLE-AUTO-GENERATED 注释块),体现该项目由广泛的社区共同维护。
八、小结
从 README.md 的骨架可以归纳出 Joplin 的技术主线:以 Markdown 纯文本为数据载体、以本地数据库为唯一事实源(离线优先)、以可插拔的 Sync Target 驱动层实现多云同步、以 E2EE 保证隐私、以 monorepo 共享后端保证多端一致。这种“后端一份代码、端上薄壳驱动”的架构,使其能够用同一套模型层、同步器与文件 API 支撑 Electron 桌面端、React Native 移动端、CLI 与浏览器剪藏器,也为自托管(packages/server/)与后续新同步服务的接入留出了清晰的扩展点。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00