从 React 源代码看 keyPress 与 keyDown 事件

keyPress 跟 keyDown 的差异

首先,我们要来看看 keyPress 与 keyDown 这两个原生事件的差异到底在那里,这部分我们直接请出 MDN 来为我们做解释:

The keypress event is fired when a key that produces a character value is pressed down. Examples of keys that produce a character value are alphabetic, numeric, and punctuation keys. Examples of keys that don't produce a character value are modifier keys such as Alt, Shift, Ctrl, or Meta.

来源:https://developer.mozilla.org/en-US/docs/Web/Events/keypress

The keydown event is fired when a key is pressed down.

Unlike the keypress event, the keydown event is fired for all keys, regardless of whether they produce a character value.

来源:https://developer.mozilla.org/en-US/docs/Web/Events/keydown

简单来说呢,keyDown 会在你按下任何按键时触发,但是 keyPress 只会在你按下的按键可以产生出一个字符的时候触发,白话一点就是你按下这按键是在打字。

例如说你按 a,画面上会出现一个字符 a,所以 keyDown 跟 keyPress 都会触发。但如果你按 shift,画面上什麽都不会出现,所以只有 keyDown 会触发。

w3c 提供了一个很不错的网页:Key and Character Codes vs. Event Types,让你可以自己实验看看。

下图中我输入 a,两者都会触发,接着我按 shift,只会触发 keyDown,再来按 backspace 把文字删掉,也只会触发 keyDown:

所以这两者的差异相信大家应该可以很清楚的知道了,keyDown 可以当作是「按下按键」,keyPress 则当作「输入东西」时会触发的事件。

接著我们来谈谈 keyCode 跟 charCode。

keyCode 与 charCode 的差异

先来谈谈 charCode 好了,或许你有看过 JavaScript 里面有个函数是这样的:

console.log(String.fromCharCode(65)); // A

charCode 其实就是某一个字符所代表的一个号码,或更精确一点地说,就是它的 Unicode 编码。

这边如果不太熟的话可以参考这篇文章:[Guide] 瞭解网页中看不懂的编码:Unicode 在 JavaScript 中的使用

在 JavaScript 里面也可以用另一个函数拿到字符所对应的编码:

console.log('嗨'.charCodeAt(0)); // 21992

若是你把这 21992 转成 16 进制,会变成 0x55E8,这个其实就是「嗨」的 Unicode:

来源:https://www.cns11643.gov.tw/wordView.jsp?ID=90944)

那什麽是 keyCode 呢?既然 charCode 代表着是一个 char(字符)的 code,那 keyCode 显然就是代表一个 key(按键)的 code。

每一个「按键」也都有一个它自己的代码,而且有时候会让你混淆,因为它跟 charCode 可能是一样的。

举例来说:「A」这个按键的 keyCode 是 65,而「A」这个字符的 charCode 也是 65。这应该是为了某种方便性所以这样设计,但你要注意到一点:

当我按下「A」这个按键的时候,我可能要打的是 a 或是 A,有两种可能。

或是举另外一个例子,当你要打数字 1 时,如果你是用 Q 上方的那颗按键而不是用纯数字键盘,你要打的字可能是「1」或是「!」或甚至是「ㄅ」,因为它们都是同一颗按键。

一颗按键对应了不只一个字符,所以单单从 keyCode,你是没办法判断使用者想打什麽字的。

讲到这裡,我们可以来想一下这两个跟 keyPress 与 keyDown 的关联了。

刚刚说到 keyPress 是你要输入文字的时候才会触发,所以这个事件会拿到 charCode,因为你要知道使用者打了什么字。那为什么不是 keyCode 呢?因为你从 keyCode 根本不知道他打了什么字,所以拿 keyCode 也没用。

keyDown 则是在你按下任何按键时都会触发,这时候一定要拿 keyCode,因为你要知道使用者按了什么按键。若是拿 charCode 的话,你按 shift 或是 ctrl 就没有值了,因为这不是一个字符,就没办法知道使用者按了什么。

总结一下,当你要侦测使用者输入文字的时候,就用 keyPress,并且搭配 charCode 来看使用者刚刚输入了什么;当你想侦测使用者「按下按键」的时候,就用 keyDown,搭配 keyCode 获得使用者所按下的按键。

这就是 keyPress、keyDown 以及 keyCode 跟 charCode 的差别。

顺带一提,在输入中文的时候 keyPress 不会有值,keyDown 则会回传一个神秘的代码 229:

key 与 which

在 keyPress 与 keyDown 这两个 event 里面,其实还有两个属性:key 与 which。

我们先来看一下 which 是什麽:

The which read-only property of the KeyboardEvent interface returns the numeric keyCode of the key pressed, or the character code (charCode) for an alphanumeric key pressed.

来源:https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/which

