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

Composition functions support and this value changes #416

Open
ktsn opened this issue Apr 26, 2020 · 27 comments
Open

Composition functions support and this value changes #416

ktsn opened this issue Apr 26, 2020 · 27 comments
Labels

Comments

@ktsn
Copy link
Member

ktsn commented Apr 26, 2020

Summary

  • Composition functions are available in class property initializers by wrapping setup helper.
    • Class property initializers are handled in setup function under the hood.
  • Only $props (and its derived prop values), $attrs, $slots and $emit are available on this in class property initializers.

Example:

<template>
  <div>Count: {{ counter.count }}</div>
  <button @click="counter.increment()">+</button>
</template>

<script lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Vue, setup } from 'vue-class-component'

function useCounter () {
  const count = ref(0)

  function increment () {
    count.value++
  }

  onMounted(() => {
    console.log('onMounted')
  })

  return {
    count,
    increment
  }
}

export default class Counter extends Vue {
  counter = setup(() => useCounter())
}
</script>

Details

Prior to v7, class component constructor is initialized in data hook to collect class properties. In v8, it will be initialized in setup hook so that the users can use composition function in class property initializer.

The above class component definition is as same as following canonical component definition.

function useCounter () {
  const count = ref(0)

  function increment () {
    count.value++
  }

  onMounted(() => {
    console.log('onMounted')
  })

  return {
    count,
    increment
  }
}

export default {
  setup() {
    return { counter: useCounter() }
  }
}

setup helper

Wrapping a composition function with setup helper is needed because we need to delay the invocation of the composition function. Let's see the following example:

function usePost(postId) {
  const post = ref(null)

  watch(postId, async id => {
    post.value = await fetch('/posts/' + id)
  }, {
    immediate: true
  })

  return {
    post
  }
}

class App extends Vue {
  postId = '1'

  // DO NOT do this
  post = usePost(toRef(this, 'postId'))
}

In the above example, this.postId will be referred by watch helper to track reactive dependencies immediately but it is not reactive value at that moment. Then the watch callback won't be called when postId is changed.

setup helper will delay the invocation until this.postId become a proxy property to the actual reactive value.

setup unwrapping

As same as setup in the Vue core library, setup in Vue Class Component unwraps ref values. The unwrapping happens shallowly:

// The returned value is:
// { 
//    count: { value: 0 },
//    nested: {
//      anotherCount: { value: 1 }
//    }
// }
function useCount() {
  const count = ref(0)
  const anotherCount = ref(1)

  return {
    count,
    nested: {
      anotherCount
    }
  }
}

class Counter extends Vue {
  // counter will be:
  // { 
  //    count: 0, <-- unwrapped
  //    nested: {
  //      anotherCount: { value: 1 }
  //    }
  // }
  // The shallow ref (count) is unwrapped while the nested one (anotherCount) retains
  counter = setup(() => useCount())
}

In addition, if you return a single ref in setup helper, the ref will also be unwrapped:

// The returned value is: { value: 42 }
function useAnswer() {
  const answer = ref(42)
  return answer
}

class Answer extends Vue {
  // answer will be just 42 which is unwrapped
  answer = setup(() => useAnswer())
}

Available built in properties on this

Since the class constructor is used in setup hook, only following properties are available on this.

  • $props
    • All props are proxied on this as well. (e.g. this.$props.foo -> this.foo)
  • $emit
  • $attrs
  • $slots

Example using $props and $emit in a composition function.

function useCounter(props, emit) {
  function increment() {
    emit('input', props.count + 1)
  }

  return {
    increment
  }
}

export default class App extends Vue {
  counter = setup(() => {
    return useCounter(this.$props, this.$emit)
  })
}

Alternative Approach

Another possible approach is using super class and mixins.

import { ref } from 'vue'
import { setup } from 'vue-class-component'

const Super = setup((props, ctx) => {
  const count = ref(0)

  function increment() {
    count.value++
  }

  return {
    count,
    increment
  }
})

export default class App extends Super {}

Pros

  • Can define properties directly on this.

Cons

  • Need duplicated props type definition.

    // Props type definition for setup
    interface Props {
      foo: string
    }
    
    const Super = setup((props: Props) => { /* ... */ })
    
    export default class App extends Setup {
      // Another definition for foo prop to use it in the class
      @Prop foo!: string
    }
@kiaking
Copy link
Member

kiaking commented Apr 30, 2020

