前端文件架构

前端文件架构

用官方脚手架建好一个 Vue 项目后, 我的第一反应是懵的: 根目录七八个配置文件, src 里一堆 .ts 和 .vue, 到底从哪开始看。
这篇就是把”每个文件是干嘛的、浏览器打开页面后代码按什么顺序跑”捋一遍。要说明的是, 这篇讲的是 Vue 项目, 但我把它排在正式学 Vue 之前: 第一遍看个大概就行, 里面 Vue 相关的语法看不懂完全正常, 学完 Vue 主线再回来看一遍就全通了。
读之前需要工具链篇的 npm、import/export 和 Vite 知识。

整个项目长什么样

官方脚手架 create-vue 生成的项目大致是这个结构:

代码块PLAINTEXT · 16 行收起展开
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 快速上手会正式学。

真正要抓住的是一条线, 也就是这个项目的启动链路:

代码块PLAINTEXT · 1 行收起展开
index.html → src/main.ts → App.vue → 当前 URL 对应的页面组件

下面顺着这条线一步步走。

启动链路: 浏览器打开页面后发生了什么

第一步: index.html, 唯一的 HTML

代码块HTML · 13 行收起展开
<!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 起来。这里也一样, 创建应用实例、装插件、最后挂载, 十行代码把整个应用组装起来:

代码块TYPESCRIPT · 12 行收起展开
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, 根组件

代码块VUE · 20 行收起展开
<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:

代码块TYPESCRIPT · 22 行收起展开
// 这两个函数都来自 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: 全局唯一一份, 各处注入使用。

代码块TYPESCRIPT · 11 行收起展开
// 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 的配置, 脚手架生成的默认版本里主要两件事:

代码块TYPESCRIPT · 16 行收起展开
// '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:

代码块PLAINTEXT · 8 行收起展开
src/
├─ components/    # 所有可复用组件平铺
├─ views/         # 路由页面
├─ router/
├─ stores/
├─ utils/         # 工具函数
├─ App.vue
└─ main.ts

项目和团队变大后, 常见的演化是按功能模块重新组织, 每个模块自带自己的组件、页面、store:

代码块PLAINTEXT · 9 行收起展开
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 目录:

代码块PLAINTEXT · 5 行收起展开
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
部署后刷新子页面 404history 模式需要服务器配合, Vue-Router 篇细讲
依赖装不上Node 版本对不对, 见 Vue/00

这篇第一遍读, 能记住启动链路那条线就够了: index.html 提供空壳, main.ts 组装挂载, App.vue 搭布局, 路由往里填页面。等学完 Vue 主线再回来看, 每个文件就都能对上号了。

延伸阅读