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

Class API RFC Draft (for internal feedback) #20

Closed
yyx990803 opened this issue Feb 25, 2019 · 22 comments
Closed

Class API RFC Draft (for internal feedback) #20

yyx990803 opened this issue Feb 25, 2019 · 22 comments

Comments

@yyx990803
Copy link
Member

yyx990803 commented Feb 25, 2019

  • Start Date: 2019-01-21
  • Target Major Version: 3.x
  • Reference Issues:
  • Implementation PR:

Summary

Introduce built-in support for authoring components as native ES2015 classes.

Basic example

import Vue from 'vue'

export default class App extends Vue {
  // options declared via static properties (stage 3)
  // more details below
  static template = `
    <div>{{ count }}</div>
  `

  // reactive data declared via class fields (stage 3)
  // more details below
  count = 0

  // lifecycle
  created() {
    console.log(this.count)
  }

  // getters are converted to computed properties
  get plusOne() {
    return this.count + 1
  }

  // a method
  increment() {
    this.count++
  }
}

Motivation

Vue's current object-based component API has created some challenges when it comes to type inference. As a result, most users opting into using Vue with TypeScript end up using vue-class-component. This approach works, but with some drawbacks:

  • Internally, Vue 2.x already represents each component instance with an underlying "class". We are using quotes here because it's not using the native ES2015 syntax but the ES5-style constructor/prototype function. Nevertheless, conceptually components are already handled as classes internally.

  • vue-class-component had to implement some inefficient workarounds in order to provide the desired API without altering Vue internals.

  • vue-class-component has to maintain typing compatibility with Vue core, and the maintenance overhead can be eliminated by exposing the class directly from Vue core.

The primary motivation of native class support is to provide a built-in and more efficient replacement for vue-class-component. The affected target audience are most likely also TypeScript users.

The API is also designed to not rely on anything TypeScript specific: it should work equally well in plain ES, for users who prefer using native ES classes.

Note we are not pushing this as a replacement for the existing object-based API - the object-based API will continue to work in 3.0.

Detailed design

Basics

A component can be declared by extending the base Vue class provided by Vue core:

import Vue from 'vue'

class MyComponent extends Vue {}

Data

Reactive instance data properties can be declared using class fields syntax (stage 3):

class MyComponent extends Vue {
  count = 0
}

This is currently supported in Chrome stable 72+ and TypeScript. It can also be transpiled using Babel. If using native ES classes without any transpilation, it's also possible to manually set this.count = 0 in constructor, which would in turn require a super() call:

// NOT recommended.
class MyComponent extends Vue {
  constructor() {
    super()
    this.count = 0
  }
}

This is verbose and also has incorrect semantics (see below). A less verbose alternative is using the special data() method, which works the same as in the object-based syntax:

class MyComponent extends Vue {
  data() {
    return {
      count: 0
    }
  }
}

A Note on [[Set]] vs [[Define]]

The class field syntax uses [[Define]] semantics in both native and transpiled implementations (Babel already conforms to the latest spec and TS will have to follow suite). This means count = 0 in the class body is executed with the semantics of Object.defineProperty and will always overwrite a property of the same name inherited from a parent class, regardless of whether it has a setter or not.

In comparison, this.count = 0 in constructor is using [[Set]] semantics - if the parent class has a defined setter named count, the operation will trigger the setter instead of overwriting the definition.

For Vue's API, [[Define]] is the correct semantics, since an extended class declaring a data property should overwrite a property with the same name on the parent class.

This should be a very rare edge case since most users will likely be using the class field syntax either natively or via a transpiler with correct semantics, or using the data() alternative.

Lifecycle Hooks

Built-in lifecycle hooks should be declared directly as methods, and works largely the same with their object-based counterparts:

class MyComponent extends Vue {
  created() {
    console.log('created')
  }
}

Props

In v3, props declarations can be optional. The behavior will be different based on whether props are declared.

Props with Explicit Declaration

Props can be declared using the props static property (static properties are used for all component options that do not have implicit mapping). When props are declared, they can be accessed directly on this:

class MyComponent extends Vue {
  // props declarations are fully compatible with v2 options
  static props = {
    msg: String
  }

  created() {
    // available on `this`
    console.log(this.msg)

    // also available on `this.$props`
    console.log(this.$props.msg)
  }
}

