本文教你开发一款模拟焦点切换的
JavaScript插件,简称焦点插件。它可以广泛应用在TV、机顶盒等终端设备的网页应用上,非常适用于采用遥控器或键盘为交互方式的使用场景中。
该插件主要功能和特点:
- 支持手动指定目标焦点
- 支持自动寻找最优焦点
- 支持动态切换寻焦策略
- 不依赖第三方库,体积小,使用简单
用到的主要技术栈:
TypeScriptrollup原生DOM API
效果演示


快速使用
<head>
...省略非重要代码...
<style type="text/css">
.item {
width: 120px;
height: 60px;
line-height: 60px;
background-color: #ced4da;
text-align: center;
}
/* 焦点库默认落焦class */
.btn-focus {background-color: #fab005;}
#f1 {position: absolute;left: 100px;top: 200px;}
#f2 {position: absolute;left: 500px;top: 200px;}
#f3 {position: absolute;left: 300px;top: 300px;}
</style>
</head>
<body>
<!-- rightTarget="f3"的含义:当焦点在f1元素时,按向右键,焦点将落在id为f3元素上。 -->
<div id="f1" class="item focus_btn" rightTarget="f3">F1(default)</div>
<div id="f2" class="item focus_btn">F2</div>
<div id="f3" class="item focus_btn">F3</div>
<script type="text/javascript" src="js库路径"></script>
<script>
// 使用非常简单,圈定可落焦元素,且初始化落焦在id为f1元素上。
ccFocus.init('focus_btn', 'f1')
</script>
</body>
上面代码效果如下:

如何开发
1. 开发思路和说明

几点说明:
- 可落焦元素:指定义了相同
class,并在焦点插件初始化时设置该class的一组元素,它们将纳入焦点插件管理之中。注意:不可见元素会被排除;- 指定焦点:元素设置
upTarget、downTarget、leftTarget、rightTarget属性时,表示按相应方向键设置了指定焦点,属性值是元素id,不带#号;- 自动寻焦:当元素没有指定焦点,按方向键时插件会根据寻焦策略寻找下一个落焦元素,这部分下节将详解;
- 落焦样式:默认的落焦
class选择器名称是btn-focus,也支持自定义class,需要在焦点插件初始化时设置;- 派发
click事件:用遥控器按确定键或用键盘敲回车键时,会对落焦元素派发click事件,使用者可监听该事件做业务逻辑处理;- 其他事件:元素落焦时会派发
focus事件,失焦时会派发blur事件,图中暂未体现。
2. 自动寻焦策略
目前焦点插件支持两种寻焦策略:距离优先策略(默认) 和 重叠优先策略。
下面举一个🌰,以操作"向右"为例(其他方向基本逻辑一致),对两种寻焦策略作一个对比说明:

上图中有4个可落焦元素,当前落焦元素是F1,其他3个可落焦元素F2、F3、F4均在F1右侧,蓝色斜杠区域表示F1和F3在Y轴上有重叠部分,此时用户操作"向右",下一落焦元素会是哪一个?
距离优先策略的寻焦流程:

结果显而易见,当采用距离优先策略时,下一个落焦元素是
F4
重叠优先策略的寻焦流程:

- 结果同样显而易见,当采用重叠优先策略时,下一个落焦元素是
F3。当存在多个重叠元素时,距离最短的是最优元素。
当然,大家也可根据具体使用场景自定义自动寻焦策略。
3. 代码设计
// index.ts
class CCFocus {
static instance: CCFocus
private focusableEles: HTMLCollectionOf<Element> // 可落焦元素集合
private curFocusEle: Element
private focusStyleClass: string = 'btn-focus'
private strategy: Strategy
static get() {
if (!CCFocus.instance) {
CCFocus.instance = new CCFocus()
}
return CCFocus.instance
}
private constructor() {
log.info('CCFocus constructor')
this.strategy = StrategyDistance.get()
}
/**
* @description: 焦点插件初始化
* @param {string} focusClass 用于圈定落焦元素集合
* @param {string} curEleId 初始化时的落焦元素
* @param {string} focusStyle 指定落焦样式class名称
* @return {*}
*/
public init(focusClass: string, curEleId?: string, focusStyle?: string): CCFocus {
// 监听onkeydown事件
window && (window.onkeydown = (ev) => {
this._onKeydown(ev)
})
// 设置可落焦元素集合
this.focusableEles = document.getElementsByClassName(focusClass)
if (this.focusableEles.length === 0) {
// 当没找到可落焦元素时,则落焦到body上
this.focusableEles = document.getElementsByTagName('body')
log.warn('focusableEles\' cannot be found', this.focusableEles)
}
let _curElement: Element = document.getElementById(curEleId)
if (!!_curElement) {
for (let j = 0; j < this.focusableEles.length; j++) {
if (this.focusableEles[j] == _curElement) {
this.curFocusEle = _curElement
break
}
}
}
// 如没找到指定落焦元素,则将可落焦元素集合中第一个可见元素设置为初始化落焦元素
if (this.curFocusEle == null) {
log.info('the curElement cannot be found')
for (let k = 0; k < this.focusableEles.length; k++) {
if (isVisible(this.focusableEles[k])) {
this.curFocusEle = this.focusableEles[k]
break
}
}
}
// 设置落焦样式
this.focusStyleClass = focusStyle == null ? 'btn-focus' : focusStyle
this._setFocusStyle()
return this
}
// 处理按键事件
private _onKeydown(ev): void {
const lastFocusEle = this.curFocusEle
switch (ev.keyCode) {
case 37: // 向左
this._moveLeft();
ev.stopPropagation();
break;
case 38: // 向上
this._moveUp();
ev.stopPropagation();
break;
case 39: // 向右
this._moveRight();
ev.stopPropagation();
break;
case 40: // 向下
this._moveDown();
ev.stopPropagation();
break;
case 13: // 确定
this._dispatchEvent(EVENT.CLICK)
break;
}
if (lastFocusEle !== this.curFocusEle) {
// 派发事件
this._dispatchEvent(EVENT.BLUR, lastFocusEle)
this._dispatchEvent(EVENT.FOCUS)
}
}
private _dispatchEvent(evtName: EVENT, ele?: Element) {
const ev = document.createEvent('HTMLEvents')
ev.initEvent(evtName, false, false)
if (ele != null) ele.dispatchEvent(ev)
else this.curFocusEle.dispatchEvent(ev)
}
private _moveUp(): void {
// 根据当前策略获取向上时最优落焦元素
let _target = this.strategy.getUpTarget(this.focusableEles, this.curFocusEle)
if (_target !== null) {
this.curFocusEle = _target
this._setFocusStyle()
} else {
console.log('没找到向上目标元素')
}
}
private _moveDown(): void {
// 根据当前策略获取向下时最优落焦元素
let _target = this.strategy.getDownTarget(this.focusableEles, this.curFocusEle)
if (_target !== null) {
this.curFocusEle = _target
this._setFocusStyle()
} else {
console.log('没找到向下目标元素')
}
}
private _moveLeft(): void {
// 根据当前策略获取向左时最优落焦元素
let _target = this.strategy.getLeftTarget(this.focusableEles, this.curFocusEle)
if (_target !== null) {
this.curFocusEle = _target
this._setFocusStyle()
} else {
console.log('没找到向左目标元素')
}
}
private _moveRight(): void {
// 根据当前策略获取向右时最优落焦元素
let _target = this.strategy.getRightTarget(this.focusableEles, this.curFocusEle)
if (_target !== null) {
this.curFocusEle = _target
this._setFocusStyle()
} else {
console.log('没找到向右目标元素')
}
}
// 设置元素落焦样式
private _setFocusStyle(): void {
if (this.curFocusEle == null) {
for (let i = 0; i < this.focusableEles.length; i++) {
if (isVisible(this.focusableEles[i])) {
this.curFocusEle = this.focusableEles[i]
break
}
}
}
for (let j = 0; j < this.focusableEles.length; j++) {
this.focusableEles[j].classList.remove(this.focusStyleClass)
}
this.curFocusEle.classList.add(this.focusStyleClass)
}
/**
* @description: 设置自动寻找最佳元素的策略
* @param {number} 0: 距离优先策略(默认) 1: 重叠优先策略
* @return 链式调用
*/
public setStrategy(which: number): CCFocus {
switch (which) {
case 0:
this.strategy = StrategyDistance.get()
break
case 1:
this.strategy = strategyCollision.get()
break
case 2:
// 可自定义寻焦策略
break
default:
this.strategy = StrategyDistance.get()
}
return this
}
}
export default CCFocus.get()
// strategyDistance 距离优先策略实现类
export class StrategyDistance extends Strategy {
static instance: StrategyDistance
static get() {
if (!StrategyDistance.instance) {
StrategyDistance.instance = new StrategyDistance()
}
return StrategyDistance.instance
}
private constructor() {
super()
log.info('StrategyDistance constructor')
}
/**
* @description: 向右移动,寻找最优落焦元素
* @param {HTMLCollectionOf} 可落焦元素列表
* @param {Element} 当前落焦元素
* @return 最佳落焦元素
*/
getRightTarget(focusableEles: HTMLCollectionOf<Element>, curFocusEle: Element): null | Element {
// 如显示声明rightTarget,则直接获取指定焦点,没找到直接返回,不再走自动寻焦逻辑
if (curFocusEle.getAttribute(Direction.RIGHT)) {
const targetId: string = curFocusEle.getAttribute(Direction.RIGHT)
if (targetId === '#') return null
if (!focusableEles.namedItem(targetId)) return null
const target: Element = document.getElementById(targetId)
if (!target) return null
return target
}
// 自动寻焦逻辑,获取当前落焦元素位置和大小
const _curFocusEleRect = curFocusEle.getBoundingClientRect()
const _curFocusEleTop = _curFocusEleRect.top,
_curFocusEleLeft = _curFocusEleRect.left,
_curFocusEleHeight = _curFocusEleRect.height,
_curFocusEleWidth = _curFocusEleRect.width,
_curFocusEleX = _curFocusEleLeft + _curFocusEleWidth / 2,
_curFocusEleY = _curFocusEleTop + _curFocusEleHeight / 2
// 定义保存距离的变量
let dist = 99999, _tempTarget = null
const len = focusableEles.length
for (let i = 0; i < len; i++) { // 遍历可落焦元素集合
const _ele = focusableEles[i]
let _dist = 0
console.log('遍历的当前元素', i, _ele)
if (_ele !== curFocusEle) {
const _eleRect = _ele.getBoundingClientRect()
const _eleTop = _eleRect.top,
_eleLeft = _eleRect.left,
_eleHeight = _eleRect.height
let upCollision = _eleTop <= _curFocusEleTop && _eleTop + _eleHeight > _curFocusEleTop
let downCollision = _eleTop >= _curFocusEleTop && _eleTop < _curFocusEleTop + _curFocusEleHeight
if (_eleLeft > _curFocusEleLeft && (upCollision || downCollision)) {
_dist = _eleLeft - _curFocusEleX
if (_dist < dist) {
dist = _dist
_tempTarget = _ele
}
console.log('检测有重叠区域,只计算X轴距离', _dist, dist)
} else if (_eleLeft > _curFocusEleLeft) {
//如果在目标上边,计算目标元素左下角与当前元素中心点距离;如果在下面,则计算目标元素左上角与当前元素中心点距离
if (_eleTop >= _curFocusEleTop)
_dist = getAbsDistance(_eleLeft, _eleTop, _curFocusEleX, _curFocusEleY)
else
_dist = getAbsDistance(_eleLeft, _eleTop + _eleHeight, _curFocusEleX, _curFocusEleY)
if (_dist < dist) {
dist = _dist
_tempTarget = _ele
}
console.log('检测无重叠区域,计算距离', _dist, dist)
} else {
console.log('检测不在目标元素右边')
}
} else {
console.log('遍历到自身元素,跳过')
}
}
return _tempTarget
}
// 向上、向下、向左的代码实现此处省略,思路有向右处理一致。
getUpTarget(focusableEles: HTMLCollectionOf<Element>, curFocusEle: Element): null | Element {}
getDownTarget(focusableEles: HTMLCollectionOf<Element>, curFocusEle: Element): null | Element {}
getLeftTarget(focusableEles: HTMLCollectionOf<Element>, curFocusEle: Element): null | Element {}
}
贴了一些关键代码,代码量不多,逻辑其实也挺简单,结合注释理解起来应该不难,完整代码请参见
github。
4. 项目构建
// rollup.config.js
import typescript from 'rollup-plugin-typescript2'
import resolve from 'rollup-plugin-node-resolve'
import { terser } from 'rollup-plugin-terser'
export default {
input: './src/index.ts',
output: [{
file: 'dist/index-umd.js',
name: 'ccFocus',
format: 'umd'
},{
file: 'dist/index-es.js',
format: 'es'
},{
file: 'dist/index-cjs.js',
format: 'cjs'
}],
plugins: [
typescript(), //转译ts代码
resolve(), // 解析外部模块
terser() // 压缩
]
}
- 要生成打包文件,直接执行
rollup -c或在package.json配置scripts都行;- 打包后的
js文件可上传npm、cdn后使用,也可直接本地引用。
结语
如果开发的网页要跑在TV,盒子等设备上,你可以使用该插件,它能满足常用的模拟焦点切换及操作的场景。 当然,该插件存在很多不足,在功能性能、兼容性、集成方式上还有很多可优化改善的地方,本篇文章仅抛砖引玉,提供参考,不喜勿喷。