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

Event Forwarding #11

Closed
wants to merge 3 commits into from
Closed

Event Forwarding #11

wants to merge 3 commits into from

Conversation

Aferz
Copy link

@Aferz Aferz commented Feb 4, 2019

Fully rendered proposal


Syntactic sugar for emitting events through a deeply nested component tree without repeating too much boilerplate.

<!-- Children -->
<div>
  <button @click="$emit('click', $event)"></button>
</div>

<!-- Parent -->
<div>
  <children-component @click.emit />
</div>

<!-- Grand Parent -->
<div>
  <parent-component @click="handleClick" />
</div>

@damianstasik
Copy link

How about including an option to specify what should be emitted? By default it would be $event, but you could also use any other variable:

Before:

<div>
  <input
    type="checkbox"
    @change="$emit('change', $event.target.checked)"
  >
</div>

After:

<div>
  <input
    type="checkbox"
    @change.emit="$event.target.checked"
  >
</div>

@panstromek
Copy link

panstromek commented Feb 4, 2019

@Aferz well, now it becomes more a rearrangement than shortcut...

On a similar note, what about forwarding as some different event?

Something like:

  <children-component @click.emit:clack />

...
<parent-component @clack="handleClack" />

Also, in most cases you can just use v-on="$listeners" (which of course is not the same)

@panstromek
Copy link

panstromek commented Feb 4, 2019

I would actually prefer to have v-on:click="$listeners.clack" but nullable - if you write this now, it will throw if the clack is not defined. But this will be ok in future JS when ?. lands..

@yyx990803 yyx990803 added the core label Feb 7, 2019
@maxim-usikov
Copy link

maxim-usikov commented Jun 28, 2019

May be add some options for $emit?

<div>
  <input
    type="checkbox"
    @change="$emit('change', payload, options)"
  >
</div>
// options
{
  bubble: true,
  once: true,
  ...
}

Or we can do it like @click — without a value means forward all click events.

<!-- Children -->
<div>
  <button @click="$emit('click', $event)"></button>
</div>

<!-- Parent -->
<div>
  <children-component v-on:click /> <!-- FORWARD -->
  <children-component @click /> <!-- short FORWARD -->
</div>

<!-- Grand Parent -->
<div>
  <parent-component @click="handleClick" />
</div>

@4refael
Copy link

4refael commented Jun 29, 2019

I think this encourages bad design, there are better patterns to deal with nested components communication.

@Aferz
Copy link
Author

Aferz commented Jun 29, 2019

I think this encourages bad design

Honestly I don't know why are you saying this because the combo emit/prop is the original implementation and the recommended way to communicate between parents and children. This RFC only pretends to shorten the way we emit some of this events.

there are better patterns to deal with nested components communication

Could you please be more specific?

@4refael
Copy link

4refael commented Jun 29, 2019

The emit is fine for child-parent communication, but less for child-grandparents communication, which is what this RFC encourages to do.

Don't get me wrong, for some cases it's okay, but I don't think it should be encouraged because it gets very hard to follow as it goes deeper.

There are other ways to deal with components communication: props, provide/inject, event bus, vuex, portals...

Each pattern has different cons/pros so it all depends on the specific situation.

@backbone87
Copy link

@rellect i think you miss the point. Event forwarding isnt bad practice. From the grandparents pov the event is emitted by the parent, and that is also exactly what happens, because the parent has explicit control over what is emitted/forwarded. The thing which is considered bad practice is a child emitting to grandparents around the parents control, and this is a violation of hierarchy.

@Aferz
Copy link
Author

Aferz commented Jun 30, 2019

@rellect I agree with @backbone87 that you may miss the point of my proposal.

I don't want to work around grandparent/parent/child communication. In fact, what I proposed is a way to write less to encourage the recommended approach of component communication.

The patterns you proposed are, in fact, workarounds to communicate between deep components hierarchies:

  • provide/inject is even discouraged in the official Vue docs because it would be unclear from which component the data is coming. From docs:

