前端文件架构
前端文件架构
用官方脚手架建好一个 Vue 项目后, 我的第一反应是懵的: 根目录七八个配置文件, src 里一堆 .ts 和 .vue, 到底从哪开始看。
这篇就是把”每个文件是干嘛的、浏览器打开页面后代码按什么顺序跑”捋一遍。要说明的是, 这篇讲的是 Vue 项目, 但我把它排在正式学 Vue 之前: 第一遍看个大概就行, 里面 Vue 相关的语法看不懂完全正常, 学完 Vue 主线再回来看一遍就全通了。
读之前需要工具链篇的 npm、import/export 和 Vite 知识。
整个项目长什么样
官方脚手架 create-vue 生成的项目大致是这个结构:
代码块收起展开
my-app/
├── index.html # 浏览器真正打开的唯一 HTML
├── package.json # 依赖清单和脚本, 工具链篇讲过
├── package-lock.json # 依赖版本锁定
├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 配置的入口
├── node_modules/ # npm 装的依赖, 不进 Git
├── public/ # 原样复制的静态文件 (favicon 之类)
└── src/ # 自己写的代码全在这
├── main.ts # JS 入口, 负责组装整个应用
├── App.vue # 根组件
├── assets/ # 全局样式、图片
├── components/ # 可复用的小组件
├── views/ # 页面级组件, 一个文件大致对应一个页面
├── router/ # 路由配置
└── stores/ # 全局共享的数据先解释两个陌生后缀。.ts 是 TypeScript 文件, 工具链篇介绍过: JS 加上类型标注, 看不懂 : string 这类标注就当注释无视, 剩下的是普通 JS。
.vue 是 Vue 的单文件组件, 一个文件里同时装着这个组件的 HTML 模板、JS 逻辑和 CSS 样式, 浏览器本身不认识这种文件, 由 Vite 的 Vue 插件负责编译, 到 Vue3 快速上手会正式学。
真正要抓住的是一条线, 也就是这个项目的启动链路:
代码块收起展开
index.html → src/main.ts → App.vue → 当前 URL 对应的页面组件下面顺着这条线一步步走。
启动链路: 浏览器打开页面后发生了什么
第一步: index.html, 唯一的 HTML
代码块收起展开
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>我的应用</title>
</head>
<body>
<!-- 整个应用唯一的"容器", 内容一开始是空的 -->
<div id="app"></div>
<!-- type="module" 允许用 import, 工具链篇讲过 -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>以前我写页面, HTML 里堆满内容。这里正相反: 整个项目只有这一个 HTML, 而且 body 里几乎是空的, 就一个空 div 加一个 script。用户最终看到的所有内容, 都是 JS 在运行时往 #app 这个空壳里塞出来的。这也是这类应用叫”单页应用”的原因: 页面文件真的只有一个。
开发时 Vite 会实时把 /src/main.ts 转换成浏览器能跑的 JS; 打包上线后, 这行 script 会被替换成指向打包产物的引用。
第二步: main.ts, 应用的启动类
main.ts 的角色很像 Spring Boot 的启动类: main 方法里先把该装配的装配好, 再 run 起来。这里也一样, 创建应用实例、装插件、最后挂载, 十行代码把整个应用组装起来:
代码块收起展开
import './assets/main.css' // 全局样式。JS 里 import CSS 是 Vite 的能力
import { createApp } from 'vue' // 'vue' 是 npm 装的包, createApp 是它导出的函数
import { createPinia } from 'pinia' // pinia: 全局共享数据的库, 后面单独讲
import App from './App.vue' // 根组件, 相对路径, 就是旁边那个文件
import router from './router' // 路由配置, './router' 会找到 router/index.ts
const app = createApp(App) // 以 App 为根组件, 创建一个应用实例
app.use(createPinia()) // 给应用装插件, 有点像往容器里注册组件
app.use(router)
app.mount('#app') // 找到 index.html 里的 <div id="app">, 开始渲染两条规矩: 插件都要装在 mount 之前, 挂载之后再装就不生效了; main.ts 只做组装, 发请求、写页面逻辑都放到组件和 store 里去, 就像不会把 Controller 的业务写进启动类。
第三步: App.vue, 根组件
代码块收起展开
<script setup lang="ts">
// .vue 文件里的 import 写在 script 块里, 规则和普通 JS 文件一样
import { RouterView } from 'vue-router'
import AppHeader from './components/AppHeader.vue'
</script>
<template>
<div class="app-container">
<AppHeader /> <!-- 自己写的导航栏组件 -->
<main>
<RouterView /> <!-- 占位符: 当前路由对应的页面渲染在这 -->
</main>
</div>
</template>
<style scoped>
.app-container {
min-height: 100vh;
}
</style>一个 .vue 文件三段结构: script 放逻辑, template 放 HTML 结构, style 放样式。
script 标签上的 lang="ts" 表示这一块用 TypeScript 写 (脚手架默认就给 TS 模板); <script setup> 是 Vue 3 的简写语法, 它是怎么从老写法一步步演化来的, 我记在 Vue3 两种写法对比; style 上的 scoped 表示这些样式只对当前组件生效, 不会泄漏出去影响别的组件。
App.vue 作为根组件, 通常只放全局都在的东西: 导航栏、页脚、全局布局。具体每个页面长什么样, 它不管, 只留一个 <RouterView /> 占位, 由路由决定往里放什么。
第四步: 路由决定显示哪个页面
“路由”第一次出现, 先用人话说清楚。传统网站里, 每个 URL 对应服务器上的一个页面, 点一个链接, 浏览器整页刷新去要新页面。
而这类 Vue 项目整个应用只有 index.html 一个页面, 是 JS 根据当前 URL 决定把哪个组件渲染进 <RouterView />, 点链接只换内容不刷新页面, 同时 URL 照样会变, 刷新、收藏、后退也都要正常。
这套 “URL 对应显示哪个组件” 的映射就叫前端路由。写过 Spring 的话可以这么想: 有点像 @RequestMapping 把 URL 映射到 Controller 方法, 只是这里映射的目标从方法换成了组件, 而且发生在浏览器里。
路由的配置集中在 router/index.ts:
代码块收起展开
// 这两个函数都来自 vue-router 包 (npm 装的, Vue 官方的路由库)
import { createRouter, createWebHistory } from 'vue-router'
// @ 是路径别名, 代表 src 目录, 在 vite.config.ts 里配置 (后面配置文件一节讲)
import HomeView from '@/views/HomeView.vue'
const router = createRouter({
// history 模式: 让 URL 长得和普通网址一样, 没有 # 号
// import.meta.env 是 Vite 注入的环境变量, BASE_URL 默认就是 '/'
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// 访问 / 时, RouterView 里渲染 HomeView 组件
{ path: '/', name: 'home', component: HomeView },
{
path: '/about',
name: 'about',
// 懒加载写法: 用户真的访问 /about 时才去加载这个组件的代码
component: () => import('@/views/AboutView.vue')
}
]
})
export default router // 默认导出, main.ts 里 import router 拿到的就是它routes 数组就是路由表, views 目录里的组件基本都在这里登记。路由的完整原理、跳转拦截、登录鉴权这些, 在 Vue-Router 篇专门展开。
第五步: stores, 全局共享的数据
多个页面经常要共享同一份数据, 最典型的是当前登录用户: 导航栏要显示昵称, 个人中心页要显示详情。这种数据在组件之间传来传去太麻烦, 就集中放进一个全局仓库, 谁需要谁来取。管这个仓库的库叫 Pinia, store 的感觉很像 Spring 容器里的单例 Bean: 全局唯一一份, 各处注入使用。
代码块收起展开
// stores/counter.ts
import { ref, computed } from 'vue' // Vue 的响应式 API, 01 篇主讲, 先看个眼熟
import { defineStore } from 'pinia' // defineStore: pinia 包导出的"定义一个仓库"函数
export const useCounterStore = defineStore('counter', () => {
const count = ref(0) // 共享的数据
const doubleCount = computed(() => count.value * 2) // 由数据算出来的值
function increment() { count.value++ } // 修改数据的方法
return { count, doubleCount, increment }
})ref 和 computed 看不懂没关系, 它们是 Vue 响应式系统的核心, Vue3 核心与响应式整篇讲它; store 本身在 Pinia 篇展开。
这里知道 stores 目录放的是”全局共享数据”就够了。
根目录那堆配置文件
package.json、package-lock.json、node_modules 这三样工具链篇讲过: 依赖清单、版本锁、装好的依赖本体。
vite.config.ts 是 Vite 的配置, 脚手架生成的默认版本里主要两件事:
代码块收起展开
// 'node:url' 是 Node 自带的模块, 不用 npm 装, node: 前缀说的就是这一点
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' // 教 Vite 认识 .vue 文件的插件
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
// 配置 @ 别名指向 src 目录
// 于是 import xx from '@/views/xx.vue' 不用写一长串 ../../
// 下面这行的写法看不懂可以不管, 作用就是算出 src 的绝对路径
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})tsconfig 我一开始想不通: 一个项目为什么要三个? 后来明白了, tsconfig.json 只是个入口, 把配置拆给两个场景: tsconfig.app.json 管 src 里跑在浏览器的代码, tsconfig.node.json 管 vite.config.ts 这类跑在 Node 里的工具代码, 两边的环境和可用 API 不一样, 所以分开配。
有一个实际的坑: @ 别名要在 vite.config.ts 和 tsconfig.app.json 两边同时配, 只配一边就会出现”编辑器能跳转但构建报错”或者反过来的诡异现象。
哪些文件进 Git
规则一句话: 自己写的和手工配置的都提交, 机器能重新生成的都不提交。所以 src、public、index.html 和所有配置文件进仓库, package-lock.json 也要进 (它保证队友装出来的依赖版本和我完全一致); node_modules 和打包产物 dist 写进 .gitignore, 前者 npm install 随时重建, 后者 npm run build 随时重出。
项目变大后, src 怎么演化
脚手架给的结构适合小项目, 组件平铺在 components, 页面平铺在 views:
代码块收起展开
src/
├─ components/ # 所有可复用组件平铺
├─ views/ # 路由页面
├─ router/
├─ stores/
├─ utils/ # 工具函数
├─ App.vue
└─ main.ts项目和团队变大后, 常见的演化是按功能模块重新组织, 每个模块自带自己的组件、页面、store:
代码块收起展开
src/
├─ features/
│ ├─ auth/ # 登录注册模块
│ ├─ dashboard/ # 仪表盘模块
│ └─ orders/ # 订单模块, 内部各有 components/views/stores
├─ shared/ # 跨模块共享的组件和工具
├─ router/
├─ App.vue
└─ main.ts这个取舍后端也熟: 按技术分层 (controller/service/dao 一人一个包) 还是按业务模块分包, 是同一个问题。小项目按类型平铺简单直接, 大项目按业务分模块才好维护。
开发模式和打包后的区别
npm run dev 时, Vite 不打包, 浏览器要哪个文件就现场转换哪个, 所以启动秒开, 改代码立即生效。此时跑在 localhost:5173, 只存在于我的电脑上。
npm run build 才是给用户的版本, 产出一个 dist 目录:
代码块收起展开
dist/
├─ index.html # script 引用已替换成下面的打包文件
└─ assets/
├─ index-a1b2c3.js # 所有 JS 压缩合并后的产物
└─ index-d4e5f6.css文件名里那串乱码是内容 hash: 文件内容变了, 名字才变。这是为了缓存, 浏览器可以放心地永久缓存这些文件, 反正内容一更新文件名就不同, 自然会重新下载。dist 里是纯静态文件, 不需要 Node 环境, 扔到 nginx 或任何静态托管上就能跑。
src/assets 和 public 的分工在这里也能看出来: assets 里的文件会参与打包, 被压缩、改名加 hash; public 里的文件原样拷贝进 dist, 名字不变, 所以需要固定 URL 的文件 (比如 favicon.ico) 才放 public, 平时的图片和样式都放 assets。
出问题先查哪
新项目跑不起来时, 我按这张表定位:
| 现象 | 先看哪里 |
|---|---|
| 白屏 | F12 Console 的报错, 以及 main.ts 的 import 是否写错 |
@/ 路径找不到 | vite.config.ts 和 tsconfig.app.json 是否两边都配了别名 |
| 样式影响到别的组件 | 那个组件的 style 是不是少了 scoped |
| 部署后刷新子页面 404 | history 模式需要服务器配合, Vue-Router 篇细讲 |
| 依赖装不上 | Node 版本对不对, 见 Vue/00 |
这篇第一遍读, 能记住启动链路那条线就够了: index.html 提供空壳, main.ts 组装挂载, App.vue 搭布局, 路由往里填页面。等学完 Vue 主线再回来看, 每个文件就都能对上号了。