ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从面板到多标签页:SkillHub 0.2.0 交互重构与状态管理实践

从面板到多标签页:SkillHub 0.2.0 交互重构与状态管理实践 老读者应该知道SkillHub 这个项目我从 0.1.0 就开始在社区同步进展它是一个面向开发者与创意工作者的本地技能工作台把高频的小工具、模板片段、常用命令统一收拢到一个应用里省得在不同软件之间来回横跳。这次 0.2.0 更新核心就两件事加入多标签页以及把底层交互逻辑彻底重写一遍。说白了这不是加一个按钮的事而是把整个应用的骨架从“面板切换”换成了“标签页 状态驱动”所以我想把这次改版背后的思考、技术选型、踩坑过程完整记录下来给同样在做工具类应用的朋友一个参考。如果你正在做的东西也面临“功能越来越多但切换成本越来越高”的窘境或者你只是好奇一个桌面端工具怎么把多标签页做顺手这篇文章应该能帮你省掉不少弯路。1. 先说背景0.1.x 用起来别不别扭0.2.0 就为一个答案1.1 项目定位SkillHub 是做什么的SkillHub 最初的想法很简单把开发和创作过程中频繁使用的零散能力比如 JSON 格式化、正则测试、时间戳转换、颜色提取、Git 命令速查、Markdown 模板等等全部集中到一个桌面应用里。它不是一个 IDE也不是笔记软件而是一个“技能工具箱”每个工具就是一个独立的能力单元。这个定位听起来很轻但实际用起来有个问题工具一多怎么切换就成了决定体验的关键。0.1.x 时代的交互是一个左侧侧边栏列出所有工具点一个就切换一个面板。单看这个逻辑好像没什么问题但用得越多越觉得别扭那种“不对劲感”不是某一个具体功能造成的而是整个交互模型的瓶颈在积累。我自己的使用频率大概一天要打开几十次切换工具的操作次数远比想象中多。时间一长我开始意识到SkillHub 缺的不是更好看的界面而是一个能够承载多任务并行、快速回访的交互容器。这也是 0.2.0 决定动手改架构的直接原因。1.2 旧版交互真正让人难受的三个点先说第一个页面切换等于状态丢失。0.1.x 里切换工具时旧工具组件会被卸载再切回来时会重新挂载结果是表单填了一半的内容、刚跑完的输出结果、甚至滚动位置全都没了。一开始我以为这是小问题直到有次填一个多字段的正则生成表单因为想去另一个工具复制一段文本切回来之后发现十几个字段全部清空那一下真的破防。第二个问题是跨工具的工作流被切碎了。比如从“JSON 转 TS 类型”复制结果再去“代码模板生成器”里粘贴两个工具之间没有任何联动也没有“最近使用”的概念。每次都要从长列表里重新找到目标工具再逐层进入路径又长又没有记忆。第三个问题其实是思维层面的页面结构把用户锁死在“单一任务”模式里。老产品在做界面设计时天然把人假设成“一次只做一件事”但实际使用者在工具箱里通常是并行推进多个小任务。一个查时间戳一个测正则一个写模板这三个任务在物理世界可以同时摊在桌面上在主流的编辑器和浏览器里也被多标签页解决了而我的应用却逼着用户只能看一个。你说用户能不难受吗所以在进入 0.2.0 开发之前我心里已经有一个很清晰的答案这个产品需要一个像浏览器标签页一样的东西来承载工具让多个工具的运行状态能够同时存在、随时切换、按需关闭。2. 多标签页方案选型为什么我不走路由模拟这条路2.1 路由模拟和独立标签系统的关键差异一开始最省事的做法是用路由模拟在 URL 里加一个?tabjson-formatter每条路由对应一个工具切换就是改 URL。这种方式实现成本很低尤其适合纯 Web 应用但仔细推演之后我放弃了。原因很简单路由天然是“进入”的逻辑而标签页是“常驻”的逻辑。路由可以记住你打开了哪个页面但记不住这个页面的内部状态。比如你在 JSON 工具里已经格式化了一段数据切到 Markdown 工具再切回来路由会告诉你回到了“JSON 工具”但不会告诉你之前输出框里的那段内容是什么。要实现状态恢复还得自己额外做一层状态缓存绕了一圈又回到了原点。独立标签系统则不一样它的核心是让每个标签对应一个带状态的工作区。标签页不只是一个“当前打开了什么”的指示器而是一个保持存活、可以被后台运行、随时恢复上下文的容器。这个差别就像浏览器里“打开新标签页”和“跳转到另一个页面”的区别前者保留了你的操作现场后者会让你丢失现场。这个判断是整个 0.2.0 取舍的根基。既然 SkillHub 的核心场景是“并行使用多个工具”那标签式的多工作区就是唯一正确的模型。方案确定后后面所有设计都围绕“如何让标签页严格处于保活状态”来展开。2.2 标签页需要承载的最小状态集怎么设计想清楚用独立标签系统之后接下来要定义“一个标签页到底是什么”。我画了一张最小状态集的草图最终收敛下来一个标签页至少需要这些字段字段作用为什么必须id每个标签的唯一标识没有 id 无法做列表 diff 与状态映射title标签上显示的名称用户识别工具的依据kind对应的工具类型/标识符决定挂载哪一个组件实例params工具初始化参数支持从外部链接/模板创建一个带参数的标签status空闲/加载中/错误表单、输出等场景需要反馈dirty内容是否被修改关闭前提醒、会话恢复时判断是否保存pinned是否固定标签固定标签不能关闭避免误操作lastActivatedAt上次激活时间实现类似浏览器“最近使用”的 CtrlTab 切换这套字段不是一次想全的。最初我只设计了 id、title、kind 三个结果开发到一半发现关闭标签时怎么提示未保存信息那必须加 dirty再做快捷键切换的时候没有最近顺序那必须加 lastActivatedAt。所以如果你也要做类似功能建议一开始就把这几个字段塞进去后面能省去不少重构成本。另外还有一个值得处理的细节标签的 params 与工具的历史记录是两回事。params 只是在创建标签时传入的参数不会随着工具的实时操作而改变它描述了“这个标签是怎么来的”而工具内部的运行状态应该在组件内部或按工具独立的 store 里管理不要一股脑塞进 TabStore否则状态会越来越臃肿。3. 核心交互重构命令总线 标签状态管理3.1 从“按钮逐层点击”到“一次按键直达”0.2.0 的另一个重头戏是核心交互重构。0.1.x 的交互路径是典型的三层结构打开侧边栏 → 找到工具 → 点击进入。路径长而且每一步都要靠鼠标完成键盘基本派不上用场。作为一个面向开发者的工具这种交互体验显然是不过关的。所以这次重构我给自己定了一个硬性指标用户完成任何一次工具切换最多不超过两次按键并且绝大多数场景可以完全不用鼠标。围绕这个目标我做了一套全局命令体系。打开命令面板只需要一个快捷键然后键盘输入工具名回车就能打开对应标签已经打开的标签再输入时切换动作会直接定位到现有标签而不是新建一个这个细节能避免标签无限膨胀。实际做下来这套交互的效果比预想中好很多因为命令面板天然适合“模糊搜索 快速进入”的心智模型。另外再多说一句再加一个CtrlTab的前后切换体感立刻不一样了。这个交互单看没什么技术含量但配合标签页使用很上瘾。3.2 TabStore 与 CommandBus 各自负责什么这次交互重构在代码层面的核心是把“职责”重新划清楚了。我采用了两个核心模块TabStore 和 CommandBus。TabStore 负责标签数据的增删改查包括标签列表、当前激活标签、固定状态、草稿状态等CommandBus 则负责接收全局命令比如打开工具、切换标签、关闭标签、固定标签然后统一调用 TabStore 的方法去执行。听起来有点像把简单问题复杂化但它解决了一个很实际的问题界面上任何一个按钮的点击和键盘快捷键触发的行为走的是同一条执行路径。以前是点击“关闭”按钮直接调用关闭函数键盘快捷键又是另一套逻辑两边非常容易产生行为不一致。现在所有操作都变成向 CommandBus 发命令由同一个处理器去执行形成统一出口后面的维护成本会明显下降。事件驱动不是唯一选择但在这次重构的场景下确实是最合适的选择。单一数据源注入到各个交互层逻辑就变得很清晰了。如果你在做的项目也出现“鼠标操作和键盘操作各写一套逻辑”的情况这一步重构是值得投入的。3.3 快捷键、未读提示这些细节值不值得做核心交互不能只照顾键盘用户也要照顾鼠标用户更要照顾“鼠标和键盘混合使用”的用户。0.2.0 里我加了几个细节看起来是小功能但实际贡献了不少体验分。第一个是中间键关闭。浏览器用户都习惯了中键关标签桌面应用如果不支持这个操作每次去点那个小叉子都很痛苦。实现上只要在 tab 容器上监听auxclick事件判断button 1就可以。第二个是标签未读闪烁。这个场景是这样的后台标签页里的工具在运行定时任务完成后希望提醒用户切回去查看这时标签标题旁边会出现一个小圆点并短暂闪烁两次。这个功能以前在面板模式下根本无法实现因为后台工具根本不会被保留。有了标签系统后后台工具常驻才能有“后台提醒”这个概念。第三个是固定标签。固定后的标签不显示关闭按钮也不能被中键关闭避免手滑把常用工具关掉。这个功能实现起来不复杂但需要在使用场景中定义清楚固定标签如果内容变 dirty 了是否要改变颜色提醒我目前的方案是底色不变但标题前加一个圆点这样既不明显打断视觉流也不会漏掉状态变化。这些功能的共同点是想清楚交互规则之后再动手写代码而不是边写边加。如果写到一半再补很容易出现逻辑分支散落调试成本高。4. 多标签页实现的代码级拆解4.1 类型定义与 Store 基础结构理论聊够了进入代码环节。这套实现的思路可以挪到任何前端技术栈里我这边用的 TypeScript 来写类型状态管理用的是一个极简的自定义 store没有引入重型状态库因为标签页的更新频率并不高用一个发布订阅模式完全足够。首先是类型定义export type TabStatus idle | loading | error; export type TabKind json-formatter | regex-tester | timestamp-converter | color-picker | string; export interface SkillTab { id: string; title: string; kind: TabKind; params: Recordstring, unknown; status: TabStatus; dirty: boolean; pinned: boolean; lastActivatedAt: number; createdAt: number; } export interface TabSnapshot { tabs: SkillTab[]; activeTabId: string | null; savedAt: number; }然后是一个最小化的 TabStore核心就是维护 tabs 数组和 activeTabIdclass TabStore { private tabs: SkillTab[] []; private activeTabId: string | null null; private listeners new Set() void(); subscribe(fn: () void) { this.listeners.add(fn); return () this.listeners.delete(fn); } emit() { this.listeners.forEach((fn) fn()); } getState() { return { tabs: this.tabs, activeTabId: this.activeTabId, activeTab: this.tabs.find((t) t.id this.activeTabId) ?? null, }; } openTab(kind: TabKind, params?: Recordstring, unknown) { const existing this.tabs.find((t) t.kind kind !t.pinned); if (existing) { this.activateTab(existing.id); return existing; } const tab: SkillTab { id: crypto.randomUUID(), title: this.resolveTitle(kind), kind, params: params ?? {}, status: idle, dirty: false, pinned: false, lastActivatedAt: Date.now(), createdAt: Date.now(), }; this.tabs.push(tab); this.activateTab(tab.id); this.emit(); return tab; } activateTab(id: string) { this.activeTabId id; const tab this.tabs.find((t) t.id id); if (tab) tab.lastActivatedAt Date.now(); this.emit(); } closeTab(id: string) { const tab this.tabs.find((t) t.id id); if (!tab || tab.pinned) return; this.tabs this.tabs.filter((t) t.id ! id); if (this.activeTabId id) { this.activeTabId this.findNextActiveTab(id); } this.emit(); } private findNextActiveTab(closedId: string) { const idx this.tabs.findIndex((t) t.id closedId); if (idx -1) return null; return (this.tabs[idx - 1] ?? this.tabs[idx 1] ?? null)?.id ?? null; } private resolveTitle(kind: TabKind) { const titles: Recordstring, string { json-formatter: JSON 格式化, regex-tester: 正则测试, timestamp-converter: 时间戳转换, color-picker: 取色器, }; return titles[kind] ?? kind; } }这段代码不复杂但里面有用两点需要注意closeTab 时不是无脑取前一个标签作为下一个激活项而是优先取左边的邻居没有左边再取右边这个行为更贴近主流编辑器而openTab 加了“已有标签则激活”的判断这样快捷键重复打开一个工具时不会产生一堆重复标签。4.2 关闭、切换、固定的边界处理代码里最容易出 bug 的就是边界处理。关闭标签这个动作听起来很简单但当它是“当前激活标签”时焦点给谁、如果关闭的是最后一个标签怎么办、如果唯一剩下的标签被固定住了怎么办这些都要单独考虑。我目前的策略是关闭当前标签后优先把焦点给左侧相邻标签如果左侧没了就给右侧。最后一个可关闭标签被关闭后应用会回到一个“空状态”界面而不是强制某个标签一直存在。固定标签不能关闭这个在 closeTab 里做了拦截。UI 层也要同步处理固定标签不渲染关闭按钮也不响应中键关闭。另一个边界是切换标签时 dirty 状态的提醒。如果一个标签处于脏状态比如有未保存的模板修改用户切换标签不应该拦截因为 SkillHub 的标签本身就是工作区切换不等于销毁。但在关闭这个标签时如果 dirty 为 true需要弹一个轻量的确认浮层提示“该标签内有未保存内容确认关闭”。这个浮层不是 modal 对话框级别那么重就是一个跟随鼠标的小弹窗避免打断操作流。切换标签时的组件生命周期也要专门处理。我的实现是给每个标签建立一个独立的容器节点切换时不是卸载而是把旧标签的容器隐藏把新标签的容器显示出来。这样组件的useEffectcleanup 不会执行React 内部也不会去销毁组件实例状态自然保留。效果上最接近浏览器的“后台标签页挂起”机制。具体实现上我维护一个 Mapconst tabContainersRef useRef(new Mapstring, HTMLDivElement()); function renderTab(tab: SkillTab) { return ( div key{tab.id} >
返回列表