Similar to v2, any attributes passed to the component but is not declared as a prop will be exposed as this.$attrs. Note that the non-props attribute fallthrough behavior will also be adjusted - it is discussed in more details in a separate RFC.

Props without Explicit Declaration

It is possible to omit props declarations in v3. When there is no explicit props declaration, props will NOT be exposed on this - they will only be available on this.$props:

class MyComponent extends Vue {
  created() {
    console.log(this.$props.msg)
  }
}

Inside templates, the prop also must be accessed with the $props prefix, .e.g. {{ $props.msg }}.

Any attribute passed to this component will be exposed in this.$props. In addition, this.$attrs will be simply pointing to this.$props since they are equivalent in this case.

Computed Properties

Computed properties are declared as getter methods:

class MyComponent extends Vue {
  count = 0

  get doubleCount() {
    return this.count * 2
  }
}

Note although we are using the getter syntax, these functions are not used a literal getters - they are converted into Vue computed properties internally with dependency-tracking-based caching.

Do we need a way to opt-out? It can probably be done via decorators.

Methods

Any method that is not a reserved lifecycle hook is considered a normal instance method:

class MyComponent extends Vue {
  count = 0

  created() {
    this.logCount()
  }

  logCount() {
    console.log(this.count)
  }
}

When methods are accessed from this, they are automatically bound to the instance. This means there is no need to worry about calling this.foo = this.foo.bind(this).

Other Options

Other options that do not have implicit mapping in the class syntax should be declared as static class properties:

class MyComponent extends Vue {
  static template = `
    <div>hello</div>
  `
}

The above syntax requires static class fields (stage 3). In non-supporting environment, manual attaching is required:

class MyComponent extends Vue {}

MyComponent.template = `
  <div>hello</div>
`

Or:

class MyComponent extends Vue {}

Object.assign(MyComponent, {
  template: `
    <div>hello</div>
  `
})

TypeScript Usage

In TypeScript, since data properties are declared using class fields, the type inference just works:

class MyComponent extends Vue {
  count: number = 1

  created() {
    this.count // number
  }
}

For props, we intend to provide a decorator that internally transforms decorated fields in to corresponding runtime options (similar to the @Prop decorator in vue-property-decorators):

import { prop } from '@vue/decorators'

class MyComponent extends Vue {
  @prop count: number

  created() {
    this.count // number
  }
}

This is equivalent to the following in terms of runtime behavior (only static type checking, no runtime checks):

class MyComponent extends Vue {
  static props = ['count']

  created() {
    this.count
  }
}

The decorator can also be called with additional options for more specific runtime behavior:

import { prop } from '@vue/decorators'

class MyComponent extends Vue {
  @prop({
    validator: val => {
      // custom runtime validation logic
    }
  })
  msg: string = 'hello'

  created() {
    this.count // number
  }
}

Note on Prop Default Value

Note that due to the limitations of the TypeScript decorator implementation, we cannot use the following to declare default value for a prop:

class MyComponent extends Vue {
  @prop count: number = 1
}

The culprit is the following case:

class MyComponent extends Vue {
  @prop foo: number = 1
  bar = this.foo + 1
}

If the parent component passes in the foo prop, the default value of 1 should be overwritten. However, the way TypeScript transpiles the code places the two lines together in the constructor of the class, giving Vue no chance to overwrite the default value properly. Vue will throw a warning when such usage is detected.

Instead, use the decorator option to declare default values:

class MyComponent extends Vue {
  @prop({ default: 1 }) foo: number
  bar = this.foo + 1
}

This restriction can be lifted in the future when the ES decorators proposal has been finalized and TS has been updated to match the spec, assuming the final spec does not deviate too much from how it works now.

$props and $data

To access this.$props or this.$data in TypeScript, the base Vue class accepts generic arguments:

interface MyProps {
  msg: string
}

interface MyData {
  count: number
}

class MyComponent extends Vue<MyProps, MyData> {
  count: number = 1

  created() {
    this.$props.msg
    this.$data.count
  }
}

Mixins

Mixins work a bit differently with classes, primarily to ensure proper type inference:

  1. If type inference is needed, mixins must be declared as classes extending the base Vue class (otherwise, the object format also works).

  2. To use mixins, the final component should extend a class created from the mixins method instead of the base Vue class.

import Vue, { mixins } from 'vue'

class MixinA extends Vue {
  // class-style mixin
}

const MixinB = {
  // object-style mixin
}

class MyComponent extends mixins(MixinA, MixinB) {
  // ...
}

