Vue3 + Vite 打造特色美食网站:状态管理、路由懒加载与Nginx部署
简介这是一套基于VUE3打造的特色美食网站源码面向Web前端初学者、课程大作业及毕业设计场景适用于快速搭建美食展示类站点或作为组件参考。压缩包共2000个文件以1622个JavaScript逻辑文件为主配合142个Markdown说明文档、130个JSON配置、96个CSS样式文件整体大小约73.99MB目录结构清晰便于按需检索。目前已有701人浏览学习。项目内置九个模板页面涵盖世界特色美食、国内美食、美食图展、关于我们、登录注册及美食详细信息等模块并集成了轮播图、视频、表单、TAB切换、导航栏、底部栏、列表、图文组合和返回顶部等常用交互效果代码注释完整、书写规范上手简单且可独立运行使用VSCode打开项目并执行npm run dev即可直接预览。1. 简洁的特色美食网站用 Vue3 写比预想中省心拿到一个 Vue3 特色美食网站源码先别翻样式表要看三处数据放在哪、筛选逻辑挂在哪、路由怎么懒加载。这个标题背后是一套内容站的标准骨架——Vite 初始化、组合式 API、Pinia 状态分层、Nginx history 回退每一步都能复用到其它项目。美食站结构克制列表、分类、详情、关于四个页面正好完整演示 Vue3 组合式开发方式又不会被复杂业务干扰。整个项目不需要后端静态数据加前端状态管理就能跑通这是这类展示站最务实的技术选型。下面按环境搭建、数据层、组件层、构建部署推进。新手可以照命令把项目跑起来熟手直接看第 5 章 history 路由的 Nginx 配置和构建产物体积验证。2. 用 Vite 搭 Vue3 美食站骨架环境配置与路由表设计2.1 新项目优先选 Vite理由在构建链而不在炫技Vite 5 基于原生 ESM开发服务器冷启动和热更新都比 webpack 系的 Vue CLI 快一个量级。美食站这种组件规模在几十个的内容站改一行样式等两秒刷新和瞬时热更新体感差别非常明显。更重要的是 Vue CLI 官方已进入维护模式新项目再用它初始化等于一开始就背上一套不再有大版本更新的构建链后续生态适配会慢半拍。Vite 对 Node 版本有硬性要求Vite 5 需要 Node 18Vite 6 需要 Node 18.18实际开发建议直接用 Node 20 LTS。如果本机有多个 Node 版本用 nvm 切到 20 再执行初始化命令否则装依赖时容易出现 engine 不匹配的警告。模板选择上纯 JavaScript 就够用团队如果强制类型安全初始化时把vue换成vue-ts。这个项目的状态管理逻辑密度不高JS 写起来更直接TS 版本适合想顺带练类型推导的人。2.2 初始化命令与依赖安装node -v npm create vitelatest food-site -- --template vue cd food-site npm install npm install vue-router4 pinia npm run dev第一行先确认 Node 版本低于 18 就先升级再继续。第二条命令创建干净的 Vue3 Vite 模板--template vue对应 JavaScript 版本需要 TypeScript 就改成vue-ts。基础依赖装完后单独安装vue-router4和pinia这两个库是 Vue3 项目路由与状态管理的标准选型。美食站没有后端交互不需要再引入 axios 这类请求层数据直接以静态模块形式进 store第 3 章会讲。执行npm run dev后 Vite 默认监听 5173 端口端口被占用会自动顺延到 5174 并在终端打印实际地址。看到Local: http://localhost:5173/就说明骨架已跑通。这一步先做最小验证删掉src/components/HelloWorld.vue在App.vue里只保留一个RouterView /页面能渲染出空白路由出口环境配置就算结束。踩坑点主要在两个方向一是 npm 版本太旧导致 create 命令卡住升级到 npm 9 再试二是公司内网镜像源同步滞后装 pinia 时出现版本解析失败切换到官方源重试即可。2.3 路由表四段式拆分美食站的路由不需要嵌套层级四个视图加一个兜底足够// src/router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: home, component: () import(/views/HomeView.vue), meta: { title: 首页 } }, { path: /foods, name: foods, component: () import(/views/FoodListView.vue), meta: { title: 觅食 } }, { path: /foods/:id, name: food-detail, component: () import(/views/FoodDetailView.vue), meta: { title: 详情 } }, { path: /about, name: about, component: () import(/views/AboutView.vue), meta: { title: 关于本站 } }, // Vue Router 4 的 catch-all未匹配路径重定向到首页 { path: /:pathMatch(.*)*, redirect: /, meta: { title: 首页 } } ] const router createRouter({ history: createWebHistory(), routes, scrollBehavior() { return { top: 0 } } }) router.afterEach((to) { document.title to.meta.title ? ${to.meta.title} | 食味集 : 食味集 }) export default routercreateWebHistory()使用 history 模式URL 里没有#观感干净但部署时必须配合服务端回退配置第 5 章会专门处理。四个视图全部用动态import()包裹首屏只加载首页代码块详情页和关于页的组件在路由命中时才请求这是内容站控制首屏体积最直接的手段也是 Vue Router 4 对懒加载的推荐姿势。路由表里有两处值得记。/foods/:id的:id是动态段详情页在route.params.id里读取:pathMatch(.*)*匹配所有未定义路径并重定向到首页避免用户手输错误地址时出现空白页。scrollBehavior让详情页返回列表时滚动位置回到顶部这个细节在长列表页面里交互差异很大。afterEach钩子里同步设置document.title比在单个页面里写useTitle要省事整个站点的标题规则收敛到一处。路由与页面的对应关系可以用一张表固定下来后续要扩展「活动专题」「探店笔记」这类新页面时直接在表里加一行同时补上对应的视图文件和 meta.title目录结构就不会乱pathname对应视图meta.title职责/homeHomeView首页精选推荐、站点入口/foodsfoodsFoodListView觅食列表与分类筛选/foods/:idfood-detailFoodDetailView详情单条美食完整信息/aboutaboutAboutView关于本站站点说明与数据来源任意未匹配-重定向到/首页404 兜底2.4 入口文件挂载顺序// src/main.js import { createApp } from vue import { createPinia } from pinia import App from ./App.vue import router from ./router const app createApp(App) app.use(createPinia()) app.use(router) app.mount(#app)createPinia()先于router注册是约定俗成的写法实际只要在mount之前注册完即可。把 Pinia 放前面的原因在于后续页面组件可能在 setup 阶段就调用useFoodStore()而 store 内部如果依赖路由信息两个插件就必须都已经就位先写 Pinia 可读性更好。入口文件保持干净。不要把全局请求封装、localStorage 初始化写进main.js这些逻辑放到 store 的 action 或独立工具模块里。main.js只做三件事创建应用、注册插件、挂载。任何团队接手都能在十秒内看懂这个文件的职责边界。接下来进入数据层看美食数据怎么被组织成可筛选的状态。3. 把美食数据收进 Pinia状态设计与筛选逻辑3.1 数据先落地为静态模块特色美食站通常没有后端数据以静态文件形式交给前端即可。常见做法是在src/data/下建foods.js统一导出数组。字段设计直接决定组件层好不好写// src/data/foods.js export const foods [ { id: 1, name: 柳州螺蛳粉, category: 粉面, region: 广西柳州, rating: 4.8, price: 18, tags: [酸辣, 米粉, 汤粉], summary: 酸笋发酵的独特香气配木耳、花生和腐竹汤底是关键。, cover: /images/foods/luosifen.jpg }, { id: 2, name: 沙县拌面, category: 面食, region: 福建沙县, rating: 4.5, price: 8, tags: [花生酱, 快手], summary: 花生酱与酱油调出咸香面条筋道拌开即食。, cover: /images/foods/shamian.jpg } ]字段设计有三个约定可以在项目里固定下来。id用数值型和路由参数做Number()转换时不会踩「字符串比较全等」的暗坑category是单选分类直接喂给分类标签栏tags是数组供关键词搜索匹配后续加「酸辣」「清淡」这类口味筛选时数组结构天然可扩展。cover用站内绝对路径而不是外链本地部署时外链图片一旦失效列表页会出现整片裂图排查起来非常被动。rating 字段暂时不参与筛选但详情页和卡片角标会用到提前留下来比后面补字段省事。3.2 Pinia store 的 setup 写法Pinia 支持选项式和 setup 两种写法。这里推荐 setup 写法因为它与组合式函数的心智模型一致内部直接用ref和computed团队里写过组合式 API 的人零成本上手// src/stores/food.js import { defineStore } from pinia import { ref, computed } from vue import { foods } from /data/foods export const useFoodStore defineStore(food, () { const list ref(foods) const activeCategory ref(全部) // 用 Set 去重tab 顺序与数据首次出现顺序保持一致 const categories computed(() { return [全部, ...new Set(list.value.map((item) item.category))] }) const filteredList computed(() { if (activeCategory.value 全部) return list.value return list.value.filter((item) item.category activeCategory.value) }) function setCategory(name) { activeCategory.value name } // 路由参数是字符串这里统一转数字再比较 function getFoodById(id) { return list.value.find((item) item.id Number(id)) } return { list, categories, activeCategory, filteredList, setCategory, getFoodById } })categories用new Set对品类去重比手写indexOf判断简洁也不会残留重复项。filteredList把分类筛选收敛成一个 computed列表页、首页推荐位只需要消费这一个数据源筛选状态变更时所有引用它的组件同步更新这是 Vue3 computed 在状态管理里的典型应用。如果以后要支持「价格区间」或「评分排序」在这两个 computed 上扩展即可组件层几乎不用动。setup 写法中返回值对应关系是 Pinia 的固定约定ref包裹的是 statecomputed包裹的是 getter普通函数是 action。对应关系整理成一张表评审代码时可以直接对照写法类型组件中访问方式ref(全部)statestore.activeCategorycomputed(...)getterstore.categoriesfunction setCategory(...)actionstore.setCategory(小吃)注意getFoodById里做了Number(id)转换。路由参数本质是字符串而数据里的id是数字不做转换的话1 1恒为 false详情页会一直匹配不到数据。这是动态路由项目里出现频率最高的隐性 bug几乎每个新手都会踩一次。转换放在 store 内部而不是组件里调用方不需要关心类型问题职责也更集中。3.3 组件里解构 store 的两种姿势组件中直接写const { categories } store会丢失响应性因为 store 本身是 reactive 对象普通解构拿到的是快照。正确做法是用storeToRefs包裹只处理 state 和 getterscript setup import { storeToRefs } from pinia import { useFoodStore } from /stores/food const store useFoodStore() const { categories, activeCategory, filteredList } storeToRefs(store) /scriptaction 不需要包storeToRefs直接store.setCategory(小吃)调用即可。注意不要对 action 使用storeToRefs虽然不会报错但返回的引用没有意义还会让阅读者误以为 action 是响应式数据。这个 API 的命名已经暗示了用途toRefs 是给响应式数据用的函数不在范围内。另一个常见误用是在 computed 内部修改 store 的 state。例如在filteredList里顺手写activeCategory.value 全部这会让计算属性产生副作用响应式依赖关系变得不可追踪。筛选状态的变更只能发生在事件处理函数或 action 中getter 保持纯函数性质。这条规则在代码评审时值得单独强调因为它不会报错只会表现为难以定位的「页面状态自己变了」。团队里如果出现过这类问题可以在 eslint 配置里加上vue/no-side-effect-in-computed-properties规则从工具层面拦截。4. 列表与详情页的组件拆分FoodCard 到详情回退4.1 组件目录按业务域划分src/components/ ├── layout/ │ ├── AppHeader.vue │ └── AppFooter.vue ├── food/ │ ├── FoodCard.vue │ └── CategoryTabs.vue └── common/ └── EmptyState.vue按业务域划分而不是按文件类型划分layout放站点骨架food放美食业务组件common放与业务无关的通用组件。这样在food目录里新增一个「食材标签」组件时不会污染全局通用组件反过来common里的组件被多个业务域复用时也不会产生业务耦合。目录名本身就是依赖边界的声明比在 README 里写规范有效得多。组件边界遵循一条原则一个组件只负责一件事输入走 props输出走事件。全站统一用 Vue3 SFC 模板语法不引入 JSX。内容站强调声明式结构模板的可读性比 render 函数的灵活性更重要只有未来某个列表的渲染逻辑复杂到 template 难以表达时再局部改成h()或 JSX现在引入是过度设计。模板里也不要去写复杂的计算表达式超过两个操作符的逻辑就抽到 computed 或方法里调试时能少一半问题。4.2 FoodCard 组件与图片兜底美食站的核心复用单元是卡片首页推荐位、列表页、搜索结果都用它!-- src/components/food/FoodCard.vue -- script setup import { ref } from vue defineProps({ food: { type: Object, required: true } }) const imgFailed ref(false) function onImgError() { imgFailed.value true } /script template RouterLink :to/foods/${food.id} classfood-card div classfood-card__cover img :srcimgFailed ? /images/placeholder.svg : food.cover :altfood.name loadinglazy erroronImgError / /div div classfood-card__body h3{{ food.name }}/h3 p classfood-card__summary{{ food.summary }}/p div classfood-card__meta span{{ food.region }}/span span classfood-card__price¥{{ food.price }}/span /div /div /RouterLink /templatedefineProps直接在script setup中声明模板里可以直接使用food属性不需要中间变量。loadinglazy让图片进入视口前不请求列表页一次渲染几十张卡片时这个属性带来的首屏收益比任何图片压缩都明显。error事件配合imgFailed标记做兜底图片 404 时切换成本地占位图避免裂图撑破卡片布局这是内容站图片稳定性上最常见的主动防御。用RouterLink而不是a href/foods/1包裹整张卡片好处是 Vue Router 会接管跳转详情页组件可以被复用和预取点击响应也更快。RouterLink默认渲染为a实际 DOM 语义没有变化SEO 和可访问性都不受影响。样式上注意.food-card__cover需要固定宽高比object-fit: cover防止图片拉伸变形这是卡片布局里最容易忽略的细节。4.3 列表页分类筛选、关键词搜索与空状态列表页的筛选分成两层分类是全局共享状态放进 Pinia搜索关键词是页面私有状态放进 composable。这样分层的原因在于分类状态在首页和列表页都要用而搜索框只存在于列表页没有必要污染全局 store// src/composables/useFoodSearch.js import { ref, computed } from vue import { storeToRefs } from pinia import { useFoodStore } from /stores/food export function useFoodSearch() { const store useFoodStore() const { filteredList } storeToRefs(store) const keyword ref() const result computed(() { const kw keyword.value.trim().toLowerCase() if (!kw) return filteredList.value return filteredList.value.filter( (item) item.name.toLowerCase().includes(kw) || item.tags.some((tag) tag.toLowerCase().includes(kw)) ) }) return { keyword, result } }composable 内部通过storeToRefs(store)拿到 store 的分类筛选结果再叠加关键词过滤。filteredList.value在 computed 中被读取Pinia 的响应式链会自动建立依赖store 里分类一变这里的搜索结果跟着重算。toLowerCase()是给标签匹配做的归一化中文不受影响英文标签不会因为大小写漏匹配。trim()去掉首尾空格避免用户输入空格时触发无意义的全量过滤。列表页视图把这些模块组装起来!-- src/views/FoodListView.vue -- script setup import { storeToRefs } from pinia import { useFoodStore } from /stores/food import { useFoodSearch } from /composables/useFoodSearch import FoodCard from /components/food/FoodCard.vue import CategoryTabs from /components/food/CategoryTabs.vue import EmptyState from /components/common/EmptyState.vue const store useFoodStore() const { categories, activeCategory } storeToRefs(store) const { keyword, result } useFoodSearch() /script template section classfood-list input v-modelkeyword typesearch placeholder搜索美食名称或标签 / CategoryTabs :categoriescategories v-modelactiveCategory / div classfood-list__grid FoodCard v-forfood in result :keyfood.id :foodfood / /div EmptyState v-ifresult.length 0 / /section /templatev-modelactiveCategory直接绑定 Pinia store 解构出来的 refstore 的 action 被v-model的赋值操作触发这是storeToRefs带来的便利。v-for的:key用food.id不要用数组索引否则筛选排序变化时组件复用会出现状态错位。EmptyState在搜索结果为空时渲染提示文案区分「没有匹配结果」和「该分类暂无内容」两种场景比白屏好很多。CategoryTabs 用经典的 modelValue 协议实现兼容所有 Vue3 版本!-- src/components/food/CategoryTabs.vue -- script setup const props defineProps({ categories: { type: Array, required: true }, modelValue: { type: String, default: 全部 } }) const emit defineEmits([update:modelValue]) /script template div classcategory-tabs button v-forcat in categories :keycat :class{ category-tabs__item--active: cat props.modelValue } clickemit(update:modelValue, cat) {{ cat }} /button /div /template提示Vue 3.4 之后可以改用defineModel()简化这段代码但modelValue update:modelValue协议在 3.2/3.3 上同样可用。团队如果无法统一版本按经典协议写最保险后续升级到 3.4 再决定要不要迁移。组件的输入输出整理成一张对照表评审和后继者维护时一眼能看懂职责组件props 输入事件/输出主要复用场景FoodCardfoodRouterLink 跳转首页推荐、列表页、搜索结果CategoryTabscategories、modelValueupdate:modelValue列表页、首页筛选区EmptyState无无搜索无结果、分类内容为空4.4 详情页动态路由参数与非法 id 回退详情页从route.params.id取参再通过 store 的getFoodById查找!-- src/views/FoodDetailView.vue -- script setup import { computed, watch } from vue import { useRoute, useRouter } from vue-router import { useFoodStore } from /stores/food const route useRoute() const router useRouter() const store useFoodStore() const food computed(() store.getFoodById(route.params.id)) watch( () route.params.id, (id) { if (!store.getFoodById(id)) { router.replace({ name: foods }) } }, { immediate: true } ) /script template article v-iffood classfood-detail img :srcfood.cover :altfood.name / h1{{ food.name }}/h1 p{{ food.summary }}/p span¥{{ food.price }}/span /article /templatecomputed负责数据查找watch负责非法 id 的拦截。{ immediate: true }让 watch 在组件创建时立刻执行一次刷新页面或直接输入不存在的 id 时立即重定向到列表页如果不加 immediate首次渲染就会用undefined数据渲染出一块空白详情。router.replace而不是push回退行为不会在历史栈里留下一条无效记录用户按返回键不会重新进入坏页面。这里不要用onMounted做数据查找。详情页内部如果有「相关推荐」链接用户从一个详情跳到另一个详情时onMounted不会重新执行而 watch 会捕捉到route.params.id的变化并重新校验这是动态路由场景下推荐 watch 的根本原因。v-iffood作为双保险保证 watch 尚未执行完成的极短窗口里模板也不会访问 undefined 的属性。5. 构建产物分析与 Nginx 部署的 history 回退5.1 先看构建产物npm run build find dist -type f构建完成后检查dist目录入口index.html引用了带内容哈希的 js/css 文件assets下的文件名形如index-3f2a9b1c.js。哈希值随文件内容变化这是长缓存的前提——文件没变就命中缓存内容变了自动换新文件名不会出现旧缓存污染新版本的问题。产物文件作用缓存策略index.html应用入口引用打包资源不缓存或短缓存assets/*.js业务与框架代码长缓存文件名带哈希assets/*.css全局样式长缓存文件名带哈希assets/*.png/jpg/svg静态图片与图标长缓存如果dist里出现体积超过 500KB 的 chunk说明把整个 Vue 运行时和业务代码打到了一起。可以在vite.config.js里用manualChunks把 vue、vue-router、pinia 拆成独立 vendor 块这样框架代码和业务代码分开缓存业务发布时用户不用重新下载框架字节。5.2 Nginx 配置与 history 回退# /etc/nginx/conf.d/food-site.conf server { listen 80; server_name food.localhost; root /opt/food-site/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /assets/ { expires 7d; add_header Cache-Control public, max-age604800, immutable; } gzip on; gzip_types text/css application/javascript application/json image/svgxml; gzip_min_length 1k; }try_files $uri $uri/ /index.html是 history 模式的核心请求/foods/3时先找是否存在同名文件找不到就回退到index.html由 Vue Router 接管路由渲染。没有这一行直接访问或刷新详情页会得到 404。注意$uri/会先尝试目录这个顺序不能颠倒否则静态资源请求会被错误回退。/assets/单独配置长缓存因为文件名带哈希内容变更必然换文件名缓存不会失效。gzip针对文本资源开启构建产物里的 js/css 压缩后体积能再降一半以上。gzip_min_length 1k过滤掉小文件避免为几字节的响应做无意义的压缩开销。5.3 部署前本地验证nginx -t curl -I http://localhost/foods/3nginx -t检查语法返回syntax is ok才继续。第二个命令模拟用户直接访问深层链接响应码应该是 200 而不是 404确认try_files生效。再补一条curl -I http://localhost/assets/xxx.js看响应头里有没有Cache-Control: public, max-age604800验证长缓存策略是否到位。注意Windows 上部署时nginx 根目录直接放nginx.exe用nginx -t检查语法、start nginx启动。配置路径用正斜杠root改为实际路径如D:/food-site/dist末尾不要带多余的斜杠。前端侧还有一个等价验证手段npm run preview会启动本地静态服务器默认监听 4173 端口先用它模拟生产环境跑一遍深链接刷新确认无异常再上真实 Nginx这一步能省掉大量部署联调时间。预览环境下如果发现assets路径 404检查vite.config.js里的base配置默认/适合根路径部署部署到子目录时要改成base: /food-site/同时RouterLink的起始路径也要同步调整。本文还有配套的精品资源点击获取