从混乱到优雅:ClarityFile 快捷键系统的重构之路
ClartifyFile 是最近一直在开发的一个自用的软件,主要是作为管理文件的存在,这个软件以后有机会再详细讲述吧
在开发 ClarityFile 这个桌面应用的过程中,快捷键管理一直是一个让我头疼的问题。从最初的简单需求到现在的统一管理系统,这个功能经历了多次重构和演进(Refactor with Augment Code)。今天想和大家分享一下这个系统的演化历程,以及我们为什么最终选择了 zustand 作为状态管理方案。
第一阶段:原始的事件监听
最开始,快捷键实现非常简单粗暴:
// 早期的实现方式
function ProjectListPage() {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.metaKey && e.key === 'n') {
createNewProject()
}
if (e.metaKey && e.key === 'i') {
importFiles()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<div>
<Button onClick={createNewProject}>新建项目 (⌘N)</Button>
<Button onClick={importFiles}>导入文件 (⌘I)</Button>
</div>
)
}
这种方式的问题很快就暴露出来了,在只有一两个页面的初期,这种方式勉强能用。
但随着应用功能的增多,它的弊端很快就暴露无遗,像失控的野草一样蔓延开来。
- 每个需要快捷键的页面,我都在复制粘贴着相似的 useEffect 逻辑;
- 不同页面定义的快捷键开始“打架”,比如 ⌘+S 在 A 页面是保存,在 B 页面可能是设置,用户一不小心就会触发意料之外的操作;-
- 当我想梳理或修改一个快捷键时,我得在整个代码库里大海捞针,维护成本高得吓人。
- 用户根本不知道有哪些快捷键可用,除非他们凑巧看到了按钮旁边的提示文字。
第二阶段:纯 React 的组件化尝试
意识到问题后,我开始尝试用纯 React 的方式来解决,我的思路是,将快捷键的注册和管理抽象出来,用 Context API 创建一个全局的 ShortcutProvider
。这个 Provider 维护一个快捷键注册表,并在顶层监听全局键盘事件,然后分发给对应的处理函数。
// 第二版:纯 React 实现
const ShortcutContext = createContext<{
shortcuts: Map<string, ShortcutRegistration>
registerShortcut: (registration: ShortcutRegistration) => void
unregisterShortcut: (id: string) => void
}>({
shortcuts: new Map(),
registerShortcut: () => {},
unregisterShortcut: () => {}
})
function ShortcutProvider({ children }: { children: ReactNode }) {
const [shortcuts, setShortcuts] = useState(new Map<string, ShortcutRegistration>())
const registerShortcut = useCallback((registration: ShortcutRegistration) => {
setShortcuts(prev => new Map(prev).set(registration.id, registration))
}, [])
const unregisterShortcut = useCallback((id: string) => {
setShortcuts(prev => {
const next = new Map(prev)
next.delete(id)
return next
})
}, [])
// 全局键盘事件监听
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
for (const [_, registration] of shortcuts) {
if (matchesShortcut(e, registration.keys)) {
e.preventDefault()
registration.action()
break
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [shortcuts])
return (
<ShortcutContext.Provider value={{ shortcuts, registerShortcut, unregisterShortcut }}>
{children}
</ShortcutContext.Provider>
)
}
这个版本解决了一些问题,但又带来了新的挑战:
遇到的问题
- Hook 依赖地狱:
useCallback
和useEffect
的依赖数组变得复杂,容易出现无限循环 - 性能问题:每次 shortcuts Map 更新都会导致所有消费组件重新渲染
- 状态同步困难:多个 Provider 嵌套时,状态同步变得复杂
- 调试困难:React DevTools 中很难追踪快捷键的注册和触发过程
// 这段代码会导致无限循环
const Shortcut = ({ shortcut, children, action }: ShortcutProps) => {
const { registerShortcut, unregisterShortcut } = useContext(ShortcutContext)
useEffect(() => {
const registration = {
id: generateId(),
keys: shortcut,
action: action // 这里的 action 每次渲染都是新的函数引用
}
registerShortcut(registration)
return () => unregisterShortcut(registration.id)
}, [shortcut, action, registerShortcut, unregisterShortcut]) // 依赖地狱
}
问题出在 action
这个 prop 上。父组件每次重新渲染时,传递给 action
的都是一个全新的函数引用。这导致 <Shortcut>
组件的 useEffect
在每次父组件更新时都会重新执行,registerShortcut
被频繁调用,ShortcutProvider
里的 shortcuts
状态随之更新,Context 的值改变,又反过来导致所有消费该 Context 的组件(包括 <Shortcut>
的父组件)重新渲染……一个完美的无限循环就此诞生。
我尝试用 useCallback
来稳定 action
函数的引用,但这让代码变得更加复杂,依赖数组像一张越织越密的网,一不小心就会出错。性能问题也接踵而至,任何一个快捷键的注册或注销,都会导致整个快捷键列表 Map
的更新,从而触发所有订阅了 Context 的组件进行不必要的重新渲染。调试过程也异常痛苦,在 React DevTools 里,我只能看到一个不断变化的 Map
对象,很难追踪到底是哪个快捷键的注册引发了问题。
这次尝试让我深刻地认识到,对于这种全局、高频变化的复杂状态,纯 React 的方案虽然可行,但往往会带来巨大的心智负担和性能隐患。
第三阶段:寻找更好的解决方案
在经历了纯 React 方案的痛苦后,我开始思考:为什么不用专门的状态管理库?
interface ShortcutStore {
registrations: Map<string, ShortcutRegistration>
conflicts: ShortcutConflict[]
register: (registration: ShortcutRegistration) => void
unregister: (id: string) => void
checkConflicts: () => void
}
const useShortcutStore = create<ShortcutStore>()(
immer((set, get) => ({
registrations: new Map(),
conflicts: [],
register: (registration) => set((state) => {
state.registrations.set(registration.id, registration)
}),
unregister: (id) => set((state) => {
state.registrations.delete(id)
}),
checkConflicts: () => set((state) => {
state.conflicts = detectConflicts(Array.from(state.registrations.values()))
})
}))
)
没有 Provider 包裹,没有复杂的样板戏,只有一个简单的 Hook。结合 immer 中间件,我甚至可以直接用可变的方式去更新状态,代码的可读性大大提升。
第四阶段:zustand 重构的收益
1. 代码简洁性
对比一下重构前后的代码:
// 重构前:纯 React 实现
const Shortcut = ({ shortcut, children, action }: ShortcutProps) => {
const { registerShortcut, unregisterShortcut } = useContext(ShortcutContext)
const stableAction = useCallback(action, []) // 试图稳定引用,但经常失败
const registrationId = useRef<string>()
useEffect(() => {
const id = generateId()
registrationId.current = id
const registration = {
id,
keys: shortcut,
action: stableAction
}
registerShortcut(registration)
return () => {
if (registrationId.current) {
unregisterShortcut(registrationId.current)
}
}
}, [shortcut, stableAction, registerShortcut, unregisterShortcut])
return <>{children}</>
}
// 重构后:zustand 实现
const Shortcut = ({ shortcut, children, action }: ShortcutProps) => {
const register = useShortcutStore(state => state.register)
const unregister = useShortcutStore(state => state.unregister)
useEffect(() => {
const id = generateId()
register({ id, keys: shortcut, action })
return () => unregister(id)
}, [shortcut, action, register, unregister])
return <>{children}</>
}
2. 性能优化
zustand 的选择器机制让我们可以精确控制组件的重新渲染:
// 只有当冲突状态改变时才重新渲染
const ConflictWarning = () => {
const conflicts = useShortcutStore(state => state.conflicts)
if (conflicts.length === 0) return null
return (
<div className="warning">
发现 {conflicts.length} 个快捷键冲突
</div>
)
}
// 只有当特定快捷键状态改变时才重新渲染
const ShortcutDisplay = ({ shortcutId }: { shortcutId: string }) => {
const registration = useShortcutStore(
state => state.registrations.get(shortcutId)
)
return <kbd>{formatShortcut(registration?.keys || [])}</kbd>
}
现在的成果
经过重构,我们现在有了一个功能完整、性能优秀的快捷键管理系统:
核心特性
- 统一管理:所有快捷键在一个 store 中管理
- 冲突检测:自动检测和解决快捷键冲突
- 作用域隔离:支持页面级和全局级快捷键
- 平台适配:自动适配 macOS/Windows/Linux
- 长按提示:长按 ⌘ 键显示可用快捷键
- 类型安全:完整的 TypeScript 类型定义
使用体验
// 现在的使用方式非常简洁
function ProjectListPage() {
return (
<ShortcutProvider scope="project-list">
<Shortcut shortcut={['cmd', 'n']} description="创建新项目">
<Button onClick={createProject}>新建项目</Button>
</Shortcut>
<Shortcut shortcut={['cmd', 'i']} description="导入文件">
<Button onClick={importFiles}>导入文件</Button>
</Shortcut>
</ShortcutProvider>
)
}