用 Vite+(vp)从零搭建 Vue3 + TypeScript + Element Plus + Pinia + Vue Router

使用 Vite+ 统一工具链(vp)一条命令搭建 Vue3 全家桶,涵盖按需导入、Pinia store、路由配置,以及常见坑的解决方案。

$2.0k 字/约 13 min👁— views

什么是 Vite+(vp)

Vite+ 是 VoidZero 推出的统一 Web 工具链,把 Vite、Vitest、Oxlint、Rolldown、tsdown 集成到一个 vp 命令里。Node.js 版本管理、包管理器检测、dev/build/test 全部通过 vp 统一入口,不需要分别装多个工具。

# 安装(Linux / macOS)
curl -fsSL https://vite.plus | bash

# 验证
vp --version

1. 创建项目

vp create vue 底层调用 create-vue,支持交互式选项,一次性把 TypeScript、Pinia、Vue Router 全勾上:

# 交互式(推荐,会问你要不要 TS/Router/Pinia 等)
vp create vue

# 非交互式(全部选 yes,直接得到完整骨架)
vp create vue -- --ts --router --pinia --eslint

进入项目目录,安装依赖:

cd my-app
npm install    # 或 pnpm install / yarn

2. 安装 Element Plus

npm install element-plus

完整引入(简单但体积大)

main.ts 中:

import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'

const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')

按需引入(推荐:自动导入,零配置)

安装 unplugin-vue-componentsunplugin-auto-import

npm install -D unplugin-vue-components unplugin-auto-import

修改 vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
      // 自动导入 Vue 相关函数:ref, reactive, computed 等
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/auto-imports.d.ts',
    }),
    Components({
      resolvers: [ElementPlusResolver()],
      dts: 'src/components.d.ts',
    }),
  ],
})

配置后,在模板中直接使用 Element Plus 组件,无需 import:

<template>
  <el-button type="primary" @click="handleClick">点击</el-button>
  <el-input v-model="inputVal" placeholder="请输入" />
</template>

<script setup lang="ts">
// ref、reactive 等也自动导入,无需手写 import
const inputVal = ref('')
const handleClick = () => {
  ElMessage.success('操作成功')
}
</script>

3. 完整的 vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/auto-imports.d.ts',
      eslintrc: {
        enabled: true,     // 生成 ESLint 配置,避免 no-undef 警告
        filepath: '.eslintrc-auto-import.json',
      },
    }),
    Components({
      resolvers: [ElementPlusResolver()],
      dts: 'src/components.d.ts',
    }),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  server: {
    port: 3000,
    open: true,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
  build: {
    outDir: 'dist',
    sourcemap: false,
    rollupOptions: {
      output: {
        // 代码分割:第三方库单独打包
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'element-plus': ['element-plus'],
        },
      },
    },
  },
})

4. Pinia Store 完整示例

定义 Store

// src/stores/user.ts
import { defineStore } from 'pinia'

interface UserInfo {
  id: number
  name: string
  email: string
  avatar: string
  roles: string[]
}

interface UserState {
  userInfo: UserInfo | null
  token: string
  isLoading: boolean
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    userInfo: null,
    token: localStorage.getItem('token') || '',
    isLoading: false,
  }),

  getters: {
    isLoggedIn: (state) => !!state.token,
    userName: (state) => state.userInfo?.name ?? '未登录',
    hasRole: (state) => (role: string) => state.userInfo?.roles.includes(role) ?? false,
  },

  actions: {
    async login(username: string, password: string) {
      this.isLoading = true
      try {
        // 调用登录 API
        const response = await fetch('/api/auth/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ username, password }),
        })
        const data = await response.json()
        this.token = data.token
        this.userInfo = data.userInfo
        localStorage.setItem('token', data.token)
      } finally {
        this.isLoading = false
      }
    },

    logout() {
      this.token = ''
      this.userInfo = null
      localStorage.removeItem('token')
    },

    async fetchUserInfo() {
      if (!this.token) return
      const response = await fetch('/api/user/info', {
        headers: { Authorization: `Bearer ${this.token}` },
      })
      this.userInfo = await response.json()
    },
  },
})

