Libon

从 Vue 事件修饰符到代码编译

2023/12/21 从 .stop/.prevent... 事件修饰符再到代码编译生成,探究 Vue 事件修饰符的实现原理。 #vue

ToC

修饰符代表了什么

在 vue 开发中时常会看到 .stop .prevent .capture 等事件修饰符,这些修饰符是如何被实现的呢?首先我们知道,事件修饰符是在编译阶段被处理的,所以我们需要从编译阶段开始看起。以 Vue2 为例,我们很容易就可以找到这段代码,它在 src/compiler/codegen/events.ts. 修饰符的定义如下:

// #4868: modifiers that prevent the execution of the listener
// need to explicitly return null so that we can determine whether to remove
// the listener for .once
const genGuard = condition => `if(${condition})return null;`
//    ↑ 传入的 condition 为判断条件

// 各种修饰符会生成的代码及它的判断条件
const modifierCode: { [key: string]: string } = {
  //     ↓ $event 参数是在使用了修饰符或者事件处理器是一个函数调用的话
  //              vue 在编译的时候就会额外包装一层函数, 并提供 $event 形参参数
  stop: '$event.stopPropagation();',
  prevent: '$event.preventDefault();',
  self: genGuard(`$event.target !== $event.currentTarget`),
  ctrl: genGuard(`!$event.ctrlKey`),
  shift: genGuard(`!$event.shiftKey`),
  alt: genGuard(`!$event.altKey`),
  meta: genGuard(`!$event.metaKey`),
  left: genGuard(`'button' in $event && $event.button !== 0`),
  middle: genGuard(`'button' in $event && $event.button !== 1`),
  right: genGuard(`'button' in $event && $event.button !== 2`)
}

修饰符是如何生效的

可以看到,这里的修饰符除了 .stop.prevent 之外都会通过 genGuard 函数生成一个运行时的判断条件。比如 .stop.prevent 修饰符,这俩是直接插入 $event.stopPropagation();$event.stopPropagation(); 到最终生成的代码里,而其他的修饰符则会根据修饰符的进行变化。比如 .ctrl,生成的条件就是 if(!$event.ctrlKey), 其他的以此类推,如果条件为 true,则返回 return null,否则返回空字符串。为什么需要显式 return null ,也在注释中说明了原因:

modifiers that prevent the execution of the listener need to explicitly return null so that we can determine whether to remove the listener for .once
防止侦听器执行需要明确返回null的修饰符,以便我们确定是否删除了侦听器。

这里的 $event 是一个参数,它是在 src/compiler/codegen/events.ts 处,由 genHandler 方法生成的,它其实表示的是一个形参,会在编译的时候包装一个新的函数,然后传入到包装的函数内部。额外包装一个函数是为了确保这个事件处理句柄一定是一个函数,而不是类似 test(1,2,3) 这一类函数调用的表达式。

处理所有事件类型

genHandlers() 的代码比较简单,主要是处理了动态事件名和动态事件句柄,就不做详细解析了:

export function genHandlers(
  events: ASTElementHandlers,
  isNative: boolean
): string {
  // 判断是否是原生事件
  const prefix = isNative ? 'nativeOn:' : 'on:'
  // 编译后的静态事件名的事件处理函数代码
  let staticHandlers = ``
  // 编译后的动态事件名的事件处理函数代码
  let dynamicHandlers = ``
  // 遍历所有事件类型挨个生成事件处理函数
  for (const name in events) {
    const handlerCode = genHandler(events[name])
    // 如果是动态事件名则编译为: test,function($event){...}, 并将事件名和事件处理函数名放入 dynamicHandlers 中
    if (events[name] && events[name].dynamic) {
      dynamicHandlers += `${name},${handlerCode},`
    } else {
      // 如果是静态事件名则编译为: "test":function($event){...},
      staticHandlers += `"${name}":${handlerCode},`
    }
  }
  // 删掉事件对象最后的逗号, 并增加 {}
  staticHandlers = `{${staticHandlers.slice(0, -1)}}`

  // 如果有动态事件则会返回类似: nativeOn|on:_d({test,function($event){...}}) 的代码
  if (dynamicHandlers) {
    // _d 的实现先不看, 在后面会讲到这个函数最终表示的是什么
    return prefix + `_d(${staticHandlers},[${dynamicHandlers.slice(0, -1)}])`
  } else {
    // 如果没有动态事件则直接返回静态事件的代码, 诸如:
    // nativeOn|on:{"test":function($event){...}}
    return prefix + staticHandlers
  }
}

事件处理函数的生成

我们重点看一下 genHandler() 函数的实现:

