刚接手一个Vue3项目时,我常常会对着src目录里十几个文件夹发懵——这些文件到底该怎么组织才合理?经过三年多的Vue3实战,我总结出一套经过大型项目验证的目录结构方案。与Vue2时代不同,Vue3的Composition API和TypeScript深度集成带来了全新的代码组织思路。
现代Vue3项目的典型结构是这样的:
code复制src/
├── assets/ # 静态资源
├── components/ # 公共组件
│ ├── ui/ # 基础UI组件
│ └── business/ # 业务组件
├── composables/ # 组合式函数
├── stores/ # 状态管理
├── router/ # 路由配置
├── utils/ # 工具函数
├── views/ # 页面级组件
├── types/ # TS类型定义
└── main.ts # 应用入口
这种结构最显著的特点是按功能而非文件类型划分。在Vue2时代常见的按components/views分目录的方式,现在被更细粒度的职责划分取代。比如composables目录专门存放use开头的组合式函数,stores目录集中管理Pinia状态模块。
关键经验:避免在components目录下直接放置大量平级组件。建议至少划分ui(基础组件)和business(业务组件)两个子目录,当组件超过20个时再按业务域细分。
在电商项目中,我采用Brad Frost的原子设计理论重构了组件体系:
code复制components/
├── atoms/ # 按钮/输入框等基础原子
│ ├── AppButton.vue
│ └── AppInput.vue
├── molecules/ # 表单组合等分子组件
│ ├── SearchForm.vue
│ └── FilterPanel.vue
├── organisms/ # 商品卡片等有机体
│ └── ProductCard.vue
└── templates/ # 页面骨架模板
└── ProductPage.vue
这种分层使得组件复用率提升了40%。比如一个AppButton原子组件,既可以被SearchForm分子组件使用,也能直接用在页面级模板中。
composables目录是Vue3的特色所在。我制定了三条编写规范:
典型的useFetch实现:
typescript复制export default function useFetch<T>(url: string) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const fetchData = async () => {
try {
const response = await axios.get(url)
data.value = response.data
} catch (err) {
error.value = err as Error
}
}
return {
data,
error,
fetchData
}
}
踩坑记录:避免在组合式函数中直接修改DOM,这会导致逻辑与视图耦合。曾经有个useModal函数因为直接操作DOM,导致在SSR环境崩溃。
从基础类型到复杂泛型,Props的类型定义经历了三个阶段:
typescript复制defineProps({
title: String,
count: Number
})
typescript复制interface Props {
title: string
items: Array<{
id: number
name: string
}>
}
defineProps<Props>()
typescript复制interface ListItem {
id: number
name: string
}
defineProps<{
data: T[]
renderItem: (item: T) => VNode
}>()
通过unplugin-auto-import插件,可以实现API的自动导入和类型推导。在vite.config.ts中配置:
typescript复制AutoImport({
imports: [
'vue',
'vue-router',
{
'@vueuse/core': [
'useMouse',
'useLocalStorage'
]
}
],
dts: 'src/types/auto-imports.d.ts'
})
这能减少30%的手动导入语句,同时保持完整的类型支持。曾经有个项目因为漏导入ref导致生产环境报错,引入自动导入后彻底解决了这类问题。
stores目录采用领域模型划分:
code复制stores/
├── cart.store.ts # 购物车领域
├── user.store.ts # 用户领域
└── product.store.ts # 商品领域
每个store遵循相同模式:
typescript复制export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[],
}),
actions: {
async addItem(product: Product) {
// 业务逻辑
}
},
getters: {
totalPrice(): number {
return this.items.reduce(...)
}
}
})
性能技巧:当state对象较大时,使用pinia-plugin-persistedstate实现自动持久化,比手动localStorage操作性能更好。
通过组合式函数封装跨store逻辑:
typescript复制export function useCheckout() {
const cart = useCartStore()
const user = useUserStore()
const canCheckout = computed(() => {
return cart.items.length > 0 && user.isLoggedIn
})
return { canCheckout }
}
这种方式比直接在store之间互相引用更清晰。曾经有个项目因为store循环引用导致内存泄漏,改用这种模式后问题消失。
使用vite-plugin-pages实现路由自动注册:
typescript复制import routes from 'pages-generated'
const router = createRouter({
history: createWebHistory(),
routes
})
文件结构约定:
code复制views/
├── index.vue # /
├── users/
│ ├── index.vue # /users
│ └── [id].vue # /users/:id
└── about.vue # /about
通过扩展RouteMeta接口增强类型检查:
typescript复制declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
permissions?: string[]
}
}
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return '/login'
}
})
在大型后台系统中,这种模式能提前发现权限配置错误。有次上线前通过类型检查发现了缺失的权限声明,避免了线上事故。
通过defineAsyncComponent实现路由级和组件级懒加载:
typescript复制const HeavyComponent = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: LoadingSpinner,
delay: 200
})
配合webpack魔法注释实现预加载:
typescript复制const LoginModal = () => import(
/* webpackPrefetch: true */
'./LoginModal.vue'
)
实测可使首屏加载时间减少40%。但要注意懒加载过度会导致交互延迟,关键组件应直接打包到主bundle。
使用rollup-plugin-visualizer分析包构成:
typescript复制import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
visualizer({
open: true,
gzipSize: true
})
]
})
通过这个工具发现某个日期库占了整体体积的15%,替换为dayjs后包大小减少30%。可视化分析是性能优化的第一步。
推荐配置组合:
javascript复制// .eslintrc.js
module.exports = {
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'@vue/typescript/recommended'
],
rules: {
'vue/multi-word-component-names': 'off'
}
}
// .prettierrc
{
"semi": false,
"singleQuote": true,
"printWidth": 100
}
通过lint-staged实现提交前检查:
json复制{
"lint-staged": {
"*.{js,ts,vue}": ["eslint --fix", "prettier --write"]
}
}
使用commitizen + commitlint规范提交信息:
bash复制npm install -g commitizen
commitizen init cz-conventional-changelog --save-dev --save-exact
配置commitlint规则:
javascript复制module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore']
]
}
}
这种规范使得changelog自动生成成为可能。在排查一次线上bug时,通过规范的提交信息快速定位到了问题提交。
code复制tests/
├── unit/
│ ├── composables/
│ ├── components/
│ └── stores/
├── e2e/
└── __mocks__/
使用Vitest测试组合式函数:
typescript复制import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('increments count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
})
组件测试的黄金法则是:只测试行为,不测试实现细节。曾经有测试因为依赖DOM结构导致频繁失败,改为只测试emit事件后变得稳定。
使用Testing Library编写可持续的组件测试:
typescript复制import { render, fireEvent } from '@testing-library/vue'
test('emits submit event', async () => {
const { emitted, getByText } = render(SubmitButton)
await fireEvent.click(getByText('Submit'))
expect(emitted().submit).toBeTruthy()
})
这种测试方式不会因为CSS类名变化而失败。在重构一个复杂表单时,这种测试方法节省了80%的测试维护成本。
使用vite-plugin-i18n实现按需加载语言包:
typescript复制// vite.config.ts
import VueI18nPlugin from 'vite-plugin-vue-i18n'
export default defineConfig({
plugins: [
VueI18nPlugin({
include: path.resolve(__dirname, 'src/locales/**')
})
]
})
语言文件分模块组织:
code复制locales/
├── en/
│ ├── common.json
│ └── product.json
└── zh/
├── common.json
└── product.json
动态导入语言包避免首屏加载所有语言:
typescript复制const messages = await import(`../locales/${lang}/product.json`)
在根组件注入CSS变量:
vue复制<script setup>
const theme = reactive({
primary: '#409eff',
danger: '#f56c6c'
})
watch(theme, () => {
Object.entries(theme).forEach(([key, value]) => {
document.documentElement.style.setProperty(`--color-${key}`, value)
})
})
</script>
<style>
.button {
background-color: var(--color-primary);
}
</style>
这种方案比CSS预处理器更灵活。在实现企业定制主题需求时,仅需修改几行JavaScript代码即可切换整套配色。
创建ErrorBoundary组件捕获子组件错误:
vue复制<template>
<slot v-if="!hasError" />
<div v-else class="error">
<h3>Something went wrong</h3>
<button @click="reset">Retry</button>
</div>
</template>
<script setup>
const hasError = ref(false)
const error = ref(null)
const reset = () => {
hasError.value = false
}
onErrorCaptured((err) => {
error.value = err
hasError.value = true
return false // 阻止错误继续向上传播
})
</script>
集成Sentry进行生产环境错误追踪:
typescript复制import * as Sentry from '@sentry/vue'
app.use(Sentry, {
dsn: 'your_dsn',
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router)
})
],
tracesSampleRate: 0.2
})
配置全局错误处理器:
typescript复制app.config.errorHandler = (err) => {
Sentry.captureException(err)
console.error(err)
}
通过这种配置,我们发现了用户环境中一个罕见的Safari兼容性问题,该问题在测试阶段未被发现。
使用vue-virtual-scroller处理大数据列表:
vue复制<template>
<RecycleScroller
:items="largeList"
:item-size="56"
key-field="id"
>
<template #default="{ item }">
<div class="item">{{ item.name }}</div>
</template>
</RecycleScroller>
</template>
在渲染10000条数据时,常规方案会导致页面卡死,而虚拟列表保持60fps流畅滚动。但要注意虚拟列表不适合高度不固定的项目。
使用Intersection Observer实现高性能懒加载:
typescript复制const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src
observer.unobserve(img)
}
})
})
onMounted(() => {
document.querySelectorAll('.lazy-img').forEach(img => {
observer.observe(img)
})
})
相比监听scroll事件,这种方案性能更好。在图片画廊项目中,首屏加载时间减少了65%。
typescript复制import DOMPurify from 'dompurify'
const cleanHtml = DOMPurify.sanitize(userInput)
vue复制<!-- 错误用法 -->
<div v-html="userContent"></div>
<!-- 正确用法 -->
<div v-text="userContent"></div>
axios默认配置:
typescript复制axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
配合后端Django的CSRF中间件:
python复制MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware'
]
这种配置下,所有修改状态的请求都会自动携带CSRF令牌。曾经有个表单因为遗漏CSRF保护导致遭受攻击,引入这套方案后彻底解决问题。
在入口文件设置基准字体大小:
typescript复制const setRem = () => {
const docEl = document.documentElement
const width = docEl.clientWidth
docEl.style.fontSize = width / 7.5 + 'px'
}
window.addEventListener('resize', setRem)
setRem()
在CSS中使用REM单位:
css复制.container {
padding: 0.4rem;
font-size: 0.28rem;
}
这种方案比媒体查询更灵活。在适配各种手机屏幕时,只需调整这一个JavaScript函数即可。
使用伪元素+transform实现真1px边框:
css复制.border-1px {
position: relative;
}
.border-1px::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 1px;
background: #eee;
transform: scaleY(0.5);
transform-origin: 0 0;
}
在Retina屏幕上,常规1px会显示为2物理像素。这个方案通过缩放实现了真正的1物理像素边框,视觉体验更精细。
使用useAsyncData组合式函数:
typescript复制export async function useAsyncData(key: string, handler: () => Promise<any>) {
const nuxtApp = useNuxtApp()
if (process.server) {
const data = await handler()
nuxtApp.payload.data[key] = data
return data
}
return nuxtApp.payload.data[key]
}
在页面组件中使用:
vue复制<script setup>
const { data } = await useAsyncData('products', () => {
return $fetch('/api/products')
})
</script>
这种模式确保SSR和CSR使用相同的数据获取逻辑。曾经因为两套逻辑不一致导致页面闪烁,统一后问题消失。
创建useHydration组合式函数处理注水数据:
typescript复制export function useHydration<T>(key: string, fallback: T) {
const data = ref<T>(fallback)
onMounted(() => {
if (window.__INITIAL_STATE__?.[key]) {
data.value = window.__INITIAL_STATE__[key]
}
})
return { data }
}
在entry-client.ts中初始化:
typescript复制const app = createApp(App)
app.mixin({
beforeMount() {
this.$root.$data = window.__INITIAL_STATE__
}
})
这套方案解决了SSR数据在客户端初始化时的类型安全问题。在TypeScript项目中,类型能完美地从服务端延续到客户端。
vite.config.ts配置示例:
typescript复制import { defineConfig } from 'vite'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
federation({
name: 'host-app',
remotes: {
remoteApp: 'http://localhost:5001/assets/remoteEntry.js'
},
shared: ['vue', 'pinia']
})
]
})
远程模块的暴露配置:
typescript复制federation({
name: 'remote-app',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button.vue'
},
shared: ['vue']
})
使用Shadow DOM实现严格隔离:
typescript复制const shadowRoot = element.attachShadow({ mode: 'open' })
const style = document.createElement('style')
style.textContent = `
button { background: red; }
`
shadowRoot.appendChild(style)
对于CSS Modules方案:
vue复制<template>
<div :class="$style.container">
<remote-component />
</div>
</template>
<style module>
.container {
/* 局部样式 */
}
</style>
在大型后台系统改造中,这种方案允许不同团队独立开发,同时保持样式不冲突。但要注意Shadow DOM会阻断全局样式继承。