The class returned from mixins also accepts the same generics arguments as the base Vue class.

Difference from 2.x Constructors

One major difference between 3.0 classes and the 2.x constructors is that they are not meant to be instantiated directly. i.e. you will no longer be able to do new MyComponent({ el: '#app' }) to mount it - instead, the instantiation/mounting process will be handled by separate, dedicated APIs. In cases where a component needs to be instantiated for testing purposes, corresponding APIs will also be provided. This is largely due to the internal changes where we are moving the mounting logic out of the component class itself for better decoupling, and also has to do our plan to redesign the global API for bootstrapping an app.

Drawbacks

Reliance on Stage 2/3 Language Features

Class Fields

The proposed syntax relies on two currently stage-3 proposals related to class fields:

These are required to achieve the ideal usage. Although there are workarounds in cases where they are not available, the workarounds result in sub-optimal authoring experience.

If the user uses Babel or TypeScript, these can be covered. Luckily these two combined should cover a pretty decent percentage of all users. For learning / prototyping usage without compile steps, browsers with native support (e.g. Chrome Canary) can also be used.

There is a small risk since these proposals are just stage 3, and are still being actively debated on - technically, there are still chances that they get further revised or even dropped. The good news is that the parts that are relevant here doesn't seem likely to change. There was a somewhat related debate regarding the semantics of class fields being [[Set]] vs [[Define]], and it has been settled as [[Define]] which in my opinion is the preferred semantics for this API.

Decorators

The TypeScript usage relies on decorators. The decorators proposal for JavaScript is still stage 2 and undergoing major revisions - it's also completely different from how it is implemented in TS today (although TS is expected to match the proposal once it is finalized). Its latest form just got rejected from advancing to stage 3 at TC39 due to concerns from JavaScript engine implementors. It is thus still quite risky to design the API around decorators at this point.

Before ES decorators are finalized, we only recommend using decorators in TypeScript.

The decision to go with decorators for props in TypeScript is due to the following:

  1. Decorators is the only option that allows us to express both static and runtime behavior in the same syntax, without the need for double declaration. This is discussed in more details in the Alternatives section.

  2. Both the current TS implementation and the current stage 2 proposal can support the desired usage.

  3. It's also highly likely that the finalized proposal is going to support the usage as well. So even after the proposal finalizes and TS' implementation has been updated to match the proposal, the API can continue to work without syntax changes.

  4. The decorator-based usage is opt-in and built on top of the static props based usage. So even if the proposal changes drastically or gets abandoned we still have something to fallback to.

  5. If users are using TypeScript, they already have decorators available to them via TypeScript's tool chain so unlike vanilla JavaScript there's no need for additional tooling.

this Identity in constructor

In Vue 3 component classes, the this context in all lifecycle hooks and methods are in fact a Proxy to the actual underlying instance. This Proxy is responsible for returning proper values for the data, props and computed properties defined on the current component, and provides runtime warning checks. It is important for performance reasons as it avoids many expensive Object.defineProperty calls when instantiating components.

In practice, your code will work exactly the same - the only cases where you need to pay attention is if you are using this inside the native constructor - this is the only place where Vue cannot swap the identity of this so it will not be equal to the this exposed everywhere else:

let instance

class MyComponent extends Vue {
  constructor() {
    super()
    instance = this // actual instance
  }

  created() {
    console.log(this === instance) // false, `this` here is the Proxy
  }
}

In practice, there shouldn't be cases where you must use the constructor, so the best practice is to simply avoid it and always use component lifecycle hooks.

Two Ways of Doing the Same Thing

This may cause beginners to face a choice early on: to go with the object syntax, or the class syntax?

For users who already have a preference, it is not really an issue. The real issue is that for beginners who are not familiar with classes, the syntax raises the learning barrier. In the long run, as ES classes stabilize and get more widely used, it may eventually become a basic pre-requisite for all JavaScript users, but now is probably not the time yet.

One way to deal with it is providing examples for both syntaxes in the new docs and allow switching between them. This allows users to pick a preferred syntax during the learning process.

Alternatives

Options via Decorator

@Component({
  template: `...`
})
class MyComponent extends Vue {}

This is similar to vue-class-component but it requires decorators - and as mentioned, it is only stage 2 and risky to rely on. We are using decorators for props, but it's primarily for better type-inference and only recommended in TypeScript. For now we should avoid decorators in plain ES as much as possible.

