This article is aimed for those who wants to make communication between their components more solid and strictly typed.
❗ Notice. Examples of code, described later in this article, is built using
@vue/composition-api
, so it's highly encouraged to skim through the RFC of the upcoming Composition API before reading.
You should also definitely check out vue-typed-emit
.
Preface
Recently, I have been working on a large-scale project which has approximately 200+ components. The project contains a lot of dumb components, which has no state and accept only props, but also a bunch of stateful components, which tie up these dumb components and contain business logic.
Project's code tends to grow. Business wants new features, and wants them being implemented as fast as possible. When you are set off to release a new feature, there is a big chance that you are going to change the logic of already defined component. Another components might still utilize this component (pass props to it, or listen to its events). So how can you be confident that your changes do not break another components and ultimately the entire system?
Further, I will describe my attempts to make communication between components more reliable and typed.
Typed contracts
The idea is to create typed contracts between components. Every component has it's own "API": props it receives and events it emits. As you might know, events can contain payload (additional data that is attached to event). Thus every component should provide its own API: "I claim that I receive such props with such types and I emit such events with such payload".
JavaScript is dynamically typed, so TypeScript in rescue.
Let's create our first contract. We will use TypeScript interfaces to express that contract.
types.d.ts
:
export interface Props {
messages: {
id: string
text: string
sender: {
username: string
avatar?: string
}
}[]
}
export interface Events {
message: MessageEvent
}
export interface MessageEvent {
text: string
}
Our component declares that it receives array of messages through messages
prop, and each message should contain id
, text
, sender
(which should contain username
, and also may contain avatar
). Also it states that event message
with payload of type { text: string }
can be emitted.
Remember yourself skimming through component's code trying figure out what events with what payloads it emits? The inner logic of component probably do not bother you at that moment, but you have to skim through it. Dedicated file which contains types(contracts) of our component solves that problem.
Implementation
To properly implement our contracts we must write our components using TypeScript. Unfortunately Vue.js 2.0 is built without proper TypeScript support, while upcoming 3.0 is fully built upon TypeScript. So we are going to use 2.0 @vue/composition-api
plugin which adds some new features from 3.0 and also provides us with better TypeScript support.
Let's define our dummy component.
import { createComponent } from '@vue/composition-api'
import { Props } from './types'
export default createComponent({
name: 'AppChat',
props: {
messages: {
type: Array,
required: true
}
},
setup(props: Props) {}
})
Vue Composition API provides us with convenient method to define prop types in setup
method (props: Props
). That's all, we have fully typed props 🎉.
The next part is to emit typed events. As stated here the second argument of setup
is context
. But typings does not suit our purpose:
interface SetupContext {
// ...
emit: (event: string, ...args: unknown[]) => void
// ...
}
So we need to create some sort of wrapper, which knows typings of our events, and will shout out at us if we emit
something wrong (something we do not state in our contract).
Let's use feats @vue/composition-api
provides to us, namely create custom hook to use it across the components. There is a handy self-speaking method exposed by @vue/composition-api
— getCurrentInstance
. Below is the code snippet of our code:
emitter.ts
:
import { getCurrentInstance } from '@vue/composition-api'
export function useEmitter<T extends Record<string, any>>() {
const instance = getCurrentInstance()
return function emit<K extends keyof T>(name: K, payload: T[K]) {
if (instance !== null) {
instance.$emit(name, payload)
}
}
}
Now it's time to test our hook to see the real benefit.
// ...
import { useEmitter } from '../../composable/emitter'
import { Props, Events } from './types'
export default createComponent({
// ...
setup(props: Props) {
const emitter = useEmitter<Events>()
function messageHandler(text: string) {
emitter('message', { text })
}
}
})
emitter
knows about our events and suggests us message
as possible event name.
Moreover, it warns us, if we pass incorrect payload (incorrect in that case means payload, that does not match with our contract).
Another benefit is that it's possible to directly import type of certain event and to use it in parent's handler:
import { createComponent, createElement as h } from '@vue/composition-api'
import AppChat from '../AppChat'
import { MessageEvent } from '../AppChat/types'
export default createComponent({
name: 'AppParent',
components: {
AppChat
},
setup() {
function messageHandler(message: MessageEvent) {
console.log(message.text)
}
return () => h(AppChat, { on: { message: messageHandler } })
}
})
Conclusion
Every approach has its advantages and disadvantages. I will try to outline pros and cons of that approach. Let's begin with pros 😊:
- strictly typed emits ✅
- strictly typed event handlers ✅
- types(contracts) is located in dedicated files ✅
It's turn for cons:
- you have to use wrapper
useEmitter
overemit
(notemit
itself), that adds some overhead to your component's code, and component is highly coupled with that hook, so cannot be easily reused in project which does not contain that hook ❌
Vue.js 3.0 is written from the scratch upon TypeScript, so we can hope that typed emit
's will be shipped from out of the box. But for now the only way is to construct our own wrappers to make Vue.js more strictly typed.
Stay tuned, because in the next article I will outline how I managed to patch types of createComponent
to substitute type of emit
method.
You can play around with the demo here.
Top comments (0)