在 Vue.js 3 中取得 Listeners

由於 $listeners 在 Vue.js 3 中已經被移除了[1],如果要在 Component 中取用 Listeners 的話,就需要一點技巧了,我們這邊就分成兩個部分來討論。

TL;DR

  • 想 inherit 到 root 的事件:從 $attrs
  • 想註冊的事件:以 onCamelCase 的名稱註冊到 props

未註冊 emits 的事件:從 $attrs 取用

依照 Vue.js 3 的說明來看,$listeners$attrs 合併了,以前需要透過 v-bind="$attrs" v-on="$listeners" 來手動把屬性及監聽器傳遞到指定的 DOM 上,而現在只需要一個 v-bind="$attrs" 就可以搞定了。

因此我們可以直接從 $attrs 中來取得想要的 listener:

<template>
  <label>
    <input type="text" v-bind="$attrs" />
  </label>
</template>

<script>
export default {
  inheritAttrs: false,
  setup(props, { attrs }) {
    console.log(attrs) // => { onClick: () => {} }
  },
}
</script>

已註冊 emits 的事件:改用 props 註冊

對於需要註冊在 emits 的事件(如果 $emit() 時所發生的事件沒有被註冊到 emits 的話,Vue.js 3 會在發生事件的時候發出警告),這時上面的方式就不管用了……

<template>
  <label>
    <input type="text" v-bind="$attrs" />
  </label>
</template>

<script>
export default {
  inheritAttrs: false,
  emits: ['click'],
  setup(props, { attrs }) {
    console.log(attrs) // => {}
  },
}
</script>

這個時候該怎麼辦呢? 當然是先看一下為什麼 Vue.js 會有如此的差別啦,首先我們經歷千辛萬苦 trace 到了這裡(componentProps.ts#setFullProps()):

function setFullProps(instance, rawProps, props, attrs) {
    const [options, needCastKeys] = instance.propsOptions;
    if (rawProps) {
        for (const key in rawProps) {
            const value = rawProps[key];
            // ...
            
            // prop option names are camelized during normalization, so to support
            // kebab -> camel conversion here we need to camelize the key.
            let camelKey;
            if (options && shared.hasOwn(options, (camelKey = shared.camelize(key)))) {
                props[camelKey] = value;
            }
            else if (!isEmitListener(instance.emitsOptions, key)) {
                // Any non-declared (either as a prop or an emitted event) props are put
                // into a separate `attrs` object for spreading. Make sure to preserve
                // original key casing
                attrs[key] = value;
            }
        }
    }
    // ...
}

我們可以看到這裡的邏輯是:

  1. 如果這個 prop key 的 camelCase 版本是合法的 props 的話,那就塞進 props[camelKey] 裡面
  2. 如果這個 prop key 不是一個註冊在 emits 的屬性的話,那就塞進 attrs[key] 裡面

這裡可以很簡單的看出,如果註冊到 emits 的事件,其 onXXXXX 是既不會放進 props 也不會放進 attrs 的,跟我們觀察到的現象吻合。

那我們要怎麼處理呢? 很簡單,把原本註冊在 emits 裡面的事件,改註冊到 props 就好啦,這麼一來我們不就可以在 props 裡面取用監聽器了嗎?

當然,有研究精神的我們還是要來看一下 Vue.js 是怎麼處理 emit 事件的,要是我們註冊到 props 結果導致事件不能被監聽就好笑了,所以我們來看一下 emit 是怎麼做的囉(componentEmits.ts#emit()):

function emit(instance, event, ...rawArgs) {
    const props = instance.vnode.props || shared.EMPTY_OBJ;
    if (__DEV__) {
        const { emitsOptions, propsOptions: [propsOptions] } = instance;
        if (emitsOptions) {
            if (!(event in emitsOptions)) {
                if (!propsOptions || !(shared.toHandlerKey(event) in propsOptions)) {
                    warn(`Component emitted event "${event}" but it is neither declared in ` +
                        `the emits option nor as an "${shared.toHandlerKey(event)}" prop.`);
                }
            }
            else {
                const validator = emitsOptions[event];
                if (shared.isFunction(validator)) {
                    const isValid = validator(...rawArgs);
                    if (!isValid) {
                        warn(`Invalid event arguments: event validation failed for event "${event}".`);
                    }
                }
            }
        }
    }
    // ...
    
    // convert handler name to camelCase. See issue #2249
    let handlerName = shared.toHandlerKey(shared.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 = shared.toHandlerKey(shared.hyphenate(event));
        handler = props[handlerName];
    }
    if (handler) {
        callWithAsyncErrorHandling(handler, instance, ErrorCodes.COMPONENT_EVENT_HANDLER, args);
    }
    // ...
}

這邊我刻意地把 if (__DEV__) 段保留,其實看到裡面的敘述大概就可以理解了,Vue.js 在處理 emit()/$emit() 的時候,會先判斷這個事件是不是有被註冊在 emits 或以 onXXXXX[2] 的形式註冊在 props 裡,因此我們以 onXXXXX 的形式註冊在 props 中還是符合設計的。 並且,Vue.js 在處理 listener 的時候,也還是回頭去 VNode 的 props 中找 onXXXXX 的屬性來呼叫。

於是,我們就可以大膽的把有取得 listener 需求的事件註冊成 props 了:

<template>
  <label>
    <!-- 這裡的 $attrs 裡面已經不會有 onClick 了 -->
    <input type="text" v-bind="$attrs" />
  </label>
</template>

<script>
export default {
  inheritAttrs: false,
  props: ['onClick'], // 注意,這裡要使用 onCamelCase 的形式
  setup(props) {
    console.log(props.onClick) // => () => {}
  },
}
</script>

:tada: 大功告成!

結語

雖然不知道拿到 listeners 可以拿來做什麼(需要呼叫的話用 $emit() 就好了……),但至少在 trace code 的時候順便看一下 Vue.js 3 是怎麼設計的也不錯XD

(BTW,我是拿來判斷 cursor 要不要是 pointer 啦(逃)

備註: 原本的程式碼都是 TypeScript,筆者將型別部分及無關部分都簡化掉,方便讀者閱讀


  1. $listeners removed | Vue.js ↩︎

  2. shared.toHandlerKey 的實作便是 (str) => (str ? `on${capitalize(str)}` : ``) ↩︎