在组件中使用(storeToRefs)

<template>
  <div>
    <p v-if="isLoggedIn">欢迎,{{ userName }}</p>
    <el-button v-else @click="handleLogin" :loading="isLoading">
      登录
    </el-button>
    <el-button v-if="isLoggedIn" @click="handleLogout">退出</el-button>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// storeToRefs:将 state 和 getter 解构为响应式 ref
// 注意:actions 不能用 storeToRefs,直接从 store 解构
const { isLoggedIn, userName, isLoading } = storeToRefs(userStore)
const { login, logout } = userStore

const handleLogin = async () => {
  await login('admin', 'password123')
  ElMessage.success('登录成功')
}

const handleLogout = () => {
  logout()
  ElMessage.info('已退出登录')
}
</script>

Composition API 风格的 Store(推荐)

// src/stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  const name = ref('Counter')

  // getters(computed)
  const doubleCount = computed(() => count.value * 2)

  // actions
  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  async function incrementAsync() {
    await new Promise((resolve) => setTimeout(resolve, 500))
    count.value++
  }

  return { count, name, doubleCount, increment, decrement, incrementAsync }
})

5. Vue Router 4 配置

路由文件

// src/router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/user'

// 懒加载路由(推荐:只在访问时才加载对应 chunk)
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Layout',
    component: () => import('@/layouts/DefaultLayout.vue'),
    children: [
      {
        path: '',
        name: 'Home',
        component: () => import('@/views/HomeView.vue'),
        meta: { title: '首页', requiresAuth: false },
      },
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/DashboardView.vue'),
        meta: { title: '仪表盘', requiresAuth: true },
      },
      {
        path: 'users',
        name: 'Users',
        component: () => import('@/views/UsersView.vue'),
        meta: { title: '用户管理', requiresAuth: true, roles: ['admin'] },
      },
    ],
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/LoginView.vue'),
    meta: { title: '登录', requiresAuth: false },
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/NotFound.vue'),
  },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    }
    return { top: 0 }
  },
})

// 全局路由守卫
router.beforeEach(async (to, from, next) => {
  // 设置页面标题
  document.title = to.meta.title ? `${to.meta.title} - My App` : 'My App'

  const userStore = useUserStore()

  // 需要登录的页面
  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    next({ name: 'Login', query: { redirect: to.fullPath } })
    return
  }

  // 已登录不能访问登录页
  if (to.name === 'Login' && userStore.isLoggedIn) {
    next({ name: 'Dashboard' })
    return
  }

  next()
})

export default router

6. TypeScript 类型扩充

扩充路由 meta 类型

// src/types/router.d.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean
    roles?: string[]
    keepAlive?: boolean
    icon?: string
  }
}

扩充全局属性

// src/types/global.d.ts

// 扩充 window 对象
declare global {
  interface Window {
    __APP_CONFIG__: {
      apiBaseUrl: string
      version: string
    }
  }
}

// 给 Vue 组件实例添加全局属性
declare module 'vue' {
  interface ComponentCustomProperties {
    $filters: {
      formatDate: (date: Date) => string
      formatMoney: (amount: number) => string
    }
  }
}

export {}

环境变量类型

// src/types/env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_APP_TITLE: string
  readonly VITE_API_BASE_URL: string
  readonly VITE_APP_ENV: 'development' | 'staging' | 'production'
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

7. 项目目录结构最佳实践