provide and inject are primarily provided for advanced plugin / component library use cases. It is NOT recommended to use them in generic application code.

  • event bus suffers from the same that provide/inject problems but it's even worse because the injected data could come from a different component hierarchy.

  • vuex and portals looks like over-engineering for simple communication. Yes, you can do it, but IMO these libraries are designed for resolving another kind of problems. Vue already has a first class API for communication.

@4refael
Copy link

4refael commented Jun 30, 2019

I don't think event forwarding is bad practice, I used it countless of times, but to a certain level.
After 3 levels deep it's confusing and hard to follow.

I understand that this RFC is only to reduce boilerplate for a common pattern, but my concern is it will be considered "official" approach for communication and abused. (Especially by newcomers).

It's only my opinion that's all

@bandleader
Copy link

On a similar note, how about passing props down and events up in the same syntax? Something I've had to do when creating a custom input

Right now:

<!--Child Input Component-->
<div>
    <input :value="value" @input="$emit('input', $event.target.value)" />
</div>

With this proposal:

<!--Child Input Component-->
<div>
    <input :value="value" @input.emit />
</div>

How can do this better? Maybe

<!--Child Input Component-->
<div>
    <input :value.pass />
</div>

In this way :value.pass="x" is akin to :value.sync="x", except that :value.sync="x" traps the @update:value event and runs x=$event, whereas :value.pass instead passes that event up: $emit('update:value', $event)

See here for a similar proposal.

(Caveat with my example above: HTML inputs (and existing custom Vue components designed to be compatible with v-model) don't raise @update:value, but rather @input. I propose to make this 'just work' as above, but won't go in to detail here as to how. An alternate suggestion would be to have a directive v-pass, or v-model.pass, which keeps the v-model model of :value/@input. See the proposal linked above for details.)

@lloydjatkinson
Copy link

lloydjatkinson commented Sep 2, 2019

I thought this was removed from Vue 2 (event bubbling existed in Vue 1) specifically because it’s accepted to be a bad practice and strongly couples components needlessly.

@backbone87
Copy link

backbone87 commented Sep 4, 2019

@lloydjatkinson Event bubbling is implicitly propagating an event along an entire path in a tree. This proposal isn’t event bubbling. It is syntactic sugar for event forwarding, which has clear boundaries on which elements receive an event and no such notion as „along an entire path of arbitrary length“ exists.

Edit: with event bubbling a node might and most likely isn’t aware of the events it’s descendants emit „through“ it. This causes coupling between parents and children of a node. Using event forwarding requires nodes to be aware of the contract that the forwarded events offer and confirms this contract and ultimately opts in to uphold the contract even if there isn’t a child emitting the event in the future. In this case the Node has to find a way to shim/replace/fulfill its contractual obligation, which it’s explicitly opted into by forwarding the event

@Aferz
Copy link
Author

Aferz commented Sep 4, 2019

@backbone87 That's correct. I couldn't explained It better.

Don't forget this RFC is just syntactic sugar for keeping your code DRY and don't loose readability. Futhermore, is entirely optional.

@bandleader
Copy link

bandleader commented Sep 4, 2019

In case people don't understand the use case, I think the simplest one is a custom input (and that is indeed explained in the rendered proposal):

  • Imagine that in a few places in your app you have an <input />, and you realize that you want to add some fancy features: auto-completion, checking against a database, calendar, validation, whatever.
  • So you replace the <input /> with a new <custom-input /> that you create, that contains a regular <input /> inside its template and adds the advanced features.
  • Now, you still want to support events like @click, @focus, @blur, @input, @change etc. To do this, you have to catch those on the inner <input /> and pass them up: <input @focus="$emit('focus', $event)" @blur="$emit('blur', $event)"/> etc... gets verbose and hard to read/understand.
  • So with this proposal, you would do <input @blur.emit @focus.emit />, etc.

Also, you probably want your custom input to support v-model, i.e. you want the value of the input to come from your prop value and updates to raise the @input event. So you would do <input :value="value" @input.emit />. Or, with the extension I described above, you would simply do <input :value.pass /> or similar.