根据我自己的理解,当你在 keyPress 里面用 which 的时候,拿到的应该就是 charCode;在 keyDown 里面用的时候就是 keyCode,所以你在写程序的时候可以统一用 event.which 来拿这个信息,不必再区分 keyCode 或是 charCode。

不过 MDN 附的参考资料写的蛮模糊的,所以这部分我也不是很确定:

which holds a system- and implementation-dependent numerical code signifying the unmodified identifier associated with the key pressed. In most cases, the value is identical to keyCode.

来源:https://www.w3.org/TR/2014/WD-DOM-Level-3-Events-20140925/#widl-KeyboardEvent-which

接着來看一下 key:

The KeyboardEvent.key read-only property returns the value of the key pressed by the user while taking into considerations the state of modifier keys such as the shiftKey as well as the keyboard locale/layou

来源:https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key

简单来说 key 会是一个字符串,你刚刚按了什么按键或是打了什么字,key 就会是什麽。上面 MDN 的网页下方有附一个简单的范例让你来测试 key 的值。

例如说我输入 A,key 就是 A,按下 Shift,key 就是 Shift。

还有一点要注意的是,这个属性在 keyPress 或是 keyDown 事件里面都拿得到。所以尽管是 keyDown 事件,你也能知道使用者刚刚输入了什麽或是按了什麽按键。

但尽管如此,关于「侦测输入」的事件应该还是用 keyPress 最合适,除非你想要侦测其他不会产生字符的按键(Ctrl, Delete, Shift…)才用 keyDown 事件。

在这边做个中场总结,其实这些 which、keyCode 跟 charCode,在不同浏览器上面都可能有不同的表现,所以是跨浏览器支持一个很麻烦的部分,从这个方向去找,你可以找到一大堆在讲浏览器相容性的文章。

但近几年来旧的浏览器渐渐被淘汰,大部分的使用者在用的浏览器应该都比较符合标准了,因此相容性并不是本篇文章的重点,所以就没有多提了。

接下来终于要到可能是最吸引你的部分:React 原代码。

初探 React 原代码

React 原代码这麽大,该从何找起呢?

这边推荐一个超级好用的方法:GitHub 的搜寻。通常只要拿你想找的 function 名称或是相关的关键字下去搜寻,就能够把范围限缩的很小,只要用肉眼再翻一下资料就能够找到相对应的原代码,是方便又好用的一个方法。

这边我们用 keyPress 来当关键字,出现了 12 笔结果:

用肉眼稍微筛选一下,发现很多都是测试,那些都可以直接跳过。你应该很快就能定位到几个相关的档案,像是这两个:

  1. packages/react-dom/src/events/SyntheticKeyboardEvent.js
  2. packages/react-dom/src/events/getEventKey.js

没错,这两个就是今天的主角。

我们先来看 SyntheticKeyboardEvent.js,如果你对 React 还算熟悉的话,应该知道你在里面拿到的事件都不是原生的事件,而是 React 会包装过之后再丢给你,而现在这个 SyntheticKeyboardEvent 就是经过 React 包装后的事件,就是你在 onKeyPress 或是 onKeyDown 的时候会拿到的 e。

为了方便起见,我们切成几个 function,一个一个来看。

charCode: function(event) {
  // `charCode` is the result of a KeyPress event and represents the value of
  // the actual printable character.

  // KeyPress is deprecated, but its replacement is not yet final and not
  // implemented in any major browser. Only KeyPress has charCode.
  if (event.type === 'keypress') {
    return getEventCharCode(event);
  }
  return 0;
}

这边注释写得很棒,説 keyPress 已经被 deprecated 了但是替代品还没准备好。再者,也提到了只有 keyPress 有 charCode。

所以这边就是判断 event 的 type 是不是 keypress,是的话就回传 getEventCharCode(event),否则回传 0。

接著我们来看一下 getEventCharCode 在做什么(小提醒,这个函数在另外一个文件):

/**
 * `charCode` represents the actual "character code" and is safe to use with
 * `String.fromCharCode`. As such, only keys that correspond to printable
 * characters produce a valid `charCode`, the only exception to this is Enter.
 * The Tab-key is considered non-printable and does not have a `charCode`,
 * presumably because it does not produce a tab-character in browsers.
 *
 * @param {object} nativeEvent Native browser event.
 * @return {number} Normalized `charCode` property.
 */