Declaring Prop Types via Generic Arguments

For declaring prop types in TypeScript, we considered avoiding decorators by merging the props interface passed to the class as a generic argument on to the class instance:

interface MyProps {
  msg: string
}

class MyComponent extends Vue<MyProps> {
  created() {
    this.msg // this becomes available
  }
}

However, this creates a mismatch between the typing and the runtime behavior. Because there is no runtime declaration for the msg prop, it will not be exposed on this. To make the types and runtime consistent, we end up with a double-declaration:

interface MyProps {
  msg: string
}

class MyComponent extends Vue<MyProps> {
  static props = ['msg']

  created() {
    this.msg
  }
}

We also considered eliminating the need for double-declaration via tooling - e.g. Vetur can pre-transform the interface into equivalent runtime declaration, or vice-versa, so that only the interface or the static props declaration is needed. However, both have drawbacks:

  • The interface cannot enforce runtime type checking or custom validation;

  • The static props runtime declaration cannot facilitate type inference for advanced type shapes.

Decorators is the only option the can unify both in the same syntax:

class MyComponent extends Vue {
  @prop({
    validator: value => {
      // custom runtime validation logic
    }
  })
  msg: SomeAdvancedType = 'hello'

  created() {
    this.msg
  }
}

Adoption strategy

  • This does not break existing usage, but rather introduces an alternative way of authoring components. TypeScript users, especially those already using vue-class-component should have no issue grasping it. For beginners, we should probably avoid using it as the default syntax in docs, but we should provide the option to switching to it in code examples.

  • For existing users using TypeScript and vue-class-component, a simple migration strategy would be shipping a build of vue-class-component that provides a @Component decorator that simply spreads the options on to the class. Since the required change is pretty mechanical, a code mod can also be provided.

@yyx990803 yyx990803 mentioned this issue Feb 25, 2019
@posva
Copy link
Member

posva commented Feb 25, 2019

Looks good, my only concern right now is regarding Two Ways of Doing the Same Thing
There will be a preferred way that we will have to advise for (advanced) users and I'm afraid we lose a big part of our users who enjoy the simplicity of Vue because we cannot ensure all articles and content around there are written using the same syntax (or both), so when people search for resources, they will be frustrated if they found the class syntax they do not understand yet.

I don't think there is a solution to this, we cannot move forward if we do not adopt new syntaxes, and it definitely improves typings, which also improves dev experience for regular devs, I'm more rising a concern I have

@nickmessing
Copy link
Member

Is there any plan to add type-checking for events?
At the moment for TSX I use this syntax:

type Props = {
  tmp: string
  alpha?: boolean
}

type Events = {
  click: string
  something: boolean
  a: number
  b: void
}

type ScopedSlots = {
  s1?: {
    click?: string
    attr: boolean
  }
  s2?: {
    s: null
  }
  s3: {
    a: 'tst'
  }
}

class Cmp extends Vue<Props, Events, ScopedSlots> {
  created() {
    console.log(this.$props.alpha)
    this.$emit('a', 2)
    this.$emit('b')
  }
}

const h = <Cmp tmp="al" on={{ b: () => console.log() }} scopedSlots={{ s3: (props) => <a /> }} />

Can we consider full TSX support in core with v3?

@yyx990803
Copy link
Member Author

@nickmessing yes, you should consider improving this file: https://github.com/vuejs/vue-next/blob/master/packages/runtime-dom/jsx.d.ts

@yyx990803
Copy link
Member Author

I just realized an important drawback of using decorators for props would be TSX inference :/

@nickmessing
Copy link
Member

@yyx990803, I will release an alpha of TSX support for Vue 2 and then will try to port that to Vue 3.

@gustojs
Copy link
Member

gustojs commented Feb 26, 2019

I like it and in the basic form, it looks relatively easy even for people without TS, React or Angular experience. I can imagine two questions being asked by potential users:

  • how does it look like with Vuex?
  • will the tools support it from day 1

But in both cases it's too early for that.

@chrisvfritz
Copy link
Contributor

I have the same hesitation as @posva regarding fragmentation and complexity. Even within the class implementation, we'd have different best practices and caveats depending on whether native classes, Babel classes, or TypeScript classes are used. We're also warning people not to use some features of classes in some or all cases, like constructors and decorators. I doubt we're even fully aware of all the caveats and edge cases, especially since more changes are likely to come. The fragmentation and complexity also places a greater burden on library authors, not only in terms of support and possible edge cases, but also documentation. It makes me wonder whether we should strongly discourage everyone except TypeScript users from using classes, at least until the features we'd rely on are fully stable and implementations more unified.

