Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Discussion options

Motivation

Aiming for a much greater code reusability between components, complete with functions override to enjoy some kind of limited inheritance, as well as common props, emits, and exposed functions.

Summary

The idea is to have some composable-alike code files, that can be referred within a component and loaded in order. They then are compiled into Vue code and ultimately merged in a single object containing all the end functionality and optionally returned to the component code. For simplicity and differentiation against normal composables I'll call them "traits", but the name is not really important.

Merging and overriding

The merge process will cycle through the traits in the order provided and merge them in a temporal object, which is then passed as a parameter to the next trait. This allows any trait to call the previous merge in a similar way to class inheritance "super.", therefore enabling function overriding without losing any inherited trait functionality.

The final merge is returned by the merge function, allowing the component to use the resulting merge. This enables the ability to use common code between components with similar funcionatilites. E.g. both a list view and an icon view may have the concept of "sort by", and to keep track of and perform the sorting, it may be reasonable to build a "has-order" trait to provide methods to perform the sorting.

Pros: All traits functionality will be consolidated in a single object, there is no need to track which one has which function. Very simple to use, and very simple to offload the code elsewhere and keep components tidy. Able to call earlier merger functions, enabling overriding while still keeping full functionality.
Cons: Merge possibly may need to be done at runtime. There may be unwanted/unexpected overrides if a component uses many traits at the same time.

Unique instance

The traits must provide the component with an unique instance of itself, to allow each component to hold their own trait with their own data. Only the code should be shared.

Pros: No collateral effects, unless the developer explicitly want it.
Cons: Probably more complicated setup when coding traits.

Sharing data between the component and the trait

Traits must be able to receive parameters when defined in the component. This will allow the component to share with the trait variables, refs/reactives, functions, and so on. The goal is to try to allow the developer to keep the most code possible in the trait, without resorting to hacks or partial implementations.

E.g. a trait for data validation based on a schema, will need both the data and the schema as parameters.

The trait can include any kind of structure in the merged object, be it functions, refs/reactives, constant/variables, or anything else.

Pros: Offload of data management to the traits where it makes sense, minimizing partial/support code in the component.
Cons: There may be unwanted/unexpected overrides if a component uses many traits at the same time.

Props sharing

The trait can define any number of props, which will be included automatically in the component defineProps, thus allowing some kind of props inheritance. Props will be overridden if they have the same name.

The component props will be received as a trait parameter in runtime, in case the trait needs to check their values.

Pros: Less props duplication in the components. Traits can define their own props needed for their own functionality, independently of the component.
Cons: IDEs may have a harder time figuring props autocomplete. Harder to track which props are available to components.

Exposed functions sharing

As with props, a trait can define exposed functions, which will end in the defineExpose macro. These functions must be bound to the particular trait instance to prevent dereference of "this". Exposed functions will be overriden if they have the same name.

Pros: Less component functions duplication for exposed functionality. Traits can define and manage exposed functionality, independently of the component.
Cons: IDEs may have a harder time figuring exposed functions. There may be unwanted/unexpected overrides if a component uses many traits at the same time.

Emit sharing

Similarly to props and exposed functions, a trait can define emits. Since the emitting logic depends on the component logic, the trait don't need to do the emitting themselves, but an emit function will be received as a trait parameter in case they need to.

Pros: Less emit definitions duplication. Traits can define emits independently of the component.
Cons: IDEs may have a harder time figuring component events. Logic should still be needed in the component, even for very common cases.

Interface

I have a working Vite plugin with a macro definition, that is, I have a real world working code of all I'm writing in this proposal. It is rough, not up to some coding standards, and is probably slower than needed. I made it work for my projects but I'm far from an expert in these waters, so you may laugh at me a your convenience (with moderation). If someone is interested and wants to, I can provide the whole code here along with an example. The following interfaces and code examples are based off my current implementation, but it is not fully what I would like it to be. Again, I made it to work with what little experience I have with Vite plugins and compilers in general (which was... zero).