my-app/
├── public/                   # 静态资源(不经过 Vite 处理)
│   └── favicon.ico
├── src/
│   ├── api/                  # API 请求层
│   │   ├── http.ts           # axios 实例配置
│   │   ├── user.ts           # 用户相关 API
│   │   └── index.ts          # 统一导出
│   ├── assets/               # 静态资源(经 Vite 处理)
│   │   ├── images/
│   │   └── styles/
│   │       ├── variables.scss
│   │       └── global.scss
│   ├── components/           # 通用组件
│   │   ├── common/           # 公共基础组件
│   │   └── business/         # 业务组件
│   ├── composables/          # 组合式函数(hooks)
│   │   ├── useRequest.ts
│   │   └── useTheme.ts
│   ├── layouts/              # 布局组件
│   │   ├── DefaultLayout.vue
│   │   └── AuthLayout.vue
│   ├── router/
│   │   └── index.ts
│   ├── stores/               # Pinia stores
│   │   ├── user.ts
│   │   ├── app.ts
│   │   └── index.ts
│   ├── types/                # TypeScript 类型定义
│   │   ├── api.d.ts
│   │   ├── router.d.ts
│   │   ├── env.d.ts
│   │   └── global.d.ts
│   ├── utils/                # 工具函数
│   │   ├── format.ts
│   │   └── storage.ts
│   ├── views/                # 页面组件
│   │   ├── HomeView.vue
│   │   ├── DashboardView.vue
│   │   └── LoginView.vue
│   ├── App.vue
│   ├── main.ts
│   ├── auto-imports.d.ts     # 自动生成(unplugin-auto-import)
│   └── components.d.ts       # 自动生成(unplugin-vue-components)
├── .env                      # 环境变量
├── .env.development
├── .env.production
├── vite.config.ts
├── tsconfig.json
└── package.json

8. 常见报错和解决方案

报错 1:找不到模块 ‘element-plus’

Cannot find module 'element-plus' or its corresponding type declarations.

解决:确保安装了 element-plus 和类型声明:

npm install element-plus

Element Plus 自带 TypeScript 类型,不需要单独安装 @types。

报错 2:ElMessage 未定义

使用 unplugin-auto-import 后,ElMessageElMessageBox 等应该自动导入,但有时需要重启 TS 服务:

# 在 VSCode 中:Ctrl+Shift+P → TypeScript: Restart TS Server

或者手动在需要的文件中引入:

import { ElMessage } from 'element-plus'

报错 3:Pinia 报错 “getActivePinia was called with no active Pinia”

在 router.beforeEach 中使用 store 时,需要确保 Pinia 已经在 createApp 时挂载:

// main.ts - 顺序很重要
const app = createApp(App)
app.use(createPinia())   // 必须在 router 之前
app.use(router)
app.mount('#app')

报错 4:路由懒加载在开发模式下很慢

这是正常现象,Vite 在开发模式下按需编译。生产构建后每个路由会生成独立 chunk,加载速度正常。

报错 5:storeToRefs 导致响应式丢失

直接解构 store(不使用 storeToRefs)会导致响应式丢失:

// ❌ 错误:这样 count 不是响应式的
const { count } = useCounterStore()

// ✅ 正确:使用 storeToRefs
const { count } = storeToRefs(useCounterStore())

注意:actions 不能用 storeToRefs,直接解构即可:

const store = useCounterStore()
const { count } = storeToRefs(store)    // state/getter 用 storeToRefs
const { increment } = store              // action 直接解构

报错 6:TypeScript 报错 “模块 ‘@/xxx’ 无法找到”

检查 tsconfig.json 是否配置了路径别名:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

vite.config.ts 中的 resolve.aliastsconfig.json 中的 paths 需要同步配置。


总结

这套组合(Vite + Vue3 + TypeScript + Element Plus + Pinia + Vue Router)是 2024 年 Vue 生态最主流的选型:

  • 开发体验:Vite 的 HMR 极快,TypeScript 提供类型安全
  • UI:Element Plus 组件丰富,按需引入零配置
  • 状态管理:Pinia 比 Vuex 更简洁,完整的 TypeScript 支持
  • 路由:Vue Router 4 的懒加载和路由守卫满足企业级需求

按照本文的配置搭建,可以得到一个生产可用的前端项目骨架。