function getEventCharCode(nativeEvent) {
  let charCode;
  const keyCode = nativeEvent.keyCode;

  if ('charCode' in nativeEvent) {
    charCode = nativeEvent.charCode;

    // FF does not set `charCode` for the Enter-key, check against `keyCode`.
    if (charCode === 0 && keyCode === 13) {
      charCode = 13;
    }
  } else {
    // IE8 does not implement `charCode`, but `keyCode` has the correct value.
    charCode = keyCode;
  }

  // IE and Edge (on Windows) and Chrome / Safari (on Windows and Linux)
  // report Enter as charCode 10 when ctrl is pressed.
  if (charCode === 10) {
    charCode = 13;
  }

  // Some non-printable keys are reported in `charCode`/`keyCode`, discard them.
  // Must not discard the (non-)printable Enter-key.
  if (charCode >= 32 || charCode === 13) {
    return charCode;
  }

  return 0;
}

接着我们一样分段来看比较方便:

/**
 * `charCode` represents the actual "character code" and is safe to use with
 * `String.fromCharCode`. As such, only keys that correspond to printable
 * characters produce a valid `charCode`, the only exception to this is Enter.
 * The Tab-key is considered non-printable and does not have a `charCode`,
 * presumably because it does not produce a tab-character in browsers.
 *
 * @param {object} nativeEvent Native browser event.
 * @return {number} Normalized `charCode` property.
 */

开头的注释先跟你说 charCode 代表的就是 character code,所以可以用 String.fromCharCode 来找出搭配的字符。

因此,只有能被印出来(或者是说可以被显示出来)的字符才有 charCode,而 Enter 是一个例外,因为 Enter 会产生空行。但 Tab 不是,因为你按 Tab 不会产生一个代表 Tab 的字符。

let charCode;
const keyCode = nativeEvent.keyCode;

if ('charCode' in nativeEvent) {
  charCode = nativeEvent.charCode;

  // FF does not set `charCode` for the Enter-key, check against `keyCode`.
  if (charCode === 0 && keyCode === 13) {
    charCode = 13;
  }
} else {
  // IE8 does not implement `charCode`, but `keyCode` has the correct value.
  charCode = keyCode;
}

这边针对浏览器的兼容性做处理,FireFox 没有帮 Enter 设定 charCode,所以要额外判断 keyCode 是不是 13。然后 IE8 没有实作 charCode,所以用 keyCode 的值来取代。

// IE and Edge (on Windows) and Chrome / Safari (on Windows and Linux)
// report Enter as charCode 10 when ctrl is pressed.
if (charCode === 10) {
  charCode = 13;
}

// Some non-printable keys are reported in `charCode`/`keyCode`, discard them.
// Must not discard the (non-)printable Enter-key.
if (charCode >= 32 || charCode === 13) {
  return charCode;
}

这边应该算是一个 special case,当使用者按下 Ctrl + Enter 时的 charCode 是 10,React 想把这个也当作按下 Enter 来处理。

另外,有些没办法被印出来的字符应该要被拿掉,所以最后做了一个范围的判断。

charCode 的处理就是这样了,仔细看看其实还蛮有趣的,针对浏览器的兼容性跟一些特殊状况做了处理。

接着我们回到 SyntheticKeyboardEvent.js,来看看 keyCode 的处理:

keyCode: function(event) {
  // `keyCode` is the result of a KeyDown/Up event and represents the value of
  // physical keyboard key.

  // The actual meaning of the value depends on the users' keyboard layout
  // which cannot be detected. Assuming that it is a US keyboard layout
  // provides a surprisingly accurate mapping for US and European users.
  // Due to this, it is left to the user to implement at this time.
  if (event.type === 'keydown' || event.type === 'keyup') {
    return event.keyCode;
  }
  return 0;
}

这边说 keyCode 的值其实是依赖于键盘的,意思是说有些键盘可能会产生不太一样的 keyCode,但因为大多数美国跟欧洲的使用者都是 US keyboard,所以这边就直接把 keyCode 丢回去而不做特殊处理。

其实这一段我没有看得完全懂,只是大概猜一下意思而已。这边指的「keyboard layout」可能是像 QWERTY 或是 Dvorak 这种的 layout,按键的排列方式完全不同。但如果这样就会产生不同的 keyCode 的话,是不是代表有些网站可能会有 bug?

不过大多数人的键盘都是同样的排列,所以好像不用太担心这个问题。

which: function(event) {
  // `which` is an alias for either `keyCode` or `charCode` depending on the
  // type of the event.
  if (event.type === 'keypress') {
    return getEventCharCode(event);
  }
  if (event.type === 'keydown' || event.type === 'keyup') {
    return event.keyCode;
  }
  return 0;
}

最后是 which 的部分,如果是 keypress 就把 charCode 传回去,keydown 或是 keyup 的话就把 keyCode 传回去。

讲到这裡,我们已经看到 React 对于 charCode、keyCode 以及 which 的处理了,charCode 针对特殊情形以及浏览器兼容性做检查,keyCode 直接回传,which 则根据事件不同回传相对应的值。

最后我们来看一下 key 的处理,这边放在另外一个文件叫做 getEventKey.js

