Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue 3事件系统 #8

Open
shen-zhao opened this issue May 21, 2021 · 0 comments
Open

Vue 3事件系统 #8

shen-zhao opened this issue May 21, 2021 · 0 comments
Labels

Comments

@shen-zhao
Copy link
Owner

shen-zhao commented May 21, 2021

背景

我们都知道 Vue 2 内部实现了事件的发布订阅,不仅在 Vue 内部机制中使用,开发人员经常把它当做事件总线来使用,主要 Api 如下:

  • $on
  • $off
  • $once
  • $emit

但是在 Vue 3 中,只剩下了 $emit,其余 Api 全部移除了,因为 Vue 3 内部不再需要这套事件发布订阅机制,所以没有必要实现,这样也能减小 Vue 的体积,倘若开发人员需要使用事件发布订阅模式,完全可以自己实现或者使用其他现成的类库。
那么vue 3脱离了事件发布订阅机制,怎么实现事件系统呢?

VNode 扁平化

研究事件之前我们来看一下 Vue 3props 的变化,Vue 3VNode 现在是一个扁平的 prop 结构,包括用户自定义的属性和事件回调,这个改变使 prop 的结构变得简单,也有利于其他功能的实现,比如事件系统,对比一下 2.x3.xprops 结构:

// 2.x 中的prop是属于嵌套结构
{
  staticClass: 'button',
  class: { 'is-outlined': isOutlined },
  staticStyle: { color: '#34495E' },
  style: { backgroundColor: buttonColor },
  attrs: { id: 'submit' },
  domProps: { innerHTML: '' },
  on: { click: submitForm },
  key: 'submit-button'
}

// 3.x 语法,扁平化
{
  class: ['button', { 'is-outlined': isOutlined }],
  style: [{ color: '#34495E' }, { backgroundColor: buttonColor }],
  id: 'submit',
  innerHTML: '', // dom属性
  onClick: submitForm, // 事件回调
  key: 'submit-button'
}

组件事件

version: 3.0.0-beta.4

既然 $emit 还存在,那么先了解一下其具体实现:

// emit方法绑定组件实例
// packages/runtime-core/src/component.ts #484
instance.emit = emit.bind(null, instance)

// packages/runtime-core/src/componentEmits.ts #46
export function emit(
  instance: ComponentInternalInstance,
  event: string,
  ...rawArgs: any[]
) {
  const props = instance.vnode.props || EMPTY_OBJ

  // 开发环境,如果触发的事件没有在emit或props选项中声明时,警告开发者
  if (__DEV__) {
    const {
      emitsOptions,
      propsOptions: [propsOptions]
    } = instance
    if (emitsOptions) {
      if (!(event in emitsOptions)) {
        if (!propsOptions || !(toHandlerKey(event) in propsOptions)) {
          warn(
            `Component emitted event "${event}" but it is neither declared in ` +
              `the emits option nor as an "${toHandlerKey(event)}" prop.`
          )
        }
      } else {
        const validator = emitsOptions[event]
        if (isFunction(validator)) {
          const isValid = validator(...rawArgs)
          if (!isValid) {
            warn(
              `Invalid event arguments: event validation failed for event "${event}".`
            )
          }
        }
      }
    }
  }

  let args = rawArgs
  // 判断是否为双向绑定事件
  const isModelListener = event.startsWith('update:')

  // for v-model update:xxx events, apply modifiers on args
  const modelArg = isModelListener && event.slice(7)
  if (modelArg && modelArg in props) {
    // 修饰符名称
    const modifiersKey = `${
      modelArg === 'modelValue' ? 'model' : modelArg
    }Modifiers`
    const { number, trim } = props[modifiersKey] || EMPTY_OBJ
    if (trim) {
      args = rawArgs.map(a => a.trim())
    } else if (number) {
      args = rawArgs.map(toNumber)
    }
  }

  // convert handler name to camelCase. See issue #2249
  // 转换事件名为 `onXxx`
  let handlerName = toHandlerKey(camelize(event))
  // 匹配事件回调
  let handler = props[handlerName]
  // for v-model update:xxx events, also trigger kebab-case equivalent
  // for props passed via kebab-case
  if (!handler && isModelListener) {
    handlerName = toHandlerKey(hyphenate(event))
    handler = props[handlerName]
  }

  // 执行事件回调
  if (handler) {
    callWithAsyncErrorHandling(
      handler,
      instance,
      ErrorCodes.COMPONENT_EVENT_HANDLER,
      args
    )
  }

  // 执行一次性回调
  const onceHandler = props[handlerName + `Once`]
  if (onceHandler) {
    if (!instance.emitted) {
      ;(instance.emitted = {} as Record<string, boolean>)[handlerName] = true
    } else if (instance.emitted[handlerName]) {
      return
    }
    callWithAsyncErrorHandling(
      onceHandler,
      instance,
      ErrorCodes.COMPONENT_EVENT_HANDLER,
      args
    )
  }
}