I really like it. Well thoughts. One question is would it be possible to pass another class property to the composition function? This might not be the best example, but something like this. See useCounter taking argument, and here I'm padding in class property as the argument.

function useCounter (amount: number) {
  const count = ref(0)

  function increment () {
    count.value + amount
  }

  return {
    count,
    increment
  }
}

export default class Counter extends Vue {
  amount = 10

  get counter () {
    reactive(useCounter(this.amount))
  }
}

@ktsn
Copy link
Member Author

ktsn commented May 3, 2020

@kiaking You mean like the following code? (passing primitive loses its reactivity and computed does not handle composition functions in this proposal)

function useCounter (amount: Ref<number>) {
  const count = ref(0)

  function increment () {
    count.value + amount.value
  }

  return {
    count,
    increment
  }
}

export default class Counter extends Vue {
  amount = 10

  counter = reactive(useCounter(
    toRef(this, 'amount')
  ))
}

I think we should discourage this usage because this in initializers is different from actual component constructor as same as v7, so amount will not be updated nor reactive in useCounter.

I would say the dependency direction between composition function and component instance should be composition <- instance. This is as same as the canonical Vue api (setup cannot access data, computed, methods etc.). If we want to define some value depended by a composition function, we can include it into the composition function.

function useCounter () {
  const amount = ref(10)
  const count = ref(0)

  function increment () {
    count.value + amount.value
  }

  return {
    count,
    amount,
    increment
  }
}

export default class Counter extends Vue {
  counter = reactive(useCounter())

  mounted() {
    this.counter.amount = 11
  }
}

@kiaking
Copy link
Member

kiaking commented May 4, 2020

Ah OK good point. Yes I was afraid of that. I don't have good example here but I suspected there might be shared 3rd party composition function that might require argument to be passed.

But as you mentioned, we could always "wrap" such composition function to be used before assigning to the class component.

// 3rd party composition function. It requires argument.
function useCounter (amount: Ref<number>) {
  const count = ref(0)

  function increment () {
    count.value + amount.value
  }

  return { count, increment }
}

// Wrap the above composition function.
function useWrappedCounter () {
  const amount = ref(10)

  return {
    amount,
    ...useCounter(amount)
  }
}

// Use wrapped function.
export default class Counter extends Vue {
  counter = reactive(useWrappedCounter())

  mounted() {
    this.counter.amount = 11
  }
}

@ktsn
Copy link
Member Author

ktsn commented May 5, 2020

An idea to allow such usecase would be to require wrapping a composition function and delay its initalization:

export default class Counter extends Vue {
  amount = 10

  counter = setup(() => {
    return useCounter(toRef(this, 'amount'))
  })
}

The current problems of this in initializers are

  1. this is not an actual reactive data.
  2. this.amount is still not reactive value in initializers.

To solve 1. we can just configure proxy getter / setter to actual reactive data for each initialized property. As for 2. we need to delay the invocation of composition function to make sure accessed properties are converted reactive value. We can solve 2. with setup function on the above example.

I noticed that mandating setup helper could also reduce the confusion of manual unwrapping as the helper always does it for us.

@ktsn
Copy link
Member Author

ktsn commented May 5, 2020

I have updated the proposal regarding the above setup helper idea.

@kiaking
Copy link
Member

kiaking commented May 8, 2020

Nice! I really dig the new setup function. It also shows that property is more "special" interns of composition api. 👍

@ktsn
Copy link
Member Author

ktsn commented Sep 17, 2020

Since Vue core changed the unwrapping behavior of setup (from deep to shallow), Vue Class Component follows that direction. I've updated the original post to add setup unwrapping section. You can see the detailed unwrapping behavior of setup in Vue Class Component there.

@LifeIsStrange
Copy link

Since setup() for variables used on the state add a bunch a unwanted boilerplate I am asking similarly as for props, would there be ways to define (state) variables used on the template inside the class ?
related: how can vue class component leverage https://github.com/vuejs/rfcs/blob/sfc-improvements/active-rfcs/0000-sfc-script-setup.md ? (maybe that you could influence the design of the RFC while its active btw)

@ktsn
Copy link
Member Author

ktsn commented Sep 28, 2020

@LifeIsStrange I'm afraid I don't understand what you mean. Could you elaborate "ways to define (state) variables used on the template inside the class"?

I'm not sure how we can integrate class component with the new <script setup> because it will not probably be class component anymore?

@ktsn
Copy link
Member Author