It's really a shame there's been no response from the TypeScript team regarding @octref's proposed addition to the TypeScript toolchain, since I think it would solve all our problems regarding TypeScript. We could provide an even better typing experience than this, without having to deal with classes while they're still stabilizing. Should we try to push the TypeScript team harder on that?

@yyx990803
Copy link
Member Author

yyx990803 commented Mar 1, 2019

@chrisvfritz

Even within the class implementation, we'd have different best practices and caveats depending on whether native classes, Babel classes, or TypeScript classes are used.

There really is only one difference, that is the usage of decorator for props in TypeScript.

The proposal intentionally covers all possible caveats in as much details as possible, but in practice all of them have very little impact on actual DX.

  • Regarding @prop decorator: this is something only TypeScript users need to be aware of, so it doesn't affect non-TS users at all. When decorators are finalized, the same syntax will also be able to work consistently between ES and TS without having to change.

  • Regarding [[Set]] vs [[Define]] semantics: this has been settled in the TC39 proposal. I've talked to @DanielRosenwasser and TypeScript will align with the [[Define]] syntax in 3.5 (opt-in) and then 3.6 (opt-out) in July. So by the time Vue 3 is actually out, all class implementations out there will be using consistent semantics.

  • Regarding this identity: this is also consistent between all class implementations. Plus this is a really really rare case because in practice 1. There's literally no use for constructor (which is more verbose as it requires super()) when there are class fields and lifecycle hooks. 2. Even when using constructor it's very rare for the user to rely on this identity from the constructor at all.

  • Regarding private fields: the runtime can detect usage of private fields by inspecting the error message and give appropriate warnings, so there's no chance for confusion here.

@chrisvfritz
Copy link
Contributor

@yyx990803 What do you think about that addition to the TypeScript toolchain though? Should we try to push harder on that before trying to balance the compromises of making Vue more complex?

@octref
Copy link
Member

octref commented Mar 4, 2019

giving Vue no chance to overwrite the default value properly

class MyComponent extends Vue {
  @prop foo: number = 1
  bar = this.foo + 1
}

I believe you can hijack the setter of foo so 1 is merged into PropOptions.default instead of written to this.foo.

I do have other concerns and I'll pass them along after discussing with @DanielRosenwasser next week.

@octref
Copy link
Member

octref commented Mar 4, 2019

since I think it would solve all our problems regarding TypeScript.

It'll help Vetur to do auto completion and diagnostic error checking, but I think that's not necessarily a solution to the motivation on:

  • Having an authoring format (class) that aligns with internal implementation for alleviate Vue's maintenance.
  • Aligning with class semantics for a more intuitive API. If anyone understands classes in JS, I believe he can pick up the class API in a shorter time than the object-based API, because the API aligns with the class fields semantics.
  • Having a more intuitive and concise way to write types for props, especially in TS.

I'm not saying a better typing experience for the object based usage is not worth working on. I'll still work on it, and I can see it becoming doable.

@littledan
Copy link

I'd like to see if the issue with private can be fixed. I'm wondering, would it be reasonable for the Vue constructor to return the observation Proxy, rather than the $state? This way, the subclass constructor would see the observed thing as the this value, and private fields and methods would be added on the Proxy itself, so they would just work from methods. The Proxy would get defineProperty calls for field definitions, so there may need to be a little bit of logic to avoid observation callbacks from that.

I'd like to try this out and test it. Are there any example programs using Vue 3 that I could base this on?

@littledan
Copy link

@nicolo-ribaudo , who has been working on the implementation of private and decorators in Babel, has volunteered to help out with looking into whether Vue could use the pattern in #20 (comment) . Is there any way he could get access to this repository?

@yyx990803
Copy link
Member Author

@littledan nice! I just did a quick experiment and it seems it would indeed work and solve quite a few issues in our current implementation. I'll try to rework the current prototype to use this strategy to confirm that it fully resolves the issues.

@yyx990803
Copy link
Member Author

@littledan great news - confirmed that this can indeed work. This also solves all the problems related to this identity in constructor, fantastic!

@littledan
Copy link

I'm very happy to hear this! Maybe we can work together on documenting this strategy well enough so that other framework authors can learn from this design.