@panstromek
Copy link

@bandleader this use case is even better covered with transparent wrapper pattern with v-on="$listeners" v-bind="$attrs"

TBH I don't think this feature is worth it, I don't even remember last time I had a use case for it, and in that case, just writing like 5-10 characters is not that big of a problem.

@Akryum
Copy link
Member

Akryum commented Sep 4, 2019

If we merge this RFC for Vue 3, you'll just need <input v-bind="$attrs"> for a transparent wrapper.

@Aferz
Copy link
Author

Aferz commented Sep 4, 2019

Just to clarify, the example of @bandleader is exactly that, an example.

This RFC doesn't pretend to handle the creation of transparent wrappers or HOCs. It aims to reduce the amount of boilerplate code you write when you prefer to explicitly define the events the component is gonna handle or simply emit upwards.

This:

<input 
  @focus="$emit('focus', $event)"
  @click="$emit('click', $event")
  @input="$emit('input', $event)" />

Becomes this:

<input 
  @focus.emit 
  @click.emit 
  @input.emit />

Sure, I could make use of v-on, but what if I just don't want to pass along all the events attached from the parent? You'd have to pluck those events from $listeners before passing them to v-on directive.

Anyway, this is completely optional, and you could not use it at all if you don't like it, but I think this feature could improve readability in our own components because of the fact that we explicitly define which events we want to handle instead of relying on $listeners container.

Cheers.

@bandleader
Copy link

bandleader commented Sep 6, 2019

@Aferz 👍 Right, we don't always want to pass up all events.

