记事本这玩意儿,看着简单,做起来全是坑。之前我接了一个内部工具需求,要做一款能随手记录、随时翻查、离线也不慌的轻量记事本,产品代号叫“轻记”。从UI线框到功能实现,前后大概花了三周时间,中间踩了不少印象深刻的坑。这篇文章把我的整个思考和落地过程拆开讲一遍,重点不是罗列代码,而是告诉你为什么这样设计UI、为什么这样选技术方案,以及在做到第三周时被什么问题坑到回去改架构。如果你正准备自己写一个记事本应用,或者只是好奇一款笔记工具是怎么从零到一被做出来的,这篇内容应该能帮你少走不少弯路。文中的方案以Web技术为底座,但思路完全可以平移到原生应用上。
1. 项目概述与需求拆解
1.1 高效记事本的定义与用户画像
什么叫高效记事本?不是堆功能,而是用最短路径完成“捕获想法”和“找回想法”这两件事。一个合格的记事本应该让用户从打开应用到开始打字,时间控制在2秒以内;从输入关键词到看到目标笔记,时间控制在3秒以内。这个目标听起来简单,但真做起来,很多细节都会拖慢其中一步。
我在开始前先列了几种典型用户:设计师想随手记录配色和排版灵感,开发人员想存常用代码片段和排查笔记,学生想记考点和错题思路。他们的共同点是输入频率高、记录场景碎片化、不愿意花时间管理文件夹、希望打开软件就能用。基于这些画像,我明确了第一版不需要做富文本排版、不需要多人协作、不需要复杂的目录树,而是把精力放在“即时打开”和“快速检索”上。
这个定义影响了后面所有设计决策。比如列表页必须默认展示最近更新的笔记,而不是让用户先选一个分类;新建按钮必须是全局且显眼的,而不是藏在菜单里;搜索框必须能在列表页直接呼出,而不是单独跳转一个页面。用户不关心你的笔记底层存成什么格式,只关心“刚才那条灵感现在能不能马上翻出来”。
1.2 核心功能清单与优先级
功能优先级如果排错了,整个项目就会越做越重。我在需求阶段把候选功能列成一张表,按P0/P1/P2分级,只有P0是第一个迭代必须完成的。
| 优先级 | 功能 | 原因 |
|---|---|---|
| P0 | 纯文本/Markdown记录 | 核心中的核心,所有其他功能都基于它 |
| P0 | 自动保存 | 没有自动保存的记事本,用户心理负担极重 |
| P0 | 全文搜索 | 笔记量上去后,唯一可靠的找回方式 |
| P0 | 离线可读 | 记事本必须能在断网、地铁、飞机上打开 |
| P1 | 标签分类 | 轻量过滤,替代文件夹 |
| P1 | 图片附件 | 随手拍名片、白板、截图是高频场景 |
| P1 | 多端同步 | 手机和电脑之间的数据打通 |
| P2 | 手绘涂鸦 | 画布功能,但优先级靠后 |
| P2 | 语音输入 | 后期可以考虑,但不影响第一版 |
为什么不做富文本?因为富文本编辑器会带来大量边界问题:复制粘贴样式混乱、不同设备渲染不一致、存储体积大、历史记录难做。而记事本的核心价值是“记录效率”而不是“排版效果”。用纯文本或Markdown,体积小、迁移容易、渲染可控。用户需要结构化展示时,一键切换预览模式就够了。
P0的组合实际上就是一个最小可行性产品:列表页、编辑页、搜索、自动保存、本地持久化。前第一周只做这些,把基础链路跑顺后,再逐步加标签、图片和同步。如果一开始就想着把所有功能都做出来,大概率两周以后还在跟富文本编辑器搏斗。
2. UI设计的关键决策
2.1 信息架构与零导航设计
我第一个版本设计了一个底部Tab栏,包含“全部笔记”“标签”“设置”三个入口。让几个同事试用后,反馈都很礼貌:功能都有,但总觉得要先想一下点哪里。问题出在导航层级过深,用户记录时被“选择位置”这件事打断。后来我取消了底部Tab,改成所谓的“零导航”结构:打开应用就是一个最近更新列表,顶部一个搜索框,右下角一个新建按钮。所有操作都在一个页面状态机里流转,而不是页面跳转。
信息架构最终是这样的:列表页是首页,展示标题、摘要、更新时间;点击列表项进入编辑页;搜索框展开后覆盖列表,实时展示搜索结果;标签则是一行可横向滚动的过滤胶囊,放在列表上方。这样设计后,从打开到新建只点一次新建按钮,从列表到搜索只点一次输入框。没有侧边栏、没有多级页面、没有先选文件夹的步骤。
列表项的内容也做了取舍。只展示三要素:标题、首段摘要、更新时间。不展示字数、附件图标、标签序号等冗余信息。因为视觉噪音多了,人眼扫描效率就会下降。标签在列表页不直接显示,而是在进入笔记详情或标签过滤时展示,避免每一条笔记都堆满彩色标签。
空状态我也专门做了设计。首次进入没有任何笔记时,不能在页面中央直接给用户一片空白,那样会有一种“软件坏了”的错觉。我放了居中插画和引导文案“记录你的第一条想法”,点击后直接新建笔记。用户永远不会站在一个空路口不知道该干嘛。
2.2 视觉细节与交互反馈
视觉上,我选择了低饱和的浅色背景,而不是纯白。纯白在长时间阅读时容易产生炫光感,纯黑文字也容易显得刺眼。正文默认16号字体、1.6倍行距、8像素段间距,保证中文排版的可读性。强调色只保留一个,用在新建按钮、保存状态、搜索高亮这几处,不会到处用彩色。
新建按钮的尺寸我特别调过。在移动端,浮动操作按钮的最小触控区域必须不小于48×48像素,否则用户经常点不到。桌面端也不能无限缩小。按钮位置固定在右下角,正好在大拇指自然覆盖的范围内。点击新建后,列表顶部立刻出现一条空笔记的占位卡片,然后自动进入编辑页面并聚焦到正文,不需要用户再点击“确定新建”之类的按钮。
自动保存的反馈,我踩过“弹Toast”的坑。最初每次保存成功都弹“已保存”,结果用户每敲几行字就看见一个弹出提示,极其干扰。后来改成在标题栏右侧显示状态文字:未保存时显示浅色“未保存”,保存中显示灰色“保存中”,保存完成后短暂显示绿色“已保存”然后淡出。这个反馈安静且明确,用户想确认时瞄一眼就行。
动画方面,列表删除不要做缩放,缩放会让文字残影发虚。我采用了侧滑位移加透明度渐变,时长控制在250到300毫秒之间。页面切换则只用30到50毫秒的透明度过渡,不做横向滑动,因为横向滑动容易让人误以为翻页。这里还有一个原则:一切动效不能让用户等待。如果动画超过400毫秒,就会明显感觉到卡顿。
3. 技术选型与架构搭建
3.1 前端框架与本地存储方案
技术选型上,我直接用Web技术栈(HTML、CSS、TypeScript)来实现,因为一套代码可以覆盖桌面浏览器和移动端WebView。没有引入重量级UI框架,列表、编辑器、状态管理都是自己封装的轻量组件,打包体积控制得更小,启动也更快。
数据存储是决定架构的关键。我对比了三种浏览器端方案:
| 方案 | 容量限制 | 异步 | 索引与查询 | 适合场景 |
|---|---|---|---|---|
| localStorage | 通常5MB左右 | 同步 | 无 | 少量配置项 |
| IndexedDB | 数百MB以上 | 异步 | 支持索引和事务 | 大量结构化数据 |
| SQLite (WASM) | 看浏览器磁盘 | 异步 | 完整SQL | 超大规模检索 |
最终选了IndexedDB。容量足够大,不怕笔记数量上涨;异步读写不会在主线程里产生同步阻塞;还支持索引、事务,可以做按时间排序查询。虽然它没有SQL那么方便,但一个记事本的核心查询无非就是按更新时间倒序、按标题过滤、按内容包含搜索,这些用object store加索引完全能实现。
数据模型我这样定义:
interface Note { id: string; // 主键,UUID或时间戳随机 title: string; // 标题字段,保存时从正文截取 content: string; // Markdown正文 tags: string[]; // 标签数组 createdAt: number; // 创建时间戳 updatedAt: number; // 最后修改时间戳 deletedAt: number | null; // 软删除时间戳,同步时使用 }很多新手会把标题直接从正文里截取而不单独存储,这是个隐患。列表页渲染时如果动态去解析全文拿第一行,每渲染一条笔记就要解析一遍,几百条笔记就会明显卡顿。所以我在保存时就把标题截取出来写成独立字段,列表页只读这个字段,性能会好很多。
IndexedDB的原生API用起来比较繁琐,我封装了一个数据访问层,对外只暴露listNotes、getNote、saveNote、softDeleteNote这些方法。内部统一处理数据库版本升级、事务、索引查询,业务层完全不感知底层细节。
3.2 编辑器内核与文本处理
编辑器是另一个容易陷进去的大坑。我对比过几种方案:原生textarea、contenteditable、基于contenteditable的开源编辑器内核、块级编辑器。contenteditable虽然看着灵活,但粘贴、光标、历史记录、中文输入法组合这些坑会让你耗费大量时间。块级编辑器将文档拆成段落数组,每段一个textarea,好处是可控制,坏处是维护复杂。对一个轻量记事本来说,最简单可靠的就是原生textarea。
因此轻记的编辑器就是<textarea>,它保存的是纯文本Markdown字符串。所有存储、搜索、传输都基于这个字符串,不做富文本状态。预览时用Markdown渲染器把字符串转为HTML展示在另一个只读面板中,默认不开启,用户可以点击“预览”按钮切换。
需要注意的是Markdown渲染的安全问题。用户输入的content是一段不可信文本,不能直接innerHTML塞进页面。渲染前必须先做HTML转义,再应用Markdown解析规则,并且过滤危险属性。比如用户输入<script>、<img onerror>这类内容,必须被转义或过滤。否则一条笔记就能在本地执行任意脚本,这是绝对不能接受的。
输入过程中还有一个容易被忽略的性能点:不要在每次keyup事件里全量解析Markdown并渲染预览。因为用户连续打字时,每个按键都会触发一次渲染,非常消耗CPU。我做了这样的策略:编辑状态下不渲染预览,只有用户主动切到预览模式或者保存后需要展示摘要时才渲染。如果未来需要实时预览,也必须使用防抖加增量渲染。
中文输入法组合状态也要处理。在compositionstart到compositionend之间,keyup事件会频繁触发,但输入内容并未真正确定。这期间如果执行自动保存,很容易把拼音中间态写入数据库,造成一串乱码。正确做法是监听composition事件,在组合期间跳过保存,在组合结束后再触发一次保存。
4. 核心功能实现详解
4.1 笔记的创建、编辑与自动保存
新建笔记的流程我设计成:点击浮动新建按钮,立刻在内存里生成一条Note对象,插入列表顶部,然后跳转编辑页并聚焦。由于内容还没有保存,这条笔记的状态就是“未保存”。用户输入第一段文字后,自动保存机制开始接管。
自动保存的核心是防抖。不能每敲一个字母就写一次数据库,那样性能太差。但也不能把间隔拉太长,否则内容容易丢。我用了500毫秒防抖:用户停止输入500毫秒后,才执行保存。代码大致如下:
let saveTimer: number | undefined; let draftDirty = false; function onEdit(note: Note) { draftDirty = true; updateTitleFromContent(note); updateStatusUI('unsaved'); clearTimeout(saveTimer); saveTimer = window.setTimeout(() => flushSave(note), 500); } function flushSave(note: Note) { if (!draftDirty) return; draftDirty = false; updateStatusUI('saving'); saveToDB(note).then(() => { updateStatusUI('saved'); }); } window.addEventListener('pagehide', () => flushSave(currentNote)); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { flushSave(currentNote); } });页面隐藏时强制保存这条逻辑非常关键。用户在手机上切到其他应用,或者直接锁屏,浏览器不一定会触发beforeunload,但一定会触发visibilitychange。如果只依赖防抖,那用户在防抖还没触发前就锁屏,内容就会丢失。
我又加了一层保险:每次输入时把当前草稿的纯文本同步写入sessionStorage。sessionStorage是同步API,写入速度快,而且页面会话期间可以安全读取。当IndexedDB写入成功后再清除这份临时备份。这样就算浏览器进程被系统杀掉,下次恢复页面时也能提示用户找回草稿。
写入数据库时还有一个细节:不能同时发起多个对同一笔记的写入。如果用户在保存还没完成时继续输入,就会触发第二次保存,两个异步操作交错会导致旧数据覆盖新数据。解决方法是建立一个写队列,所有保存操作串行执行。前一个Promise resolve后,再取队列里的最新数据执行下一个写入。
4.2 搜索、标签与组织体系
搜索是记事本的灵魂功能之一。轻记的搜索覆盖标题和正文,但实现上没有用复杂的全文索引,而是采用了一个折中方案:列表页启动时只加载笔记的基础字段,搜索时再按需读取正文内容。
具体做法是:用户输入关键词后,先在标题字段上进行快速过滤,得到一批候选结果;然后对候选结果逐条取出content,用包含匹配判断是否命中。当笔记总量在几千条这个量级时,整个流程可以控制在几十毫秒内,完全够用。只有当用户有上十万条笔记时,才需要引入倒排索引或让后端来做搜索。
搜索结果要展示命中位置。我用一个高亮函数,把匹配到的关键词前后截取一段作为摘要,并用<mark>标签包裹。这里必须注意先转义再拼接,否则笔记里的HTML内容会被浏览器执行。搜索关键词本身也要做正则转义,否则用户输入(,[这类字符时正则表达式可能会报错。
function escapeRegExp(str: string) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function highlight(text: string, keyword: string) { const escaped = escapeRegExp(keyword.trim()); if (!escaped) return escapeHtml(text); const regex = new RegExp(`(${escaped})`, 'gi'); return escapeHtml(text).replace(regex, '<mark>$1</mark>'); }标签体系上,我没有做文件夹树。文件夹的问题是强制用户在新建之前思考“这条笔记应该放哪里”,时间成本高,归档率低。而标签是弱结构,用户写完之后顺手打一个词,后面靠点击筛选就能找到同类笔记。轻记里每条Note有一个tags: string[]字段,全局标签列表通过遍历所有笔记的tags汇总得出。要筛选某个标签,就过滤出所有tags包含该标签的笔记。
设计标签筛选入口时,我最初放在设置页,后来发现使用率极低。改成列表页顶部的一排横向滚动胶囊后,使用率明显上升。这印证了一个观点:工具类产品的常用功能必须暴露在首页,不能藏在菜单深处。
4.3 跨端同步与离线可用
同步是记事本项目里最复杂的部分。我的设计原则是“本地优先,离线优先”:应用启动后先渲染本地数据,同步在后台悄悄进行,绝不能因为网络问题卡住用户输入。
每条笔记携带updatedAt时间戳和deletedAt软删除标记。同步服务端保存每篇笔记的元数据和内容。客户端启动后,把本地updatedAt大于上次同步时间的笔记作为增量上传;同时拉取服务端比自己新的增量,更新本地。这样正常情况下的多端数据能保持最终一致。
同步冲突是最容易踩坑的地方。最简单粗暴的做法是LWW,谁的时间戳新就用谁的。但这里面有个问题:用户在手机端编辑了一段话,更新时间是10:00,电脑端也编辑同一篇笔记,更新时间是10:01,电脑端获胜后,手机端10:00的改动就静默丢失了。用户无法感知,等发下一条笔记时已经无法找回。
我采用的策略是“冲突副本”:服务端比较同一笔记的updatedAt,如果远端比本地新,就返回一个冲突标记。客户端收到标记后,不直接覆盖本地内容,而是把本地的当前版本复制成一篇新笔记,标题末尾加“_副本”,再推送服务端。这样用户能看到两个版本,手动决定保留哪一个。虽然不够智能,但至少不会静默丢数据。
软删除的意义在同步中特别重要。如果用户删除笔记时直接物理删除记录,那么其他设备同步的时候无法得知“这条笔记已经被删了”,因为增量拉取里根本没有它,它就会在其他设备上一直存在。改成写deletedAt字段后,删除操作就有了一条可以被同步的记录,其他设备拉取后也会执行逻辑删除。
同步队列也要设计好。所有本地修改先写入IndexedDB,同时往syncQueue里追加一条同步记录。网络未连接时,队列不断堆积;网络恢复后,按顺序逐条上传,收到服务端成功响应后再把队列项移除。上传失败时需要指数退避重试,不能死循环。如果用户编辑频繁,队列里同一篇笔记可以合并成最新的一条,避免重复上传历史版本。
5. 性能优化与体验打磨
5.1 启动速度与列表渲染优化
启动速度直接决定工具的第一印象。我优化时定了一个指标:从应用加载到列表首屏可交互,在普通中端手机上不能超过1秒。为此做了两件事:第一,首屏只加载笔记列表的基础字段,不加载正文内容;第二,列表接口只查询最近100条,滚动到底部再加载下一页。
IndexedDB查询时,我建了updatedAt索引,按时间倒序读取游标。每次取一页,渲染到DOM后记录当前游标位置,滚动到底部时继续取下一批。这样启动时IO开销很小,内存也不会被几千条全文占满。
列表项渲染这块,如果需要无分页的连续滚动体验,可以上虚拟列表。一个更轻量的方案是在CSS中使用content-visibility: auto:
.note-item { content-visibility: auto; contain-intrinsic-size: 72px; }这样浏览器会自动跳过视口外元素的渲染,对性能提升非常明显。我实测在几百条笔记的页面上,滚动流畅度提高了不少,接入成本却几乎为零。
图片附件的处理也要优化。不要把图片的完整Blob放进列表查询结果里,否则渲染列表时要解码大量图片,滚动就会掉帧。保存图片时先生成一张小尺寸缩略图,列表页只显示缩略图地址;点击进入详情页后再加载原图,并且给原图加上loading="lazy"。缩略图的Object URL在用完之后要手动释放,否则会有内存泄漏。
另一个容易被忽略的问题是编辑时列表排序跳动。用户正在编辑笔记A,因为每次保存都更新了updatedAt,列表会自然把A排到顶部。但用户此时很可能没有看列表,而是正盯着编辑页。所以我在“编辑未退出”期间,不让列表重新排序。只有退出编辑页或新建笔记时,才重新拉取列表。否则回到列表时看到的顺序和自己离开时不一样,会造成“东西凭空出现又消失”的错觉。
5.2 动效与反馈的细节处理
功能完整不代表体验好,关键在于反馈是否恰如其分。保存状态用顶部状态文字这个方案已经说过了,它比弹窗友好得多。删除功能我建议做成“删除后出现撤销按钮,而不是二次确认弹窗”,因为二次确认会打断节奏。用户点删除时其实心里很明确,真正的问题只是“删错了能不能回来”。所以删除后显示一条轻量提示条“笔记已删除,点击撤销”,5秒后消失,同时在隐藏区保留一份备份,防止撤销超时后找不回。
动画性能上有一个原则:不要对高频率变化的元素做高开销动画。比如列表项滑动删除时,可以用transform: translateX,但will-change不要全局开启,否则浏览器会为每个列表项分配独立合成层,内存暴涨。我只在触摸开始的那一个列表项上开will-change,触摸结束后移除。
用户的系统可能开启了“减少动态效果”。我应该用媒体查询尊重这个偏好:
@media (prefers-reduced-motion: reduce) { .note-item { animation: none; transition: opacity 50ms; } }这样既照顾了普通用户,也照顾了对动画敏感的人群。记事本本来就不是一个强调炫技的产品,动效的存在是为了让状态变化清晰可见,而不是为了好看。
移动端编辑页面还有一个很影响体验的点:软键盘弹出时,光标不能被键盘遮挡。实现上,在光标位置变化时调用scrollIntoView({ block: 'nearest' }),让当前行滚到可视区;或者监听键盘高度变化,给编辑区底部留出足够padding。桌面端没有这个问题,但移动端必须真机测试,模拟器测不出来。
6. 常见问题与调试记录
6.1 输入卡顿问题排查
项目做到第二周时,试用的同事反馈:在低端安卓机上连续打字,界面每隔几百毫秒就卡一下。我第一反应是框架问题,后来逐步排查才发现根本不是。
我先做了一个隔离实验:把所有笔记清空到只剩一条,卡顿依然存在,说明不是列表渲染问题,而是编辑器路径。接着用浏览器性能分析工具录制了五秒输入过程,看到大量长任务,每执行一次IndexedDB写入大约占用主线程100到200毫秒。再一看代码,原来每次keyup都直接调用了saveNote,完整序列化整个大对象,并且同步刷新了列表摘要。
修复方法有三步:第一,把“每次输入都保存”改成“停止输入500毫秒后保存”;第二,IndexedDB写入放到异步分支里,不要同步阻塞;第三,列表标题的截取也放到保存时做,不再每个keyup算一遍。性能优化后,长任务从平均200毫秒降到50毫秒以下,输入不再有粘滞感。
这个坑给我一个很深的教训:不要把数据库写入和渲染直接绑进输入事件里。输入事件是高频率事件,任何超过1毫秒的同步工作都应该想办法移出去。同级的问题还包括在输入时更新列表排序、渲染Markdown预览、更新字数统计。这些都应该用防抖或者放到后台。
6.2 数据丢失场景与预防
自动保存机制看起来完善,但还是有一个真实场景会丢数据:用户快速输入一段文字,在500毫秒防抖窗口内直接锁屏,然后锁屏时间太长,浏览器将页面置为冷冻或销毁。防抖还没来得及执行,内容就没了。
防丢策略我在前面已经说过,核心是监听visibilitychange。这里再补充一个更底层的手段:申请持久化存储。浏览器在存储空间紧张时会把某些站点数据回收,尤其是有隐私模式或长时间不访问的标签。通过navigator.storage.persist()申请持久化授权,成功后浏览器不会被自动清除。
async function requestPersistentStorage() { if (navigator.storage && navigator.storage.persist) { const persistent = await navigator.storage.persist(); if (persistent) { console.log('持久化存储已获得'); } } }每天首次启动时,我会生成一个JSON导出备份保存到本地,保留最近7个。这个备份不仅防丢,还能在IndexedDB损坏时做恢复。实际开发中就遇到过一次:连续几次强杀进程后,IndexedDB的某个object store读不出来,整个页面白屏。后来我做了启动时的完整性检查,发现数据库状态异常就清空重来,再从备份恢复。有备份兜底,风险就可控了。
6.3 多端冲突的处理策略
同步冲突问题是在项目第三周才真正暴露的。最初我自己测试都是单设备,看起来一点问题没有。后来让两个设备同时打开同一篇笔记,各改几段文字然后分别上线,就出现了“手机上编辑的内容被电脑端覆盖”“合并后段落顺序错乱”的情况。
原因就是前面说的LWW策略太粗暴。虽然服务端有updatedAt比较,但A设备上传时,B设备已经提交了更新;A设备不知道服务端有新版本,就直接覆盖了。覆盖发生在服务端,客户端完全无感知。
改进后的流程是:上传前,服务端比对请求中的baseVersion与当前版本。如果baseVersion小于当前版本,说明存在冲突,返回冲突标记。客户端收到标记后,将当前本地内容复制为新笔记“内容_副本”,原内容保留为服务端最新版本。同时给用户一个清晰提示:“该笔记已在其他设备被修改,本机版本已保留为副本”。用户可以选择丢弃副本或手动合回。
如果未来想做更智能的合并,可以尝试段落级diff合并:先对两端文本做分块diff,如果不同区块没有交集就自动合并,交集冲突再生成副本。但这个逻辑复杂,对普通用户来说“生成副本”这个策略已经足够清晰和安全。
比较一下三种冲突策略:
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| LWW覆盖 | 实现最简单 | 静默丢数据 | 单人单设备 |
| 冲突副本 | 不丢数据,逻辑清晰 | 会出现重复笔记 | 绝大多数场景 |
| 段落级合并 | 自动合并较好 | 复杂,难准确 | 需要深度协同 |
轻记选择“冲突副本”,用户自己决定去留。这条经验想强调一点:同步功能一定要尽早搭建独立测试环境,至少准备两台设备或两个浏览器配置文件,模拟断网、编辑、恢复、上线的流程。不要等发布前一天才去测,那时候会发现的问题永远比想象中多。
整个项目做下来,我的体会是记事本这种看似简单的工具,难点全在边界场景。你花掉的时间里,大概50%在调试自动保存的时机,30%在应付同步冲突,20%才是UI细节。如果你想从零做个类似的东西,我建议先做最小闭环:列表、编辑器、本地存储,跑通之后再考虑标签和同步。UI上宁可少一点花哨,也要保住“打开就能记,记完就能找”的感觉。另外还有一个小技巧:同步功能千万别拖到上线前才测,最好在项目第二周就建起来,让两个设备持续各写各的,第三天就能暴露大量冲突问题。