从 0 到 1 搭建 Vue3+Vite 工程化项目:含路由、状态管理、按需引入

目标
- 快速脚手架:TypeScript + Vite + Vue3 + Pinia + Vue Router
- 工程化:ESLint/Prettier、Husky/lint-staged、环境变量、按需引入与自动导入
- 质量与性能:Vitest 单测、构建分包与路由懒加载
初始化与目录结构
bash
# 初始化(推荐 pnpm)
pnpm create vite@latest my-app -- --template vue-ts
cd my-app
pnpm i
# 工程化依赖
pnpm i -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-vue prettier eslint-config-prettier eslint-plugin-prettier
pnpm i -D husky lint-staged
pnpm i pinia vue-router
pnpm i -D unplugin-auto-import unplugin-vue-components @element-plus/icons-vue
pnpm i element-plus
pnpm i -D vitest @vitest/coverage-v8 jsdom
目录建议:
my-app/
src/
assets/
components/
pages/
Home.vue
About.vue
stores/
user.ts
router/
index.ts
styles/
index.css
App.vue
main.ts
env.d.ts
index.html
tsconfig.json
vite.config.ts
.eslintrc.cjs
.prettierrc.json
.husky/
package.json
TypeScript 与 ESLint/Prettier
tsconfig.json:
json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"types": ["vite/client", "jsdom"]
}
}
.eslintrc.cjs:
js
module.exports = {
root: true,
env: { browser: true, es2021: true, node: true },
extends: [
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'prettier'
],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
rules: { 'vue/multi-word-component-names': 0 }
}
.prettierrc.json:
json
{ "semi": false, "singleQuote": true, "printWidth": 100 }
Husky 与 lint-staged
bash
npx husky init
package.json:
json
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"lint": "eslint src --ext .ts,.vue",
"format": "prettier --write ."
},
"lint-staged": {
"src/**/*.{ts,vue,css}": ["eslint --fix", "prettier --write"]
}
}
添加钩子:
bash
echo 'npx lint-staged' > .husky/pre-commit
Vite 配置与按需引入
vite.config.ts:
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({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()],
dts: 'src/auto-imports.d.ts'
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts'
})
],
resolve: { alias: { '@': '/src' } },
server: {
proxy: {
'/api': {
target: process.env.VITE_API_BASE || 'http://localhost:3000',
changeOrigin: true,
rewrite: p => p.replace(/^\/api/, '')
}
}
},
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
ui: ['element-plus']
}
}
}
}
})
说明:
- AutoImport/Components 自动导入 API 与组件,Element Plus 按需引入免手配
manualChunks分包:框架与 UI 独立,提高缓存与首屏加载
路由(懒加载)
src/router/index.ts:
ts
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{ path: '/', name: 'home', component: () => import('@/pages/Home.vue') },
{ path: '/about', name: 'about', component: () => import('@/pages/About.vue') },
{ path: '/dashboard', name: 'dashboard', meta: { requiresAuth: true }, component: () => import('@/pages/Dashboard.vue') }
]
export const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to) => {
const user = useUserStore()
if (to.meta.requiresAuth && !user.token) return { name: 'home' }
})
src/main.ts:
ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { router } from '@/router'
import App from './App.vue'
createApp(App).use(createPinia()).use(router).mount('#app')
状态管理(Pinia)
src/stores/user.ts:
ts
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({ name: 'Alice', token: '' }),
getters: { isLogin: (s) => !!s.token },
actions: {
login(name: string) { this.name = name; this.token = 'token-' + Date.now() },
logout() { this.token = '' }
}
})
持久化与会话恢复:
ts
import { watch } from 'vue'
import { useUserStore } from '@/stores/user'
const store = useUserStore()
const saved = localStorage.getItem('user')
if (saved) Object.assign(store, JSON.parse(saved))
watch(() => ({ name: store.name, token: store.token }), (v) => localStorage.setItem('user', JSON.stringify(v)), { deep: true })
页面使用:
vue
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
const user = useUserStore()
</script>
<template>
<div>Hi, {{ user.name }} <el-button @click="user.login('Bob')">Login</el-button></div>
</template>
页面示例
src/pages/Home.vue:
vue
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<h1>Home</h1>
<el-button type="primary" @click="count++">Count: {{ count }}</el-button>
<router-link to="/about">About</router-link>
</template>
src/pages/About.vue:
vue
<template>
<h1>About</h1>
<el-alert title="Vue3 + Vite 工程化" type="success" />
</template>
环境变量与配置
.env.development:
VITE_API_BASE=https://api.dev.example.com
src/env.d.ts:
ts
interface ImportMetaEnv { VITE_API_BASE: string }
interface ImportMeta { readonly env: ImportMetaEnv }
HTTP 封装:
ts
export const API = import.meta.env.VITE_API_BASE
src/services/http.ts:
ts
import axios from 'axios'
import { useUserStore } from '@/stores/user'
export const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000 })
http.interceptors.request.use((config) => {
const user = useUserStore()
if (user.token) config.headers.Authorization = `Bearer ${user.token}`
return config
})
http.interceptors.response.use(
(res) => res.data,
(err) => Promise.reject(err)
)
单元测试(Vitest)
vitest.config.ts(可选):
ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], test: { environment: 'jsdom', coverage: { provider: 'v8' } } })
示例测试:
ts
import { describe, it, expect } from 'vitest'
describe('sum', () => { it('works', () => { expect(1 + 2).toBe(3) }) })
组件与 Store 测试:
ts
import { describe, it, expect } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
describe('user store', () => {
setActivePinia(createPinia())
it('login/logout', () => {
const s = useUserStore()
s.login('Bob')
expect(s.isLogin).toBe(true)
s.logout()
expect(s.isLogin).toBe(false)
})
})
性能与工程建议
- 路由懒加载 +
manualChunks分包 - AutoImport/Components 实现按需与自动导入,减少手工维护
- 统一脚本:
lint/test/build在 CI 中执行,保护主干 - 使用 CDN 加速静态资源,图片与字体采用现代格式
CI(GitHub Actions):
yaml
name: CI
on: { push: { branches: [main] }, pull_request: { branches: [main] } }
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'pnpm' }
- run: corepack enable
- run: pnpm i --frozen-lockfile
- run: pnpm lint
- run: pnpm test -- --coverage
- run: pnpm build
常见问题
- 组件库样式未加载:Element Plus 需全局样式,Vite 自动导入已内置;若需手动样式可引入
import 'element-plus/theme-chalk/src/index.scss' - 类型提示缺失:检查
env.d.ts、AutoImport/Components 的 dts 文件是否生成在src/ - 路由 404:确认
createWebHistory下的部署服务已转发到index.html
排错清单:
- AutoImport 未生效:确认
src/auto-imports.d.ts存在,重启vite后生效 - 代理未转发:校验
server.proxy的rewrite与目标地址,浏览器请求是否命中/api - Pinia 未注入:确认在
main.ts调用了use(createPinia())
文件式路由(import.meta.glob)
ts
// src/router/index.ts(替代手写 routes)
import { createRouter, createWebHistory } from 'vue-router'
const pages = import.meta.glob('@/pages/**/*.vue')
const routes = Object.keys(pages).map((path) => {
const name = path.replace('/src/pages/', '').replace('.vue', '')
return { path: '/' + name.toLowerCase(), name, component: pages[path] }
})
export const router = createRouter({ history: createWebHistory(), routes })
说明:无需手工维护 routes,新增页面即自动加入;可为首页与特殊路由保留手写项并合并。
CSS 预处理与主题定制
ts
// vite.config.ts 片段
export default defineConfig({
css: {
preprocessorOptions: {
scss: { additionalData: `@use "@/styles/variables" as *;` }
}
}
})
scss
// src/styles/variables.scss
$primary: #0ea5e9;
Element Plus 主题变量(可选):
scss
// src/styles/ep-theme.scss
@use 'element-plus/theme-chalk/src/common/var.scss' as *;
$colors: (
'primary': (
'base': #0ea5e9
)
);
国际化(vue-i18n)
bash
pnpm i vue-i18n
ts
// src/i18n.ts
import { createI18n } from 'vue-i18n'
export const i18n = createI18n({ legacy: false, locale: 'zh', messages: { zh: { hello: '你好' }, en: { hello: 'Hello' } } })
ts
// src/main.ts
import { i18n } from '@/i18n'
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app')
vue
<template>
<div>{{ $t('hello') }}</div>
<el-select v-model="$i18n.locale"><el-option value="zh" label="中文"/><el-option value="en" label="English"/></el-select>
</template>
组件测试(@vue/test-utils)
bash
pnpm i -D @vue/test-utils
ts
// src/pages/__tests__/Home.test.ts
import { mount } from '@vue/test-utils'
import Home from '../Home.vue'
test('click increments', async () => {
const w = mount(Home)
await w.get('button').trigger('click')
expect(w.html()).toContain('Count: 1')
})
部署:Docker + Nginx(可选)
Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY . .
RUN corepack enable && pnpm i --frozen-lockfile && pnpm build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
nginx
# nginx.conf(history 路由)
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html;
location / { try_files $uri $uri/ /index.html; }
location /api { proxy_pass http://backend:3000; }
}
提交规范与版本治理(可选)
bash
pnpm i -D @commitlint/cli @commitlint/config-conventional
echo "module.exports = { extends: ['@commitlint/config-conventional'] }" > commitlint.config.cjs
echo 'npx commitlint --edit "$1"' > .husky/commit-msg
示例提交信息:feat(router): 文件式路由支持 import.meta.glob
依赖预构建与性能微调
ts
// vite.config.ts 片段
export default defineConfig({
optimizeDeps: { include: ['axios', 'pinia', 'vue-i18n'] },
build: { minify: 'esbuild', sourcemap: false }
})
说明:预构建加快冷启动,生产构建关闭 source map,减小包体;结合路由懒加载优化首屏。
多环境与模式
env
VITE_API_BASE=https://api.staging.example.com
env
VITE_API_BASE=https://api.example.com
ts
const mode = import.meta.env.MODE
const base = import.meta.env.VITE_API_BASE
错误处理与日志上报
ts
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.config.errorHandler = (err) => {
console.error(err)
}
app.mount('#app')
PWA 支持
bash
pnpm i -D vite-plugin-pwa
ts
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [VitePWA({ registerType: 'autoUpdate', manifest: { name: 'App', short_name: 'App' } })]
})
打包分析与预算
bash
pnpm i -D rollup-plugin-visualizer
ts
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
build: { rollupOptions: { plugins: [visualizer({ filename: 'stats.html' })] } }
})
性能预算建议:控制首屏路由包体、第三方依赖体积与图片资源体积,逐路由分析与优化。
动态菜单与权限
ts
import { router } from '@/router'
const menus = router.getRoutes().filter(r => !r.meta?.hidden).map(r => ({ path: r.path, name: r.name }))
组件懒加载
ts
import { defineAsyncComponent } from 'vue'
const AsyncChart = defineAsyncComponent(() => import('@/components/Chart.vue'))
E2E 测试(Playwright)
bash
pnpm i -D @playwright/test
ts
import { test, expect } from '@playwright/test'
test('home counter', async ({ page }) => {
await page.goto('http://localhost:5173')
await page.click('text=Count')
const content = await page.textContent('text=Count:')
expect(content).toContain('1')
})
总结
- 从脚手架到工程化关键组件(路由、状态、按需引入)一套打通
- 通过自动导入与分包优化,降低维护成本、提升首屏与复用效率
- 配合质量与性能守护(Lint、Test、Coverage),项目可进入持续交付与迭代