// 判断是箭头函数还是 function 这种行内处理函数
const fnExpRE = /^([\w$_]+|\([^)]*?\))\s*=>|^function(?:\s+[\w$]+)?\s*\(/
// 匹配函数调用的正则
const fnInvokeRE = /\([^)]*?\);*$/
// 是否是 test.test todoSomething 这种直接传入函数名的调用
const simplePathRE =
  /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*$/

function genHandler(
  handler: ASTElementHandler | Array<ASTElementHandler>
): string {
  // 如果没有传入值,返回空函数
  // 比如: @click.stop @click.prevent
  if (!handler) {
    return 'function(){}'
  }

  // 如果传入的是数组, 递归调用 genHandler 处理每一个函数, 最后返回处理函数的字符串数组
  if (Array.isArray(handler)) {
    return `[${handler.map(handler => genHandler(handler)).join(',')}]`
  }

  // 如果是普通函数名, 比如 @click="test"  @click="utils.test"
  const isMethodPath = simplePathRE.test(handler.value)
  // 如果是在模板中直接写的函数
  // 比如: @click="() => console.log(1)"
  // 又比如: @click="function () { console.log(1) }"
  const isFunctionExpression = fnExpRE.test(handler.value)
  // 如果去掉最后一次函数调用的括号后, 是一个普通的函数, 则说明可能是在运行时直接传的参数
  // 比如: test(row) 会被替换为 test
  // 又比如: test(row)(123) 会被替换为 test(row)
  // 在被替换了以后, 判断是否是一个普通的函数
  const isFunctionInvocation = simplePathRE.test(
    handler.value.replace(fnInvokeRE, '')
  )

  // 如果不包含事件修饰符
  if (!handler.modifiers) {
    // 如果它是一个普通的函数名或者是 inline function 则直接返回
    if (isMethodPath || isFunctionExpression) {
      return handler.value
    }
    // 否则返回一个函数, 并判断传入的句柄的类型
    return `function($event){${
      // 如果它本身是一个函数调用, 则直接将这个函数调用作为返回值, 否则可能是个非函数的值(show = false), 需要将其包裹在函数调用中
      isFunctionInvocation ? `return ${handler.value}` : handler.value
    }}` // inline statement
  } else {
    // 最终输出的code
    let code = ''
    // 包含注入了修饰符代码的code
    let genModifierCode = ''

    const keys: string[] = [] // 保存所有的事件修饰符

    // 遍历所有事件修饰符
    for (const key in handler.modifiers) {
      // 如果是预定义好的修饰符
      if (modifierCode[key]) {
        // 将其转换为对应的代码
        genModifierCode += modifierCode[key]
        // 如果是按键code作为修饰符, 比如 @click.ctrl @click.enter
        if (keyCodes[key]) {
          keys.push(key)
        }
      } else if (key === 'exact') {
        // 如果是 exact 修饰符, 则表示是按照精确的修饰符来触发事件
        const modifiers = handler.modifiers
        genModifierCode += genGuard(
          ['ctrl', 'shift', 'alt', 'meta']
            // 过滤其他的修饰符
            .filter(keyModifier => !modifiers[keyModifier])
            // 将剩余的修饰符转换为对应的代码, $event.ctrlKey, $event.shiftKey...
            .map(keyModifier => `$event.${keyModifier}Key`)
            // 将上面的代码用 || 连接起来, $event.ctrlKey || $event.shiftKey || $event.altKey || $event.metaKey
            .join('||')
        )
      } else {
        // 意料之外的修饰符, 可能是自定义的修饰符, 或者是键盘按键的code修饰符
        keys.push(key)
      }
    }
    // 如果定义了其他修饰符则去匹配键盘的keyCode
    if (keys.length) {
      code += genKeyFilter(keys)
    }
    // 确保在 keys 筛选后执行prevent和stop等修饰符
    if (genModifierCode) {
      code += genModifierCode
    }
    // 根据传入的参数种类生成对应的代码, 所以只要使用了至少一个修饰符的话, 事件的调用栈深度会至少增加1曾
    const handlerCode = isMethodPath
      ? `return ${handler.value}.apply(null, arguments)`
      : isFunctionExpression
      ? `return (${handler.value}).apply(null, arguments)`
      : isFunctionInvocation
      ? `return ${handler.value}`
      : handler.value

    // 生成最终的代码, 提供 $event 参数
    return `function($event){${code}${handlerCode}}`
  }
}

修饰符的按键如何映射到键盘的keyCode

同时还有 genKeyFilter 函数, 用来生成对按下的按键的判断的 if 代码, 以及 genFilterCode 函数, 用来生成对按下的按键 code 的代码。

// KeyboardEvent.keyCode aliases
// 修饰符对应的keyCode
const keyCodes: { [key: string]: number | Array<number> } = {
  esc: 27,
  tab: 9,
  enter: 13,
  space: 32,
  up: 38,
  left: 37,
  right: 39,
  down: 40,
  delete: [8, 46]
}

