1. 为什么我要从零造一个笔记应用
市面上笔记工具多如牛毛,随手一抓就是一大把。但真正用下来你会发现一个尴尬的现实:要么是云端优先、离线几乎不可用,要么是数据格式封闭、想导出都费劲,要么是插件生态贫瘠、想定制个功能得等官方排期。我自己的工作流里,笔记是核心资产,每天要记技术方案、会议纪要、代码片段、读书摘录,时间一长积累了几千条。这些数据如果被锁在某个平台的数据库里,我会非常不安。
所以当我第一次接触 Joplin 的时候,它的几个设计决策直接击中了我:本地优先存储、Markdown 原生格式、端到端加密同步、开放插件体系。更关键的是,它整个项目是开源的,这意味着我可以完全掌控自己的数据,甚至可以根据自己的需求去改它、扩展它。
但问题来了——网上关于 Joplin 的资料,绝大多数是“怎么用”,很少有人讲“怎么开发”。如果你想基于 Joplin 做二次开发,比如写一个自定义插件、改一个同步逻辑、或者干脆 fork 一份做自己的定制版,你会发现文档散落在各处,中文资料更是稀缺。我自己在摸索的过程中踩了不少坑,从环境搭建到插件调试,从数据模型理解到同步机制拆解,每一步都有那种“文档没写但你必须知道”的细节。
这篇内容就是把我这段时间的实战经验完整梳理出来。不管你是想给 Joplin 写个插件解决自己的痛点,还是想深入理解一个成熟笔记应用的架构设计,或者单纯想学习一个大型 Electron + React 项目的工程组织方式,下面这些内容都能给你一个可复现的路径。我会从最基础的环境准备讲起,一直讲到插件发布和性能调优,中间穿插大量我实际踩过的坑和验证过的方案。
2. 开发环境搭建:比想象中多踩三个坑
2.1 基础工具链的版本选择
Joplin 的技术栈核心是 Electron + React + TypeScript,桌面端和移动端共享大量逻辑代码。官方推荐用 Node.js 来管理依赖,但这里第一个坑就来了:Node 版本不能太新也不能太旧。我一开始用 Node 20 跑构建,结果node-gyp编译原生模块时直接报错,换到 Node 18 LTS 才顺利通过。后来查了项目的engines字段,发现官方锁定的是>=18,但实际测试下来 18.x 的兼容性最稳。
包管理器方面,Joplin 用的是 Yarn(经典版,不是 Berry)。如果你习惯用 npm,理论上也能跑,但yarn.lock的存在意味着依赖版本会被严格锁定,用 npm 安装可能会解析出不同的依赖树,导致一些微妙的构建问题。我的建议是老老实实装 Yarn 1.x,别在这上面省事。
# 确认 Node 版本 node -v # 应该是 v18.x # 安装 Yarn 经典版 npm install -g yarn # 克隆仓库(这里用虚构的仓库地址示意) git clone https://example.com/joplin-fork.git cd joplin-fork # 安装依赖,这一步会比较久 yarn installyarn install这一步可能要跑五到十分钟,取决于网络状况。如果卡在某个原生模块编译上,大概率是缺少系统级的构建工具。在 Ubuntu 上需要build-essential和libsqlite3-dev,在 macOS 上需要 Xcode Command Line Tools,Windows 上则需要 Visual Studio Build Tools 里的 C++ 工作负载。这些在官方文档里提了一句,但没展开讲,很多人第一次跑就卡在这里。
2.2 项目结构的关键目录
依赖装完之后,别急着跑yarn start。先花十分钟把目录结构摸清楚,后面能省很多时间。Joplin 的 monorepo 组织方式比较典型,核心目录大致是这样的:
| 目录 | 作用 | 你什么时候会用到 |
|---|---|---|
packages/app-desktop | 桌面端主程序 | 改 UI、调桌面端行为 |
packages/app-mobile | 移动端主程序 | 改移动端逻辑 |
packages/lib | 共享核心库 | 改数据模型、同步逻辑、加密 |
packages/renderer | Markdown 渲染 | 改预览样式、加自定义语法 |
packages/plugin-repo | 插件相关 | 开发插件时参考 |
packages/tools | 构建工具 | 一般不用动 |
其中packages/lib是最核心的部分,笔记的增删改查、同步算法、加密解密、数据库操作全在这里。如果你想理解 Joplin 的数据流,从这个包入手最直接。packages/app-desktop则是 Electron 的主进程和渲染进程代码,React 组件基本都在这里。
我建议第一次跑起来之前,先打开packages/lib/models目录看看。里面每个文件对应一种数据模型,比如Note.ts、Folder.ts、Tag.ts、Resource.ts。这些模型类定义了字段、关联关系和基础操作,是理解整个应用数据结构的钥匙。
2.3 首次启动的编译陷阱
环境准备好之后,跑yarn start启动桌面端。这里第二个坑:首次启动会触发大量 TypeScript 编译和 webpack 打包,内存占用可能飙到 4GB 以上。如果你的机器内存小于 16GB,建议关掉其他占资源的程序,否则可能遇到JavaScript heap out of memory的错误。
如果真遇到了,可以临时调大 Node 的内存上限:
export NODE_OPTIONS="--max-old-space-size=8192" yarn start第三个坑是数据库初始化。Joplin 桌面端默认用 SQLite 存储数据,首次启动会在用户目录下创建数据库文件。如果你之前装过正式版的 Joplin,开发版可能会和正式版抢同一个数据目录,导致数据混乱。解决办法是给开发版指定一个独立的数据目录:
# 通过环境变量指定开发数据目录 export JOPLIN_APP_DATA_DIR="/tmp/joplin-dev-data" yarn start这个环境变量在官方文档里藏得很深,但实际开发中非常有用。你可以用它来模拟全新安装、测试数据迁移、或者隔离不同分支的数据。
3. 理解 Joplin 的数据模型与同步机制
3.1 笔记、文件夹、标签的三角关系
Joplin 的数据模型设计得相当克制,核心实体就三个:Note(笔记)、Folder(文件夹)、Tag(标签)。但它们之间的关系不是简单的树形结构,而是带有多对多关联的图结构。
一个 Note 必须属于一个 Folder,这是硬性约束。Folder 可以嵌套 Folder,形成树形目录。而 Tag 和 Note 是多对多关系,一条笔记可以有多个标签,一个标签也可以关联多条笔记。这种设计的好处是灵活,坏处是查询时经常需要 join 操作。
在packages/lib/models里,每个模型类都继承自一个BaseModel,提供了save()、delete()、load()等基础方法。但要注意,这些方法不是直接操作数据库的,而是通过一个BaseModel的静态方法来调度。实际的数据持久化走的是packages/lib/database.ts里的接口,桌面端用 SQLite,移动端用 SQLite 的移动版本,同步时则走另一套逻辑。
我一开始以为改数据就是直接调note.title = 'xxx'然后note.save(),后来发现这样改完之后,同步模块可能感知不到变化。正确的做法是通过Note.save()的静态方法,或者用BaseModel.save()并确保触发了变更事件。Joplin 内部有一套变更追踪机制,每条记录都有updated_time和is_shared字段,同步算法依赖这些字段来判断哪些数据需要上传。
3.2 同步算法的核心逻辑
Joplin 的同步机制是我见过的最精巧的设计之一。它不依赖中心服务器做冲突解决,而是把同步目标抽象成一个“同步目标接口”,支持文件系统、WebDAV、对象存储等多种后端。核心思路是:每个同步目标上维护一份完整的数据库快照,本地也有一份,同步时对比两边的差异,然后双向合并。
具体来说,同步过程分几个阶段:
- 获取远程快照:从同步目标下载最新的
info.json和items元数据。 - 计算本地变更:找出本地自上次同步以来新增、修改、删除的记录。
- 计算远程变更:对比远程快照和本地记录的差异。
- 冲突检测与解决:如果同一条记录两边都改了,根据
updated_time决定保留哪个,或者生成冲突副本。 - 上传本地变更:把本地的变更推送到远程。
- 应用远程变更:把远程的变更应用到本地数据库。
这套逻辑的代码主要在packages/lib/synchronizer.ts和packages/lib/sync目录下。如果你想改同步行为,比如加一个新的同步后端,需要实现SyncTarget接口,提供put()、get()、delete()、list()等方法。接口定义在packages/lib/SyncTarget.ts里,不算复杂,但要注意处理网络异常和重试逻辑。
注意:同步过程中如果中断,可能会留下临时状态。Joplin 用
sync_state表来记录同步进度,下次同步时会从断点继续。调试同步问题时,可以查这个表来确认状态。
3.3 端到端加密的实现细节
Joplin 的端到端加密(E2EE)是可选的,但一旦开启,所有同步数据都会在本地加密后再上传。加密算法用的是 AES-256-GCM,密钥派生用 PBKDF2。主密钥(Master Key)在本地生成,然后用用户设置的密码加密后存储。同步时,加密后的主密钥也会上传,但只有知道密码的人才能解密。
这套机制的关键在于:加密和解密都发生在本地,同步目标上存储的永远是密文。即使同步目标被攻破,攻击者拿到的也只是加密后的数据。但代价是,如果你忘了密码,数据就真的找不回来了——没有后门,没有恢复机制。
在代码层面,加密逻辑在packages/lib/services/e2ee目录下。EncryptionService.ts是入口,负责管理密钥、加解密数据。如果你要开发涉及加密的插件,需要先通过EncryptionService获取当前的主密钥,然后调用encrypt()和decrypt()方法。注意,这些方法是异步的,因为密钥派生和加解密都比较耗时。
我实测下来,开启 E2EE 后同步速度会下降 30% 到 50%,取决于笔记数量和设备性能。如果同步频繁,可以考虑只在敏感笔记上启用加密,而不是全局开启。但 Joplin 目前不支持按笔记粒度加密,只能全局开关,这是一个可以改进的点。
4. 插件开发:从零写一个可用的插件
4.1 插件架构与生命周期
Joplin 的插件系统基于一个沙箱化的运行环境。插件代码运行在独立的上下文中,通过一套消息传递机制和主程序通信。这样做的好处是安全——插件不能直接访问文件系统或数据库,所有操作都必须通过官方提供的 API。坏处是性能有损耗,而且 API 覆盖面有限,有些功能想实现但官方没暴露接口,就只能干瞪眼。
一个插件的基本结构包括:
manifest.json:声明插件元信息,包括 ID、名称、版本、支持的 Joplin 版本范围、权限等。index.ts或index.js:插件入口,导出onStart()等生命周期函数。package.json:依赖管理,构建脚本。
插件的生命周期很简单:Joplin 启动时加载插件,调用onStart();插件可以注册命令、菜单项、工具栏按钮、设置面板等;Joplin 关闭时调用onClose()。没有复杂的钩子体系,够用但不冗余。
manifest.json里最容易出错的是app_min_version字段。如果你写了一个太高的版本号,低版本 Joplin 会直接拒绝加载;写得太低,又可能用到不存在的 API。我的建议是参考官方插件仓库里同类插件的写法,取一个经过验证的版本号。
4.2 用 API 操作笔记数据
Joplin 插件 API 的核心是joplin.data对象,提供了对笔记、文件夹、标签、资源的增删改查方法。所有方法都是异步的,返回 Promise。比如要创建一条笔记:
// 创建一条新笔记 const note = await joplin.data.post(['notes'], null, { title: '我的新笔记', body: '这是通过插件创建的笔记内容', parent_id: folderId // 必须指定所属文件夹 }); // 查询笔记列表 const notes = await joplin.data.get(['notes'], { fields: ['id', 'title', 'updated_time'], order_by: 'updated_time', order_dir: 'DESC', limit: 10 }); // 更新笔记 await joplin.data.put(['notes', note.id], null, { title: '修改后的标题' });这里有个坑:parent_id是必填的,但如果你不知道当前选中的文件夹 ID,需要通过joplin.workspace.selectedFolder()获取。如果用户没有选中任何文件夹,这个方法返回null,你得处理这种情况,否则插件会报错。
另一个坑是字段名。Joplin 的 API 用的是下划线命名(parent_id、updated_time),但返回的对象里有些字段是驼峰命名。这个不一致性在官方文档里没有明确说明,我调试了好一阵才发现。建议在代码里统一做一层转换,避免混淆。
4.3 注册命令与菜单项
插件最常用的功能是注册一个命令,然后把它挂到菜单或工具栏上。命令的注册方式如下:
// 注册命令 await joplin.commands.register({ name: 'myPlugin.insertTimestamp', label: '插入当前时间戳', iconName: 'fas fa-clock', execute: async () => { const timestamp = new Date().toISOString(); // 在当前笔记光标位置插入文本 await joplin.commands.execute('insertText', timestamp); } }); // 添加到菜单 await joplin.views.menus.create('myPluginMenu', '我的插件', [ { commandName: 'myPlugin.insertTimestamp', label: '插入时间戳' } ]);insertText是 Joplin 内置的命令,可以直接在编辑器光标处插入文本。类似的还有replaceSelection、selectAll等。这些内置命令没有完整的文档列表,需要去源码里翻packages/app-desktop/commands目录。
菜单创建时,create()方法的第一个参数是菜单 ID,必须全局唯一。如果你在多个插件里用了相同的 ID,后面的会覆盖前面的。我建议用插件 ID 作为前缀,比如com.example.myplugin.menu,避免冲突。
4.4 调试与热重载
插件开发最痛苦的是调试。Joplin 没有提供官方的热重载机制,每次改完代码都要手动重启应用才能看到效果。我的做法是写一个简单的文件监听脚本,检测到插件目录变化时自动重启 Joplin:
# 用 nodemon 监听插件目录,变化时重启 Joplin nodemon --watch ./my-plugin --exec "yarn start"但这样重启一次要十几秒,开发效率还是低。后来我发现可以用 Electron 的开发者工具来调试插件代码。在 Joplin 里按Ctrl+Shift+I(Windows/Linux)或Cmd+Option+I(macOS)打开开发者工具,然后在 Console 里可以直接调用joplin对象,测试 API 调用。这比反复重启快多了。
还有一个技巧:把插件代码里的console.log输出到开发者工具的 Console 里。Joplin 会把插件的日志转发到主进程的 Console,但有时候会被其他日志淹没。你可以在开发者工具的 Console 设置里过滤关键字,只看自己插件的输出。
5. 自定义渲染与样式改造
5.1 Markdown 渲染管线拆解
Joplin 的 Markdown 渲染不是简单的marked或markdown-it调用,而是一条完整的管线:原始 Markdown 文本先经过预处理(比如处理数学公式、图表语法),然后交给 Markdown 解析器生成 HTML,再经过后处理(比如代码高亮、链接处理),最后注入到预览面板的 DOM 里。
这条管线的核心在packages/renderer目录下。MarkdownIt.ts是解析器的封装,markdownItPlugins.ts注册了所有插件。如果你想加自定义语法,比如支持==高亮==这种标记,可以写一个 markdown-it 插件,然后在markdownItPlugins.ts里注册。
// 自定义 markdown-it 插件示例:支持 ==高亮== function highlightPlugin(md) { md.inline.ruler.before('emphasis', 'highlight', (state, silent) => { const start = state.pos; if (state.src.slice(start, start + 2) !== '==') return false; const end = state.src.indexOf('==', start + 2); if (end === -1) return false; if (!silent) { const token = state.push('highlight', '', 0); token.content = state.src.slice(start + 2, end); } state.pos = end + 2; return true; }); md.renderer.rules.highlight = (tokens, idx) => { return `<mark>${tokens[idx].content}</mark>`; }; }这个插件注册后,预览面板里==文字==就会渲染成高亮效果。但要注意,Joplin 的渲染管线在移动端和桌面端略有差异,移动端可能不支持某些插件。如果你的插件要跨平台,需要在两端都测试。
5.2 自定义 CSS 的注入方式
改样式比改渲染逻辑简单得多。Joplin 支持通过userstyle.css和userchrome.css注入自定义样式。userstyle.css作用于渲染后的笔记内容,userchrome.css作用于应用界面本身。
文件位置在用户配置目录下,可以通过joplin.settings.globalValue('profileDir')获取。但更推荐的做法是在插件里通过 API 动态注入:
// 在插件中注入自定义样式 await joplin.views.panels.setHtml(panelId, ` <style> .my-custom-class { color: #e74c3c; } </style> <div class="my-custom-class">自定义内容</div> `);如果你要改的是笔记预览的样式,比如调整代码块背景色、修改标题字号,直接写userstyle.css更简单。但要注意,Joplin 的预览面板用了 Shadow DOM,某些样式可能被隔离,需要用::part()或:host选择器穿透。
我踩过的一个坑是:在userstyle.css里用了!important覆盖样式,结果升级 Joplin 后内置样式变了,我的覆盖规则导致显示异常。后来学乖了,尽量用更具体的选择器而不是!important,并且每次升级后都检查一遍样式。
5.3 代码高亮的定制
Joplin 默认用 highlight.js 做代码高亮,支持的语言很多,但默认主题不一定符合你的审美。你可以在设置里切换主题,也可以自己写 CSS 覆盖。
如果你想加一个 highlight.js 不支持的语言,需要注册自定义语言定义。这个在插件里做比较麻烦,因为 highlight.js 的实例是 Joplin 内部管理的。一个变通方案是:在渲染前用正则把自定义语言的代码块替换成 HTML,绕过 highlight.js。
// 在 markdown-it 插件中处理自定义语言 md.renderer.rules.fence = (tokens, idx) => { const token = tokens[idx]; if (token.info === 'mylang') { // 自定义渲染逻辑 return `<pre class="mylang">${escapeHtml(token.content)}</pre>`; } // 其他语言交给默认渲染器 return defaultFenceRenderer(tokens, idx); };这种方式适合语法简单的自定义语言,如果语法复杂,还是建议老老实实写 highlight.js 的语言定义。
6. 构建、打包与性能调优
6.1 生产构建的配置差异
开发环境跑通之后,下一步是构建生产版本。Joplin 的构建脚本在package.json的scripts字段里,桌面端用yarn dist命令。但直接跑这个命令可能会失败,因为生产构建对代码质量要求更严格——TypeScript 类型检查更严、ESLint 规则全开、未使用的变量会报错。
我建议在构建前先跑一遍yarn lint和yarn tsc,把类型错误和 lint 问题都修掉。特别是 TypeScript 的strict模式,开发时可能没开,但生产构建默认是开的。一些在开发时能跑的代码,生产构建时会直接报错。
构建产物在packages/app-desktop/dist目录下,Windows 是.exe安装包,macOS 是.dmg,Linux 是.AppImage或.deb。如果你只是自己用,不需要打安装包,可以直接跑yarn start-prod,它会用生产配置启动应用,但不打包。
6.2 启动速度的优化空间
Joplin 启动慢是社区里经常被吐槽的点。我实测下来,冷启动大概要 3 到 5 秒,笔记多了之后更慢。分析下来,瓶颈主要在几个地方:
- 数据库初始化:SQLite 打开数据库、执行迁移脚本、加载索引,这一步耗时最多。
- 插件加载:每个插件都要初始化,插件多了会明显拖慢启动。
- React 渲染:主界面组件树很大,首次渲染耗时不少。
优化手段有限,但有几个可以尝试:禁用不常用的插件、定期清理数据库(VACUUM命令)、减少笔记列表的初始加载数量。Joplin 设置里有一个“最大渲染笔记数”的选项,调小它可以加快启动,但代价是滚动时可能卡顿。
如果你在开发自己的分支,可以考虑把数据库初始化改成懒加载——启动时只加载必要的数据,其他数据等用到时再查。但这涉及较大的架构改动,风险不小。
6.3 大数据量下的性能表现
我自己的笔记库有大约 5000 条笔记,加上附件总共 2GB 左右。在这个量级下,Joplin 的表现总体可接受,但有几个场景会明显卡顿:
| 场景 | 表现 | 优化建议 |
|---|---|---|
| 全文搜索 | 首次搜索 2-3 秒 | 建立 FTS 索引,避免LIKE查询 |
| 切换文件夹 | 1-2 秒 | 减少初始加载数量,用虚拟滚动 |
| 打开大笔记 | 3-5 秒 | 分块渲染,延迟加载图片 |
| 同步 | 30 秒到 2 分钟 | 增量同步,避免全量对比 |
全文搜索是最大的痛点。Joplin 默认用 SQLite 的 FTS5 扩展做全文索引,但索引更新是异步的,新笔记可能要等一会儿才能搜到。如果你经常搜索,可以在设置里调大索引更新频率,但会增加 CPU 占用。
另一个坑是附件处理。Joplin 把图片、PDF 等附件存在resources目录下,数据库里只存元数据。如果附件很多,同步时会逐个上传,速度很慢。可以考虑把附件单独同步,或者用外部图床替代。
7. 我踩过的那些坑与对应的解法
7.1 数据库迁移失败导致启动崩溃
有一次我改了一个数据模型的字段,忘了写迁移脚本,结果启动时数据库 schema 和代码不匹配,直接崩溃。Joplin 的迁移机制在packages/lib/database/migrations目录下,每个迁移是一个单独的文件,按时间戳排序。如果你改了模型定义,必须同步加一个迁移文件,否则老用户升级时会出问题。
迁移文件的写法有固定模板:
// 迁移文件示例 export const up = async (db: any) => { await db.exec('ALTER TABLE notes ADD COLUMN my_new_field TEXT'); }; export const down = async (db: any) => { await db.exec('ALTER TABLE notes DROP COLUMN my_new_field'); };up是升级时执行,down是回滚时执行。注意 SQLite 的ALTER TABLE支持有限,不能直接改列类型或删列(老版本),需要用临时表的方式绕过去。
7.2 插件权限被拒的排查思路
插件安装后不生效,最常见的原因是权限声明不对。manifest.json里的permissions字段需要明确列出插件要用的 API 权限,比如data、commands、settings等。如果插件调用了未声明的权限,Joplin 会静默拒绝,不会报错。
排查方法:打开开发者工具,在 Console 里看有没有权限相关的警告。如果有,检查manifest.json的permissions数组,确保包含了所有用到的 API 类别。另外,app_min_version如果设得太高,低版本 Joplin 会直接不加载插件,也不会有明显提示。
7.3 同步冲突的真实处理过程
同步冲突是分布式系统的经典问题。Joplin 的策略是:如果同一条笔记两边都改了,保留updated_time较新的那个,同时把旧版本另存为冲突副本。冲突副本的标题会加上“冲突”前缀,放在同一个文件夹下。
我遇到过一种情况:两台设备都离线编辑了同一条笔记,然后先后上线同步。第一台设备同步后,第二台设备同步时检测到冲突,生成了冲突副本。但问题是,冲突副本的内容是第二台设备的版本,而主笔记变成了第一台设备的版本。如果你没注意到冲突副本,可能会以为自己的修改丢了。
处理建议:定期检查有没有冲突副本,特别是在多设备频繁切换的场景下。Joplin 没有自动合并冲突内容的功能,只能手动对比合并。如果你经常遇到冲突,可以考虑减少同时编辑同一笔记的频率,或者用版本控制工具管理笔记库。
7.4 构建产物体积过大的问题
默认构建出来的安装包大概 200MB 左右,主要是 Electron 运行时占了大头。如果你要分发自己的定制版,这个体积可能有点大。优化手段包括:用electron-builder的asar打包、剔除不必要的语言包、压缩图片资源。但 Electron 本身的体积很难降下来,除非换用 Tauri 之类的轻量方案,但那意味着重写整个桌面端。
我的建议是:如果只是自己用,不用太在意体积;如果要分发给团队,可以考虑只分发核心文件,让用户自己装 Electron 运行时。但这样部署起来麻烦,不太推荐。
8. 从开发到分发的完整路径
走到这一步,你应该已经能跑起来一个可用的 Joplin 开发环境,理解核心数据模型和同步机制,能写一个功能完整的插件,也知道怎么构建和优化。但开发只是第一步,真正让插件或定制版产生价值,还需要考虑分发和维护。
插件分发最直接的渠道是 Joplin 官方插件仓库。提交插件需要遵循一套格式规范,包括manifest.json的字段要求、README 的写法、版本号的管理。官方仓库的审核不算严格,但基本的功能测试和代码规范检查还是有的。提交后一般几天内会有反馈。
如果你不想走官方渠道,也可以自己托管插件文件,让用户手动安装。Joplin 支持从文件安装插件,用户下载.jpl文件后在设置里导入即可。这种方式适合内部工具或实验性插件。
维护方面,最大的挑战是跟上 Joplin 主版本的更新。Joplin 的 API 虽然相对稳定,但偶尔会有破坏性变更。我的做法是:在插件里声明一个较宽的app_min_version范围,然后在 CI 里定期跑测试,确保新版本 Joplin 下插件仍然可用。如果官方 API 有变更,及时跟进适配。
最后分享一个我自己的经验:开发 Joplin 插件最大的收获不是插件本身,而是通过阅读它的源码,学到了一个成熟开源项目是如何组织代码、处理边界情况、设计扩展点的。这些经验在我后来做其他项目时反复用到。如果你也在做类似的事情,建议不要只盯着自己的功能,多花点时间理解整体架构,长期来看回报更大。