如果需要触发一个用户的事件,只需要在组件内调用 emit,并传入事件名,根据事件名解析 prop 中的回调并执行,这样就达到了事件绑定、事件触发的效果了,确实不需要一个完整的发布订阅的实现。
其中,VNode 中事件回调的 key 必须是已 on 开头的驼峰式命名,内部会把这种命名的prop 解析到事件对象中,当然如果使用 template 我们不用关心命名问题,跟 Vue 2 中的语法一样,在 template 编译后,事件 prop 会被编译成 onXxx 的形式。
注意:上述 emit 的过程是组件自定义事件的触发流程,Vue 3 也增加了 emits 选项给与特定的限制和功能的支持。

原生DOM事件

原生事件是由外部的输入设备交互所触发的,所以不需要关心事件的触发问题,关键是在于事件是在什么时候被注册的。


下面我们来追溯一下原生元素的创建以及事件的注册。
原生 domprop 格式也遵循上面的新规范,所以事件回调传递的方式是一样的,原生事件最终肯定是需要注册在 dom 上,那么我们就来分析一下 mountElement 中关于 props 的处理逻辑,废话少说,咱么直接定位到源码位置

// packages/runtime-core/src/renderer.ts #768

// props
if (props) {
  for (const key in props) {
    if (!isReservedProp(key)) {
      hostPatchProp(
        el,
        key,
        null,
        props[key],
        isSVG,
        vnode.children as VNode[],
        parentComponent,
        parentSuspense,
        unmountChildren
      )
    }
  }
  if ((vnodeHook = props.onVnodeBeforeMount)) {
    invokeVNodeHook(vnodeHook, parentComponent, vnode)
  }
}

这里我们发现,对于 dom 的处理,Vue 采用依赖注入的方式,这样可以更好的提供跨平台能力,接下来我们来分析基于 web dom 的 patchProp,代码定位:

// packages/runtime-dom/src/patchProp.ts #36
// 这里就是关于事件prop的处理
if (isOn(key)) {
  // ignore v-model listeners
  if (!isModelListener(key)) {
    patchEvent(el, key, prevValue, nextValue, parentComponent)
  }
}

// packages/runtime-dom/src/modules/events.ts #59
export function patchEvent(
  el: Element & { _vei?: Record<string, Invoker | undefined> },
  rawName: string,
  prevValue: EventValue | null,
  nextValue: EventValue | null,
  instance: ComponentInternalInstance | null = null
) {
  // vei = vue event invokers
  const invokers = el._vei || (el._vei = {})
  const existingInvoker = invokers[rawName]
  if (nextValue && existingInvoker) {
    // patch
    existingInvoker.value = nextValue
  } else {
    // 解析参数option /(?:Once|Passive|Capture)$/
    const [name, options] = parseName(rawName)
    if (nextValue) {
      // add
      const invoker = (invokers[rawName] = createInvoker(nextValue, instance))
      // 注册事件
      addEventListener(el, name, invoker, options)
    } else if (existingInvoker) {
      // 移除事件
      removeEventListener(el, name, existingInvoker, options)
      invokers[rawName] = undefined
    }
  }
}

export function addEventListener(
  el: Element,
  event: string,
  handler: EventListener,
  options?: EventListenerOptions
) {
  el.addEventListener(event, handler, options)
}

export function removeEventListener(
  el: Element,
  event: string,
  handler: EventListener,
  options?: EventListenerOptions
) {
  el.removeEventListener(event, handler, options)
}

到了这里我们定位到了原生元素事件注册的地方,当原生元素创建后,紧接着会进行 props 处理,其中包括事件的注册。

