layout | download | highlighter | info |
---|---|---|---|
cover |
shiki |
## Composable Vue
Pattens and tips for writing good composable logic in Vue
[Anthony Fu](https://antfu.me/) at [VueDay 2021](https://2021.vueday.it/)
- [Recording](https://www.youtube.com/watch?v=IMJjP6edHd0)
- [Transcript](https://antfu.me/posts/composable-vue-vueday-2021)
- [Source code](https://github.com/antfu/talks/tree/master/2021-04-29)
|
Pattens and tips for writing good composable logic in Vue
Creator of VueUse, i18n Ally and Type Challenges.
A fanatical full-time open sourceror.
- Works for both Vue 2 and 3
- Tree-shakeable ESM
- CDN compatible
- TypeScript
- Rich ecosystems
a brief go-through
import { ref } from 'vue'
let foo = 0
let bar = ref(0)
foo = 1
bar = 1 // ts-error
Get rid of .value
for most of the time.
watch
accepts ref as the watch target, and returns the unwrapped value in the callback
const counter = ref(0)
watch(counter, count => {
console.log(count) // same as `counter.value`
})
- Ref is auto unwrapped in the template
<template>
<button @click="counter += 1">
Counter is {{ counter }}
</button>
</template>
- Reactive will auto-unwrap nested refs.
import { ref, reactive } from 'vue'
const foo = ref('bar')
const data = reactive({ foo, id: 10 })
data.foo // 'bar'
- If it gets a Ref, returns the value of it.
- Otherwise, returns as-is.
of writing composable functions
Sets of reusable logic, separation of concerns.
export function useDark(options: UseDarkOptions = {}) {
const preferredDark = usePreferredDark() // <--
const store = useStorage('vueuse-dark', 'auto') // <--
return computed<boolean>({
get() {
return store.value === 'auto'
? preferredDark.value
: store.value === 'dark'
},
set(v) {
store.value = v === preferredDark.value
? 'auto' : v ? 'dark' : 'light'
},
})
}
The setup()
only runs once on component initialization, to construct the relations between your state and logic.
- Input → OutputEffects
- Output reflects to input's changes automatically
Just the same as authoring JavaScript functions.
- Extract duplicated logics into composable functions
- Have meaningful names
- Consistent naming conversions -
useXX
createXX
onXX
- Keep function small and simple
- "Do one thing, and do it well"
function add(a: number, b: number) {
return a + b
}
let a = 1
let b = 2
let c = add(a, b) // 3
returns a reactive result.
function add(a: Ref<number>, b: Ref<number>) {
return computed(() => a.value + b.value)
}
const a = ref(1)
const b = ref(2)
const c = add(a, b)
c.value // 3
function add(
a: Ref<number> | number,
b: Ref<number> | number
) {
return computed(() => unref(a) + unref(b))
}
const a = ref(1)
const c = add(a, 5)
c.value // 6
A custom type helper
type MaybeRef<T> = Ref<T> | T
In VueUse, we use this helper heavily to support optional reactive arguments
export function useTimeAgo(
time: Date | number | string | Ref<Date | number | string>,
) {
return computed(() => someFormating(unref(time)))
}
import { computed, unref, Ref } from 'vue'
type MaybeRef<T> = Ref<T> | T
export function useTimeAgo(
time: MaybeRef<Date | number | string>,
) {
return computed(() => someFormating(unref(time)))
}
Make your functions like LEGO, can be used with different components in different ways.
import { useTitle } from '@vueuse/core'
const title = useTitle()
title.value = 'Hello World'
// now the page's title changed
Take a look at useTitle
's implementation
import { ref, watch } from 'vue'
import { MaybeRef } from '@vueuse/core'
export function useTitle(
newTitle: MaybeRef<string | null | undefined>
) {
const title = ref(newTitle || document.title)
watch(title, (t) => {
if (t != null)
document.title = t
}, { immediate: true })
return title
}
<-- 1. use the user provided ref or create a new one
<-- 2. sync ref changes to the document title
If you pass a ref
into ref()
, it will return the original ref as-is.
const foo = ref(1) // Ref<1>
const bar = ref(foo) // Ref<1>
foo === bar // true
function useFoo(foo: Ref<string> | string) {
// no need!
const bar = isRef(foo) ? foo : ref(foo)
// they are the same
const bar = ref(foo)
/* ... */
}
Extremely useful in composable functions that take uncertain argument types.
MaybeRef<T>
works well withref
andunref
.- Use
ref()
when you want to normalized it as a Ref. - Use
unref()
when you want to have the value.
type MaybeRef<T> = Ref<T> | T
function useBala<T>(arg: MaybeRef<T>) {
const reference = ref(arg) // get the ref
const value = unref(arg) // get the value
}
Getting benefits from both ref
and reactive
for authoring composable functions
import { ref, reactive } from 'vue'
function useMouse() {
return {
x: ref(0),
y: ref(0)
}
}
const { x, y } = useMouse()
const mouse = reactive(useMouse())
mouse.x === x.value // true
- Destructurable as Ref
- Convert to reactive object to get the auto-unwrapping when needed
With Composition API, we can actually turn async data into "sync"
const { data } = useFetch('https://api.github.com/').json()
const user_url = computed(() => data.value?.user_url)
Establish the "Connections" first, then wait for data to be filled up. The idea is similar to SWR (stale-while-revalidate)
export function useFetch<R>(url: MaybeRef<string>) {
const data = shallowRef<T | undefined>()
const error = shallowRef<Error | undefined>()
fetch(unref(url))
.then(r => r.json())
.then(r => data.value = r)
.catch(e => error.value = e)
return {
data,
error
}
}
The watch
and computed
will stop themselves on components unmounted.
We'd recommend following the same pattern for your custom composable functions.
import { onUnmounted } from 'vue'
export function useEventListener(target: EventTarget, name: string, fn: any) {
target.addEventListener(name, fn)
onUnmounted(() => {
target.removeEventListener(name, fn) // <--
})
}
A new API to collect the side effects automatically. Likely to be shipped with Vue 3.1
vuejs/rfcs#212
// effect, computed, watch, watchEffect created inside the scope will be collected
const scope = effectScope(() => {
const doubled = computed(() => counter.value * 2)
watch(doubled, () => console.log(double.value))
watchEffect(() => console.log('Count: ', double.value))
})
// dispose all effects in the scope
stop(scope)
To get DOM element, you can pass a ref to it, and it will be available after component mounted
import { defineComponent, ref, onMounted } from 'vue'
export default defineComponent({
setup() {
const element = ref<HTMLElement | undefined>()
onMounted(() => {
element.value // now you have it
})
return { element }
}
})
<template>
<div ref="element"><!-- ... --></div>
</template>
Use watch
instead of onMounted
to unify the handling for template ref changes.
import { defineComponent, ref, watch } from 'vue'
export default defineComponent({
setup() {
const element = ref<HTMLElement | undefined>()
watch(element, (el) => {
// clean up previous side effect
if (el) {
// use the DOM element
}
})
return { element }
}
})
Use the InjectionKey<T>
helper from Vue to share types across context.
// context.ts
import { InjectionKey } from 'vue'
export interface UserInfo {
id: number
name: string
}
export const injectKeyUser: InjectionKey<UserInfo> = Symbol()
Import the key from the same module for provide
and inject
.
// parent.vue
import { provide } from 'vue'
import { injectKeyUser } from './context'
export default {
setup() {
provide(injectKeyUser, {
id: '7', // type error: should be number
name: 'Anthony'
})
}
}
// child.vue
import { inject } from 'vue'
import { injectKeyUser } from './context'
export default {
setup() {
const user = inject(injectKeyUser)
// UserInfo | undefined
if (user)
console.log(user.name) // Anthony
}
}
By the nature of Composition API, states can be created and used independently.
// shared.ts
import { reactive } from 'vue'
export const state = reactive({
foo: 1,
bar: 'Hello'
})
// A.vue
import { state } from './shared.ts'
state.foo += 1
// B.vue
import { state } from './shared.ts'
console.log(state.foo) // 2
Use provide
and inject
to share the app-level state
export const myStateKey: InjectionKey<MyState> = Symbol()
export function createMyState() {
const state = {
/* ... */
}
return {
install(app: App) {
app.provide(myStateKey, state)
}
}
}
export function useMyState(): MyState {
return inject(myStateKey)!
}
// main.ts
const App = createApp(App)
app.use(createMyState())
// A.vue
// use everywhere in your app
const state = useMyState()
- Vue Router v4 is using the similar approach
A helper to make props/emit easier
export function useVModel(props, name) {
const emit = getCurrentInstance().emit
return computed({
get() {
return props[name]
},
set(v) {
emit(`update:${name}`, v)
}
})
}
export default defineComponent({
setup(props) {
const value = useVModel(props, 'value')
return { value }
}
})
<template>
<input v-model="value" />
</template>
Make the model able to be updated independently from the parent logic
export function usePassiveVModel(props, name) {
const emit = getCurrentInstance().emit
const data = ref(props[name]) // store the value in a ref
watch(() => props.value, (v) => data.value = v) // sync the ref whenever the prop changes
return computed({
get() {
return data.value
},
set(v) {
data.value = v // when setting value, update the ref directly
emit(`update:${name}`, v) // then emit out the changes
}
})
}
Composition API support for Vue 2.
vuejs/composition-api
import Vue from 'vue'
import VueCompositionAPI from '@vue/composition-api'
Vue.use(VueCompositionAPI)
import { ref, reactive } from '@vue/composition-api'
- Backport
@vue/composition-api
into Vue 2's core. <script setup>
syntax in Single-File Components.- Migrate codebase to TypeScript.
- IE11 support.
- LTS.
Creates Universal Library for Vue 2 & 3
vueuse/vue-demi
// same syntax for both Vue 2 and 3
import { ref, reactive, defineComponent } from 'vue-demi'
- Think as "Connections"
- One thing at a time
- Accepting ref as arguments
- Returns an object of refs
- Make functions flexible
- Async to "sync"
- Side-effect self clean up
- Shared state
Slides can be found on antfu.me