lobo_tuerto's notes
Home
Blog
Notes
About

Build a Vue 3 + TypeScript dev environment with Vite

Production grade DX for all your web projects.

📅Published22 August 2021Last updatedJan 2023
🏷️
frontendtailwindcsstypescriptvitevue 3

Table of contents

Introduction

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:

  • Prettier
  • Husky
  • ESLint + styleLint + commitlint
  • Maybe Tailwind CSS 😉

And more…

Here are a couple of cheat sheets I wrote for Vue 3 + TypeScript:

Prerequisites

Let’s get started

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

Basic project configuration

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.

common node types

To be able to use __dirname or import * as path from 'path':

pnpm add -D @types/node

Typed ENV vars

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.

alias @ to src

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.

Tools to elevate your DX

These dependencies aims to set up an advanced DX for web development.

prettier + Tailwind CSS plugin

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.

ESLint

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.

Stylelint

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}",
    // ...

commitlint

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.

husky

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.

Common project dependencies

These are some really common dependencies that you’ll see basically in every project.

vue-router

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>

Pinia

Add Pinia to the project:

pnpm add pinia

Read more about Pinia over here.

HEAD management

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>

Extras

Setup Tailwind CSS

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à!

More intellisense

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
  ],
}

Vitest

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'

On window.matchMedia

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. 😎

On mocking Axios

In your test/setup.ts add:

vi.mock('axios', () => {
  return {
    default: {
      create() {
        return {
          get() {
            return Promise.resolve({})
          },
          post() {
            return Promise.resolve({})
          }
        }
      }
    }
  }
})

Other recommended packages/libraries

VueUse

The swiss army knife for Vue 3.
A collection of essential Vue composition utilities.

pnpm add @vueuse/core

Lodash

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

Vuelidate

A simple, but powerful, lightweight model-based validation for Vue.

pnpm add @vuelidate/core @vuelidate/validators

Axios

Promise based HTTP client for the browser and node.js

pnpm add axios

Big.js

A small, fast JavaScript library for arbitrary-precision decimal arithmetic.

pnpm add big.js
pnpm add -D @types/big.js

Histoire

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

🎉

References


Got comments or feedback?
Follow me on
v-57f6bb4