Production grade DX for all your web projects.
This is an opinionated guide on how to set up a new Vue 3 project with the aforementioned tech stack plus some extras that can really help in the DX (Developer Experience) department.
Stuff like:
And more…
Here are a couple of cheat sheets I wrote for Vue 3 + TypeScript:
Generate a new project:
pnpm create vite
# introduce project name
# select vue
# select vue-ts
Let’s set up our project with pnpm.
Enter the project directory then:
pnpm install
pnpm up --latest
Quickly create a prettier.config.cjs
file:
module.exports = {
semi: false,
singleQuote: true,
}
This is in case you have the VSCode extension installed and you want to format code right away. 😉
We will set it up properly later.
To be able to use __dirname
or import * as path from 'path'
:
pnpm add -D @types/node
To type your environment variables in Vite, add a new src/env.d.ts
file with typing information like:
interface ImportMetaEnv {
VITE_BASE_URL: string
}
This indicates that we will have a VITE_BASE_URL
env var that will contain a string.
Use @ in imports like import HelloWorld from '@/components/HelloWorld.vue'
.
Edit vite.config.ts
and add a new resolve
key to it, like this:
import vue from '@vitejs/plugin-vue'
import * as path from 'path'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
Let’s allow TypeScript to resolve this alias, and also enable autocomplete on paths.
In tsconfig.json
add paths
and baseUrl
under compilerOptions
:
{
"compilerOptions": {
"baseUrl": ".",
// ...
// "skipLibCheck": true, -> true by default now
// ...
"paths": {
"@/*": ["./src/*"]
}
As to why "skipLibCheck": true
, here is some context about how it might be useful.
These dependencies aims to set up an advanced DX for web development.
Add Prettier to the project:
pnpm add -D prettier prettier-plugin-tailwindcss
Create a prettier.config.cjs
file:
module.exports = {
plugins: [require('prettier-plugin-tailwindcss')],
semi: false,
singleQuote: true,
// trailingComma: "none"
}
Create a .prettierignore
file:
coverage/
dist/
__snapshots__/
pnpm-lock.yaml
*.md
Format project:
pnpm prettier . --write
Add the following scripts to your package.json
file:
{
// ...
"scripts": {
// ...
"format": "prettier . --write",
"format:check": "prettier . --check",
// ...
Read more about Prettier over here.
And read more about the Tailwind CSS plugin over here.
Let’s configure eslint + prettier + typescript support.
Add eslint and related dependencies to the project:
pnpm add -D eslint eslint-plugin-vue eslint-config-prettier
pnpm add -D vue-eslint-parser @typescript-eslint/parser
pnpm add -D @typescript-eslint/eslint-plugin
Create a .eslintrc.cjs
file:
module.exports = {
env: { node: true },
extends: [
'eslint:recommended', // this maybe causes errors in defineEmits<{}>() ???
'plugin:@typescript-eslint/recommended',
'plugin:vue/vue3-recommended',
'prettier',
],
/* globals: {
defineEmits: 'readonly',
defineProps: 'readonly',
}, */
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
sourceType: 'module',
},
// plugins: ['@typescript-eslint'], // might not be needed
/* rules: {
'@typescript-eslint/explicit-function-return-type': 'warn',
}, */
}
Add the following scripts to your package.json
file:
{
// ...
"scripts": {
// ...
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src/",
"lint:check": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src/",
// ...
You might need to add an eslint-disable line in src/vite-env.d.ts
:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any
const component: DefineComponent<{}, {}, any>
export default component
}
Read more about eslint-plugin-vue over here.
Add Stylelint to the project:
pnpm add -D stylelint stylelint-config-standard \
stylelint-config-prettier stylelint-config-recommended-vue \
postcss-html
Create a stylelint.config.cjs
:
module.exports = {
extends: [
'stylelint-config-standard',
'stylelint-config-recommended-vue',
'stylelint-config-prettier'
],
rules: {
'at-rule-no-unknown': [true, { ignoreAtRules: ['tailwind'] }],
'function-no-unknown': [true, { ignoreFunctions: ['theme'] }],
'custom-property-empty-line-before': null,
'selector-class-pattern': null,
'value-keyword-case': null,
}
}
Read more about Stylelint over here.
Add the following scripts to your package.json
file:
{
// ...
"scripts": {
// ...
"lint:style": "stylelint ./src/**/*.{css,vue} --fix",
"lint:style:check": "stylelint ./src/**/*.{css,vue}",
// ...
Add commitlint to the project:
pnpm add -D @commitlint/cli @commitlint/config-conventional
Create a commitlint.config.cjs
:
module.exports = {
extends: ['@commitlint/config-conventional'],
}
Read more about commitlint over here.
Add husky to the project:
pnpm add -D husky
pnpm husky install
npm pkg set scripts.prepare="husky install"
pnpm husky add .husky/commit-msg 'pnpm commitlint --edit $1'
pnpm husky add .husky/pre-commit 'pnpm lint:check'
pnpm husky add .husky/pre-commit 'pnpm lint:style:check'
pnpm husky add .husky/pre-commit 'pnpm format:check'
Whenever we clone a repository that contains huskygit hooks we need to run pnpm husky install
—or, in this case pnpm prepare
— for husky set them up.
These are some really common dependencies that you’ll see basically in every project.
Add vue-router to the project:
pnpm add vue-router
Set up router with example:
Add a src/pages/HomePage.vue
file:
<template>Home page</template>
Add a src/router.ts
file:
import { createRouter, createWebHistory } from 'vue-router'
import HomePage from '@/pages/HomePage.vue'
export const routes = [
{
path: '/',
name: 'home-page',
component: HomePage,
},
// For lazy loading components
/* {
path: '/',
name: 'home-page',
component: () => import('@/pages/HomePage.vue'),
}, */
]
export const router = createRouter({
history: createWebHistory(),
routes,
})
Adjust your src/main.ts
file:
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { router } from './router'
createApp(App).use(router).mount('#app')
Add a router outlet in you src/App.vue
file:
<template>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div>
<HelloWorld msg="Vite + Vue" />
<!-- ROUTER OUTLET -->
<RouterView />
</template>
Add Pinia to the project:
pnpm add pinia
Read more about Pinia over here.
Let’s add @vueuse/head
to the project to handle SEO for our app:
Add vuex to the project:
pnpm add @vueuse/head
In your src/main.ts
:
import { createHead } from '@vueuse/head'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import routes from './routes'
import { store } from './store'
const router = createRouter({
history: createWebHistory(),
routes,
})
const head = createHead()
createApp(App).use(head).use(router).use(store).mount('#app')
Use it in your src/App.vue
or page components like this:
<template>
<h1>The APP template</h1>
<RouterLink to="/">Home</RouterLink>
|
<RouterLink to="/about">About</RouterLink>
<RouterView></RouterView>
</template>
<script lang="ts">
import { useHead } from '@vueuse/head'
import { defineComponent } from 'vue'
import HelloWorld from '@/components/HelloWorld.vue'
export default defineComponent({
name: 'App',
components: {
HelloWorld,
},
setup() {
useHead({
title: 'Default title',
meta: [
{
name: 'description',
content: 'This is a DEFAULT description',
},
{
name: 'other-stuff',
content: 'This is some OTHER stuff',
},
],
})
},
})
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
Add Tailwind CSS dependencies to the project, then generate some config files:
pnpm add -D tailwindcss postcss autoprefixer
pnpm tailwindcss init --postcss
Add some basic configuration to tailwind.config.cjs
:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,vue}'],
theme: {
extend: {},
},
plugins: [],
}
And some to stylelint.config.js
:
module.exports = {
// ...
rules: {
'at-rule-no-unknown': [true, { ignoreAtRules: ['tailwind'] }],
'function-no-unknown': [true, { ignoreFunctions: ['theme'] }],
'custom-property-empty-line-before': null,
'selector-class-pattern': null,
'value-keyword-case': null,
}
}
Replace the contents of src/style.css
file with:
@tailwind base;
@tailwind components;
@tailwind utilities;
Make sure it’s imported in your src/main.ts
file:
import './style.css'
You can use Tailwind CSS classes in your app now.
Change your src/pages/HomePage.vue
to:
<template>
<div class="text-xl">Home page</div>
</template>
You might need to restart your dev server.
Refresh the page and voilà!
You can enable Tailwind CSS Intellisense on more attributes or variables by editing your VSCode settings.json
:
{
"tailwindCSS.classAttributes": [
"class", // for html attribute class (html, vue)
"className", // for html attribute className (react)
"ngClass", // for html attribute ngClass (angular)
".*Classes.*", // for variables xClasses, yClassesZ, etc
".*-class" // for variables enter-from-class, leave-active-class, etc
],
}
Add these to your .gitignore
file:
# Vitest related files
__snapshots__/
coverage/
Add Vitest to the project:
pnpm add -D vitest @vitest/coverage-c8 @vue/test-utils jsdom
Add the following scripts to your package.json
file:
{
// ...
"scripts": {
// ...
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:update": "vitest --update",
"test:watch": "vitest",
// ...
Add this line to your tsconfig.json
:
{
"compilerOptions": {
// ...
"types": ["vitest/globals"]
}
}
Add these lines to your vite.config.ts
:
/// <reference types="vitest" />
// ...
test: {
environment: 'jsdom', // or happy-dom ???
globals: true,
}
})
Let’s add a test file example in src/App.test.ts
:
import { mount } from '@vue/test-utils'
import { router } from '@/router'
import App from './App.vue'
test('mount component', async () => {
expect(App).toBeTruthy()
const wrapper = mount(App, {
global: {
plugins: [router],
},
})
expect(wrapper.html()).toMatchSnapshot()
expect(wrapper.text()).toContain('Vite + Vue')
expect(wrapper.text()).toContain('count is 0')
await wrapper.get('button').trigger('click')
expect(wrapper.text()).toContain('count is 1')
await wrapper.get('button').trigger('click')
expect(wrapper.text()).toContain('count is 2')
})
To run tests once:
pnpm test
To update the test snapshots:
pnpm test:update
To have test watcher running:
pnpm test
To see the test coverage:
pnpm test:coverage
To run tests before pushing to repo:
pnpm husky add .husky/pre-push 'pnpm test'
If you are using jsdom for the test environment and also using window.matchMedia
somewhere, you’ll probably see:
TypeError: window.matchMedia is not a function
The solution is to mock matchMedia
inside window
.
Create a test/setup.ts
file with this content:
import { vi } from 'vitest'
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
Then update your vite.config.js
:
{
// ...
test: {
// ...
setupFiles: [resolve(__dirname, 'test/setup.ts')]
}
}
Error should be gone now. 😎
In your test/setup.ts
add:
vi.mock('axios', () => {
return {
default: {
create() {
return {
get() {
return Promise.resolve({})
},
post() {
return Promise.resolve({})
}
}
}
}
}
})
The swiss army knife for Vue 3.
A collection of essential Vue composition utilities.
pnpm add @vueuse/core
The swiss army knife for JavaScript/TypeScript.
A modern JavaScript utility library delivering modularity, performance & extras.
pnpm add lodash-es
pnpm add -D @types/lodash-es
A simple, but powerful, lightweight model-based validation for Vue.
pnpm add @vuelidate/core @vuelidate/validators
Promise based HTTP client for the browser and node.js
pnpm add axios
A small, fast JavaScript library for arbitrary-precision decimal arithmetic.
pnpm add big.js
pnpm add -D @types/big.js
Write stories to showcase and document your components.
pnpm i -D histoire @histoire/plugin-vue
Add a histoire.config.ts
file:
import { defineConfig } from 'histoire'
import { HstVue } from '@histoire/plugin-vue'
export default defineConfig({
plugins: [HstVue()],
setupFile: 'src/histoire.setup.ts',
// If you intend to serve from a subdirectory...
vite: {
base: '/ui-library/',
},
})
Create a src/histoire.setup.ts
:
import { createPinia } from 'pinia'
import { defineSetupVue3 } from '@histoire/plugin-vue'
import './style.css'
export const setupVue3 = defineSetupVue3(({ app, story, variant }) => {
const pinia = createPinia()
app.use(pinia)
})
Then add these scripts to your package.json
:
{
"scripts": {
"story:dev": "histoire dev",
"story:build": "histoire build",
"story:preview": "histoire preview"
}
}
Create an env.d.ts
:
/// <reference types="@histoire/plugin-vue/components" />
And add it in the include
field of your tsconfig.json
:
{
"include": [
"env.d.ts",
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
]
}
Let’s write an example story file for the src/components/HelloWorld.vue
component.
src/components/HelloWorld.story.vue
:
<script setup lang="ts">
import HelloWorld from './HelloWorld.vue'
</script>
<template>
<Story>
<HelloWorld msg="Hello!" />
</Story>
</template>
To see the results:
pnpm story:dev
# Now visit http://localhost:6006
🎉