ktsn commented Oct 3, 2020

Please file a new story for a bug report. Thanks.

@LifeIsStrange
Copy link

LifeIsStrange commented Oct 4, 2020

@LifeIsStrange I'm afraid I don't understand what you mean. Could you elaborate "ways to define (state) variables used on the template inside the class"?

<template>
  <div>Count: {{ counter.count }}</div>
  <button @click="counter.increment()">+</button>
</template>

<script lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Vue, setup } from 'vue-class-component'

function useCounter () {
  const count = ref(0)

  function increment () {
    count.value++
  }

  onMounted(() => {
    console.log('onMounted')
  })

  return {
    count,
    increment
  }
}

export default class Counter extends Vue {
  counter = setup(() => useCounter())
}
</script>

If I understand correctly, this (your) example show how we can make hybrid class components using composition functions, which is nice even if I don't get much the value of it because part of the logic is outside the class.
But like for Vue2, will we still be able to make the functionally similar but much more concise component:

<template>
  <div>Count: {{ count }}</div>
  <button @click="increment()">+</button>
</template>

<script lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Vue, setup } from 'vue-class-component'

export default class Counter extends Vue {
  count = 0

  increment () {
    this.count++
  }
}
</script>

If so do we have guarantee that this existing way will remain supported for the foreesable future and not be deprecated by the composing function api ?
And can this existing way leverage Vue3 features ?

@ktsn
Copy link
Member Author

ktsn commented Oct 4, 2020

@LifeIsStrange You can still do the existing way.

The purpose of adding the setup helper is just to be able to use composition functions in a class component so that it allows more flexible usage with it. e.g. Allow using a composition function provided by external libraries. I don't mean to deprecate the existing way to define the component nor recommending the new way.

@Mikilll94
Copy link

@ktsn

<template>
  <div>Count: {{ counter.count }}</div>
  <button @click="counter.increment()">+</button>
</template>

<script lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Vue, setup } from 'vue-class-component'

function useCounter () {
  const count = ref(0)

  function increment () {
    count.value++
  }

  onMounted(() => {
    console.log('onMounted')
  })

  return {
    count,
    increment
  }
}

export default class Counter extends Vue {
  counter = setup(() => useCounter())
}
</script>

For me this approach looks too verbose and a little bit strange. Why are we assiging setup function to counter variable. Why can't we just use the setup function in a similar way as lifecycle hooks?

<template>
  <div>Count: {{ counter.count }}</div>
  <button @click="counter.increment()">+</button>
</template>

<script lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Vue, setup } from 'vue-class-component'

function useCounter () {
  const count = ref(0)

  function increment () {
    count.value++
  }

  onMounted(() => {
    console.log('onMounted')
  })

  return {
    count,
    increment
  }
}

export default class Counter extends Vue {
  setup(props) {
    const { count, increment } = useCounter();
    return {
        count,
        increment
    };
  }
}
</script>

@ktsn
Copy link
Member Author

ktsn commented Nov 21, 2020

It won't be typed in that way as class type can't be changed from method itself.

@Mikilll94
Copy link

Ok. Thanks for your answer.

I have another question. Can you change that the setup function will receive props and context as arguments. This is how it works in Vue core API.

Using this.$props and this.$emit looks a little bit weird.

Also this.$props has strange Intellisense support:

Screenshot 2020-11-21 at 13 49 08

msg is the actual prop. Rest of hints should not be visible.

Additionally this.$emit has no TypeScript support. I can emit any events even though I have an emits option in the component.

@ktsn
Copy link
Member Author

ktsn commented Nov 21, 2020

Why don't you just use props value under this? I don't think giving props value through setup callback is a good idea because there is no benefit to it. As you already know, you can use props value through this already. Besides, you have to annotate callback argument type manually because setup helper cannot know the component props type automatically.

Using this.$props and this.$emit looks a little bit weird.

Even though it's exactly as same as normal Vue component API?

Additionally this.$emit has no TypeScript support. I can emit any events even though I have an emits option in the component.

This should be covered in another feature.

@Mikilll94
Copy link

Mikilll94 commented Nov 21, 2020

@ktsn
Thanks for your answer. It really helped me.

I have a question about the alternative approach

Alternative Approach
Another possible approach is using super class and mixins.

import { ref } from 'vue'
import { setup } from 'vue-class-component'

const Super = setup((props, ctx) => {
const count = ref(0)

function increment() {
count.value++
}

return {
count,
increment
}
})

