

# 初始化(推荐 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.jsontsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"types": ["vite/client", "jsdom"]
}
}.eslintrc.cjs:
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:
{ "semi": false, "singleQuote": true, "printWidth": 100 }npx husky initpackage.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"]
}
}添加钩子:
echo 'npx lint-staged' > .husky/pre-commitvite.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({
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']
}
}
}
}
})说明:
manualChunks 分包:框架与 UI 独立,提高缓存与首屏加载src/router/index.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:
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')src/stores/user.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 = '' }
}
})持久化与会话恢复:
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 })页面使用:
<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:
<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:
<template>
<h1>About</h1>
<el-alert title="Vue3 + Vite 工程化" type="success" />
</template>.env.development:
VITE_API_BASE=https://api.dev.example.comsrc/env.d.ts:
interface ImportMetaEnv { VITE_API_BASE: string }
interface ImportMeta { readonly env: ImportMetaEnv }HTTP 封装:
export const API = import.meta.env.VITE_API_BASEsrc/services/http.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.config.ts(可选):
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], test: { environment: 'jsdom', coverage: { provider: 'v8' } } })示例测试:
import { describe, it, expect } from 'vitest'
describe('sum', () => { it('works', () => { expect(1 + 2).toBe(3) }) })组件与 Store 测试:
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 分包lint/test/build 在 CI 中执行,保护主干CI(GitHub Actions):
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 buildimport 'element-plus/theme-chalk/src/index.scss'env.d.ts、AutoImport/Components 的 dts 文件是否生成在 src/createWebHistory 下的部署服务已转发到 index.html排错清单:
src/auto-imports.d.ts 存在,重启 vite 后生效server.proxy 的 rewrite 与目标地址,浏览器请求是否命中 /apimain.ts 调用了 use(createPinia())// 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,新增页面即自动加入;可为首页与特殊路由保留手写项并合并。
// vite.config.ts 片段
export default defineConfig({
css: {
preprocessorOptions: {
scss: { additionalData: `@use "@/styles/variables" as *;` }
}
}
})// src/styles/variables.scss
$primary: #0ea5e9;Element Plus 主题变量(可选):
// src/styles/ep-theme.scss
@use 'element-plus/theme-chalk/src/common/var.scss' as *;
$colors: (
'primary': (
'base': #0ea5e9
)
);pnpm i vue-i18n// src/i18n.ts
import { createI18n } from 'vue-i18n'
export const i18n = createI18n({ legacy: false, locale: 'zh', messages: { zh: { hello: '你好' }, en: { hello: 'Hello' } } })// src/main.ts
import { i18n } from '@/i18n'
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app')<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>pnpm i -D @vue/test-utils// 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')
})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.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; }
}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
// vite.config.ts 片段
export default defineConfig({
optimizeDeps: { include: ['axios', 'pinia', 'vue-i18n'] },
build: { minify: 'esbuild', sourcemap: false }
})说明:预构建加快冷启动,生产构建关闭 source map,减小包体;结合路由懒加载优化首屏。
VITE_API_BASE=https://api.staging.example.comVITE_API_BASE=https://api.example.comconst mode = import.meta.env.MODE
const base = import.meta.env.VITE_API_BASEimport { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.config.errorHandler = (err) => {
console.error(err)
}
app.mount('#app')pnpm i -D vite-plugin-pwaimport { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [VitePWA({ registerType: 'autoUpdate', manifest: { name: 'App', short_name: 'App' } })]
})pnpm i -D rollup-plugin-visualizerimport { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
build: { rollupOptions: { plugins: [visualizer({ filename: 'stats.html' })] } }
})性能预算建议:控制首屏路由包体、第三方依赖体积与图片资源体积,逐路由分析与优化。
import { router } from '@/router'
const menus = router.getRoutes().filter(r => !r.meta?.hidden).map(r => ({ path: r.path, name: r.name }))import { defineAsyncComponent } from 'vue'
const AsyncChart = defineAsyncComponent(() => import('@/components/Chart.vue'))pnpm i -D @playwright/testimport { 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')
})