纯前端 ORK 目标管理工具:IndexedDB 本地存储与进度计算 目标管理这件事卡住大多数人的从来不是工具而是写下第一个目标那一刻的犹豫。我见过的失败案例里十有八九不是软件不好用而是软件太重了——要先注册、建空间、邀请成员、配置权限折腾半小时热情已经耗光了。所以我一直想做一个东西打开网页就能用数据全在自己机器上关掉网络也能继续写功能上把 ORKObjectives、Results、Key results目标与关键结果那套方法论的核心骨架完整撑起来。这篇就是我把这个纯前端 ORK 管理软件从零做出来的完整过程记录包括数据模型怎么拆、进度怎么算、本地存储怎么保证不丢、以及上线之后被真实使用打脸的那几个地方。适合有基础前端能力、想自己撸一个效率工具的人也适合产品同学看看一个目标管理工具的最小完整形态到底长什么样。1. 纯前端做 ORK 工具到底是省事还是自找麻烦先把这个决定的账算清楚。纯前端意味着没有后端、没有数据库服务、没有账号体系所有逻辑跑在浏览器里所有数据存在 IndexedDB 里。很多人第一反应是这不就是个玩具吗但在 ORK 这个具体场景下它的合理性比想象中高得多。1.1 ORK 的本质是一套结构化记录而不是协作系统把 ORK 拆开看它其实就是四类数据的增删改查目标Objective、关键结果Key Result、进展记录Check-in、周期Cycle。用户的核心动作是季度初写目标 → 每周填进展 → 季度末复盘整个链路上没有强实时协作需求。我做过统计个人和小团队5 人以下使用目标管理工具时真正高频的操作是写目标、改数字、看进度条、翻历史。这些操作全部可以在单机完成。真正的多人协同——比如两个人同时改同一个 KR 的数值——一年可能发生不了几次为它付出后端 用户体系 权限模型的成本非常不划算。所以我的判断是在第一版里把结构化记录这件事做到极致协作部分用导出/导入文件解决是性价比最高的路径。这也是纯前端方案能成立的根本原因。1.2 数据留在本地反而成了一个卖点有个做咨询的朋友跟我说过一句话我印象很深目标里写的东西往往比简历还敏感——收入目标、团队问题、个人短板全在里面。让他把这些填到一个不知道服务器在哪的在线工具里他心里是打鼓的。本地存储在这件事上天然占优。数据不出浏览器不经过任何网络请求用户甚至可以断网使用。这个特性在演示的时候特别有说服力打开开发者工具的网络面板全程零请求说服力比任何隐私政策都强。代价也很明确我必须提前想清楚并在 UI 里明确告知用户风险点具体表现应对策略存储被清理部分浏览器对长期未访问的站点会回收存储引导安装为桌面应用 定期导出提醒换设备即失联没有云端同步一键导出 JSON新设备导入容量上限IndexedDB 通常有配额不像后端可无限扩定期归档旧周期数据无审计日志谁改了什么没有服务端记录本地记录操作流水随备份一起导出这张表我在需求阶段就列出来了后面每一行都对应了具体的功能实现而不是写完再说。1.3 技术栈选型的三个约束条件选型我给自己定了三条硬约束首屏要快、离线要能用、数据层要可测试。基于这三点最终的组合是这样的框架部分 React 和 Vue 都可以我下面用 React 举例但逻辑是通用的构建工具Vite。冷启动和热更新速度快产物是纯静态文件扔到任何静态托管上就能跑不需要 Node 运行时。语言TypeScript。这一条不可妥协因为 ORK 的数据模型有大量嵌套和联合类型不同类型的 KR 有不同的字段没有类型系统后面的进度计算逻辑会写成一团乱麻。存储层IndexedDB用 Dexie 做薄封装。不用 localStorage 的原因后面会详细说简单讲就是容量太小、同步阻塞、只能存字符串。状态管理Zustand 或 Pinia 这类轻量方案。目标管理的数据量不大一个季度几十条记录不需要重型状态库。样式Tailwind 或任意原子化方案。这个项目的 UI 元素高度重复进度条、标签、输入行原子化写起来最快。真正需要花心思的不是用哪个框架而是数据层怎么和 UI 解耦。我采用的是经典的三层UI 组件 → 状态层含派生计算→ 仓储层Repository唯一接触 IndexedDB 的地方。这个分层在第 4 章会展开讲它是整个项目能不能长期维护的关键。提示如果你也打算做类似工具第一条要守住的纪律是——任何组件里都不允许直接出现 IndexedDB 的调用。所有读写必须经过仓储层否则后面加缓存、加迁移、加测试的时候会哭。2. 把 ORK 拆成四张表数据结构定生死我做这类工具有一个习惯先不写一行 UI先把数据模型在纸上画完。因为目标管理软件最怕的就是用着用着发现数据存不下等到有用户数据了再改结构迁移成本会高到你想推倒重来。2.1 四个核心实体的字段设计先说 Objective。它承载的是方向字段要少而准interface Objective { id: string; // uuid客户端生成保证可离线创建 title: string; description?: string; cycleId: string; // 所属周期如 2025-Q2 parentId: string | null; // 上级目标顶层为 null status: active | done | archived; orderKey: string; // 排序用见 5.2 节 createdAt: number; updatedAt: number; }KeyResult 是重点因为我让一个 KR 支持多种度量方式这是它比一个进度条复杂的地方type KRType percent | number | boolean | milestone; interface KeyResult { id: string; objectiveId: string; title: string; type: KRType; startValue: number; // 起始值number 类型必填 targetValue: number; // 目标值 currentValue: number; // 当前值 unit?: string; // 单位如 %、个、篇 weight: number; // 权重默认 1 confidence: number; // 信心指数 1-10 milestones?: { id: string; title: string; done: boolean }[]; // 里程碑型专用 createdAt: number; updatedAt: number; }CheckIn 是流水只增不改改动用软删除标记这样能完整还原一条 KR 的变化曲线interface CheckIn { id: string; krId: string; value: number; confidence: number; note?: string; createdAt: number; }Cycle 相对简单但有个细节周期不能只存名字必须存起止时间戳因为健康度计算要按已过时间占比来算。interface Cycle { id: string; // 2025-Q2 name: string; // 2025 年第二季度 startAt: number; // 毫秒时间戳 endAt: number; status: planning | active | closed; }2.2 对齐关系为什么我最终放弃了单 parentIdORK 的精髓之一是对齐——下级目标要能说清楚自己支撑了哪个上级目标。一开始我用的是 Objective 上的parentId单字段简单直接。做到第二周就撞墙了一个团队的目标经常同时支撑两个上级目标比如提升内容产出效率既服务于用户增长也服务于品牌建设。单 parentId 逼着用户二选一这在真实场景里是错的。所以我改成了独立的关系表interface Alignment { id: string; fromObjectiveId: string; // 下级 toObjectiveId: string; // 上级 weight: number; // 这个下级对上级的贡献权重 }改完之后立刻多出两个必须处理的问题环检测和权重归一。环检测好理解——A 支撑 BB 又支撑 A递归计算进度的时候会栈溢出。我的做法是在写入 Alignment 之前做一次深度优先搜索检查toObjectiveId是否已经能到达fromObjectiveId如果能到达就拒绝写入。这个检查必须在写库之前同步完成不能放到渲染时再判断否则脏数据已经进库了。权重归一是个容易被忽略的细节。假设上级目标 A 有三个下级目标支撑权重分别是 2、1、1那 A 的进度应该是(B进度×2 C进度×1 D进度×1) / 4而不是除以 3。我第一版就漏了归一导致进度条经常超过 100%。这个 bug 在测试阶段没被发现因为测试数据里权重全是 1一除以 N 看不出问题直到我自己录入真实数据才炸出来。2.3 IndexedDB 的索引怎么建用 Dexie 建表的时候声明字符串里的加号代表复合索引方括号代表联合索引这个语法一开始很反直觉我列一下最终版本db.version(3).stores({ objectives: id, cycleId, parentId, status, orderKey, keyResults: id, objectiveId, [objectiveIdorderKey], checkIns: id, krId, [krIdcreatedAt], createdAt, alignments: id, fromObjectiveId, toObjectiveId, cycles: id, status, startAt, meta: key });几个索引的用途说明一下这些都是实际查询时被逼出来的keyResults上的[objectiveIdorderKey]复合索引目标详情页要按顺序拉取某个 O 下的所有 KR有复合索引可以一次范围查询搞定不用取出来再排序。checkIns上的[krIdcreatedAt]画趋势图时按 KR 取时间序列这是最高频的查询之一。alignments上的两个索引既要查我的上级是谁也要查谁支撑了我两个方向都得快。还有一点值得单独说所有主键都用客户端生成的 uuid。不用自增 ID 的原因很简单——纯前端没有中心化的 ID 分配器多标签页同时打开时自增会冲突。uuid 生成成本可以忽略换来的是彻底避免 ID 冲突以及导入外部数据时不会和现有数据撞号。3. 进度与健康度ORK 软件最容易做错的地方如果只能保留一个功能我留进度计算。这块看起来是小学数学实际是重灾区——我见过太多工具在这里偷懒用简单平均糊弄过去结果就是整套方法论失真。3.1 四种 KR 类型四条不同的进度公式不同类型的 KR进度算法完全不同不能一把梭百分比型最直观progress currentValue / 100。适合完成度覆盖率这种天然带百分比的事情。数值型这是最容易写错的一个。正确公式是(current - start) / (target - start)而不是current / target。举例起始值 20目标值 100当前 60正确进度是(60-20)/(100-20) 50%而错误的算法会给出 60%。差别在于前者承认了起点已经有的基础。布尔型只有完成和未完成progress done ? 1 : 0。看起来简单但要处理一个语义问题一个被标记为完成的 KR如果后续被改回未完成历史 Check-in 该怎么算我的处理是以最新状态为准历史记录保留但只用于展示。里程碑型progress 已完成里程碑数 / 总里程碑数。它的存在价值在于让上线一个大功能这种模糊目标可以被拆开度量。这里有个坑必须提逆向指标。像线上事故数降到 5 次以下页面加载时间降到 800ms这类目标target 小于 start。如果不特殊处理数值型公式会算出负数进度。我的做法是不改公式而是在计算前统一判断方向function calcNumberProgress(kr: KeyResult): number { const { startValue: s, targetValue: t, currentValue: c } kr; if (t s) return c t ? 1 : 0; // 防止除零 const raw (c - s) / (t - s); return clamp(raw, 0, 1); } function clamp(v: number, min: number, max: number) { return Math.min(max, Math.max(min, v)); }clamp这一步不能省。因为降事故数这种目标如果当前值已经低于目标值(c-s)/(t-s)会算出大于 1 的数不截断的话进度条会溢出。更麻烦的是负数——如果当前值比起点还差事故数涨了会算出负数进度条渲染直接变形。截断到 [0,1] 之后语义也更清晰你已经完成了或者你一点没动。3.2 加权汇总简单平均是方法论杀手假设一个目标有三个 KRKR1 权重 3最重要的核心指标当前 10%、KR2 权重 1当前 100%、KR3 权重 1当前 100%。简单平均算出来是 70%看起来还不错加权算出来是(0.1×3 1×1 1×1) / 5 26%这才是真实情况——最重要的那件事几乎没动。ORK 方法论里权重就是你有多在乎的量化表达。抹掉权重工具就退化成待办清单了。所以我把权重做成了必填项默认值都是 1但 UI 上会明确提示重要的 KR 请调高权重。汇总到 Objective 层的完整逻辑是这样的function objectiveProgress( obj: Objective, krs: KeyResult[], childObjectives: { progress: number; weight: number }[] ): number { const own krs.length ? krs.reduce((sum, kr) sum krProgress(kr) * kr.weight, 0) / krs.reduce((sum, kr) sum kr.weight, 0) : null; const fromChildren childObjectives.length ? childObjectives.reduce((s, c) s c.progress * c.weight, 0) / childObjectives.reduce((s, c) s c.weight, 0) : null; if (own null fromChildren null) return 0; if (own null) return fromChildren!; if (fromChildren null) return own; return (own fromChildren) / 2; // 自身 KR 与下级目标各占一半 }最后那一行(own fromChildren) / 2是我权衡过的结果。理论上应该再给权重但实测下来用户很难理解我的 KR 和我的子目标谁更重要这种抽象问题五五开是个可解释的默认值而且我在 UI 上把两部分进度分开显示了用户能看明白这个数字是怎么来的。还有个边界情况父目标的进度是递归计算的必须做记忆化。一个三层结构的目标树如果每次都从头递归每次输入框变化都触发全量重算在中低端手机上会明显掉帧。我加了一个按objectiveId updatedAt缓存的 Map只要数据没变就直接命中。3.3 健康度用斜率判断还来不来得及进度条只告诉你走了多远不告诉你走得多快。ORK 复盘时真正有价值的问题是按现在这个速度季度末能不能达成。这就是健康度。我的算法不复杂但阈值调了好几轮。核心思路是取最近 N 次 Check-inN 默认 3少于 2 次就跳过判断对(时间, 进度)做一元线性回归得到斜率然后和剩余时间内的所需斜率做对比。function healthOf(kr: KeyResult, checkIns: CheckIn[], cycle: Cycle) { if (checkIns.length 2) return unknown; const pts checkIns.slice(-3).map(c ({ x: (c.createdAt - cycle.startAt) / DAY, y: c.value })); const slope linearSlope(pts); // 每天涨多少 const remainDays Math.max(1, (cycle.endAt - Date.now()) / DAY); const need (kr.targetValue - kr.currentValue) / remainDays; if (need 0) return done; if (slope 0) return red; const ratio slope / need; if (ratio 1) return green; if (ratio 0.6) return yellow; return red; } function linearSlope(pts: { x: number; y: number }[]): number { const n pts.length; const sx pts.reduce((s, p) s p.x, 0); const sy pts.reduce((s, p) s p.y, 0); const sxy pts.reduce((s, p) s p.x * p.y, 0); const sxx pts.reduce((s, p) s p.x * p.x, 0); const denom n * sxx - sx * sx; if (Math.abs(denom) 1e-9) return 0; return (n * sxy - sx * sy) / denom; }这个 0.6 的阈值是我自己用了一个季度之后定的。最初用 0.8结果满屏飘红反而让人焦虑到放弃看改成 0.5 又太宽松到最后一周才发现红了已经来不及。0.6 大致对应当前速度只有需求的六成需要加把劲但还有救。注意健康度用的是斜率而不是绝对值这点很关键。一个进度只有 20% 的 KR如果每周围都在稳步上涨它是绿的一个进度 90% 的 KR如果过去三周一动不动它应该是红的。只看绝对值的工具会把最危险的情况藏起来。4. 状态层与持久化纯前端的数据可靠性该怎么做纯前端最容易翻车的地方不是 UI是数据。用户敲了半小时的目标一个刷新全没了这个产品就死了。我在这一层花的精力比 UI 多得多。4.1 三层架构UI 只负责渲染仓储只负责存取我把代码分成三层边界非常清楚仓储层Repository唯一的 IndexedDB 入口提供listObjectives(cycleId)、upsertKeyResult(kr)这类方法返回 Promise。状态层Store内存中的完整数据快照 所有派生计算进度、健康度、排序后的列表。UI 从这里订阅。UI 层纯展示和事件转发拿到数据就渲染不要做任何计算。写操作走的是乐观更新 异步落盘先在内存里改UI 立刻响应然后再把变更写进 IndexedDB。这里有个关键设计——写操作要进队列。因为用户快速连点进度 1 按钮时可能同时触发十几个写请求如果不管顺序后写的可能被先写的覆盖。class WriteQueue { private chain: Promisevoid Promise.resolve(); push(task: () Promisevoid): Promisevoid { this.chain this.chain.then(task).catch(err { console.error([write-queue] task failed, err); }); return this.chain; } }用 Promise 链做串行化是最省事的办法不需要引入任何库。代价是同一时刻只有一个写操作在执行但 ORK 这种数据量这个代价完全可以接受。4.2 IndexedDB 事务的坑await 一下就没了这是我在整个项目里踩得最深的一个坑值得单独讲。IndexedDB 的事务有一个非常反直觉的规则当事务创建之后如果当前事件循环里没有新的请求被排入队列事务会自动提交commit。翻译成人话就是如果你在事务回调里await了一个和 IndexedDB 无关的操作比如一个 fetch、一个 setTimeout、一个文件读取等你 await 回来事务已经是 inactive 状态了后续任何请求都会抛TransactionInactiveError。我最初的代码是这样写的看起来很合理// 错误写法 await db.transaction(rw, db.keyResults, db.checkIns, async () { const kr await db.keyResults.get(id); // 这一步之后就危险了 const computed await someAsyncCalc(kr); // 只要这里有 await事务可能已提交 await db.keyResults.put({ ...kr, ...computed }); });修复方式很朴素但需要纪律把所有纯计算放在事务外面事务内部只留 IndexedDB 操作。// 正确写法 const kr await db.keyResults.get(id); // 独立读 const computed someSyncCalc(kr); // 纯同步计算 await db.transaction(rw, db.keyResults, db.checkIns, async () { await db.keyResults.put({ ...kr, ...computed }); await db.checkIns.add(buildCheckIn(kr, computed)); });这个改动看起来只是挪了位置但它是数据偶发丢失这类玄学问题的根源。如果你在做类似项目我建议一开始就把这条纪律写进代码规范里。4.3 数据迁移schema 从 v1 到 v3 的实操只要产品还在迭代schema 就一定会变。我从 v1 到 v3 改了两次结构v1 是 Objective 带 parentId 的单亲模式v2 引入了 Alignment 表v3 给 KeyResult 加了 weight 和 milestones。Dexie 的版本机制是链式的每次db.version(n)只在上一个版本升级完成后执行db.version(1).stores({ objectives: id, cycleId, parentId, keyResults: id, objectiveId, checkIns: id, krId }); db.version(2).stores({ alignments: id, fromObjectiveId, toObjectiveId }).upgrade(async tx { // 把 v1 的 parentId 迁移成 Alignment 记录 const objs await tx.table(objectives).toArray(); const rows objs .filter(o o.parentId) .map(o ({ id: crypto.randomUUID(), fromObjectiveId: o.id, toObjectiveId: o.parentId, weight: 1 })); if (rows.length) await tx.table(alignments).bulkAdd(rows); }); db.version(3).stores({ keyResults: id, objectiveId, [objectiveIdorderKey] }).upgrade(async tx { await tx.table(keyResults).toCollection().modify(kr { if (kr.weight null) kr.weight 1; if (kr.confidence null) kr.confidence 5; }); });有三条经验值得分享第一迁移函数必须幂等。如果用户升级到一半关掉页面下次打开会重新跑一次迁移。我在 v3 里用了if (kr.weight null)这种判断而不是无脑赋值就是为了幂等。第二迁移前自动备份。执行version之前我先把整个库 dump 成一个 JSON 存到 meta 表里。这样即使用户升级后发现问题也能通过一个隐藏入口回滚。这个功能从来没被用过但我知道它在那儿就安心。第三永远不要删除旧版本的stores声明。Dexie 需要完整的版本链才能从 v1 直升 v3删掉 v1 的声明会让老用户升级失败。5. 交互细节决定这个工具有没有人用到这里功能其实已经完整了能建目标、能填 KR、能打卡、能看进度。但我很清楚功能完整和目标管理工具真的被用起来是两件事。目标管理是典型的低频高意愿、极易半途而废的场景交互摩擦每增加一点放弃的概率就上升一点。5.1 键盘流和内联编辑是我最满意的两个设计我自己用了一个季度之后最大的感受是填进展这件事必须快到不需要思考。所以我做了几乎全键盘的操作路径N新建目标E编辑当前选中的条目Esc取消编辑Cmd/Ctrl Enter保存↑ / ↓在 KR 列表里移动1-5快速给信心指数打分内联编辑这块我踩了一个中文输入法的大坑。一开始用的是contentEditable结果发现每次中文输入法还在拼写候选阶段input事件就触发了导致每敲一个拼音字母就保存一次字符串被截断得乱七八糟。后来换成textarea配合自动高度并且用compositionstart/compositionend事件来控制保存时机let composing false; textarea.addEventListener(compositionstart, () { composing true; }); textarea.addEventListener(compositionend, () { composing false; }); textarea.addEventListener(input, () { if (composing) return; // 输入法拼写过程中不保存 autoResize(textarea); scheduleSave(textarea.value); });scheduleSave用的防抖是 400ms。这个值我调过几次200ms 太频繁打字快的时候会触发大量写入800ms 又会让用户在打字后立刻关闭页面时丢失最后一句。400ms 是一个体感上几乎即时保存又不至于频繁写库的平衡点。配套的还有自动高度function autoResize(el: HTMLTextAreaElement) { el.style.height auto; el.style.height ${el.scrollHeight}px; }这两行代码解决了输入框高度跳动和内容被截断两个问题成本几乎为零收益极大。5.2 拖拽排序为什么我放弃了整数 sortIndexKR 的排序是很自然的需求——用户想按重要性从上到下排列。最直觉的做法是整数字段sortIndex但用整数的代价是每次插入都要重排后面所有记录。一个目标下有 10 个 KR把最后一个拖到第一位就要写 10 条记录。虽然数据量不大但配合乐观更新和事务代码会变得很啰嗦而且并发时容易产生顺序错乱。我改用 fractional indexing分数索引思路是每次插入时取相邻两个元素 orderKey 的中间值function midKey(prev?: string, next?: string): string { if (!prev !next) return m; if (!prev) return shiftDown(next!); if (!next) return shiftUp(prev); return between(prev, next); } // 简化实现用浮点数 function betweenSimple(prev: string, next: string): string { const a parseFloat(prev), b parseFloat(next); return String((a b) / 2); }浮点数版本写起来简单但有个致命缺陷精度会被耗尽。字符串长度固定 53 位有效数字的情况下两个数之间反复取中值大概 50 次之后就没有间隙了插入会失败。所以我实际用的是基于字符串的版本思路是把排序键当成 36 进制的小数在两个字符串之间生成一个新的字符串。核心是找到第一个不同的字符位置在它中间插入一个字符如果没间隙就往后补位。这套算法在开源社区有成熟实现思路不复杂但边界条件很多我建议直接移植一个经过验证的实现不要自己从零推。拖拽的交互实现上我没有用 HTML5 的 drag and drop API。原因很实际它在移动端基本不可用触屏设备上会触发页面滚动而不是拖拽。改用 Pointer Events 之后拖拽逻辑统一了移动端和桌面端是一套代码// 简化后的拖拽流程 onPointerDown - 记录起点、设置 setPointerCapture onPointerMove - 超过 4px 阈值才认定为拖拽更新占位位置 onPointerUp - 计算目标位置写入新的 orderKey那个 4px 的阈值很重要不加的话轻点也会被误判成拖拽导致点击事件失效。5.3 周期切换默认视图就该是现在周期这个设计我纠结过很久。有的工具需要用户手动选择我要看哪个季度多一步操作有的工具只显示当前周期历史数据要翻好几层。我的方案是默认永远显示当前周期顶部有一个横向的周期切换器历史周期以归档的形式平铺在旁边。用户打开就干活想复盘就点一下。这里有个容易被忽略的细节——周期边界的自动切换。用户可能在 4 月 1 日凌晨打开应用这时候当前周期应该自动变成 Q2但如果 Q2 还不存在是自动创建还是提示用户我选择了自动创建如果今天落在某个周期的区间内但库里没有对应记录就静默创建一个空的 cycles 记录。用户不会看到任何弹窗写第一个目标时它已经在那儿了。另外还做了个时间旅行的小功能在周期选择器上按住 Shift 点击可以查看任意时间点的数据快照基于 Check-in 历史重建。这个功能使用频率不高但在做季度复盘的时候特别好用——能看到某个 KR 在第三周是什么状态从而判断是一开始就没想清楚还是中途掉队了。6. 数据逃生舱导入导出和存储保底纯前端没有服务器兜底那数据安全这件事就只能靠用户自己。所以我把导入导出做成了核心功能而不是藏在设置里的附属品。6.1 JSON 全量备份必须带版本号和校验导出的 JSON 长这样几个字段是刻意留的{ format: ork-backup, schemaVersion: 3, exportedAt: 1730000000000, checksum: sha256:..., data: { objectives: [], keyResults: [], checkIns: [], alignments: [], cycles: [] } }schemaVersion是给导入端用的如果用户的备份是 v2 导出的而当前应用是 v3导入时就要跑一遍迁移逻辑否则字段缺失会导致整个应用报错。checksum用于防御文件被部分下载的情况——用户从网盘下了一个断掉的 JSON没有校验的话会导入一半然后数据错乱。导入流程我设计得比较保守因为它是最危险的写操作读取文件 → JSON.parse失败直接报错。校验format和schemaVersion不匹配就走迁移。逐表做结构校验字段类型、必填项、外键存在性。展示预览将导入 12 个目标、47 个 KR、183 条进展记录。用户确认后先自动导出一份当前数据作为回滚点。执行导入采用upsert 而非清空重建避免误删本地新数据。第 6 步的策略值得多说一句。有些工具导入时会先清空再写入逻辑简单但非常危险——用户本来只想补充几条数据结果把本地的东西全洗了。用 upsert 的话同 id 的覆盖、不同 id 的新增行为更符合直觉。当然如果用户明确选择完全替换那另说。6.2 Markdown 和 CSV各有各的用处导出 HTML 或者 PDF 适合存档但我发现自己实际用得最多的是两种纯文本格式Markdown用于季度复盘。结构大致是这样## 目标把内容生产效率提升 50% **总进度**62%加权 ### KR1 单篇稿件的平均产出时间从 8 小时降到 4 小时 - 类型数值逆向 - 当前5.2 小时 / 目标4 小时 - 进度70% - 健康度绿 - 最近记录 - 06-12 5.2h 信心 7 - 模板化改造已生效 - 06-05 6.0h 信心 6导出成 Markdown 的好处是能直接粘进任何笔记工具也能被搜索引擎检索。比封在 IndexedDB 里强得多。CSV用于向上汇报。领导要的通常是一张能塞进表格的清单一行一个 KR带进度和负责人。做这个功能的时候我特意加了一个仅导出当前周期的选项不然全量几百行导出去没人看。6.3 存储被清掉怎么办能做的防御都做了这是纯前端方案的固有风险我没法彻底消除但做了三层防御第一层申请持久化存储权限。navigator.storage.persist()可以请求浏览器不要自动清理这个站点的数据async function ensurePersistent() { if (!navigator.storage?.persist) return false; if (await navigator.storage.persisted()) return true; return await navigator.storage.persist(); }这个 API 的授予策略各浏览器不同有的看用户是否安装为应用、是否加了书签、是否有过交互。所以调用时机放在用户创建第一个目标之后成功率比首屏就调高得多。第二层做成 PWA。加上 manifest 和 service worker用户可以安装到桌面或主屏。安装后的应用在存储策略上通常更宽松而且能离线启动——这两个收益都很实在。第三层也是最有效的一层把该备份了做成可见的状态。我在顶栏放了一个小圆点记录上次导出时间超过 14 天变成橙色超过 30 天变红。点击就弹出导出面板。这个设计比任何提示文案都管用——用户看到颜色变化会主动点一下而一条请定期备份的提示会被永久无视。提示千万不要用 localStorage 当主力存储。它有两个硬伤容量通常只有 5MB 左右一个季度的 Check-in 加上备注很快就到顶以及它是同步 API在存大 JSON 时会阻塞主线程输入框会明显卡顿。localStorage 我只用来存 UI 偏好比如上次选中的周期、侧栏是否展开这类数据丢了也无所谓。7. 上线后被打脸的六个问题这部分是我最想写的因为前面那些设计再周全真实使用还是会把没想过的地方一个个捅出来。7.1 日期与时区跨时区的今天不是一个今天第一版我用new Date().toISOString().slice(0,10)存日期字符串在本地测得好好的。后来一位出差的朋友反馈说他的打卡记录时间戳显示成了前一天。原因很清楚toISOString()永远返回 UTC 时间UTC8 的晚上 8 点转成 UTC 是当天 12 点没问题但凌晨 1 点转成 UTC 就是前一天的 17 点。所有涉及哪一天的判断全错位了。修复方案是所有时间戳统一存毫秒数格式化在展示层做按本地时区// 存的时候永远存时间戳 const createdAt Date.now(); // 展示的时候按本地时区格式化 function formatDay(ts: number): string { const d new Date(ts); return ${d.getFullYear()}-${pad(d.getMonth() 1)}-${pad(d.getDate())}; }这个原则看起来是常识但在写第一版的时候真的很容易图省事用字符串。教训是凡是涉及日期的地方存时间戳绝不存字符串。7.2 数字输入的精度0.1 0.2 不是 0.3KR 的当前值可能是小数比如平均响应时间 1.3 秒进度条又需要百分比。中间经过currentValue / targetValue * 100一系列浮点运算展示时会出现66.66666666666667%这种鬼东西。处理方式是两层计算过程不做任何取整只在最终展示时格式化并且用toFixed(1)而不是Math.round保留一位小数。另外绝对不要用浮点数做等值比较判断是否达成目标要用容差const reached Math.abs(current - target) 1e-6;7.3 空目标的除零0 除以 0 是 NaN不是 0新建一个目标但还没加 KR 的时候进度的分母是 0。第一版直接算得到NaN然后进度条宽度变成NaN%整个 UI 布局会崩掉——浏览器遇到非法宽度值会忽略该属性进度条就消失了。修法是在所有汇总函数的入口做守卫function safeDivide(numerator: number, denominator: number, fallback 0) { if (!Number.isFinite(denominator) || denominator 0) return fallback; const r numerator / denominator; return Number.isFinite(r) ? r : fallback; }这个函数在整个项目里被调用了三十多次。事后看这应该是第一天就写的工具函数。7.4 对齐环导致的递归爆栈虽然我在写入时做了环检测但还是漏了一条路径导入数据的时候没走检测。用户从别人那里拿到一份 JSON里面正好有个环可能是手工编辑过的导入之后一渲染整个页面就白屏控制台刷出一屏Maximum call stack size exceeded。补了两个地方导入校验阶段加环检测递归函数本身加深度上限和访问集合function collectDescendants(rootId: string, edges: Alignment[], seen new Setstring()): string[] { if (seen.has(rootId) || seen.size 500) return []; seen.add(rootId); const children edges.filter(e e.toObjectiveId rootId).map(e e.fromObjectiveId); return children.flatMap(c [c, ...collectDescendants(c, edges, seen)]); }seen集合既解决环也顺便解决了菱形依赖导致的重复计算。深度上限则是一道兜底保险——宁可少算不能白屏。7.5 大量 Check-in 让列表卡顿一个用了半年的用户Check-in 记录可能过千条。第一版我是全量toArray()拉出来再在内存里过滤页面加载时明显能感觉到卡。后来做了三件事分页读取列表只取最近 50 条滚动到底部再取下一页。虚拟滚动长列表只渲染可视区域的十几行配合IntersectionObserver做懒加载。趋势图按需查询只在用户展开某个 KR 时才去查它的时间序列而不是一次性全查。改完之后首屏加载从接近 2 秒降到 200 毫秒以内。这个优化让我意识到纯前端不代表性能自动好浏览器里的数据量也是会增长的。7.6 撤销用户删掉一个目标五秒后才反应过来删除操作是最容易出事的。我最初的实现是弹个确认框确定删除吗但用户点确定的速度比看文案快得多。改成软删除 延迟清理之后体验好很多删除时只把status标成archived并记录deletedAtUI 立刻移除同时右下角弹出已删除撤销提示条8 秒后提示条消失真正的物理删除放到下次应用启动时执行。这套机制还顺带解决了另一个问题用户误删之后关掉页面重开只要还在宽限期内我设的是下次启动前数据依然能通过历史入口找回。8. 后续还能往哪儿长如果这个工具继续迭代我脑子里排队的几个方向是这样的。第一是多标签页同步现在两个标签页同时打开会各写各的用BroadcastChannel做一次写后广播就能解决成本很低但体验提升明显。第二是目标模板把我自己用下来觉得好用的几套季初模板内置进去用户新建周期时可以直接套用这比教方法论有用得多。第三是复盘引导季度末生成一份带提问的复盘页——哪个 KR 掉队最严重、原因写一句——引导用户把数据变成结论而不是看完进度条就关掉。我个人在实际操作中的体会是做这类工具最大的诱惑是不断加功能而真正的门槛在于减法哪些字段是必须的哪些视图可以直接砍掉哪个按钮应该让用户永远不用点。我砍掉过甘特图、砍掉过标签系统、砍掉过自定义字段每一次都心疼但每次砍完产品都更清楚。如果你的目标也是做一个自己愿意天天打开的效率工具建议先把每周填一次进展这条最短路径打磨到极致的顺滑剩下的都可以慢慢来。