@panstromek Even if we wanted to pass up all events, your solution would still only work for components with a single child. However, most components aren't like that at all. Just as one example, think of a date control: it's a that contains 3 children: a textbox, an 'open calendar' button, and a hidden div with the actual calendar. Maybe @focus and @blur should be passed up from the textbox, but @click from any of the controls, and the mouse events should probably passed up from the parent <span> (and actually probably shouldn't be raised at all because Vue will automatically attach them to the element)...

@panstromek
Copy link

@bandleader I don't deny that there is a use case for this. I just don't think it's worth implementing in the compiler. Every change like that comes with a maintanence burden for core team so it better be worth it - ie. used by many and saving a lot of work. This is a syntax sugar for very narrow use case, saving just a few keystrokes in a very specific case. Not worth it IMO.

@panstromek
Copy link

panstromek commented Sep 6, 2019

Another point is that you could implement your own directive for this. Something like

<input v-pass:input>

or even better with modifiers

<input v-pass.input.blur.click >

[EDIT]
This is a minimum to make it work. Not worth a compiler change if you ask me

 Vue.directive('pass', {
    bind: (el, binding, vnode) => {
      Object.keys(binding.modifiers).forEach(event => {
        el.addEventListener(event, e => {
          vnode.context.$emit(event, e)
        })
      })
    }
  })

@bandleader
Copy link

bandleader commented Sep 11, 2019

@panstromek I like that, but don't forget checking if the node is a Vue component, because then you need to run $on instead of addEventListener. Also, if it's an HTML input, you have to extract out e.target.value, otherwise it won't work with v-model, since the parent node is a custom component.

Also, the scenario I mentioned earlier <input v-bothways:value /> which would pass value down and also pass up update:value probably can't be done at all through a directive, because you can't set v-bind manually at runtime, right?

 Vue.directive('bothways', {
    bind: (el, binding, vnode) => {
      el.addEventListener("update:" + binding.arg, e => {
        vnode.context.$emit("update:" + binding.arg, e)
      })
      // But how do we pass the prop down?
      vnode.addVBind(binding.arg, () => binding.value) //??
      // No such syntax, obviously!
    }
  })

(We could re-implement v-bind, i.e. by catching updated() and manually setting the property on the child... difficult. Not to mention re-implementing the other "magic" of v-model. If only that could be factored out functionally somehow...)

@bandleader
Copy link

bandleader commented Sep 11, 2019

@panstromek I actually got a bit further in implementing it as a directive, by not using v-bind, but rather doing it myself: catching updated() and manually updating el.value. Fiddle here

But it's going to be really hard to re-implement everything that's in v-model and v-bind. That's why built-in directives would be helpful, or else access to some of the v-model and v-bind internals via an API...

@yyx990803
Copy link
Member

yyx990803 commented Nov 6, 2019

In Vue 3, this will no longer be an issue as all listeners are now part of attrs and will be automatically inherited if the child component returns another component as root (just like class and style's current behavior). In fragment-root cases, v-bind="$attrs" will include listeners ($listeners are no longer necessary).

@yyx990803 yyx990803 closed this Nov 6, 2019
@JosephSilber
Copy link

@yyx990803 it very frequently isn't the root.

@yyx990803
Copy link
Member

yyx990803 commented Nov 6, 2019

@JosephSilber in that case you can use inhertiAttrs: false with explicit v-bind="$attrs" on the non-root element.

@Aferz
Copy link
Author

Aferz commented Nov 6, 2019

Sorry @yyx990803 but I don't get it. Could you explain with a code example? Because what you said is already available in Vue2 and I don't see how it fixes what this RFC proposes. Thanks!

@yyx990803
Copy link
Member

More details can be found in the new attrs fallthrough behavior RFC: #92

I assume the use case mostly happens with "wrapper components" where you not only want to forward a specific event, but rather anything that has not been declared as a prop by a component in the hierarchy. Therefore this is should cover a lot of such cases:

<!-- Child that wraps a non-root target element: use inheritAttrs: false with explicit v-bind="$attrs" -->
<div>
  <input v-bind="$attrs">
</div>

<!-- If the Parent renders Child as root: nothing needed, all attrs implicitly falls through -->
<children-component />

<!-- Grand Parent -->
<div>
  <parent-component @click="handleClick" />
</div>

The @click handler is compiled into an onClick prop and gets passed all the way to the final <input> element.

@Aferz
Copy link
Author

Aferz commented Nov 6, 2019

Understood.

Thanks @yyx990803 !

@bandleader
Copy link

bandleader commented Nov 8, 2019

@yyx990803 What about the case where you want to forward specific events? For example: a component that provides a form control with some extra elements (validation feedback, calendar icon, label, etc.) before/after the <input />. You probably want to pass up input, focus and blur from the <input />, whereas the other events (and attrs) would be on your whole root element (which doesn't have input/focus/blur).

<div>
  <label>
    <input :value="value" @input.emit @focus.emit @blur.emit />
  </label>
</div>

@yyx990803
Copy link
Member

@bandleader you can do something like

<div v-bind="excludeEvents($attrs)">
  <label>
    <input :value="value" v-bind="pluckEvents($attrs)" />
  </label>
</div>

Where you include/exclude the events you want in the method. JavaScript provides full flexibility in these niche cases without the need for introducing additional template syntax.

@bandleader
Copy link

bandleader commented Nov 8, 2019

@yyx990803 Fair enough. In a similar vein, how would you recommend doing what I mentioned in this comment above -- passing an event up and a prop down in a single syntax? Can it be done similarly:

<div>
  <input v-bind="twoWayValue" />
</div>
computed: {
  twoWayValue: function() {
    return { value: this.value, oninput: e => this.$emit("input", e.target.value) }
  }
}

Or via a helper:

// Helper function: returns a computed property function that you can v-bind to your element.
function twoWayHelper(propName) {  
  return function() {
    const eventName = propName === "value" ? "input" : propName
    const eventHandler = e => this.$emit(eventName, e instanceof InputEvent ? e.target.value : e)
    return { [propName]: this[propName], ["on" + eventName]: eventHandler }
  }
}
<div>
   <input v-bind="valueBinding" />
</div>
computed: {
  valueBinding: twoWayHelper("value")
}

Would this work?

@yyx990803
Copy link
Member

@bandleader yeah that should work.

@Aferz
Copy link
Author

Aferz commented Nov 10, 2019

@bandleader that looks very clever. Thanks for sharing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.