@octref
Copy link
Member

octref commented Mar 6, 2019

So the decorator-less TS equivalent of this:

import { prop } from '@vue/decorators'

class MyComponent extends Vue {
  @prop count: number
}

Would be this:

interface Props {
  count: number;
}

class MyComponent extends Vue<Props> {
  static props = {
    count: Number;
  }
}

But for

class MyComponent extends Vue {
  @prop foo: ComplexType;
}

Is the equivalent below?

interface Props {
  foo: ComplexType; // but this types `this.$props.foo` not `this.foo`
}

class MyComponent extends Vue<Props> {
  static props = {
    foo: Object as Prop<ComplexType>
  }
}

So if you want to keep accessing props on this like in 2.0, the Vue.Component<Props> wouldn't help you, and you should still use the Prop<Type> on the static props declaration. Is that correct?

But then why should I still write the Props interface, if I can just do below? What's the reason someone would use this.$props.foo over this.foo and keep this.$props typed?

class MyComponent extends Vue<Props> {
  static props = {
    foo: Object as Prop<ComplexType>
  }
}

@yyx990803
Copy link
Member Author

yyx990803 commented Mar 8, 2019

@octref

For the following

class MyComponent extends Vue {
  @prop foo: ComplexType;
}

This doesn't provide types for this.$props. So the equivalent would only have static props = { ... }.

On the other hand, I don't know if we can keep the inference working with the following:

class MyComponent extends Vue {
  static props = {
    foo: Object as Prop<ComplexType>
  }
}

This works for Object-based API by inferring it as an argument for Vue.extend. I don't know if we can even make it work with classes.

If a prop is already exposed and properly typed on this, I don't think there's much point in using this.$props unless they want to dynamically inspect / copy existing props, which should be rare. So in most cases, TypeScript users should just use the decorator.

@DanielRosenwasser
Copy link
Contributor

DanielRosenwasser commented Mar 8, 2019

So I spoke with @octref a bit about the current proposal. On the whole, data, computed, and methods all seem great. The only strange piece is props for TypeScript users. Here's a bit of the friction that users will likely run into that you may want to consider.

Issues with decorators

While decorators make internal type-checking easy, they don't solve the problem for external type-checking. Given that one of the ideas was to allow Vue to work better with JSX syntax or h calls, this may be an issue.

class SpecialButton extends Vue.Component {
    @prop text: string;

    static template = `
        <button>
            I am a special button!
            {{text}}
        </button>
    `;
}

class App {
    render(h) {
        // How does this get type-checked?
        return h(SpecialButton, {
            text: "click",
        });
    }

    static components = {
        SpecialButton,
    };
}

The solution is to create another interface.

interface Props {
    text: string;
}

class SpecialButton extends Vue.Component<Props> {
    @prop text: string;

    static template = `
        <button>
            I am a special button!
            {{text}}
        </button>
    `;
}

It may be reasonable to say "well, templates are the primary way users will consume these either, and we'll always be able to read the source, so users who use render methods can opt in here", but that's not entirely true and there are caveats to that.

Anything that tries to check templates will inevitably have to deal with components in npm which are already compiled. .d.ts files are a lightweight way to serialize the public API without containing the full source code, and that's the only thing TypeScript understands today.

Issues with the schema

The props schema itself is just a lot of repetition for TypeScript users, and in our experience, it's not expressive enough. Users quickly hit this as soon as an they have props more complex than primitive types. Even if it was expressive enough, TypeScript doesn't currently support the sort of inference you'd need to make that work correctly when subtyping.

The most reasonable thing to do with the schema is to just define the names of each prop instead of defining the respective validators.

interface Props {
    text: string;
}

class SpecialButton extends Vue.Component<Props> {
    static props = ["text"];
}

Issues with no schema/decorators

By default, users who provide no schema for props need to access props through this.$props.

interface Props {
    text: string;
}

class SpecialButton extends Vue.Component<Props> {
    static template = `
        <button>
            I am a special button!
            {{$props.text}}
        </button>
    `;
}

This feels a little strange to explain to a user, and it can be a pain to refactor to if you already have a props schema.

I think the best experience I can imagine is just

interface Props {
    text: string;
}

class SpecialButton extends Vue.Component<Props> {
    static template = `
        <button>
            I am a special button!
            {{text}}
        </button>
    `;
}