export default class App extends Super {}

What if I want to have a class component which extends composition function and also have props? I wrote the following component:

<template>
  <div>{{ msg }}</div>
  <div>{{ counter.count }}</div>
  <button @click="counter.increment">Increment</button>
  <div>{{ secondCounter.count }}</div>
  <button @click="secondCounter.increment">Increment</button>
</template>

<script lang="ts">
import { onMounted, ref } from "vue";
import { Vue, prop, setup, mixins } from "vue-class-component";

class Props {
  msg = prop({ type: String, required: true });
}

const Super = setup(() => {
  const text = ref('A')

  const addLetter = () => {
    text.value += 'A';
  }

  return {
    text,
    addLetter
  }
})

export default class HelloWorld extends mixins(Super).with(Props) {
  private counter = setup(() => {
    const count = ref(0);

    console.log("MSG", this.msg);

    const increment = () => {
      count.value++;
    };

    onMounted(() => {
      console.log("onMounted first counter");
    });

    return {
      count,
      increment,
    };
  });

  private secondCounter = setup(() => {
    const count = ref(0);

    const increment = () => {
      count.value++;
    };

    onMounted(() => {
      console.log("onMounted second counter");
    });

    return {
      count,
      increment,
    };
  });
}
</script>

<style>
</style>

However, I receive this error:

Screenshot 2020-11-22 at 00 03 29

@Mikilll94
Copy link

@ktsn
Is there any chance that you answer my question?

@ktsn
Copy link
Member Author

ktsn commented Dec 21, 2020

Please open a new issue. That's a bug.

@liuestc
Copy link

liuestc commented Dec 28, 2020

is support provide in compositionAPI ? how to use provide in compositionAPI

@4innocent
Copy link

Cons

  • Need duplicated props type definition.
    // Props type definition for setup
    interface Props {
      foo: string
    }
    
    const Super = setup((props: Props) => { /* ... */ })
    
    export default class App extends Setup {
      // Another definition for foo prop to use it in the class
      @Prop foo!: string
    }

Is it wrong word in "export default class App extends Setup" ?
And it don't work .

Error Info:

Uncaught TypeError: Super expression must either be null or a function

@lgarczyn
Copy link

Couldn't the setup function simply be static class function?

@soylomass
Copy link

soylomass commented Apr 24, 2021

Will there be another way of implementing setup that makes the returned properties accesible in this? (aside from the current alternative one).

Maybe defining in in the @options decorator?

@trusktr
Copy link

trusktr commented Oct 20, 2021

@lgarczyn as @ktsn already mentioned prior, the type of this of a class cannot be defined by the return value of a method in TypeScript. It is possible for that to work in plain JS, but no way to make types work out in TypeScript.

@trusktr
Copy link

trusktr commented Oct 20, 2021

@soylomass Same answer for you too: decorators cannot modify the type of a class in TypeScript.

See here: microsoft/TypeScript#4881 and microsoft/TypeScript#4881 (comment)

Of course this is all possible in plain JS, but this library also supports TypeScript users and must work within those limitations (a subset of JS).

@trusktr
Copy link

trusktr commented Oct 20, 2021

@ktsn Idea: the return type of a method can be used as the type of any other class property.

Suppose the Vue base class defines a composed: ReturnType<this['setup']> property (naming to be bike shedded), like so:

// In the Vue definition:
class Vue {
  constructor(...) {
    this.#setupComposedProperty(this.setup(...))
  }
  composed: ReturnType<this['setup']>
  setup(): object { return {} } // default
  #setupComposedProperty(...) {...} // internal
}

Then users can write the following:

<script>
// User code
export default class Counter extends Vue {
  setup(...) {
    const { count, increment } = useCounter();
    return {
      count,
      increment
    };
  }
}
</script>

<template>
  <div>{{ composed.count }}</div>
</template>

The this type propagates forward into subclasses, so the type of composed within the Counter subclass is the return type of Counter's setup() method.

This seems pretty clean compared to wrapping class field initializers with setup(): this.composed.count is fully typed in TypeScript based on the return value of setup(), and people get to use composition API inside their classes in a way that is similar to the options API, plus they get the benefits of classes.

@lgarczyn @soylomass @Mikilll94 I think this would satisfy the desires you had, in a slightly different way.

@alamhubb
Copy link

What about this problem? Why doesn't setup work when I use it in options

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

10 participants