/**
 * Normalization of deprecated HTML5 `key` values
 * @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent#Key_names
 */
const normalizeKey = {
  Esc: 'Escape',
  Spacebar: ' ',
  Left: 'ArrowLeft',
  Up: 'ArrowUp',
  Right: 'ArrowRight',
  Down: 'ArrowDown',
  Del: 'Delete',
  Win: 'OS',
  Menu: 'ContextMenu',
  Apps: 'ContextMenu',
  Scroll: 'ScrollLock',
  MozPrintableKey: 'Unidentified',
};

/**
 * Translation from legacy `keyCode` to HTML5 `key`
 * Only special keys supported, all others depend on keyboard layout or browser
 * @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent#Key_names
 */
const translateToKey = {
  '8': 'Backspace',
  '9': 'Tab',
  '12': 'Clear',
  '13': 'Enter',
  '16': 'Shift',
  '17': 'Control',
  '18': 'Alt',
  '19': 'Pause',
  '20': 'CapsLock',
  '27': 'Escape',
  '32': ' ',
  '33': 'PageUp',
  '34': 'PageDown',
  '35': 'End',
  '36': 'Home',
  '37': 'ArrowLeft',
  '38': 'ArrowUp',
  '39': 'ArrowRight',
  '40': 'ArrowDown',
  '45': 'Insert',
  '46': 'Delete',
  '112': 'F1',
  '113': 'F2',
  '114': 'F3',
  '115': 'F4',
  '116': 'F5',
  '117': 'F6',
  '118': 'F7',
  '119': 'F8',
  '120': 'F9',
  '121': 'F10',
  '122': 'F11',
  '123': 'F12',
  '144': 'NumLock',
  '145': 'ScrollLock',
  '224': 'Meta',
};

/**
 * @param {object} nativeEvent Native browser event.
 * @return {string} Normalized `key` property.
 */
function getEventKey(nativeEvent: KeyboardEvent): string {
  if (nativeEvent.key) {
    // Normalize inconsistent values reported by browsers due to
    // implementations of a working draft specification.

    // FireFox implements `key` but returns `MozPrintableKey` for all
    // printable characters (normalized to `Unidentified`), ignore it.
    const key = normalizeKey[nativeEvent.key] || nativeEvent.key;
    if (key !== 'Unidentified') {
      return key;
    }
  }

  // Browser does not implement `key`, polyfill as much of it as we can.
  if (nativeEvent.type === 'keypress') {
    const charCode = getEventCharCode(nativeEvent);

    // The enter-key is technically both printable and non-printable and can
    // thus be captured by `keypress`, no other non-printable key should.
    return charCode === 13 ? 'Enter' : String.fromCharCode(charCode);
  }
  if (nativeEvent.type === 'keydown' || nativeEvent.type === 'keyup') {
    // While user keyboard layout determines the actual meaning of each
    // `keyCode` value, almost all function keys have a universal value.
    return translateToKey[nativeEvent.keyCode] || 'Unidentified';
  }
  return '';
}

这边一样是针对浏览器的兼容性做处理,如果 event 本身就有 key 的话,先做 normalize,把回传的结果统一成相同的格式。而 FireFox 会把可以印出的字符都设定成 MozPrintableKey,这边 normalize 成 Unidentified。

如果 normalize 完之后的 key 不是 Unidentified 的话就回传,否则再做进一步处理。

而这个进一步处理指的就是 polyfill,如果没有 key 可以用的话就自己针对 charCode 或是 keyCode 来做处理,回传相对应的字符或是按键名称。

React 对于这些按键相关事件的处理就到这边差不多了。

原代码注释写的很好,可以获得很多相关信息,而程序代码很短又不复杂,看起来也很轻松,是个很适合入门的切入点。

总结

以前用了这么多次这些按键相关事件,我自已却从来没想过这些的区别。要嘛就是随意写写然后出 bug,要嘛就是直接从 stackoverflow 上面复制最佳解答,从来都不知道这些的差异。

这次刚好是因为要帮人解惑才去深入研究,没想到一个简单的按键事件其实也是水很深,可能要真的踩过雷才会更有感触。最麻烦的其实是浏览器的兼容性,各个浏览器可能都有自己不同的操作,要怎么处理这些不同的情况才是麻烦的地方。

提到 React 原代码,大家想到的可能都是 render 的相关机制或是 component 的处理,那些原代码十分复杂,而且必须要对整体的架构有一定的理解才比较好看懂。

这篇选择从 keyboard 的事件出发,来看 React 针对这部份的处理。相信程序代码大家都看得懂,也不会觉得特别难,就是想告诉大家若是你想研究其他人的原始码,有时候不一定要整个文件都看懂,可以先从一些小地方开始下手。

从 utils 这种简单的 function 开始也行,不一定要从最难的开始挑战,你都能学到很多东西。