How To Build Vue Components That Play Nice
Kevin Ball | June 18th, 2018 | 6 min read
Very few people write Vue components originally intending them to be open-sourced. Most of us start out writing components for ourselves - we have a problem, and then decide to solve it by building a component. Sometimes we find ourselves wanting to solve the same problem in new locations in our codebase, and so we take our component and refactor it a bit to make it reusable. Then we want to use it in a different project, and so we move it out into an independent package. And then we think "hey, why not share this with the world?", and so we open-source the component.
On the one hand, this is great, it means a large and growing availability of open-source components out there for anyone working in Vue (a search for "vue" on npmjs.com turns up over 12000 packages).
On the other hand, because most of these components evolved from a specific situation, and not all of us have experience designing components for reuse across many environments, many of these components do not "play nice" with the Vue ecosystem.
What does it mean to "play nice"? At a high level, it means behaving in a way that feels natural to Vue developers, and that is easy to extend and integrate into any sort of application.
After exploring a wide range of open source components, here's what I think goes into making a Vue component that plays nice:
- Implement v-model compatibility
- Be transparent to events
- Assign attributes to the right elements
- Embrace browser norms for keyboard navigation
- Use events preferentially over callbacks
- Limit in-component styles
Implement v-model compatibility
For components that are essentially form fields - whether it be autocompleting search fields, calendar date fields, or anything else that is applying additional functionality around a single field that lets a user specify data - one of the most important ways to be idiomatic is to support v-model
.
According to the Vue Guide on components, v-model on a component essentially works by passing in a value
prop, and applying an input
event handler.
For example, if we were implementing a date picker that wraps an input, we would initialize our datepicker using the value
prop, and on selection emit an input
event, looking something like this:
import datepicker from 'my-magic-datepicker';
export default {
props: ['value'],
mounted() {
datepicker(this.$el, {
date: this.value,
onDateSelected: (date) => {
this.$emit('input', date);
},
});
}
}
Be transparent to events
In order to implement v-model, components need to implement the input
event. But what about other events? Things like click events, keyboard handling, etc? While the native events bubble as HTML, Vue's event handling does not by default bubble.
For example, unless I do something specific, this will not work:
<my-textarea-wrapper @focus="showFocus">
Unless we write code in the wrapper component that actually emits the focus
event, the showFocus event handler will never get called. However, Vue does give us a way to programmatically access listeners applied to a component, so we can assign them to the right place: the $listeners
object.
On a second thought, the reason is apparent: This allows us to pass through listeners to the right place in our component. For example, with our text area wrapper component:
<div class="my-textarea-wrapper">
<textarea v-on="$listeners" ></textarea>
</div>
Now events that happen on the textarea are the ones that are passed through.
Assign attributes to the right elements
What about attributes such as rows
for textareas or a title
tag to add a simple tooltip on any element?
By default, Vue takes attributes applied to the component and puts them on the root element of that component. This is often, but not always what you want. However, if we look again at the textarea wrapper from above, in that case, it would make more sense to apply the attributes to the textarea
itself rather than the div.
To do this, we tell the component not to apply the attributes by default, and instead apply them directly using the $attrs
object. In our JavaScript:
export default {
inheritAttrs: false,
}
And then in our template:
<div class="my-textarea-wrapper">
<textarea v-bind="$attrs"></textarea>
</div>
Embrace browser norms for keyboard navigation
Accessibility and keyboard navigation is one of the most often forgotten pieces of web development, and one of the most important things to get right if you're writing a component to play nice in the ecosystem.
At the root of it, this means making sure your component complies with browser norms: The tab key should allow selecting form fields. Enter is typically used for activating a button or link.
A complete list of keyboard navigation recommendations for common components can be found on the W3C website. Following these recommendations will allow your component to be used throughout any application, not just those who aren't concerned with accessibility.
Use events preferentially over callbacks
When it comes to communication about data and user interactions from your component to its parents, there are two common options: callback functions in props, and events. Because Vue's custom events don't bubble up like native browser events do, the two are functionally equivalent, but for a reusable component, I would almost always recommend using events over callbacks. Why?
On an episode of Fullstack Radio, Vue core team member Chris Fritz gave the following reasons:
- Using events makes it very explicit what parents can know about. it creates a clear separation between "things we get from a parent" and "things we send to a parent".
- You can use expressions directly in event handlers, allowing extremely compact event handlers for simple cases.
- It's more idiomatic - Vue examples and documentation tend to use events for communication from a component to its parent.
Fortunately, if you're currently using a callbacks-in-props approach, it's pretty easy to modify your component to emit events instead. A component using callbacks might look like:
// my-custom-component.vue
export default {
props: ['onActionHappened', ...]
methods() {
handleAction() {
... // your custom code
if (typeof this.onActionHappened === 'function') {
this.onActionHappened(data);
}
}
}
}
And then when it is being included, it looks like:
<my-custom-component :onActionHappened="actionHandler" />
Changing to an event-based approach would look like this:
// my-custom-component.vue
export default {
methods() {
handleAction() {
... // your custom code
this.$emit('action-happened', data);
}
}
}
and the parent would change to:
<my-custom-component @action-happened="actionHandler" />
Limit in-component styles
Vue's single-file component structure allows us to embed our styles directly into components, and especially when combined with scoping gives us a great way to ship completely packaged, styled components in a way that won't influence other parts of the application.
Because of the power of this system, it can be tempting to put all of your component styles into your component and ship a fully styled component around. The problem is this: No application's styles are the same, and the very things that make the component look polished in your application will make it stand out like a sore thumb in someone else's. And because component styles are typically included later than a global stylesheet, it can turn into a specificity nightmare to override it.
To prevent this, I recommend that any CSS that is not structurally necessary for your component (colors, borders, shadows, etc) should either be excluded from your component file itself or be able to be turned off. Instead, consider shipping a customizable SCSS partial that will allow your users to customize to their heart's content.
The downside of shipping only SCSS is it requires users of your component to pull that SCSS into their stylesheet compilation or see a very unstyled component. To get the best of both worlds, you can scope your in-file styles with a class that can be turned off via a prop for users who want to customize the style. If you structure your SCSS as a mixin you can use the same SCSS partial your users could use for more custom styles.
<template>
<div :class="isStyledClass">
<!-- my component -->
</div>
</template>
And then in your JavaScript:
export default {
props: {
disableStyles: {
type: Boolean,
default: false
}
},
computed: {
isStyledClass() {
if (!this.disableStyles) {
return 'is-styled';
}
},
}
You can then
@import 'my-component-styles';
.is-styled {
@include my-component-styles();
}
This will allow out-of-the-box styling to be however you desire, but users wanting to customize no longer need to create high-specificity overrides, they just turn off styling by setting the disableStyles
prop to true and can either use your mixin with their own settings or restyle everything themselves purely from scratch.
Click to load comments...