Vue.js Workshop
Guillaume Chau
@Akryum
Vue.js Core Team
Prologue:
The Progressive Framework
💡️ = community-made
Higher-level Frameworks
Faster
More memory efficient
Smaller and more tree-shakable
Better TypeScript support
Vue 3
Migrating from Vue 2 to Vue 3
App Creation
import Vue from 'vue'
import VueRouter from 'vue-router'
import Vuex from 'vuex'
import App from './App.vue'
Vue.use(VueRouter)
Vue.use(Vuex)
const router = new VueRouter(...)
const store = new Vuex.Store(...)
new Vue({
el: '#app',
router,
store,
// render: h => h(App),
...App,
})
import { createApp } from 'vue'
import { createRouter } from 'vue-router'
import { createStore } from 'vuex'
import App from './App.vue'
const router = createRouter(...)
const store = createStore(...)
const app = createApp(App)
app.use(router)
app.use(store)
app.mount('#app')
Removed: Filters
<template>
<div>
{{ date | formatDate }}
</div>
</template>
<template>
<div>
{{ formatDate(date) }}
</div>
</template>
<template>
<div>
{{ date |> formatDate(%) }}
</div>
</template>
In the future?
v-model changes
<template>
<MyComponent v-model="msg" />
</template>
export default {
model: {
prop: 'count',
event: 'update',
},
props: {
count: {
type: Number,
required: true,
},
},
emits: [
'update',
],
}
<template>
<MyComponent v-model="msg" />
</template>
export default {
props: {
modelValue: {
type: Number,
required: true,
},
},
emits: [
'update:modelValue',
],
}
v-model changes
<template>
<MyComponent v-model="msg" />
</template>
export default {
model: {
prop: 'count',
event: 'update',
},
props: {
count: {
type: Number,
required: true,
},
},
emits: [
'update',
],
}
<template>
<MyComponent v-model:count="msg" />
</template>
export default {
props: {
count: {
type: Number,
required: true,
},
},
emits: [
'update:count',
],
}
v-model changes
<template>
<MyComponent :count.sync="msg" />
</template>
export default {
props: {
count: {
type: Number,
required: true,
},
},
emits: [
'update:count',
],
}
<template>
<MyComponent v-model:count="msg" />
</template>
export default {
props: {
count: {
type: Number,
required: true,
},
},
emits: [
'update:count',
],
}
Attribute changes
Nested component inheritance
<script>
export default {
inheritAttrs: false,
}
</script>
<template>
<SomeComponent
v-bind="$attrs"
>
<slot />
</SomeComponent>
</template>
<template>
<SomeComponent>
<slot />
</SomeComponent>
</template>
Attribute changes
Class and style included in $attrs
<script>
export default {
inheritAttrs: false,
}
</script>
<template>
<section>
<input
v-bind="$attrs"
>
</section>
</template>
<script>
export default {
inheritAttrs: false,
}
</script>
<template>
<section
v-bind="{
class: $attrs.class,
style: $attrs.style,
}"
>
<input
v-bind="{
...$attrs,
class: null,
style: null,
}"
>
</section>
</template>
<template>
<MyComponent
type="password"
class="foobar"
/>
</template>
Attribute changes
Class and style included in $attrs
Why? (Sneak peek at new features)
<script>
export default {
inheritAttrs: false,
}
</script>
<template>
<Teleport to="body">
<div v-bind="$attrs">
<slot />
</div>
</Teleport>
</template>
<template>
<div v-bind="$attrs">Div 1</div>
<div v-bind="$attrs">Div 2</div>
<div>Div 3</div>
</template>
Attribute changes
Listeners fallthrough
<!-- MyComponent -->
<template>
<SomeChild />
</template>
<template>
<MyComponent
@click.native="onClick"
/>
</template>
<template>
<MyComponent
@click="onClick"
/>
</template>
Attribute changes
Listeners fallthrough
<script>
export default {
inheritAttrs: false,
}
</script>
<template>
<div>
<input
v-bind="$attrs"
v-on="$listeners"
>
</div>
</template>
<script>
export default {
inheritAttrs: false,
}
</script>
<template>
<div>
<input
v-bind="$attrs"
>
</div>
</template>
Async components
<script>
const SomeAsyncComp = () => import('./SomeAsyncComp.vue')
export default {
components: {
SomeAsyncComp,
},
}
</script>
<script>
import { defineAsyncComponent } from 'vue'
const SomeAsyncComp = defineAsyncComponent(() => import('./SomeAsyncComp.vue'))
export default {
components: {
SomeAsyncComp,
},
}
</script>
Functional components
<script>
import { h } from 'vue'
function DynamicHeading (props, context) {
return h(`h${props.level}`, context.attrs, context.slots)
}
export default {
components: {
DynamicHeading,
},
}
</script>
<template>
<section>
<DynamicHeading level="1">
Hello World
</DynamicHeading>
<DynamicHeading level="2">
Hello World
</DynamicHeading>
</section>
</template>
Removed: Events API
It's recommended not to use Event Bus pattern
import Vue from 'vue'
export const bus = new Vue()
bus.$on('event', data => {
console.log(data)
})
bus.$emit('event', { foo: 'bar' })
import mitt from 'mitt'
export const bus = mitt()
bus.on('event', data => {
console.log(data)
})
bus.emit('event', { foo: 'bar' })
Removed: Events API
It's recommended not to use Event Bus pattern
import Vue from 'vue'
export const bus = new Vue()
bus.$on('event', data => {
console.log(data)
})
bus.$emit('event', { foo: 'bar' })
import mitt from 'mitt'
export const bus = mitt()
bus.on('event', data => {
console.log(data)
})
bus.emit('event', { foo: 'bar' })
- Component Props & Events
- Provide / Inject
- State management, such as Pinia
Renamed lifecycle options
export default {
beforeDestroy () {
},
destroyed () {
},
}
export default {
beforeUnmount () {
},
unmounted () {
},
}
Renamed transition classes
.v-enter,
.v-leave-to {
opacity: 0;
}
.v-leave,
.v-enter-to {
opacity: 1;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
.v-leave-from,
.v-enter-to {
opacity: 1;
}
App not replacing target element
<!DOCTYPE html>
<html>
<body>
<div id="app"/>
<script type="module" src="src/main.js"></script>
</body>
</html>
<template>
<div id="app">
<header>
<AppMenu />
</header>
<RouterView />
</div>
</template>
<template>
<header>
<AppMenu />
</header>
<RouterView />
</template>
Learn more in migration guide
Migration build
Special version of Vue 3 with Flags to enable Vue 2 retro-compatibility on certain changes
New features in Vue 3
Composition API
(Also available in Vue 2.7)
Multi-Root components
(aka Fragments)
<script setup>
import { ref } from 'vue'
const showModal = ref(false)
</script>
<template>
<div
class="my-class"
v-bind="$attrs"
>
<button @click="showModal = true">
Open modal
</button>
</div>
<BaseModal v-if="showModal">
...
</BaseModal>
</template>
Emits option
<script setup>
const emit = defineEmits([
'update',
])
function update () {
emit('update')
}
</script>
<template>
<button @click="update()">
Open modal
</button>
</template>
<script>
export default {
emits: [
'update',
],
methods: {
update () {
this.$emit('update')
},
},
}
</script>
<template>
<button @click="update()">
Open modal
</button>
</template>
SFC CSS Variables
(Also available in Vue 2.7)
<script setup>
import { ref, reactive } from 'vue'
const roses = ref('red')
const poem = reactive({
violets: 'blue',
})
</script>
<template>
<div class="my-class">Roses</div>
<div class="my-other-class">Violets</div>
</template>
<style scoped>
.my-class {
color: v-bind(roses);
}
.my-other-class {
color: v-bind('poem.violets');
}
</style>
New Scoped Style Selectors
Teleport
move content to an element outside of the app
<template>
<div
class="my-class"
v-bind="$attrs"
>
<button @click="showModal = true">
Open modal
</button>
</div>
<Teleport to="body">
<BaseModal v-if="showModal">
...
</BaseModal>
</Teleport>
</template>
Teleport Defer
target late element (rendered during same tick - available next tick)
<template>
<div
class="my-class"
v-bind="$attrs"
>
<button @click="showModal = true">
Open modal
</button>
</div>
<Teleport to="#some-el-in-app" defer>
<BaseModal v-if="showModal">
...
</BaseModal>
</Teleport>
</template>
Safe Teleport
components to target even later elements
<template>
<header>
<RouterLink to="/">
Home
</RouterLink>
<nav class="my-toolbar">
<TeleportTarget id="toolbar" />
</nav>
</header>
<RouterView />
</template>
<template>
<div class="my-messages">
<SafeTeleport to="#toolbar">
<button>
Export messages
</button>
</SafeTeleport>
</div>
</template>
Suspense
(experimental)
<!-- UserList.vue -->
<script setup>
const users = await fetch('...')
.then(r => r.json())
</script>
<template>
<User
v-for="user of users"
:key="user.id"
:user="user"
/>
</template>
<template>
<Suspense>
<Dashboard />
<template #fallback>
Loading...
</template>
</Suspense>
</template>
<template>
<div class="dashboard">
<UsersList />
</div>
</template>
Components
Props
<template>
{{ item.title }}
</template>
<script>
export default {
props: {
item: {
type: Object,
required: true,
},
},
methods: {
doSomething () {
console.log(this.item)
},
},
}
</script>
<MyComponent
v-bind:item="{ title: 'Hello '}"
/>
<MyComponent
:item="{ title: 'Hello '}"
/>
Default Prop value
<script>
export default {
props: {
item: {
type: Object,
default: null,
},
otherItem: {
type: Object,
default: () => ({
foo: 'bar',
}),
},
answer: {
type: Number,
default: 42,
},
myCallback: {
type: Function,
default: () => () => { /* ... */ },
},
},
}
</script>
Multiple possible prop types
<script>
export default {
props: {
item: {
type: [Object, Array],
},
other: {
type: [String, Number],
},
answer: {
type: [Object, Array, String, Number],
},
},
}
</script>
Events
<template>
Hello!
<button v-on:click="onClick">
Click me
</button>
</template>
<script>
export default {
emits: [
'update-item',
],
methods: {
onClick (event) {
this.$emit('update-item', 'toto', 42)
},
},
}
</script>
<MyComponent
v-for="item of items"
v-bind:key="item.id"
v-on:update-item="(param1, param2) => updateItem(item)"
/>
Communication Flow
<template>
Hello!
<button v-on:click="onClick">
Click me
</button>
</template>
<script>
export default {
props: {
item: {
type: Object,
required: true,
},
},
emits: [
'update-item',
],
methods: {
onClick (event) {
this.$emit('update-item')
},
},
}
</script>
<MyComponent
v-for="item of items"
v-bind:key="item.id"
v-bind:item="item"
v-on:update-item="updateItem(item)"
/>
Props down
Events up
v-model on Components
<script>
export default {
props: {
modelValue: {
type: String,
required: true,
},
},
emits: [
'update:modelValue',
],
}
</script>
<MyComponent
v-model="myText"
/>
<MyComponent
:modelValue="myText"
@update:modelValue="myText = $event"
/>
v-model on Components props
<script>
export default {
props: {
title: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
},
emits: [
'update:title',
'update:content',
],
}
</script>
<MyComponent
v-model:title="myTitle"
v-model:content="myContent"
/>
<MyComponent
v-bind:title="myTitle"
v-on:update:title="myTitle = $event"
v-bind:content="myContent"
v-on:update:content="myContent = $event"
/>
Slots
<template>
<h2>
Title
</h2>
<div>
Content:<br>
<slot />
</div>
</template>
<MyComponent>
Hello
</MyComponent>
v-slot
<template>
<h2>
Title
</h2>
<div>
Content:<br>
<slot />
</div>
</template>
<MyComponent>
<template v-slot:default>
Hello
</template>
</MyComponent>
Default slot content
<template>
<h2>
Title
</h2>
<div>
Content:<br>
<slot>
Default content
</slot>
</div>
</template>
<MyComponent />
Named slot
<template>
<h2>
<slot name="title" />
</h2>
<div>
Content:<br>
<slot>
Default content
</slot>
</div>
</template>
<MyComponent>
<template v-slot:default>
Hello
</template>
<template v-slot:title>
My own title
</template>
</MyComponent>
<MyComponent>
<template #default>
Hello
</template>
<template #title>
My own title
</template>
</MyComponent>
Dynamic components
<template>
Dynamic component:
<component
:is="condition ? 'MyComponent' : 'AnotherComponent'"
v-bind:title="'Some title'"
/>
Dynamic HTML element:
<component
:is="condition ? 'div' : 'span'"
v-bind:title="'Some title'"
/>
</template>
Async components
<script>
import { defineAsyncComponent } from 'vue'
const MyAsyncComponent = defineAsyncComponent(() => import('./MyAsyncComponent.vue'))
export default {
component: MyAsyncComponent,
}
</script>
<template>
<MyAsyncComponent v-if="someCondition" />
</template>
Async components
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from './views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/me/bookings',
name: 'my-bookings',
// route level code-splitting
// this generates a separate chunk (MyBookings.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('./views/MyBookings.vue'),
},
{
path: '/shop/:shopId/book',
name: 'create-booking',
component: () => import('./views/CreateBooking.vue'),
},
],
})
export default router
Provide/Inject
Provide/Inject
Provide/Inject
export default {
data() {
return {
message: 'hello!'
}
},
provide() {
// use function syntax so that we can access `this`
return {
message: this.message
}
}
}
export default {
inject: [
'message',
],
created() {
console.log(this.message) // injected value
}
}
TypeScript
tsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"target": "esnext",
"useDefineForClassFields": true,
"module": "esnext",
"moduleResolution": "node",
"isolatedModules": true,
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"paths": {
"@/*": ["src/*"]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"],
"skipLibCheck": true
},
"include": ["vite.config.*", "env.d.ts", "src/**/*", "src/**/*.vue"]
}
Take Over Mode
defineComponent
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
// options here
})
</script>
<template>
Hello from TypeScript
</template>
defineComponent
import { defineComponent } from 'vue'
export default defineComponent({
// type inference enabled
props: {
name: String,
msg: { type: String, required: true }
},
data() {
return {
count: 1
}
},
mounted() {
this.name // type: string | undefined
this.msg // type: string
this.count // type: number
}
})
PropType
import { defineComponent, PropType } from 'vue'
interface MyItem {
id: string
label: string
price: number
}
export default defineComponent({
props: {
item: {
type: Object as PropType<MyItem>,
required: true,
},
},
mounted() {
this.item // type: MyItem
}
})
Typing Events
import { defineComponent } from 'vue'
interface MyItem {
id: string
label: string
price: number
}
export default defineComponent({
emits: {
'update-item': (item: MyItem) => true,
},
mounted () {
this.$emit('update-item', {
id: '123',
label: 'foo',
price: 123.45,
})
},
})
TypeScript in the template
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
props: {
count: {
type: [Number, String],
required: true,
},
},
})
</script>
<template>
{{ count.toFixed(2) }}
</template>
TypeScript in the template
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
props: {
count: {
type: [Number, String],
required: true,
},
},
})
</script>
<template>
{{ (count as number).toFixed(2) }}
</template>
Global Properties
// global.d.ts
import axios from 'axios'
declare module 'vue' {
interface ComponentCustomProperties {
$http: typeof axios
$i18n: (key: string) => string
}
}
import axios from 'axios'
app.config.globalProperties.$http = axios
app.config.globalProperties.$i18n = (id: string) => {
// ...
}
Global Components
// global.d.ts
import BaseButton from '@/components/base/BaseButton.vue'
declare module 'vue' {
export interface GlobalComponents {
BaseButton: typeof BaseButton
}
}
Type-Check Vue components
Emit .d.ts files from Vue components
Composition API
Why the Composition API?
Component readability as it grows
Code Reusing Patterns have drawbacks
Limited TypeScript support
Options API Limitations:
export default {
data () {
return {
searchText: '',
}
},
computed: {
filteredItems () {
// ...
},
},
}
Component readability as it grows
Search ▶
Search ▶
export default {
data () {
return {
searchText: '',
sortBy: 'name',
}
},
computed: {
filteredItems () {
// ...
},
sortedItems () {
// ...
},
},
}
Component readability as it grows
Search ▶
Search ▶
Sort ▶
Sort ▶
Features are organized by component options
Organized by component options
Organized by features
With the Composition API
export default {
data () {
return {
searchText: '',
sortBy: 'name',
}
},
computed: {
filteredItems () {
// ...
},
sortedItems () {
// ...
},
},
}
Search ▶
Search ▶
Sort ▶
Sort ▶
With the Composition API
Search ▶
Sort ▶
export default {
setup () {
// Search feature
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
// Sort feature
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
},
}
With the Composition API
Search ▶
Sort ▶
export default {
setup () {
// Search feature
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
// Sort feature
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
},
}
Organized by feature
With the Composition API
Search ▶
Sort ▶
export default {
setup () {
// Search feature
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
// Sort feature
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
return {
searchText,
sortBy,
sortedItems,
}
},
}
With the Composition API
Search ▶
Sort ▶
export default {
setup () {
// Search feature
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
// Sort feature
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
return {
searchText,
sortBy,
sortedItems,
}
},
}
Expose to the template
With the Composition API
Search ▶
Sort ▶
export default {
setup () {
const { searchText, filteredItems } = useSearch()
const { sortBy, sortedItems } = useSort(filteredItems)
return {
searchText,
sortBy,
sortedItems,
}
},
}
// Search feature
function useSearch (items) {
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
return { searchText, filteredItems }
}
// Sort feature
function useSort (items) {
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
return { sortBy, sortedItems }
}
With the Composition API
Search ▶
Sort ▶
export default {
setup () {
const { searchText, filteredItems } = useSearch()
const { sortBy, sortedItems } = useSort(filteredItems)
return {
searchText,
sortBy,
sortedItems,
}
},
}
// Search feature
function useSearch (items) {
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
return { searchText, filteredItems }
}
// Sort feature
function useSort (items) {
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
return { sortBy, sortedItems }
}
Extract features into Composition Functions
Other Code Reuse Patterns have drawbacks
Other Code Reuse Patterns
Mixins
Mixin Factories
Scoped Slots
Mixins
export default {
data () {
return {
searchText: '',
sortBy: 'name',
}
},
computed: {
filteredItems () {
// ...
},
sortedItems () {
// ...
},
},
}
Mixins
const searchMixin = {
data () {
return { searchText: '' }
},
computed: {
filteredItems () { /* ... */ },
},
}
const sortMixin = {
data () {
return { sortBy: 'name' }
},
computed: {
sortedItems () { /* ... */ },
},
}
export default {
mixins: [ searchMixin, sortMixin ],
}
Mixins
const searchMixin = {
data () {
return { searchText: '' }
},
computed: {
filteredItems () { /* ... */ },
},
}
const sortMixin = {
data () {
return { sortBy: 'name' }
},
computed: {
sortedItems () { /* ... */ },
},
}
export default {
mixins: [ searchMixin, sortMixin ],
}
Organized by feature
Conflict Prone
Unclear relationships
Not easily reusable
Mixin Factories
Functions that return a mixin
Mixin Factories
function searchMixinFactory ({ ... }) {
return {
data () {
return { searchText: '' }
},
computed: {
filteredItems () { /* ... */ },
},
}
}
function sortMixinFactory ({ ... }) {
return {
data () {
return { sortBy: 'name' }
},
computed: {
sortedItems () { /* ... */ },
},
}
}
export default {
mixins: [ searchMixinFactory(), sortMixinFactory() ],
}
Mixin Factories
Organized by feature
Weak namespacing
Implicit property addition
No instance access to customize the behavior
Reusable & configurable
Clearer relationship
function searchMixinFactory ({ prefix }) {
return {
data () {
return { [prefix + 'searchText']: '' }
},
computed: {
[prefix + 'filteredItems'] () { /* ... */ },
},
}
}
function sortMixinFactory ({ ... }) {
return {
data () {
return { sortBy: 'name' }
},
computed: {
sortedItems () { /* ... */ },
},
}
}
export default {
mixins: [ searchMixinFactory({prefix:'m'}), sortMixinFactory() ],
}
Scoped slots
Scoped slots
<script>
export default {
// Search feature here
}
</script>
<template>
<div>
<slot v-bind="{ searchText, filteredItems }" />
</div>
</template>
<script>
export default {
// Sort feature here
}
</script>
<template>
<div>
<slot v-bind="{ sortBy, sortedItems }" />
</div>
</template>
<SearchService
v-slot="searchProps"
>
<SortService
:item="searchProps.filteredItems"
v-slot="sortProps"
>
<li v-for="item of sortProps.sortedItems">
...
</li>
</SearchService>
</SearchService>
SearchService.vue
SortService.vue
Scoped slots
<script>
export default {
// Search feature here
}
</script>
<template>
<div>
<slot v-bind="{ searchText, filteredItems }" />
</div>
</template>
<script>
export default {
// Sort feature here
}
</script>
<template>
<div>
<slot v-bind="{ sortBy, sortedItems }" />
</div>
</template>
<SearchService
v-slot="searchProps"
>
<SortService
:item="searchProps.filteredItems"
v-slot="sortProps"
>
<li v-for="item of sortProps.sortedItems">
...
</li>
</SearchService>
</SearchService>
Solves mixin problems
Increased indentation
Lost of configuration
Less flexible
Less performant
Composition API
Composition API
export default {
data () {
return {
searchText: '',
sortBy: 'name',
}
},
computed: {
filteredItems () {
// ...
},
sortedItems () {
// ...
},
},
}
Composition API
export default {
setup () {
const { searchText, filteredItems } = useSearch()
const { sortBy, sortedItems } = useSort(filteredItems)
return {
searchText,
sortBy,
sortedItems,
}
},
}
// Search feature
function useSearch (items) {
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
return { searchText, filteredItems }
}
// Sort feature
function useSort (items) {
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
return { sortBy, sortedItems }
}
Composition API
export default {
setup () {
const { searchText, filteredItems } = useSearch()
const { sortBy, sortedItems } = useSort(filteredItems)
return {
searchText,
sortBy,
sortedItems,
}
},
}
// Search feature
function useSearch (items) {
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
return { searchText, filteredItems }
}
// Sort feature
function useSort (items) {
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
return { sortBy, sortedItems }
}
Less code
Familiar vars & functions
Extremely flexible
Tooling friendly
New concepts to learn
ref()
import { ref, watch } from 'vue'
export default {
setup () {
const count = ref(0)
function increment () {
count.value++
}
watch(count, value => {
console.log('count changed', value)
})
return {
count,
increment,
}
},
}
Why do we need count.value?
Refs are Unwrapped in Template
<script lang="ts">
import { ref } from 'vue'
export default {
setup () {
const count = ref(0)
function increment() {
count.value++
}
return {
count,
increment,
}
}
}
</script>
<template>
<button @click="increment">
{{ count }} <!-- no .value needed -->
</button>
</template>
reactive()
import { reactive, watch } from 'vue'
export default {
setup () {
const countState = reactive({
count: 0,
})
function increment () {
countState.count++
}
watch(() => countState.count, value => {
console.log('count changed', countState.count)
})
return {
countState,
increment,
}
},
}
computed()
import { ref, computed, watch } from 'vue'
export default {
setup () {
const count = ref(0)
const double = computed(() => count.value * 2)
watch(double, value => {
console.log('double changed', value)
})
return {
double,
}
},
}
Props
import { ref, watch } from 'vue'
export default {
props: {
initialCount: {
type: Number,
default: 0,
},
},
setup (props) {
const count = ref(props.initialCount)
watch(() => props.initialCount, value => {
count.value = value
})
return {
count,
}
},
}
Composable
// Search feature
function useSearch (items: Ref<MyItem[]>) {
const searchText = ref('')
const filteredItems = computed(() => items.value.filter(/* ... */))
return { searchText, filteredItems }
}
// Sort feature
function useSort (items: Ref<MyItem[]>) {
const sortBy = ref('label')
const sortedItems = computed(() => items.value.slice().sort(/* ... */))
return { sortBy, sortedItems }
}
import { toRefs } from 'vue'
import { useSearch, useSort } from './my-composables'
export default {
setup (props) {
const { items } = toRefs(props)
const { search, filteredItems } = useSearch(items)
const { sortBy, sortedItems } = useSort(filteredItems)
return {
search,
sortBy,
sortedItems,
}
}
}
Setup script
<script lang="ts">
import { defineComponent, ref, computed } from 'vue'
export default defineComponent({
setup () {
// Search feature
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
// Sort feature
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
return {
searchText,
sortBy,
sortedItems,
}
},
})
</script>
<template>
<!-- ... -->
</template>
Setup script
<script lang="ts" setup>
import { defineComponent, ref, computed } from 'vue'
export default defineComponent({
setup () {
// Search feature
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
// Sort feature
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
return {
searchText,
sortBy,
sortedItems,
}
},
})
</script>
<template>
<!-- ... -->
</template>
Setup script
<script lang="ts" setup>
import { defineComponent, ref, computed } from 'vue'
// export default defineComponent({
// setup () {
// Search feature
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
// Sort feature
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
return {
searchText,
sortBy,
sortedItems,
}
// },
// })
</script>
<template>
<!-- ... -->
</template>
Setup script
<script lang="ts" setup>
import { ref, computed } from 'vue'
// Search feature
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
// Sort feature
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
return {
searchText,
sortBy,
sortedItems,
}
</script>
<template>
<!-- ... -->
</template>
Setup script
<script lang="ts" setup>
import { ref, computed } from 'vue'
// Search feature
const searchText = ref('')
const filteredItems = computed(() => /* ... */)
// Sort feature
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
</script>
<template>
<!-- ... -->
</template>
defineProps
<script lang="ts" setup>
const props = defineProps({
item: {
type: Object as PropType<MyItem>,
required: true,
},
})
console.log(props.item)
</script>
<template>
{{ item }}
</template>
defineProps
<script lang="ts" setup>
const props = defineProps<{
item: MyItem
}>()
console.log(props.item)
</script>
<template>
{{ item }}
</template>
defineEmits
import { PropType } from 'vue'
interface MyItem {
id: string
label: string
price: number
}
const props = defineProps({
item: {
type: Object as PropType<MyItem>,
required: true,
},
})
const emit = defineEmits({
'update-item': (item: MyItem) => true,
})
function updateItem () {
emit('update-item', {
...props.item,
price: props.item.price + 1,
})
}
defineEmits
import { PropType } from 'vue'
interface MyItem {
id: string
label: string
price: number
}
const props = defineProps<{
item: MyItem
}>()
const emit = defineEmits<{
(e: 'update-item', item: MyItem): void
}>()
function updateItem () {
emit('update-item', {
...props.item,
price: props.item.price + 1,
})
}
React Hooks vs Composition API
React Hooks
- Hooks are call-order sensitive and cannot be conditional
- Need to declare dependencies and manage closures
- Need manual usage of `useMemo` + manual deps
- Unnecessary update caused by event listeners by default
Vue Composition API
- Setup and composables are called only once
- No stale closure
- No need to declare dependencies manual thanks to the reactivity system
- Automatic fine-grained child updates
Components
With Composition API & Typescript
Communication Flow
<script setup>
const props = defineProps<{
item: Item
}>()
const emit = defineEmits<{
updateItem: [item: Item]
}>()
function onClick () {
emit('updateItem', props.item)
}
</script>
<template>
Hello!
<button v-on:click="onClick">
Click me
</button>
</template>
<MyComponent
v-for="item of items"
:key="item.id"
:item="item"
@update-item="onUpdateItem($event)"
/>
Props down
Events up
Child component
Parent component
<script lang="ts" setup>
import type { PropType } from 'vue'
interface MyItem {
id: string
label: string
price: number
}
const props = defineProps({
item: {
type: Object as PropType<MyItem>,
required: true,
},
})
props.item // MyItem
</script>
defineProps
<script lang="ts" setup>
interface MyItem {
id: string
label: string
price: number
}
const props = defineProps<{
item: MyItem
}>()
props.item // MyItem
</script>
defineProps
<script lang="ts" setup>
interface MyItem {
id: string
label: string
price: number
}
const props = defineProps<{
item: MyItem
optionalLabel?: string
}>()
props.item // MyItem
props.optionalLabel // string | undefined
</script>
defineProps
<script lang="ts" setup>
interface MyItem {
id: string
label: string
price: number
}
const props = withDefaults(defineProps<{
item: MyItem
optionalLabel?: string
}>(), {
optionalLabel: 'default label',
})
props.item // MyItem
props.optionalLabel // string
</script>
defineProps / withDefaults
<script lang="ts" setup>
interface MyItem {
id: string
label: string
price: number
}
const { item, optionalLabel = 'default label' } = defineProps<{
item: MyItem
optionalLabel?: string
}>()
console.log(item) // MyItem
console.log(optionalLabel) // string
watch(() => item, () => {}) // Use arrow function
</script>
Reactive Props Destructure
<script lang="ts" setup>
interface MyItem {
id: string
label: string
price: number
}
const props = defineProps<{
item: MyItem
optionalLabel?: string
}>()
console.log(props.item)
console.log(props.optionalLabel)
watch(() => props.item, () => {})
</script>
Compiled to
<script lang="ts" setup>
import { onMounted } from 'vue'
interface MyItem {
id: string
label: string
price: number
}
const emit = defineEmits({
updateItem: (item: MyItem) => true,
})
onMounted(() => {
emit('updateItem', {
id: '123',
label: 'foo',
price: 123.45,
})
})
</script>
defineEmits
<script lang="ts" setup>
import { onMounted } from 'vue'
interface MyItem {
id: string
label: string
price: number
}
const emit = defineEmits<{
updateItem: [item: MyItem]
}>()
onMounted(() => {
emit('updateItem', {
id: '123',
label: 'foo',
price: 123.45,
})
})
</script>
defineEmits
<script lang="ts" setup>
import { ref, reactive } from 'vue'
import type { Ref } from 'vue'
interface MyItem {
id: string
label: string
price: number
}
const value = ref<string | number>('2022')
const state = reactive<{ item: MyItem | null }>({
item: null,
})
function foo (someRef: Ref<string | number>) {
console.log(someRef)
}
function bar (someState: { item: MyItem | null }) {
console.log(someState)
}
foo(value)
bar(state)
</script>
ref & reactive
Template refs
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const input = useTemplateRef<HTMLInputElement>('my-input')
function focus () {
input.value?.focus()
}
</script>
<template>
<input ref="my-input" />
</template>
Template refs / component
<!-- MyModal.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const isContentShown = ref(false)
function open () {
isContentShown.value = true
}
defineExpose({
open,
})
</script>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import MyModal from './MyModal.vue'
type MyModalType = InstanceType<typeof MyModal>
const modal = useTemplateRef<MyModalType>('my-modal')
const openModal = () => {
modal.value?.open()
}
</script>
<template>
<MyModal ref="my-modal" />
</template>
Expose typed properties/methods
Template refs with v-for
<script setup lang="ts">
import { useTemplateRef, ref, onMounted } from 'vue'
const list = ref([1, 2, 3])
const itemEls = useTemplateRef<HTMLLIElement[]>('items')
onMounted(() => {
console.log(itemEls.value)
})
</script>
<template>
<ul>
<li v-for="item in list" ref="items">
{{ item }}
</li>
</ul>
</template>
Slots
<template>
<h2>
Title
</h2>
<div>
Content:<br>
<slot />
</div>
</template>
<MyComponent>
Hello
</MyComponent>
<MyComponent>
<template v-slot:default>
Hello
</template>
</MyComponent>
Equivalent
Will be rendered here
<MyComponent>
<template #default>
Hello
</template>
</MyComponent>
Child component
Parent component
Slot Fallback Content
<template>
<h2>
Title
</h2>
<div>
Content:<br>
<slot>
Default text here
</slot>
</div>
</template>
<MyComponent />
Child component
Parent component
Named Slots
<template>
<h2>
<slot name="title" />
</h2>
<div>
Content:<br>
<slot />
</div>
<div>
<slot name="actions" />
</div>
</template>
<MyComponent>
<template #title>
My Title
</template>
Some content in default slot
<template #actions>
<MyButton />
</template>
</MyComponent>
Child component
Parent component
Scoped Slots
<script lang="ts" setup>
import { ref } from 'vue'
defineSlots<{
default(props: { msg: string }): any
}>()
const text = ref('Meow')
</script>
<template>
<div>
<slot :msg="text" />
</div>
</template>
<MyComponent v-slot="props">
{{ props.msg }}
</MyComponent>
Child component
Parent component
<MyComponent>
<template #default="props">
{{ props.msg }}
</template>
</MyComponent>
<MyComponent>
<template #default="{ msg }">
{{ msg }}
</template>
</MyComponent>
Slot props
v-model on Components
<script lang="ts" setup>
defineProps<{
modelValue: string
}>()
defineEmits<{
'update:modelValue': [newValue: string]
}>()
</script>
<MyComponent
v-model="myText"
/>
<MyComponent
:modelValue="myText"
@update:modelValue="myText = $event"
/>
<script lang="ts" setup>
const model = defineModel<string>({
required: true
})
</script>
v-model on Components / named
<script lang="ts" setup>
defineProps<{
shown: boolean
}>()
defineEmits<{
'update:shown': [newValue: boolean]
}>()
</script>
<MyModal
v-model:shown="isModalShown"
/>
<MyModal
:shown="isModalShown"
@update:shown="isModalShown = $event"
/>
<script lang="ts" setup>
const shown = defineModel<boolean>('shown')
</script>
Dynamic components
<script setup>
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'
import AnotherComponent from './AnotherComponent.vue'
const condition = ref(true)
</script>
<template>
Dynamic component:
<component
:is="condition ? MyComponent : AnotherComponent"
:title="'Some title'"
/>
Dynamic HTML element:
<component
:is="condition ? 'div' : 'span'"
v-bind:title="'Some title'"
/>
</template>
Provide/Inject
Component
Component
Component
Component
Props
Props
Provide / Inject
Component
Component
Component
Component
const result = inject(key)
provide(key, { ... })
<script lang="ts" setup>
import { provide } from 'vue'
import { key } from './key'
provide(key, {
id: 'abc',
label: 'Computer',
price: 1_000,
})
</script>
Provide / Inject
// key.ts
import type { InjectionKey } from 'vue'
export interface MyItem {
id: string
label: string
price: number
}
export const key = Symbol() as InjectionKey<MyItem>
<script lang="ts" setup>
import { inject } from 'vue'
import { key } from './key'
const item = inject(key) // MyItem | undefined
</script>
typing information
injected value
Parent component
Child component
Provide / Inject
<script lang="ts" setup>
import { inject } from 'vue'
import { key } from './key'
import type { MyItem } from './key'
const item = inject(key) // MyItem | undefined
const item2 = inject<MyItem>('some-string-key') // MyItem | undefined
const sureItem = inject('some-string-key') as MyItem // MyItem
const text = inject<string>('some-other-key') // string | undefined
const textWithDefault = inject<string>('some-other-key', 'default value') // string
</script>
Async components
<script setup>
import { defineAsyncComponent } from 'vue'
const MyAsyncComponent = defineAsyncComponent(() => import('./MyAsyncComponent.vue'))
const someCondition = ref(false)
</script>
<template>
<MyAsyncComponent v-if="someCondition" />
</template>
Async components with vue-router
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from './views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/me/bookings',
name: 'my-bookings',
// route level code-splitting
// this generates a separate chunk (MyBookings.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('./views/MyBookings.vue'),
},
{
path: '/shop/:shopId/book',
name: 'create-booking',
component: () => import('./views/CreateBooking.vue'),
},
],
})
export default router
State Management
Vuex
State & Mutation
import { createStore } from 'vuex'
const store = createStore({
state () {
return {
count: 0
}
},
mutations: {
increment (state) {
state.count++
}
}
})
app.use(store)
export default {
methods: {
increment() {
this.$store.commit('increment')
console.log(this.$store.state.count)
},
},
}
import { mapState, mapMutations } from 'vuex'
export default {
computed: {
...mapState([
'count',
]),
},
methods: {
...mapMutations([
'increment',
]),
},
}
Getters
const store = createStore({
state: {
todos: [
{ id: 1, text: '...', done: true },
{ id: 2, text: '...', done: false }
]
},
getters: {
doneTodos (state) {
return state.todos.filter(todo => todo.done)
}
}
})
<script>
import { mapGetters } from 'vuex'
export default {
// ...
computed: {
// mix the getters into computed
...mapGetters([
'doneTodos',
// ...
])
}
}
</script>
<template>
{{ doneTodos }}
</template>
Actions
const store = createStore({
state: {
count: 0
},
mutations: {
increment (state) {
state.count++
}
},
actions: {
increment (context) {
// We can do async ops and multiple commits here
context.commit('increment')
},
},
})
import { mapActions } from 'vuex'
export default {
// ...
methods: {
...mapActions([
'increment',
// ...
])
}
}
Modules
const moduleA = {
state: () => ({ ... }),
mutations: { ... },
actions: { ... },
getters: { ... },
}
const moduleB = {
state: () => ({ ... }),
mutations: { ... },
actions: { ... },
}
const store = createStore({
modules: {
a: moduleA,
b: moduleB,
},
})
import { mapGetters, mapActions } from 'vuex'
export default {
computed: {
...mapGetters('a', [
// Getters from module 'a'
]),
...mapGetters('b', [
// Getters from module 'b'
]),
},
methods: {
...mapActions('a', [
// Actions from module 'a'
]),
...mapActions('b', [
// Actions from module 'b'
]),
},
}
Pinia
is now the recommended library
Pinia vs Vuex
- Mutations no longer exist
- Simple and better TypeScript support
- No "magic" strings
- Dynamically added stores by default
- Flat structures instead of nested modules
+ circular dependencies possible - No namespacing
Pinia setup
import { createPinia } from 'pinia'
app.use(createPinia())
Pinia store
import { defineStore } from 'pinia'
export const useShopStore = defineStore('shops', {
state: () => ({
loading: false,
error: null as Error | null,
shops: [] as Shop[],
}),
getters: {
shopsCount: state => state.shops.length,
},
actions: {
async fetchShops () {
this.loading = true
this.error = null
try {
const response = await $fetch('/shops')
this.shops = response.data
} catch (e: any) {
this.error = e
} finally {
this.loading = false
}
},
},
})
Setup store
import { defineStore } from 'pinia'
export const useShopStore = defineStore('shops', () => {
const loading = ref(false)
const error = ref<Error | null>(null)
const shops = ref<Shop[]>([])
const shopsCount = computed(() => state.shops.length)
async function fetchShops () {
loading.value = true
error.value = null
try {
const response = await $fetch('/shops')
shops.value = response.data
} catch (e: any) {
error.value = e
} finally {
loading.value = false
}
}
return {
loading,
error,
shops,
fetchShops,
}
})
Store usage
<script lang="ts" setup>
import BaseLoading from './base/BaseLoading.vue'
import BaseError from './base/BaseError.vue'
import ShopItem from './ShopItem.vue'
import { useShopStore } from '@/stores/shop'
const shopsStore = useShopStore()
shopsStore.fetchShops()
</script>
<template>
<BaseLoading v-if="shopsStore.loading">
Loading...
</BaseLoading>
<BaseError v-if="shopsStore.error">
{{ shopsStore.error.message }}
</BaseError>
<ShopItem
v-for="shop in shopsStore.shops"
:key="shop.id"
:shop="shop"
/>
</template>
Testing
Guiding principles
Don't test the implementation details
Only test inputs and outputs
Arrange, Act, Assert
Don't test implementation details
Inputs | Examples |
---|---|
Interations | Clicking, typing... any "human" interaction |
Props | The arguments a component receives |
Data streams | Data incoming from API calls, data subscriptions… |
Outputs | Examples |
---|---|
DOM elements | Any observable node rendered to the document |
Events | Emitted events (using $emit) |
Side Effects | Such as console.log or API calls |
Everything else is implementation details.
Testing a component
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'
describe('HelloWorld', () => {
it('renders properly', () => {
const wrapper = mount(HelloWorld, {
props: {
msg: 'Hello Vitest',
},
})
expect(wrapper.text()).toContain('Hello Vitest')
})
})
Input
describe('HelloWorld', () => {
it('handles input', async () => {
const wrapper = mount(HelloWorld)
await wrapper.get('input').setValue('Hello Vitest')
expect(wrapper.text()).toContain('Hello Vitest')
})
})
Trigger events
describe('HelloWorld', () => {
it('increments', async () => {
const wrapper = mount(HelloWorld)
await wrapper.get('button').trigger('click')
expect(wrapper.text()).toContain('2')
})
})
Mocking module
import { vi } from 'vitest'
vi.mock('lodash/debounce', () => {
return {
default: (cb: any) => cb,
}
})
Mocking network
import { afterAll, afterEach, beforeAll, describe } from 'vitest'
import { setupServer } from 'msw/node'
import { rest } from 'msw'
import { shops } from './fixtures/shops'
const baseURL = 'http://localhost:4000'
const server = setupServer(
rest.get(`${baseURL}/shops`, (req, res, ctx) => {
if (req.url.searchParams.get('q') === 'cat') {
return res(ctx.status(200), ctx.json(shops.slice(0, 1)))
}
return res(ctx.status(200), ctx.json(shops))
}),
// other handlers...
)
describe('shop store', () => {
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' })
})
afterEach(() => {
server.resetHandlers()
})
afterAll(() => {
server.close()
})
// tests here...
})
Mocking pinia
import { describe, expect, test, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { useShopStore } from '@/stores/shop'
import { shops } from './fixtures/shops'
describe('ShopsList', () => {
test('displays shop items', async () => {
const wrapper = mount(ShopsList, {
global: {
plugins: [createTestingPinia({
createSpy: () => vi.fn(),
})],
},
})
const store = useShopStore()
store.shops = shops
await flushPromises()
expect(wrapper.findAllComponents(ShopItem).length).toBe(3)
expect(store.fetchShops).toHaveBeenCalled()
})
})
Testing pinia store
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test } from 'vitest'
import { useShopStore } from '../shop'
describe('shop store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
test('loads shops', async () => {
const store = useShopStore()
const promise = store.fetchShops()
expect(store.loading).toBe(true)
await promise
expect(store.shops.length).toBe(3)
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})
})
SSR
Server
App
Render
<div> Hello world! </div>
Server
App
Render
<html>
<ul>
<li>James Holden</li>
<li>Naomi Nagata</li>
</ul>
</html>
Database
Read
Browser
Request page
Send HTML
<html>
<a>Roccinante crew</a>
</html>
<html>
<ul>
<li>James Holden</li>
<li>Naomi Nagata</li>
</ul>
</html>
Replace the whole page
Server-Side Only App
Server-Side Only App
Request page
Receiving page
Received page
Click button
Request page
Database Read
Receiving page
Received page
Examples
Server
API
Database
Read
Browser
Request page
Send JSON
<html>
<div id="app">
<a>Roccinante crew</a>
</div>
</html>
<html>
<div id="app">
<ul>
<li>James Holden</li>
<li>Naomi Nagata</li>
</ul>
</div>
</html>
Single Page App
CDN
HTML
JS
Request API
Request script
<html>
<div id="app"></div>
</html>
<html>
<div id="app">
<div class="loading"/>
</div>
</html>
App
Single Page App
Request page
Received JS
Click button
Change route
Request API
Database Read
Received JSON
Received page
Request JS
Examples
Server
API
Database
Read
Browser
Request page
Send JSON
<html>
<div id="app">
<a>Roccinante crew</a>
</div>
</html>
<html>
<div id="app">
<ul>
<li>James Holden</li>
<li>Naomi Nagata</li>
</ul>
</div>
</html>
Universal App
CDN
JS
Request API
Request script
<html>
<div id="app">
<div class="loading"/>
</div>
</html>
Hydration
Server
App
JS
App
Universal App
Request page
Received JS
Click button
Change route
Request API
Database Read
Received JSON
Receiving page
Received page
Request JS
Server
API
Database
Read
Browser
Request page
Send JSON
<html>
<div id="app">
<ul>
<li>James Holden</li>
<li>Naomi Nagata</li>
</ul>
</div>
</html>
Universal App
CDN
JS
Request API
Request script
Server
App
Hydration
JS
App
Universal App
Request page
Received page
Request JS
Received JS
Database Read
Receiving page
Examples
Nuxt
What's inside?
Vite (or Webpack)
Node.js Server Framework
HTTP Server
Bundler
Nuxi
Command-line interface
pnpx nuxi init <project-name>
assets
processed by bundler
components
auto-imported
composables
auto-imported
layouts
wraps page content (<NuxtLayout>)
middleware
executed during route navigation
pages
generates routes
plugins
app setup
public
static assets
server
API (backend)
utils
auto-imported
app.vue
main component
nuxt.config.ts
Nuxt configuration
Vue.js Workshop
By Guillaume Chau
Vue.js Workshop
- 263