React 电视端应用:处理遥控器焦点管理(Focus Management)的 React 高阶组件封装

张开发
2026/4/21 0:34:47 15 分钟阅读

分享文章

React 电视端应用:处理遥控器焦点管理(Focus Management)的 React 高阶组件封装
各位好欢迎来到今天的“React 电视端应用开发”特别讲座。我是你们的老朋友一个在屏幕前敲代码敲得手指比遥控器按键还灵活的资深工程师。今天我们要聊的话题听起来很枯燥但却是每一个电视应用开发者的噩梦也是每一个坐在沙发上只想换台却找不到“确认”键的用户的心头大恨——那就是焦点管理。在手机上我们有触摸屏手指指哪打哪那叫一个随心所欲。但在电视上哈我们手里拿的是个“瞎子”遥控器。它只知道方向不知道你在哪更不知道你心里想的是哪个按钮。如果你作为开发者不能把这个“瞎子”指挥得服服帖帖那你的应用体验就等于是在给用户设置障碍赛。所以今天我们不讲怎么写漂亮的 CSS不讲怎么优化 Bundle 大小。我们来聊聊怎么给 React 组件装上“大脑”让它们知道什么时候该抢镜什么时候该隐身以及当用户按“下”键时到底该跳到哪个倒霉蛋身上。准备好了吗把手里的薯片放下咱们开始这场关于“遥控器与 DOM 的博弈”。第一部分DOM 是平的但电视 UI 是立体的首先我们要面对一个残酷的现实HTML DOM 是平的是一棵树但电视应用的 UI 往往是复杂的、立体的甚至是重叠的。当你写一个 React 组件你在div里面套div这很正常。但是当你按下遥控器的“下”键时浏览器只知道document.activeElement是谁。如果你只是简单地在键盘事件里写e.key ArrowDown然后去遍历 DOM 节点你会发现一个巨大的坑DOM 节点的顺序并不总是等于视觉上的顺序。想象一下你的页面布局是这样的左侧是一个导航栏右侧是一个内容网格。在 DOM 结构里导航栏可能在 HTML 标签的底部而内容网格在顶部。如果你盲目地按顺序找下一个tabindex用户按“下”屏幕上的光标可能直接从“播放”跳到了“页脚的版权声明”上。这就像你告诉一个盲人“往南走”他却直接走进了厕所——虽然也是南方但显然不对。核心概念虚拟焦点树为了解决这个问题我们需要在 React 组件内部维护一个“虚拟焦点树”。这个树不依赖 DOM 的物理顺序而是依赖你的业务逻辑。在这个虚拟树里每个组件都是一颗节点都有坐标都有父子关系。当你移动焦点时你不是在 DOM 里乱逛而是在这个虚拟树上“爬树”。我们的目标是封装一个高阶组件HOC让所有需要被遥控器控制的组件都继承这个 HOC 的能力。这就好比给每个组件都发了一本“驾驶员手册”告诉它“嘿小子当别人按上键时你得告诉他们去哪。”第二部分基础 HOC —— 给组件加上“焦点”属性让我们从最简单的开始。我们创建一个withFocusHOC。它的职责很简单监听焦点变化当组件获得焦点时调用focus()方法失去焦点时调用blur()方法。这听起来很简单对吧但细节决定成败。代码示例 1基础版的 withFocusimport React, { useEffect, useRef, ReactNode } from react; // 定义 HOC 的类型确保组件必须包含 focus 和 blur 方法 const withFocus P extends object( WrappedComponent: React.ComponentTypeP { focused: boolean } ) { return (props: P) { const ref useRefHTMLElement(null); const [focused, setFocused] React.useState(false); // 核心逻辑当 focused 状态改变时操作 DOM useEffect(() { if (focused ref.current) { ref.current.focus(); } else if (!focused ref.current) { ref.current.blur(); } }, [focused]); // 暴露给父组件的方法手动让这个组件获得焦点 const handleFocus () { setFocused(true); }; const handleBlur () { setFocused(false); }; // 将 ref 挂载到第一个子元素上这样 ref.current 就能拿到真实的 DOM 节点 return ( div ref{ref as any} onFocus{handleFocus} onBlur{handleBlur} WrappedComponent {...props} focused{focused} / /div ); }; }; export default withFocus;专家点评看到这里你可能会说“这就完了这也太简单了吧。”别急这只是第一步。上面的代码有个巨大的逻辑漏洞它没有处理遥控器的方向键上面的代码只是被动地响应了onFocus和onBlur事件。也就是说如果用户在电视上按“确认”键我们并没有捕获这个事件。而且如果用户按“上”或“下”我们的组件完全不知道该跳到哪个兄弟组件身上。所以我们的 HOC 需要升级。我们需要引入一个全局焦点管理器。第三部分焦点管理器 —— 中央集权制在 React 中最好的方式是使用 Context API。我们需要一个全局的FocusManager所有的组件都向它注册自己当焦点移动时它告诉下一个组件“嘿该你上场了。”代码示例 2FocusManager Contextimport React, { createContext, useContext, useState, ReactNode } from react; // 定义焦点移动的方向 type Direction up | down | left | right | enter; // 焦点管理的 Context const FocusManagerContext createContext({ register: (id: string) {}, // 注册组件 unregister: (id: string) {}, // 注销组件 focus: (id: string) {}, // 聚焦到指定组件 navigate: (direction: Direction) void, // 导航方向 focusNext: () void // 简单的下一个 }); // 全局状态 const useFocusManager () { // 这里我们用一个 Map 来存储所有可聚焦组件的 ID // 实际项目中你可能需要存储更复杂的数据比如网格的坐标 const [components, setComponents] useStateMapstring, HTMLElement(new Map()); const [currentFocus, setCurrentFocus] useStatestring | null(null); // 注册 const register (id: string) { setComponents(prev new Map(prev).set(id, document.getElementById(id)!)); }; // 注销 const unregister (id: string) { setComponents(prev { const next new Map(prev); next.delete(id); return next; }); }; // 聚焦到指定 ID const focus (id: string) { const el components.get(id); if (el) { el.focus(); setCurrentFocus(id); } }; // 核心功能处理方向键 const navigate (direction: Direction) { if (!currentFocus) return; // 这里需要根据具体的布局逻辑网格、列表、树形来实现 // 简单起见我们假设是线性列表 const ids Array.from(components.keys()); const currentIndex ids.indexOf(currentFocus); let nextIndex currentIndex; if (direction down) nextIndex currentIndex 1; if (direction up) nextIndex currentIndex - 1; if (nextIndex 0 nextIndex ids.length) { focus(ids[nextIndex]); } }; return { register, unregister, focus, navigate, focusNext }; }; export { FocusManagerContext, useFocusManager };专家点评上面的代码实现了一个非常基础的“线性导航”。在大多数电视应用中这已经能跑通 80% 的场景了。但是现实是残酷的。如果用户在播放视频的界面按“上”他应该是想回到播放列表而不是回到视频播放器本身的上一帧如果有的话。如果用户在一个 3×3 的网格里按“下”他应该跳到下一行的第一个而不是下一行的第二个。这就要求我们的 HOC 必须知道自己在网格中的位置。第四部分进阶 HOC —— 网格与坐标系统为了支持复杂的布局我们需要在注册组件时传递坐标信息。代码示例 3带坐标的 HOCimport React, { useEffect, useRef, ReactNode } from react; import { FocusManagerContext } from ./FocusManager; type GridPosition { x: number; // 列索引 y: number; // 行索引 cols: number; // 总列数 rows: number; // 总行数 }; const withFocus P extends object { focused: boolean }( WrappedComponent: React.ComponentTypeP, position: GridPosition // 关键组件需要知道自己在哪 ) { return (props: P) { const ref useRefHTMLElement(null); const [focused, setFocused] React.useState(false); const { register, unregister, focus, navigate } React.useContext(FocusManagerContext); // 组件挂载时注册自己并带上坐标 useEffect(() { const id Math.random().toString(36).substr(2, 9); // 生成唯一 ID register({ id, position, element: ref.current! }); return () { unregister(id); }; }, []); // 焦点状态变化时操作 DOM useEffect(() { if (focused ref.current) { ref.current.focus(); } else if (!focused ref.current) { ref.current.blur(); } }, [focused]); // 处理遥控器事件 const handleKeyDown (e: React.KeyboardEvent) { if (e.key Enter) { e.preventDefault(); // 触发自定义的确认事件 (e.currentTarget as HTMLElement).click(); return; } // 交给全局管理器处理方向键 navigate(e.key as any); }; return ( div ref{ref as any} onFocus{() setFocused(true)} onBlur{() setFocused(false)} onKeyDown{handleKeyDown} className{focused ? focused-style : } WrappedComponent {...props} focused{focused} / /div ); }; }; export default withFocus;专家点评现在我们的 HOC 已经具备基本的导航能力了。但是FocusManagerContext里的navigate函数还是个空壳。我们需要在FocusManager里实现真正的“寻路算法”。这就像是玩贪吃蛇。当前是x, y按“下”键新的坐标nextX, nextY应该是多少代码示例 4实现网格导航逻辑// 在 FocusManagerContext 的逻辑中 const navigate (direction: Direction) { if (!currentFocus) return; // 获取当前组件的所有信息 const current components.get(currentFocus); if (!current || !current.position) return; const { x, y, cols, rows } current.position; let nextX x; let nextY y; // 简单的网格寻路逻辑 switch (direction) { case up: nextY y 0 ? y - 1 : rows - 1; // 循环滚动或者在这里 return 不动 break; case down: nextY y rows - 1 ? y 1 : 0; break; case left: nextX x 0 ? x - 1 : cols - 1; break; case right: nextX x cols - 1 ? x 1 : 0; break; default: return; } // 找到目标组件 // 注意这里需要根据 ID 生成规则来查找或者我们在 register 时存一个 Mapposition, id // 假设我们有一个 Map 存储位置到 ID 的映射 const targetId positionMap.get(${nextX},${nextY}); if (targetId) { focus(targetId); } };到这里基础的网格导航就完成了。但是电视应用还有一个大问题焦点陷阱。第五部分焦点陷阱 —— 别让用户跑出你的游戏想象一下你正在玩一个电视游戏突然弹出一个广告或者设置窗口。你按“下”键焦点还在广告上怎么也点不到“关闭”按钮。你按“上”键焦点还在广告上。你绝望了只能去关电视。这就是焦点陷阱。当一个模态框出现时焦点必须被锁死在这个模态框内部。一旦用户按“退出”键焦点必须回到上一个位置。代码示例 5焦点陷阱模式我们需要在FocusManager中引入“栈”的概念。// 修改 FocusManagerContext const FocusManagerContext createContext({ // ... 原有的 register, focus trapFocus: (id: string) void, // 进入陷阱 releaseFocus: () void, // 退出陷阱 }); // 修改 useFocusManager 逻辑 const [focusStack, setFocusStack] useStatestring[]([]); // 记录栈 const trapFocus (id: string) { setFocusStack(prev [...prev, id]); focus(id); // 进入陷阱时强制聚焦到该组件 }; const releaseFocus () { setFocusStack(prev prev.slice(0, -1)); // 退出陷阱时通常需要回到上一个栈顶元素或者让用户手动导航 if (prev.length 0) { focus(prev[prev.length - 1]); } };代码示例 6HOC 中的陷阱逻辑const withFocus (WrappedComponent, position) { return (props) { // ... 前面的逻辑 const { trapFocus, releaseFocus } useFocusManager(); const handleKeyDown (e: React.KeyboardEvent) { if (e.key Backspace || e.key Exit) { // 检查当前组件是否是陷阱的栈顶 // 这里需要更复杂的逻辑来判断是否应该退出 releaseFocus(); return; } // ... 其他方向键逻辑 }; return ( div // ... ref, on... 事件 onClick{() { // 当用户用鼠标点击时也要进入陷阱 trapFocus(id); }} WrappedComponent {...props} / /div ); }; };专家点评焦点陷阱的核心在于控制权。一旦进入陷阱所有的键盘事件都只能在这个组件内部处理。如果 HOC 没有拦截“退出”键焦点就会逃逸出去。第六部分自动焦点 —— 当鼠标遇上遥控器这是电视端开发中最容易翻车的地方。用户在电视上用鼠标或者触控板点击了“播放”按钮。此时焦点应该自动跳到“播放”按钮上以便用户紧接着按“确认”键。如果你没有处理好这个逻辑用户就得多按一次键体验极差。代码示例 7自动聚焦在 HOC 中我们需要监听组件的onClick事件。const withFocus (WrappedComponent, position) { return (props) { const { focus } useFocusManager(); const [focused, setFocused] React.useState(false); // ... ref, onKeyDown 逻辑 const handleClick (e: React.MouseEvent) { e.stopPropagation(); // 防止冒泡 focus(id); // 强制聚焦 }; return ( div ref{ref as any} // ... 其他属性 onClick{handleClick} // 关键 WrappedComponent {...props} focused{focused} / /div ); }; };专家点评注意e.stopPropagation()。如果父组件也有onClick而你又想保持焦点在子组件上你必须阻止事件冒泡。否则父组件可能会抢走焦点导致子组件失去“焦点状态”从而在下一次按键时无法接收键盘事件。第七部分数字键盘 —— 快捷键的艺术现在的电视遥控器左下角通常有一排数字键0-9。这是电视端应用的一大特色。我们需要支持通过数字键直接跳转到对应的组件。代码示例 8数字键支持const handleKeyDown (e: React.KeyboardEvent) { // ... 方向键逻辑 if (e.key 0 e.key 9) { const num parseInt(e.key); // 假设我们有一个数字映射表或者直接按顺序找第 N 个组件 const allIds Array.from(components.keys()); if (num allIds.length) { focus(allIds[num]); } } };专家点评数字键支持通常与自动聚焦结合使用。比如用户按“1”焦点跳到“首页”然后用户按“2”焦点跳到“我的”页面。这种交互非常符合电视用户的习惯。第八部分性能优化 —— 不要在渲染中聚焦React 是声明式的。如果你在render函数里写if (focused) element.focus()那你的应用会卡得像老牛拉车。为什么因为element.focus()会触发浏览器的重排。如果你在组件树的深层节点里做这个操作每次父组件更新焦点就会闪烁性能会直线下降。最佳实践只操作真实 DOM确保ref.current是一个真实的 DOM 元素。使用 useEffect只在focused状态改变时触发focus()。避免在 render 中调用 focus永远不要在 return 语句中调用ref.current.focus()。代码示例 9优化的 useEffectuseEffect(() { if (focused ref.current) { // 使用 requestAnimationFrame 确保在浏览器下一帧渲染后再聚焦 requestAnimationFrame(() { ref.current.focus(); }); } }, [focused]);第九部分总结与展望好了各位我们讲完了。回顾一下我们通过一个withFocusHOC解决了从基础 DOM 聚焦、到全局状态管理、再到复杂的网格导航、焦点陷阱最后到自动聚焦和数字键支持的完整链路。在这个过程中我们不仅仅是写代码我们是在构建一个“交通指挥系统”。遥控器是车组件是路而我们的 HOC 就是那个在路口挥舞旗帜的交通警察。核心要点回顾不要依赖 DOM 顺序使用虚拟树和 Context 来管理焦点。HOC 是好帮手它能让我们在不修改业务组件逻辑的情况下统一添加遥控器交互能力。坐标系统对于网格布局必须使用坐标x, y来计算导航路径。焦点陷阱模态框、弹窗必须锁住焦点防止用户迷失。自动聚焦鼠标/触控操作必须同步到遥控器焦点。最后一点忠告开发电视端应用心态很重要。你是在和“像素”打交道是在和“距离”打交道。有时候你觉得逻辑是对的但用户在电视上就是觉得别扭。多去测试。用真正的遥控器测试不要只在键盘上按方向键。有时候一个按键的响应延迟或者一个焦点的闪烁都会让用户觉得这个应用“卡顿”或“不智能”。记住优秀的电视应用应该让用户觉得遥控器是手指的延伸而不是枷锁。当用户按下一个键屏幕上的变化应该是流畅、精准、符合预期的。好了今天的讲座就到这里。如果你在实现过程中遇到了什么坑比如焦点跳到了不该跳的地方或者数字键不灵不妨回来翻翻这篇文章看看是不是我们的“交通警察”指挥错了方向。祝大家开发愉快遥控器永远不坏咱们下期再见

更多文章