but I know that there are concerns over accidentally defining an instance member with the same name as a prop.
Maybe if that's the concern, you could run the type of props through the Readonly type to get the same sort of checking at design-time.

@yyx990803
Copy link
Member Author

yyx990803 commented Mar 9, 2019

@DanielRosenwasser thanks for the feedback! I agree that ideally, a single interface would offer a decent dev experience and covers both internal and external type checking, however, our current constraints are:

  • A runtime schema is required to be able to expose props on this, due to potential conflicts;

    • We can potentially remove this requirement, however that could make it too easy for plain ES users to author components that lack proper documentation for its own props.
  • The interface generic argument doesn't offer any runtime metadata, so there's no way for Vue's runtime to know about the declared props, thus it won't proxy the props on this (in runtime land). If the interface exposes props on this in type land, there would be a mismatch between the types and runtime behavior.

    • Maybe we can provide a tool that auto-compiles such interfaces into equivalent runtime schema, but it's one additional thing the user needs on top of standard TS tooling (or is there anyway to hook into TS codegen?) It feels too heavy-handed to require an additional build step for almost any TS user.

    • Decorators can leave runtime metadata so Vue's runtime can pick it up and proxy the props properly.

  • The interface does not cover the full runtime behavior available from the props schema, e.g. custom validators, default value and required-ness. To specify a default value the user still need separate declarations.

    • Decorators on the other hand can do both in one place.

So it seems the main downside of decorators is that it doesn't work well with TSX. Is the generic argument a hard requirement for TSX to work? Maybe instead of ElementAttributesProperty we can introduce something like this:

interface ElementAttributesDecorator {
  '@prop': {}
}

Alternatively, is there anyway to infer this.$props based on decorators?

@octref
Copy link
Member

octref commented Mar 10, 2019

@yyx990803 You might want to look into Stencil.

  • They have a similar @Prop API: https://stenciljs.com/docs/my-first-component/

  • From @Prop decorator they generate such d.ts for each component (so JSX/TSX auto completion works):

    import { Component, Prop } from '@stencil/core';
    
    @Component({
      tag: 'my-component',
    })
    export class MyComponent {
    
      /**
       * My Prop
       */
      @Prop() size: 'small' | 'medium' | 'large';
    
      render() {
        return <div>Hello, Stencil! I'm {this.size}</div>
      }
    }
    import '../../stencil.core';
    export declare class MyComponent {
        /**
         * My Prop
         */
        size: 'small' | 'medium' | 'large';
        render(): JSX.Element;
    }

What do you think about dropping <Props> as a public generic argument?

  • It doesn't help typing this props (for either in template or JSX/TSX)
  • It doesn't help d.ts generation for others to use your component
  • From what I read $props is not the recommended way to access props inside your own components
  • From parent component you should use static props of child component to infer props, not the <Props> interface

IMO $props should be an implementation detail and typing it is not useful.

@yyx990803
Copy link
Member Author

yyx990803 commented Mar 11, 2019

@octref interesting, so the .dts is updated by a build step... (and can be done continuously during dev) which I think acceptable for users using TSX. However the way Stencil does it requires the JSX elements to be registered as global intrinsic elements instead of value-based elements. Is there anyway for a .d.ts file to augment an existing module?

RE dropping <Props>: for some users they do want access to props on this.$props because they feel that is more explicit (i.e. this is definitely a prop, not a computed property or state). And in some cases a user may want to dynamically inspect this.$props to see what props are being passed in, or copy/augment it, or pass it down to another component via spread. So I think typing this.$props is definitely still needed.

$props is also used for TSX inference for now with:

interface ElementAttributesProperty {
  $props: {}
}

But technically, this doesn't have to be done via a generic argument... the user can just declare $props themselves on the class. So yeah I think the generic argument can be dropped (assuming we don't allow it to expose properties on this).

The problem with inferring from static props is that TSX doesn't have a way to do it easily (unlike grabbing it from $props with ElementAttributesProperty).

So if we can find a way to generate the proper type augmentations (basically auto-generate proper $props types for the user based on their @prop or static props usage, but without modifying the file the user is editing) we can get TSX covered - however, I think it would be even better if we can open up the TSX API a little bit by offering alternative ways to specify where to infer props (e.g. a way to say: "these properties using the @prop decorator are the ones exposed as JSX attributes.")

/cc @DanielRosenwasser

@github-actions github-actions bot locked and limited conversation to collaborators Nov 17, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants