做自定义虚拟键盘这个需求,听起来特别直接:屏幕上摆几个按键,点击就往输入框里怼字符,完事。但我最早在触摸屏一体机上试水的时候,光"焦点"这一个问题就折腾了一周——每点一下键盘,输入框就失焦,用户辛辛苦苦输到一半的内容直接断在中间。后来才明白,虚拟键盘真正的难度根本不在画按键,而在于它要把自己伪装成一套完整的输入系统,去对接页面原有的输入逻辑,还不能喧宾夺主。
这篇文章是我几次实操下来的一次完整复盘,内容包括技术方案选型、事件绑定链路、触摸屏适配、数据驱动布局,还有那些文档里不会写的坑。不管你是要在普通Web页面里做一个可定制按键的软键盘,还是在工业触摸屏、自助终端上做一套专属输入组件,这份指南都能当做一个还算靠谱的起点。
1. 虚拟键盘不是"改个系统键盘皮肤"这么简单
1.1 先划清楚边界:系统键盘和自定义键盘各自管什么
移动端浏览器里,一个<input>聚焦之后,系统键盘自动弹出来,这是操作系统给出的默认输入方案。它体验好、兼容强,但你几乎没法控制它:按键长什么样、允许输入什么字符、按下之后额外触发什么业务逻辑,全部黑盒。
自定义虚拟键盘恰恰相反——它拿回的是"输入过程"的控制权。你可以决定键盘上只出现数字和大写字母,可以让某个按键按下后不仅输入字符还联动查询,可以为了让戴手套的工人也能准确点按而把按键做到60px高。但代价是,系统原本替你处理的焦点管理、光标移动、输入法组合、撤销栈全部要自己接一遍。
这里要特别提醒一下:如果不是因为"控制权"的需求,只是觉得系统键盘丑,想换个皮肤,那大概率不该自己开发。改造现有开源库或者用现成组件改样式,成本低得多。
1.2 我拆解过的三类真实需求
场景A:工业触摸屏人机界面。某设备厂商的10寸工控屏,没有物理键盘,操作工人需要输入批次号、物料编码和工号。系统自带的软键盘字体小、按键密,在车间光照下几乎看不清,而且弹出来之后经常遮住正在查看的字段。这类需求的核心不是输入效率,而是"大按键、高对比度、稳定可靠",按键布局要能按客户要求定制,比如把数字和字母分区排列。
场景B:自助终端。挂号机、排队叫号机、信息查询机这类设备,输入范围非常收敛,通常只有数字、身份证号、手机号。业务上要求不能输字母和特殊符号,界面风格要和整机统一。这类需求最重要的是输入限制和防误触,一个按键只能触发一个明确的结果。
场景C:Web收银/点单页面。营业员在触摸屏一体机上快速录入会员手机号、折扣码,或者调用常用短语。这类页面通常还需要"输完手机号自动查询会员"这种联动动作。此时自定义键盘的价值不只是好看,而是能把业务动作直接绑定到按键上,缩短操作路径。
1.3 什么情况别硬上自定义键盘
反过来也说说。如果需求只是"手机网页里想换一套自定义键盘",我一般会劝退。原因很简单:在浏览器里,只要<input>聚焦,系统键盘就有机会弹出来,你没有可靠手段完全阻止它——所谓的readonly方案、inputmode方案都只是在"减少弹出的概率",做不到100%屏蔽。强行把输入框设成只读,再靠虚拟键盘往里填内容,又会引入光标模拟、选区管理、中文输入法冲突一堆新问题。
所以我的判断原则是:键盘需要和系统输入法长期共存(尤其是手机浏览器场景),就别自己做;键盘是设备上的唯一输入入口(比如工控屏、一体机、封闭Webview),才值得彻底自研。
2. 技术方案选型:不是所有"网页键盘"都长一个样
2.1 四种主流落地方式对比
我整理了一下自己接触过的虚拟键盘实现路径:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| DOM + CSS 渲染按键 | 常规Web、触摸屏H5、一体机Webview | 开发快、样式定制灵活、天然支持无障碍语义 | 动画和特效能力有限,性能瓶颈在DOM量级 |
| Canvas / WebGL 绘制 | 对动效、按键特效要求极高的展示型界面 | 渲染自由度最大,可以实现非常复杂的视觉 | 命中测试、文字排版、焦点管理都要自己造,维护成本高 |
| 桌面端方案(Electron / Python + Qt等) | 桌面应用里的模拟键盘 | 能直接和原生窗口、硬件交互,可做全局热键 | 跨平台打包体积大,更新节奏受框架限制 |
| 系统级自定义键盘(如移动端输入法扩展) | 需要全局键盘替代系统输入法 | 体验最接近原生 | 开发成本高、审核门槛高、只覆盖特定平台 |
2.2 为什么我的项目最后都回归DOM方案
我做过好几个版本的虚拟键盘,最早也试过Canvas方案,因为想着"反正每个按键都是自己画的,Canvas自由度更高"。结果发现,Canvas方案里最难处理的不是画按键,而是"点按命中":你需要自己维护每个按键的矩形坐标,处理像素比适配,点击后还要判断点到哪个键。最痛苦的其实是无障碍——Canvas里的每个键对屏幕阅读器来说都是一张无声的图片。
DOM方案完全没有这些烦恼。每个按键就是一个真实的DOM节点,天然支持:active伪类、click事件、aria-label、键盘事件模拟。对虚拟键盘这种"结构固定、交互相对简单"的组件来说,DOM的性能完全够用——我在几百毫秒的渲染周期内渲染几十个按键没有任何压力。
2.3 依赖与框架层面的取舍
不要一上来就引入一个重型UI框架。虚拟键盘本质上是一个组件,如果项目里已经在用某个框架,那就把它做成对应框架的组件;如果是一个原生页面,原生JS加少量模板逻辑就够了。
比较推荐的做法是:键盘核心逻辑(输入映射、焦点管理、布局配置)保持与框架无关,用纯函数和普通对象实现;UI层只做薄薄一层的绑定。这样以后从原生迁到Vue或者从Vue迁到React,底层完全不用动,只改渲染层。这个设计我吃过甜头——有个项目最早是原生写的,后来要嵌入到一个React应用里,因为核心逻辑没和DOM写在一起,迁移只花了一天。
3. 事件绑定与输入映射:虚拟键盘的核心链路
3.1 把字符"送进"输入框的三种思路
你要敲一个字符到<input>里,说白了就三件事:定位光标、插入文本、让框架感知到值变化。查了下我踩过的三条路:
直接改input.value。最简单,把旧值和字符拼起来再赋回去。但这样会破坏浏览器的原生撤销栈,输入框的光标位置会跳到末尾,而且如果输入框是有maxlength校验的,直接赋值可能导致字符被截断。不推荐作为主方案。
使用document.execCommand('insertText')。老一代方案里比较靠谱的,它走的是浏览器的编辑命令通道,能保留撤销栈。但这东西官方明确标记过废弃状态,在部分Webview里行为不一致,新项目我不会再用。
使用selectionStart/selectionEnd + setRangeText。现代浏览器里最接近原生输入的做法。先拿到光标位置,再用setRangeText把文本插进去,最后手动派发一个input事件通知框架。
我现在的推荐写法是:
function insertTextAtCursor(input, text) { const start = input.selectionStart; const end = input.selectionEnd; input.focus(); input.setRangeText(text, start, end, 'end'); // 给 Vue / React 等框架一个信号:值变了 input.dispatchEvent(new Event('input', { bubbles: true })); }setRangeText的第四个参数传'end',意思是插入后光标定位在插入文本的后面,非常顺手,不用自己管光标回收。
3.2 焦点锁定:键盘不能抢走输入框的焦点
这一点是很多第一次做虚拟键盘的人翻车的地方。点击键盘按键,默认行为是会触发一次"焦点转移"——如果键盘容器可以被聚焦,输入框就失焦了,下一次insertTextAtCursor就找不到目标。
解法有两个层面:
第一,让键盘容器本身不可聚焦,避免焦点进来。比如给它设置tabindex="-1",并且不要主动调用它的focus()。
第二,对pointerdown事件调用preventDefault(),阻止默认的焦点改变行为。注意,pointerdown上阻止默认行为会连带阻止后续兼容性的mouse事件和click事件,所以按键的点击逻辑不能依赖click,要把逻辑放到pointerup或者自己处理。
keyboardElement.addEventListener('pointerdown', (e) => { // 阻止焦点从输入框转移到键盘上 e.preventDefault(); });这个写法的副作用是键盘组件本身无法通过鼠标触发click,所以如果你同时在开发PC端,需要额外在keydown里补一份对回车键和空格键的支持,保证只用键盘也能操作虚拟键盘。
3.3 功能键与状态键:Shift、CapsLock、Backspace 的完整处理
真正的虚拟键盘不可能只有字母和数字,功能键的处理才是布局是否好用的关键。
最典型的是Shift键。它不是一个输入字符,而是一个"状态切换器"。我的做法是维护一个布局映射关系,按下Shift时,把当前布局切换到上层字符,同时给Shift键本身加一个active视觉状态;再次按下或者输入一个字母后自动回到小写布局。CapsLock则是一个锁定状态,只切换大小写,不切换按键位置。
const LAYOUT = { lower: [ ['1','2','3','4','5','6','7','8','9','0'], ['q','w','e','r','t','y','u','i','o','p'], ['shift','a','s','d','f','g','h','j','k','l','backspace'], ['alt','z','x','c','v','b','n','m','enter'], ['space'] ], upper: [ ['!','@','#','$','%','^','&','*','(',')'], ['Q','W','E','R','T','Y','U','I','O','P'], ['shift','A','S','D','F','G','H','J','K','L','backspace'], ['alt','Z','X','C','V','B','N','M','enter'], ['space'] ] };Backspace删除字符这里有讲究。多数人第一反应是"把value减一个字符",但遇到中文、Emoji这些由多个UTF-16编码单元组成的字符时,直接按编码单元删除会把字符劈成两半。用Array.from转成真正的字符数组,再算好边界删除:
function deletePrevChar(input) { const start = input.selectionStart; const end = input.selectionEnd; if (start !== end) { // 存在选区,直接删选区 input.setRangeText('', start, end, 'end'); } else if (start > 0) { // 按码点取最后一个字符,避免拆散表情或组合字符 const codePoints = Array.from(input.value.slice(0, start)); const lastChar = codePoints[codePoints.length - 1]; input.setRangeText('', start - lastChar.length, start, 'end'); } input.dispatchEvent(new Event('input', { bubbles: true })); }Enter键的逻辑要按场景区分:如果键盘使用在表单里,应该触发表单提交;如果用在聊天窗口,则应该是换行或发送。我习惯在按键分发层把动作类型定义成action,不在UI层写死。
3.4 和输入法合成事件的冲突
这里要单拎出来说,因为它真的困扰了我很久。当用户在真实中文输入法状态下点击你的虚拟键盘时,输入法可能正处于"拼音候选"的合成过程中。典型症状是:明明按的是字母键,结果输入法把字母组合成拼音,直接蹦出繁体字或者乱码。
标准做法是监听compositionstart和compositionend:
let isComposing = false; input.addEventListener('compositionstart', () => { isComposing = true; }); input.addEventListener('compositionend', () => { isComposing = false; }); // 在插入逻辑入口判断 if (isComposing) { // 合成期间放弃直接插入,或者把按键内容保存到暂存区 return; }再深入一层,自定义虚拟键盘在设计之初就要想清楚:你打算支持中文输入吗?如果支持,工作量翻倍不止——需要拼音映射、候选词面板、多音字处理。如果不支持,只是面向数字、字母、符号的录入,那就在键盘外层明确屏蔽中文输入法的干扰,这通常更符合工控和自助终端的实际场景。
4. 键盘布局与UI细节:决定"好用"和"能用"的那20%
4.1 用二维数组驱动布局渲染,而不是手写一堆按钮
我见过不少项目里,键盘的每个按键都是手写的一个<button>,几十个按钮在HTML里排成一大片。一旦客户说"把删除键从第四行挪到第一行",整个布局就崩了。
更合理的做法是把布局抽象成数据:
const keyboardData = [ { type: 'key', value: '1', label: '1' }, { type: 'key', value: 'q', label: 'q' }, { type: 'function', action: 'backspace', label: '删除' }, { type: 'function', action: 'shift', label: 'Shift' } ];再写一个纯粹的渲染函数,把数组变成DOM,按type分发不同的样式和行为。客户要改布局,只需要改数据,不需要碰代码。这个设计对"自定义虚拟键盘"里的"自定义"三个字是决定性支撑——没有数据驱动,你永远只能做一款固定的键盘。
4.2 按键尺寸、间距与视觉密度
尺寸这件事,用数据说话。手指触摸的目标最小建议是44px,这是移动端普遍接受的一个下限;但在工业触摸屏上,操作者可能戴手套、车间环境有震动,我实际项目里都会做到至少48px,甚至60px。有一个10寸屏项目,客户坚持要把字体做到28px以上、按键间距4px,实测下来误触率明显下降。
间距也不能太紧,但也不需要太松。按键之间留4到8px的间隙,视觉上既不会粘连,又不会显得松散。另外要考虑键盘总高度:底部固定式键盘,高度建议控制在屏幕的40%以内,否则会挤压内容区。
4.3 按压反馈:虚拟键盘体验的灵魂
没有反馈的虚拟键盘会让人感觉"按键没按下去"。物理键盘有键程、有声音,触摸屏上必须自己造出这些反馈。
轻量级方案是CSS解决:
.key { transition: transform 0.05s ease, background-color 0.1s ease; } .key:active { transform: translateY(2px); background-color: #e8e8e8; }进阶一点,按下去的时候用JavaScript给按键添加一个active类,在pointerup/pointerleave时移除,这样即使手指滑出按键区域也不会有"粘住"的问题。如果设备自带震动马达,还可以在pointerdown时调用navigator.vibrate(10),一个10毫秒的短震,手感立刻不一样。
4.4 功能键的视觉状态管理
Shift按下、CapsLock开启,按键本身必须给出可见的状态变化。我习惯用数据传输状态,而不是靠临时改样式:
const keyboardState = { shift: false, caps: false }; function getCurrentLayout() { const upper = keyboardState.shift !== keyboardState.caps ? 'upper' : 'lower'; return LAYOUT[upper]; }这里有个细节:Shift + CapsLock组合时,大小写应该是取反的关系。如果CapsLock是大写锁定,再按Shift会临时切换成小写——和物理键盘行为保持一致,用户才不会有认知错乱。
5. 触摸屏与移动端:从鼠标点按到手指操作的距离
5.1 为什么推荐用 Pointer Events 而不是 click
在PC网页里,click事件很可靠。但在触摸屏和移动端,click存在两三百毫秒的延迟窗口,用户会明显感觉到"按下去没反应,等了一下才触发"。旧时代我们靠touchstart处理,但touchstart和mouse事件并存时又会触发多次点击。
统一方案是pointerdown/pointerup。Pointer Events 把鼠标、触摸、笔统一成一种抽象,pointerdown在手指触摸时立刻触发,没有延迟;而且可以用pointerType区分是手指还是鼠标,方便做差异逻辑。实际项目里,我把虚拟键盘的所有按键逻辑都挂在pointerup上,pointerdown只负责视觉反馈和焦点锁定。
5.2 防止触摸引发的页面滚动和缩放
触摸屏上点键盘时,手指稍微一滑,页面就跟着滚动了,这个体验很糟糕。两个层面的处理:
第一,给键盘容器设置touch-action样式。touch-action: manipulation表示允许平移和缩放,但禁止双击缩放;如果想彻底禁用容器内的手势,可以用touch-action: none。
第二,在pointermove时判断是否发生了明显滑动,如果滑动距离超过一定阈值,就视为"取消本次点击",防止用户在按住一个键想滑到另一个键的过程中误触发。这是一个很细节但很实用的手感调优点。
5.3 系统键盘干扰:移动端最大的头痛问题
在手机浏览器里做自定义虚拟键盘,最尴尬的事情是你自己的键盘弹出来了,系统键盘也跟着弹出来。前面说过,这块没有完美的方案,但我实际摸索出一个折中策略:
- 在封闭Webview或一体机设备上,系统键盘本来就不会出现,直接用真实
<input>加自定义键盘,体验最好。 - 在手机浏览器上,如果一定要隐藏系统键盘,可以把输入框设为只读,由虚拟键盘直接操纵它的值。坏处是无法用
selectionStart精准控制光标,也无法支持输入法合成。
实测下来,如果要支持"光标位置精确操作" "中文输入法" "选区操作"这三件事,只读方案完全扛不住;如果只是数字加字母的简单输入,只读方案勉强可用。所以这个难题的根本解法还是:移动端浏览器页面尽量别做自定义键盘,如果要做,就接受"不能完全屏蔽系统键盘"的现实。
5.4 键盘定位与页面resize
底部固定式键盘最常见的实现是position: fixed; bottom: 0。但在移动端,当系统键盘弹出时,visualViewport高度会变化,键盘和输入框的相对位置需要动态调整。可以监听visualViewport的 resize 事件,实时计算键盘的top值,把键盘推高到系统键盘上方;如果是自己弹出的键盘,则保持固定在底部即可。
6. 焦点丢失、输入重复与光标漂移:实测最常遇见的三个坑
6.1 焦点丢失:点击一次,输入框就失焦一次
在某个自助终端项目里,用户每点一个数字,输入框的光标就消失一次。排查过程很有意思:一开始以为是我的键盘焦点问题,后来发现在输入框的全局blur监听里,有一段来自外层框架的逻辑,任何失焦都会触发一个隐藏操作,把输入框隐藏了。
要复现这个问题的排查链路,要点是:
- 在输入框的
blur事件里打日志,确认失焦发生的时间点; - 在键盘
pointerdown里打日志,确认事件触发的先后顺序; - 检查是否有全局的
focus/blur代理逻辑在干扰。
真正的解法还是前面说过的:键盘pointerdown阻止默认行为 + 键盘容器不可聚焦。如果失焦是外层框架造成的,则需要在外层框架的失焦回调里做一个"焦点即将转移给键盘"的豁免判断。
6.2 输入重复:一次按键触发了两次插入
这个坑主要出在"手动派发input事件 + 框架自己的v-model"同时生效的配置里。比如Vue的v-model本身就监听input事件去更新数据,你又手动派发了一次input,数据更新逻辑跑了两次,屏幕上就出现了字符重复。
解法是在派发input事件前,先对值做一次比对:
function updateInputValue(input, newValue) { if (input.value === newValue) return; // React 需要走原生 setter,Vue 直接赋值即可 input.value = newValue; input.dispatchEvent(new Event('input', { bubbles: true })); }React 还有一个额外问题:它内部会用一个"值跟踪器"记录输入框当前值,直接修改input.value后派发事件,React 可能认为值没有变化。标准技巧是:
const valueSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' ).set; valueSetter.call(input, newValue); input.dispatchEvent(new Event('input', { bubbles: true }));6.3 光标漂移:输入一个字符后光标跳到了末尾
setRangeText在多数情况下能保持光标位置,但如果你在插入之后又随手做了一次input.value = input.value + text这种操作,光标必然跳到末尾。另外一个隐蔽场景是:输入框有maxlength限制,setRangeText插入后触发了浏览器的截断,光标位置就不在预期位置了。
处理思路是:每次插入之前,把selectionStart/selectionEnd保存到变量里;插入之后如果框架发生了重渲染,再手动恢复一次光标位置。这个方法笨但可靠,尤其在远程调试一些老Webview时特别管用。
6.4 隐藏键盘的触发策略
虚拟键盘在自助终端上通常常驻,但在共享屏幕上用完后要及时隐藏。我常用的触发策略是:
- 点击键盘之外的空白区域,隐藏键盘;
- 输入框
blur且焦点没有转移到键盘本身,隐藏键盘; - 按
Enter确认后,如果业务允许,隐藏键盘。
注意"点击空白区域"要用pointerdown监听,并且要排除掉键盘容器本身,否则每次点击按键都会先触发隐藏逻辑再触发插入逻辑,出现一种"键盘闪一下"的糟糕体验。
7. 值得提前做好的工程化设计
7.1 按键事件统一走分发器,而不是散落在各个按钮上
虚拟键盘的按键种类再多,归纳起来无非三类:字符键、功能键、状态键。我习惯把它们统一抽象成一个动作分发器:
function dispatchKeyAction(type, payload) { switch (type) { case 'input': insertTextAtCursor(activeInput, payload); break; case 'delete': deletePrevChar(activeInput); break; case 'shift': keyboardState.shift = !keyboardState.shift; renderLayout(); break; case 'enter': handleEnter(); break; default: break; } }所有按键在HTML层面只负责一个职责:告诉分发器"我是什么类型的按键"。业务逻辑不散落在几十个按钮的监听器里,排查问题就只需要看这一个函数。
7.2 布局和按键行为配置化
"自定义虚拟键盘"如果只是代码层面改数组,还不算真正的自定义。我后期的实现里,布局通常是一个JSON配置,业务人员可以直接改配置文件重新生成键盘。
{ "rows": [ [{"label": "Q", "value": "q", "type": "input"}], [{"label": "删除", "type": "function", "action": "backspace"}] ] }比如有的客户要放一个"快捷录入上次订单"的按键,只需要在配置里增加一个{ "type": "function", "action": "repeatLastOrder" },核心代码只需要注册一下这个动作。把扩展成本从"改代码"降到"改配置",虚拟键盘才真正配得上"自定义"三个字。
7.3 中文输入的冷思考
最后必须泼一盆冷水。如果你以为虚拟键盘只是把英文字母做成可以点击的按钮,然后要求它支持中文输入,那工程量会瞬间爆炸:拼音到汉字的映射库、多音字处理、候选词排序、词库加载,随便一个都是独立项目的规模。
我现在的判断标准很清楚:支持中文输入的自定义键盘,优先考虑现有中文输入法SDK或者系统输入法;自定义键盘默认定位是"受限字符集输入工具",也就是数字、拉丁字母、常用符号。这个定位能让交付质量和工期都保持在可控范围。如果确实必须中文,抱着一整块工程量去做规划和报价,千万不要在开发中后期临时加需求。
我的个人体会是,虚拟键盘项目的成败,一半在立项之前的边界划分,一半在事件链路的细节处理。前者决定了你是不是在做一个不可能完成的需求,后者决定了做出来能不能被用户接受。技术上高深的部分不多,但每一个"看起来没什么"的细节——焦点、光标、事件派发、触摸反馈——都足够让一个没有经验的人卡上两三天。其实这也是我写下这份指南的原因:希望有更多人能少踩我当年踩过的那些坑,直接把精力放到真正有价值的功能设计上去。