延伸

我们都知道,在vue的template中事件是支持修饰符的,如下:

  • .stop - 调用 event.stopPropagation()
  • .prevent - 调用 event.preventDefault()
  • .capture - 添加事件侦听器时使用 capture 模式。
  • .self - 只当事件是从侦听器绑定的元素本身触发时才触发回调。
  • .{keyAlias} - 仅当事件是从特定键触发时才触发回调。
  • .once - 只触发一次回调。
  • .left - 只当点击鼠标左键时触发。
  • .right - 只当点击鼠标右键时触发。
  • .middle - 只当点击鼠标中键时触发。
  • .passive - { passive: true } 模式添加侦听器

其中**/(?:Once|Passive|Capture)$/**这三个修饰符是属于原生监听的选项(第三个参数),所以在事件绑定(上面代码中)时已经处理,那么其他的修饰符在什么时候处理的呢、怎样处理的呢?
经过一顿操作,终于定位到了,当然,过程比较坎坷


修饰符这里也是基于不同平台而实现的,上层包为下层包提供具体 Api 实现,底层包实现函数名注入(编译层)

// packages/runtime-dom/src/directives/vOn.ts #7
// 修饰符守卫,
const modifierGuards: Record<
  string,
  (e: Event, modifiers: string[]) => void | boolean
> = {
  stop: e => e.stopPropagation(),
  prevent: e => e.preventDefault(),
  self: e => e.target !== e.currentTarget,
  ctrl: e => !(e as KeyedEvent).ctrlKey,
  shift: e => !(e as KeyedEvent).shiftKey,
  alt: e => !(e as KeyedEvent).altKey,
  meta: e => !(e as KeyedEvent).metaKey,
  left: e => 'button' in e && (e as MouseEvent).button !== 0,
  middle: e => 'button' in e && (e as MouseEvent).button !== 1,
  right: e => 'button' in e && (e as MouseEvent).button !== 2,
  exact: (e, modifiers) =>
    systemModifiers.some(m => (e as any)[`${m}Key`] && !modifiers.includes(m))
}

// # 28
/**
 * 修饰符守卫执行包装层
 * @private
 */
export const withModifiers = (fn: Function, modifiers: string[]) => {
  return (event: Event, ...args: unknown[]) => {
    for (let i = 0; i < modifiers.length; i++) {
      const guard = modifierGuards[modifiers[i]]
      if (guard && guard(event, modifiers)) return
    }
    return fn(event, ...args)
  }
}

// packages/compiler-dom/src/transforms/vOn.ts #58
// template解析v-on修饰符
if (isNonKeyModifier(modifier)) {
  nonKeyModifiers.push(modifier)
}

// #17
const isNonKeyModifier = /*#__PURE__*/ makeMap(
  // event propagation management
  `stop,prevent,self,` +
    // system modifiers + exact
    `ctrl,shift,alt,meta,exact,` +
    // mouse
    `middle`
)

// # 95
// 边一层注入修饰符包装函数
// context.helper(V_ON_WITH_MODIFIERS) 其实是包括函数名:'withModifiers'
if (nonKeyModifiers.length) {
  handlerExp = createCallExpression(context.helper(V_ON_WITH_MODIFIERS), [
    handlerExp,
    JSON.stringify(nonKeyModifiers)
  ])
}

我们通过一个例子来看一下编译的结果:

https://vue-next-template-explorer.netlify.app/

<button @click.stop="handleClick">按钮</button>

编译结果:

import { withModifiers as _withModifiers, createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock("button", {
    onClick: _withModifiers(_ctx.handleClick, ["stop"])
  }, "按钮", 8 /* PROPS */, ["onClick"]))
}

我们看到了_withModifiers 方法,这就是实际调用的地方,当有修饰符时,才会编译出这样的代码。

总结

Vue 3 中删除了 Vue 2 中发布订阅机制的实现,改为更简单直接的实现方式,一方面减少了源码的体积,另一方面更加专注于框架本身,删除了一些没有必要的概念和实现方式,避免 Api 的滥用造成过高的维护成本,大道至简!

@shen-zhao shen-zhao changed the title Vue 3 事件系统 Vue 3事件系统 May 27, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant