vuejs / rfcs

RFCs for substantial changes / feature additions to Vue core
4.86k stars 548 forks source link

Dynamic event names in emits option #204

Open CyberAP opened 4 years ago

CyberAP commented 4 years ago

What problem does this feature solve?

Components might emit events that can not be statically analyzed. For example v-model already does this with the update:[name || 'modelValue']. Where name is a v-model argument. A custom event could look like this for example: change:[name] where name is derived from a component's prop.

What does the proposed API look like?

I think emits should support these dynamic event names via guard callbacks:

<script>
export default {
  emits: [(eventName) => /^change:/.test(eventName)] // will match any event listener that starts with `onChange:`
}
</script>

This could also work for event validations:

<script>
export default {
  emits: [
    {
      name: (eventName) => /^change:/.test(eventName),
      validate: (payload) => payload !== null,
    }
  ]
}
</script>
yyx990803 commented 4 years ago

Transferred to RFCs for discussion.

jods4 commented 4 years ago

Can you motivate this RFC with more use cases? It's about components but the only example given is v-model, which is a directive.

I never had a component with dynamic events in my projects, it would help me think about the patterns if a real use case was given.

A point to consider: isn't a potential benefit of emits to eventually be integrated with tooling? E.g. so that when editing a template, you could hopefully get completion for @ on a component. This proposal wouldn't mesh well with this, would it?

CyberAP commented 4 years ago

v-model was given just as an example, this change has actually nothing to do with v-model since it doesn't require any special treatment by default. It's also not about components, but more about props and attribute inheritance. As you know if we don't use inheritAttrs: false or emits then event listeners will be assigned to the root element of a single root component. A common use-case would be a component that receives some config, reacts to events and emits new events that specifically target some part of the config.

<DynamicFilters :filters="{ foo: [], bar: [], baz: [] }" @update:foo="onUpdateFoo" />

This can not be solved with a generic update event because it will also fire on things we don't want to handle.

There's a workaround with an inheritAttrs: false which is sort of usable, but limits wrapping options. Consider DynamicFilters was renderless and had an inheritAttrs: false. It won't have attribute inheritance anymore when used as a root component. (though I haven't thoroughly tested that it had proper attribute inheritance with inheritAttrs enabled)

<DynamicFilters :filters="filters" @update:foo="onUpdateFoo">
  <div>
    <!-- actual component... -->
    <!-- will not receive class or attrs -->
  </div>
</DynamicFilters>

I have a library that emits these kind of events if you're interested: https://github.com/CyberAP/vue-component-media-queries

jods4 commented 4 years ago

Thanks I understand better now, it's an interesting usage.

I'm not totally convinced by those 2 examples that the use-cases justify the extra complexity added to Vue core. I feel like you could design those components in a satisfactory manner today:

This can not be solved with a generic update event because it will also fire on things we don't want to handle.

I think you could totally fire an event @update, which either has 2 parameters, or has one parameter that is an object with a filter: 'foo' property.

Instead of filtering in the event name, which is a disguised parameter; you'll have to filter in the handler code, or in the template: @update="$event.filter == 'foo' && onUpdateFoo($event)"

One may even argue that for a generic component it might be better because if I'm interested in a change in any filter (e.g. to refresh a list), I can do that by listening to just one event.

Another design could be to not provide this event at all and rely on the consumer passing a reactive object to :filters. This might lead to more concise code, as listening to change events is not very Composition API-like. You could run an effect that refreshes the data based on the filters objects (or does anything else) and it would be called any time filters change. You can use the 2 parameters watch if you absolutely need to react to a single property without running an eager effect.

CyberAP commented 4 years ago

I'm not totally convinced by those 2 examples that the use-cases justify the extra complexity added to Vue core. I think you could totally fire an event @update, which either has 2 parameters, or has one parameter that is an object with a filter: 'foo' property.

With the increasing complexity on the developer's side, yes. But in this case we're solving the consequences of attribute inheritance, while we could completely avoid going into that in the first place.

Take for example an abstract StateWatcher component. It can watch some external state of any complexity, but the component will incorporate that state complexity inside an event:

<StateWatcher @update="$event.prop !== 'foo.bar.baz' && handleEvent($event)" />

This will have very poor performance overall.

Compare that to a dynamic listener example:

<StateWatcher @update:foo:bar:baz="handleEvent" />

Another design could be to not provide this event at all and rely on the consumer passing a reactive object to :filters.

This is unrelated since props and events are partially related. The actual data may be stored somewhere else.

jods4 commented 4 years ago

Very slightly increased complexity on developer's side, but there's complexity on the Vue side as well that needs to be factored in. Not saying it should not be done but I think it's good to have compelling use cases to motivate such additions, especially if it could work in user land.

Your solution to the StateWatcher perf issues is to tell it what you want to watch. You could pass that info in a more straightforward way without custom event names, in it the same way MutationObserver and co. are designed:

<StateWatcher watches="foo.bar.baz" @update="handleEvent" />

I'm gonna say it's even more flexible as you could easily add and remove watchers dynamically if that component supports an array:

<script> let fields = reactive(['foo', 'bar']); </script>
<StateWatcher :watches="fields" @update="handleEvent" />
CyberAP commented 4 years ago

I would argue that it is not more flexible, but quite the opposite.

If we want to watch multiple sources:

<StateWatcher :watches="fields" @update="handleEvent" />
const fields = reactive(['foo', 'bar'])
const handleEvent = (event) => {
  if (event.field === 'foo') { handleFooEvent(event) }
  else if (event.field === 'bar') { handleBarEvent(event) }
}

We have created a new reactive property and an event handler just to deal with two events. I think it's a cumbersome way to deal with dynamic events.

With dynamic event handlers we get this:

<StateWatcher
  @update:foo="handleFooEvent"
  @update:bar="handleBarEvent"
/>

No extra properties or event handlers required. It is a much more concise API no matter how you look at it.

The whole idea of this change is to give developers more ways to express their components API. And this actually works for Vue 2, so I don't see why it shouldn't work for Vue 3 since it is a perfectly valid case.

jods4 commented 4 years ago

Doesn't have to be reactive, I think this is perfectly fine:

<StateWatcher :fields="['foo', 'bar']" @update="handleChange" />
<script>
function handleChange(e) {
  switch (e.field) {
    case 'foo':
       // do A
       break;
    case 'bar':
      // do B
      break;
  }
}
</script>

And of course you can go fancy if you have many handlers:

const handlers = {
  foo(e) { },
  bar(e) { },
};

function handleChange(e) { 
  handlers[e.field]?.(e)
}

No extra properties or event handlers required. It is a much more concise API no matter how you look at it.

You missed my point. Say I want to dynamically add or remove a listener for properties. How are you gonna do that? This is what I meant when I said the other solution is more flexible.

The whole idea of this change is to give developers more ways to express their components API.

Sure, I'm just playing the devil's advocate here.

Adding this requires more code in Vue, more documentation, creates more knowledge for users, doesn't mesh well with a potential autocomplete for templates in IDE and will need to remain supported for the foreseeable future...

I'm not opposed to the idea, just trying to see if the use cases are many or compelling enough to justify the addition. Maybe they are 😄

CyberAP commented 4 years ago

Say I want to dynamically add or remove a listener for properties. How are you gonna do that? This is what I meant when I said the other solution is more flexible.

That would be easy to achieve with a v-on directive.

<StateWatcher v-on="{ 'update:foo': onFooUpdate, 'update:bar': onBarUpdate }" />

Adding this requires more code in Vue, more documentation, creates more knowledge for users, doesn't mesh well with a potential autocomplete for templates in IDE and will need to remain supported for the foreseeable future...

I wouldn't say this feature is required to have IDE support. It is required to filter out dynamic event listeners from attribute fallthrough and that's basically it. We don't have dynamic slot props IDE support and the feature itself is there nonetheless.

I would like to know how much maintenance burden it would actually introduce for Vue maintainers. From my perspective it doesn't really seem that hard to maintain if we ignore IDE support, but I might be wrong of course.

jods4 commented 4 years ago

That would be easy to achieve with a v-on directive.

Right! So used to using the shortcut @ I even forgot the long form supports objects. 😁

I wouldn't say this feature is required to have IDE support.

Hopefully one day Vetur (or one of the new alternative plugins) will support event completion in IDE. I would really like to get suggestions for a component events when I type <MyButton @| in a template. This could be possible now that Vue 3 requires emits to declare component events.

If this happens, dynamic events as proposed here won't be supported (I don't see how). That's unfortunate and will feel like a limitation. 😞

Another issue today is Typescript support: when using TS emit("myevent", args) is strongly typed. I think this is highly desirable and must not be broken. Dynamic events as proposed here can't be understood by the type system. So emit("update:field") will either be an error (not good), or any value will be accepted and typos are missed emit("nyewent") and that's not great either.

Now, all your examples are not completely random event names, but rather hiding a parameter inside the event name, such as update:field. If we want to support that, maybe we can find an alternative design? For example Vue could formally define support for using : in event names, as it does internally with v-model and update:XXX?

We can come up by many ways to do that, but one simple idea is to consider events in a special way if their name ends with :. For proper validation and TS support they could define that their first parameter is a string and comes from the event name?

CyberAP commented 4 years ago

If we use an update: prefix we get the desired behaviour out of the box, but these events are reserved for v-model and I would like to avoid confusion here (my events can not be used on a v-model).

Having a separate rule for change: prefixed events would solve the problem.

jods4 commented 4 years ago

I meant having some kind of API to define any event prefix, such as if you declare change: then Vue understands your component emits events like change:bar + others, but you could also declare delete: on the same component, etc.

CyberAP commented 4 years ago

I think adding a special case of a change: event prefix is a good enough trade-off.

If the goal is to have both versatility and at the same time leave emits API intact as much as possible the only viable solution I see is to have a prefix character for event names:

const emits = ['foo', '^bar:']
// matches `foo`, `bar:anything`

Vue 2 has a similar story with event modifiers for render functions (that didn't last in Vue 3 though).

szulcus commented 1 year ago

I think adding a special case of a change: event prefix is a good enough trade-off.

If the goal is to have both versatility and at the same time leave emits API intact as much as possible the only viable solution I see is to have a prefix character for event names:

const emits = ['foo', '^bar:']
// matches `foo`, `bar:anything`

Vue 2 has a similar story with event modifiers for render functions (that didn't last in Vue 3 though).

Any updates?

laygir commented 1 year ago

I use the following approach quite often in my components such as navigation items, modal actions, data-table actions, even data-table row items to dynamically provide list of possible actions for that component..

Is this an anti-pattern? What would you suggest I do? (to prevent warnings and be able to declare emits that I do not know in advance)

// ActionItems.vue
<div>
  <my-button
    v-for="(action, i) in actions"
    :key="i"
    :variant="action.variant"
    :icon="action.icon"
    @click="$emit(action.event)"
  >
    {{ action.label }}
  </my-button>
</div>
actions() {
  return [
    {
      label: 'Create',
      event: 'createDocument',
      variant: 'button',
      icon: 'create',
    },
    {
      label: 'Send',
      event: 'sendDocument',
      variant: 'button-ghost',
      icon: 'send',
    },
  ];
}
<action-items 
    :actions="actions" 
    @create-document="createDoc" 
    @send-document="sendDoc"  
    />
Mestpal commented 11 months ago

I use the following approach quite often in my components such as navigation items, modal actions, data-table actions, even data-table row items to dynamically provide list of possible actions for that component..

Is this an anti-pattern? What would you suggest I do? (to prevent warnings and be able to declare emits that I do not know in advance)

Maybe for this case you can use defineEmits() https://vuejs.org/api/sfc-script-setup.html#defineprops-defineemits

And after import defineEmits() in your file, you can use something like this to define de emits from the events defined in your actions():

   mounted () {
     const emits = this.actions.map(action => action.event);
     defineEmits(emits)
   }
laygir commented 11 months ago

Maybe for this case you can use defineEmits() https://vuejs.org/api/sfc-script-setup.html#defineprops-defineemits

And after import defineEmits() in your file, you can use something like this to define de emits from the events defined in your actions():

   mounted () {
     const emits = this.actions.map(action => action.event);
     defineEmits(emits)
   }

Awesome! I had no idea this was possible, this should do it. Thank you!

emperorsegxy commented 5 months ago

I use the following approach quite often in my components such as navigation items, modal actions, data-table actions, even data-table row items to dynamically provide list of possible actions for that component.. Is this an anti-pattern? What would you suggest I do? (to prevent warnings and be able to declare emits that I do not know in advance)

Maybe for this case you can use defineEmits() https://vuejs.org/api/sfc-script-setup.html#defineprops-defineemits

And after import defineEmits() in your file, you can use something like this to define de emits from the events defined in your actions():

   mounted () {
     const emits = this.actions.map(action => action.event);
     defineEmits(emits)
   }

How is this used?

Usually when we define emits, we do this into a variable that can be called when component emits an event, I can't seem to find that here. Thanks