由於 $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;
}
}
}
// ...
}
我們可以看到這裡的邏輯是:
- 如果這個 prop key 的 camelCase 版本是合法的
props
的話,那就塞進props[camelKey]
裡面 - 如果這個 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,筆者將型別部分及無關部分都簡化掉,方便讀者閱讀
shared.toHandlerKey
的實作便是(str) => (str ? `on${capitalize(str)}` : ``)
↩︎