// KeyboardEvent.key aliases
const keyNames: { [key: string]: string | Array<string> } = {
  // #7880: IE11 and Edge use `Esc` for Escape key name.
  // #7880:IE11和Edge使用`Esc`作为Escape键名。
  esc: ['Esc', 'Escape'],
  tab: 'Tab',
  enter: 'Enter',
  // #9112: IE11 uses `Spacebar` for Space key name.
  // #9112:IE11使用`Spacebar`作为Space键名。
  space: [' ', 'Spacebar'],
  // #7806: IE11 uses key names without `Arrow` prefix for arrow keys.
  // #7806:IE11使用没有箭头键前缀的键名。
  up: ['Up', 'ArrowUp'],
  left: ['Left', 'ArrowLeft'],
  right: ['Right', 'ArrowRight'],
  down: ['Down', 'ArrowDown'],
  // #9112: IE11 uses `Del` for Delete key name.
  // #9112:IE11使用`Del`作为Delete键名。
  delete: ['Backspace', 'Delete', 'Del']
}

function genKeyFilter(keys: Array<string>): string {
  return (
    // make sure the key filters only apply to KeyboardEvents
    // #9441: can't use 'keyCode' in $event because Chrome autofill fires fake
    // key events that do not have keyCode property...

    // 确保键过滤器仅适用于KeyboardEvents
    // #9441:不能在$event中使用'keyCode',因为Chrome自动填充会触发没有keyCode属性的伪事件……
    `if(!$event.type.indexOf('key')&&` +
    `${keys.map(genFilterCode).join('&&')})return null;`
  )
}

function genFilterCode(key: string): string {
  const keyVal = parseInt(key, 10)
  // 如果 keyCode 是数字, @keydown.30="test" 会被编译成 $event.keyCode!==30
  if (keyVal) {
    return `$event.keyCode!==${keyVal}`
  }
  const keyCode = keyCodes[key]
  const keyName = keyNames[key]

  // _k 的实现细节见下文:
  return (
    `_k($event.keyCode,` +
    `${JSON.stringify(key)},` +
    `${JSON.stringify(keyCode)},` +
    `$event.key,` +
    `${JSON.stringify(keyName)}` +
    `)`
  )
}

_k() 与 _d() 方法的实现

_k() 方法的实际实现如下,在 此处 有对其进行赋值target._k = checkKeyCodes,主要用于判断按键是否匹配,_d() 函数则是用于处理动态事件名的: target._d = bindDynamicKeys,代码量比较少,处理的分支情况页不多,直接看代码吧:

// /src/core/instance/render-helpers/check-keycodes.ts
import config from 'core/config'
import { hyphenate, isArray } from 'shared/util'

function isKeyNotMatch<T>(expect: T | Array<T>, actual: T): boolean {
  if (isArray(expect)) {
    return expect.indexOf(actual) === -1
  } else {
    return expect !== actual
  }
}

/**
 * Runtime helper for checking keyCodes from config.
 * exposed as Vue.prototype._k
 * passing in eventKeyName as last argument separately for backwards compat
 * 从 config 中检查 keyCodes 的运行时辅助程序。
 * 作为 Vue.prototype._k 暴露
 * 为了向后兼容,最后一个参数单独传入 eventKeyName
 */
export function checkKeyCodes(
  eventKeyCode: number,
  key: string,
  builtInKeyCode?: number | Array<number>,
  eventKeyName?: string,
  builtInKeyName?: string | Array<string>
): boolean | null | undefined {
  const mappedKeyCode = config.keyCodes[key] || builtInKeyCode
  if (builtInKeyName && eventKeyName && !config.keyCodes[key]) {
    return isKeyNotMatch(builtInKeyName, eventKeyName)
  } else if (mappedKeyCode) {
    return isKeyNotMatch(mappedKeyCode, eventKeyCode)
  } else if (eventKeyName) {
    return hyphenate(eventKeyName) !== key
  }
  return eventKeyCode === undefined
}

// /src/core/instance/render-helpers/bind-dynamic-keys.js
export function bindDynamicKeys(
  baseObj: Record<string, any>,
  values: Array<any>
): Object {
  for (let i = 0; i < values.length; i += 2) {
    const key = values[i]
    if (typeof key === 'string' && key) {
      baseObj[values[i]] = values[i + 1]
    } else if (__DEV__ && key !== '' && key !== null) {
      // null is a special value for explicitly removing a binding
      warn(
        `Invalid value for dynamic directive argument (expected string or null): ${key}`,
        this
      )
    }
  }
  return baseObj
}

以上。

CD ..