从混乱到优雅:ClarityFile 快捷键系统的重构之路

2025 年 7 月 6 日 星期日(已编辑)
48
这篇文章上次修改于 2025 年 7 月 7 日 星期一,可能部分内容已经不适用,如有疑问可询问作者。

从混乱到优雅: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>
  )
}

这个版本解决了一些问题,但又带来了新的挑战:

遇到的问题

  1. Hook 依赖地狱useCallbackuseEffect 的依赖数组变得复杂,容易出现无限循环
  2. 性能问题:每次 shortcuts Map 更新都会导致所有消费组件重新渲染
  3. 状态同步困难:多个 Provider 嵌套时,状态同步变得复杂
  4. 调试困难: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>
}

现在的成果

经过重构,我们现在有了一个功能完整、性能优秀的快捷键管理系统:

核心特性

  1. 统一管理:所有快捷键在一个 store 中管理
  2. 冲突检测:自动检测和解决快捷键冲突
  3. 作用域隔离:支持页面级和全局级快捷键
  4. 平台适配:自动适配 macOS/Windows/Linux
  5. 长按提示:长按 ⌘ 键显示可用快捷键
  6. 类型安全:完整的 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>
  )
}
  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...