In component

Can be anything sugary enough, really.

Parameters to traits may need to be named for the trait code to find them, so a parameter object may be desirable instead of direct function parameters, therefore I came up with this interface and macro:

const self = defineTraits({
    '@/traits/basic-component': null,
    '@/traits/basic-form': { data: data },
    '@/traits/schema-validated-form': { schema: schema, data: data },
});

I would really prefer to be able to skip the "null" (or empty object, or wathever) when no parameters are needed, but couldn't find a way to do it with my limited knowledge.

Due to the need to return a merged object, I think this kind of interface may be desirable over individual calls for each trait.

Then, in your component you will be able to use "self" just directly, of course that includes the template:

<ValidationMessagesBlock v-model='self.validation.fields.roles' />

Trait definition and code

Remember this is a rough, even if working, code. There's not a lot of syntactic sugar and is not exactly what I would like it to be. The proper syntax is not something to discuss here either but by actual experts, if this proposal is even considered.

Currently, the best approach I found is implementing a class and returning an instance in a specific structure. That alone match with the unique instance requirement, among other things.

basic-form trait:

class T {
    // Would love to be able to set them like with defineExpose
    // unlike props and emits, this currently needs to be here to properly bind them to their instance when merging
    exposed = { "Pack": this.Pack, "Unpack": this.Unpack, "Clear": this.Clear };

    #component;

    // component_params = { props: <component props>, params: <params as passed in defineTraits>, emit: <emit function> }
    constructor ( component_params ) {
        this.#component = component_params;
        ...other_code_here
    }

    // More implementation
}

export default {
    // These are here to be merged with defineProps and defineEmits in compile time with the Vite plugin
    // they are always static so there's no need to have them in the class
    props: {
        // props, just like in any component
    },
    emits: [
        // emits, just like in any component
    ],
    
    // This function is the one that the Vite plugin wants to merge the traits
    Apply: ( params ) => new T( params );
}

Overriding and previous merge

Overriding a function or variable is made simply by using the same name. A "Clear" function in trait A will be overridden with a "Clear" function in trait B, as long as trait B is loaded after trait A.

As for accesing the previous merge, right now it's just a quick and dirty patch: if the instance has a "previous" property, it will be set with the previous merge reference.

Obivously this should be made in a better way, but at the time I needed it "NOW".

schema-validated-form trait:

// This will be lower than basic-form and therefore override basic-form
class T {
    // Notice that "Clear" is exposed in both traits, this one will override the basic-form one
    exposed = { "Validate": this.Validate, "Clear": this.Clear };

    Clear () {
        this.Clear_validation();
        // Invoking basic-form "Clear" function!
        this.previous?.Clear();
    }

    // More implementation
}

Technicalities on current implementation

The current implementation works with a Vite plugin, which is run before the Vue compiler.

In reality, what it does is transform the defineTraits macro, generate some unique names for each loaded trait, and setup the main merger function along the "Apply" functions for each trait. The plugin will also merge the props, emits and exposed with their corresponding defineProps, defineEmits and defineExpose macros, or create them if they do not exist.

After that, since the new code injected is valid Vue code, the Vue compiler does the rest.

Currently the main limitation I felt is that the merging of traits is done in runtime. I feel that there's probably room to make it work at compile time, but my expertise and time available is not enough to delve that deep. Of course, the syntax sugar is another point to improve.

End, TL;DR

This is a proposal for the Vue team to consider how to build much more reusable pieces of code. Particularly code that allows also reuse props definitions, emits, exposed functions, and even allowing overriding the reused code based on the load order. Ideally while still allowing a single point of access to the functionality.

It absolutely doesn't mean that needs to be remotely similar to the interfaces and code provided, of course.

[edited] some typos and better descriptions here and there

You must be logged in to vote

Replies: 0 comments

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
1 participant
Morty Proxy This is a proxified and sanitized view of the page, visit original site.