简介:一份基于VUE3开发的简洁特色美食网站源码,面向前端初学者、课程设计及毕业设计人群,可直接运行并用于快速搭建个人站点。网站实现世界特色美食、国内特色美食、美食图展、关于我们、登录注册与美食详情介绍等功能模块,内置九个页面,集成轮播图、视频、表单、TAB、导航栏、底部栏、列表、图文组合、返回顶部等常用交互组件,代码注释完整、结构规范,便于二次开发。资源包共2000个文件,以1622个js脚本、142个md说明文档、130个json配置及96个css样式为主,总大小约73.99MB,适合作为课程大作业或毕业设计参考。已有701人学习下载,运行npm run dev即可快速预览效果。
1. 想快速搭一个特色美食站点,这套 Vue3 模板源码比从零写靠谱
做课程设计或毕业设计最怕的不是需求复杂,而是时间全耗在搭框架上。一套能直接运行、已经实现轮播图、视频、TAB、表单、导航栏和底部栏的 Vue3 特色美食网站源码,可以让你把精力放到填内容和调样式上。这套模板内置九个页面,覆盖世界美食、国内美食、美食图展、关于我们、登录注册和详情介绍等场景,代码注释完整,用 VSCode 打开后执行 npm run dev 就能看到效果。如果你以前主要写 vue3 后台管理系统,再看这种内容型网站模板,会发现组件划分和信息密度完全是另一套思路。下面我按工程结构、交互实现、构建部署和二次改造四个维度拆开讲,都是可以直接套用的做法。
2. 先把九个模板页面的路由和组件结构理清楚
2.1 从 main.js 到 vue-router:页面入口的加载链路
拿到源码第一件事不是打开浏览器,而是看入口文件。这类模板通常把路由独立成一个模块,而不是全部塞在 main.js 里。入口文件的职责只有一个:创建应用实例、注册插件、挂载 DOM。用 Vite 搭的 Vue3 项目里,入口文件大概是这样:
import { createApp } from 'vue' import App from './App.vue' import router from './router' import './assets/css/swiper-bundle.min.css' const app = createApp(App) app.use(router) app.mount('#app')这段代码里createApp(App)创建一个根实例,app.use(router)把路由插件注册进去,之后根组件里所有的<router-view>才能识别。如果模板里还用了状态管理,通常会在app.use(router)后面再加一条app.use(pinia)。要注意的是,全局样式文件在这里引入后,会被所有组件复用,目录里那些 swiper-bundle.css、pagination.css 就是通过这种路径在入口层统一的。
2.2 九个模板页面在路由表里是怎么分布的
九个页面的路由一般由createRouter管理,历史模式用createWebHistory。这个模式开发时没问题,但部署到子目录或刷新页面时容易 404,所以实际项目中要配合服务器回退配置。下面是一份典型路由表:
import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', name: 'home', component: () => import('@/views/HomeView.vue') }, { path: '/world', name: 'world', component: () => import('@/views/WorldFoodView.vue') }, { path: '/china', name: 'china', component: () => import('@/views/ChinaFoodView.vue') }, { path: '/gallery', name: 'gallery', component: () => import('@/views/GalleryView.vue') }, { path: '/food/:id', name: 'food-detail', component: () => import('@/views/FoodDetailView.vue') }, { path: '/about', name: 'about', component: () => import('@/views/AboutView.vue') }, { path: '/login', name: 'login', component: () => import('@/views/LoginView.vue') }, { path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue') }, { path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFoundView.vue') } ] export default createRouter({ history: createWebHistory(), routes })这里采用模块化引入,每个component都是异步函数,首页打开时不会一次性加载全部页面资源。实际下载到的文件可能不是这些英文名,但页面职责基本能对应上。我按信息架构整理成一张表,方便你定位要改的页面:
| 路由 | 页面职责 | 核心模块 |
|---|---|---|
/ | 首页:轮播、推荐、视频展示 | Swiper、VideoBlock |
/world | 世界特色美食:按地区切换 | TabList、FoodCard |
/china | 国内特色美食:按菜系分组 | CategoryNav、FoodCard |
/gallery | 美食图展:图片网格与详情 | ImageGrid、Lightbox |
/food/:id | 美食详细信息:菜品字段展示 | FoodInfo、评论列表 |
/about | 关于我们:品牌故事和合作入口 | AboutIntro、ContactForm |
/login | 登录:账号密码表单 | FormValidate |
/register | 注册:手机号/邮箱表单 | FormValidate |
/404 | 页面不存在:空状态引导 | ResultPage |
这张表里比较关键的是/food/:id。动态路由让列表页跳详情时只需要router.push({ path: '/food/' + id }),这个模式在做 vue3 商城时也一样,把food换成goods就是商品详情页。登录和注册拆成两个独立页面,而不是弹窗,说明模板把认证流程放在独立路由下,后续接真实接口时,路由守卫做白名单会容易很多。
2.3 公共组件:导航栏、底部栏和返回顶部怎么被复用
导航栏和底部栏基本是全局布局的一部分,模板不会在每个页面里重复复制。常见做法是在App.vue里放好导航和底部组件,再用<router-view>渲染当前路由页面。这样切换页面时头部和底部保持稳定,只有中间主体区域重新渲染,体验上接近原生应用。返回顶部则是一个独立组件,监听window.scrollY,超过阈值后显示按钮,点击后用scrollTo平滑回到顶部:
<template> <button v-show="visible" class="back-top" @click="goTop">返回顶部</button> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue' const visible = ref(false) const onScroll = () => { visible.value = window.scrollY > 300 } const goTop = () => { window.scrollTo({ top: 0, behavior: 'smooth' }) } onMounted(() => window.addEventListener('scroll', onScroll)) onUnmounted(() => window.removeEventListener('scroll', onScroll)) </script>这段代码有两个容易忽略的点。第一,滚动监听必须挂在 window 上,如果你挂在某个设了overflow: auto的容器上,事件对象不同,scrollY可能一直是 0。第二,组件卸载时要移除监听,否则页面切换到详情页后,列表页的滚动回调还在执行,会留下性能隐患。如果你在 Edge 浏览器里发现返回顶部按钮时显时隐,多半是滚动事件回调里直接读scrollY导致值不同步,用requestAnimationFrame包一层再赋值就能缓解。
3. 轮播图、视频与 Tab 切换:交互模块的 Vue3 实现细节
3.1 Swiper 在 Vue3 里的引入与参数调整
项目目录下放了 swiper-bundle.css、swiper.css、pagination.css 等多个样式文件,很多人不知道选哪个。我的经验是:如果整个项目只在首页用一个轮播,直接在入口或组件里引入swiper-bundle.min.css最省事;如果多个页面各自有轮播,再用swiper/css配合对应模块样式按需加载。最忌讳的是把 bundle 和按需样式一起引,样式顺序不对会出现两个分页器圆点叠在一起。Swiper 7 以上的 Vue3 写法是按需注册模块,示例:
<template> <swiper class="banner-swiper" :modules="modules" :loop="true" :autoplay="{ delay: 3500, disableOnInteraction: false }" :pagination="{ clickable: true }"> <swiper-slide v-for="item in bannerList" :key="item.id"> <img :src="item.imgUrl" :alt="item.title" /> </swiper-slide> </swiper> </template> <script setup> import { Swiper, SwiperSlide } from 'swiper/vue' import { Autoplay, Pagination } from 'swiper/modules' import 'swiper/css' import 'swiper/css/pagination' const modules = [Autoplay, Pagination] const bannerList = [ { id: 1, imgUrl: '/images/banner-1.jpg', title: '麻辣火锅' }, { id: 2, imgUrl: '/images/banner-2.jpg', title: '广式早茶' } ] </script>这里的modules数组是 Swiper 8 以后必须传的,否则分页器、自动播放都不生效。参数说明如下:
| 参数 | 作用 | 建议值 |
|---|---|---|
loop | 首尾循环 | 数据大于 2 条时开启,否则会报错 |
autoplay.delay | 自动播放间隔 | 3000 - 5000ms,过短体验差 |
autoplay.disableOnInteraction | 用户操作后是否继续自动播放 | false,避免划一下就不动了 |
pagination.clickable | 分页器点按切换 | true |
还有一个常见坑:老模板里写:options="{ ... }",这种写法在 Swiper 9 里已经失效。如果下载的源码里轮播不转,首先看package.json里 swiper 版本,再决定按模块写法还是 options 写法。
3.2 HTML5 video 的静音策略与路径处理
美食网站通常会在首页放一段展示制作过程的视频,目的是在几秒内抓住用户注意力。但浏览器对自动播放有严格限制:Chrome 只有在视频muted或用户点击后才能播放有声视频。因此模板里最常见的就是静音循环播放,让用户进入页面就有画面动起来,声音留给你自己加控制按钮。代码如下:
<video class="food-video" autoplay muted loop playsinline> <source src="/videos/food-intro.mp4" type="video/mp4"> </video>autoplay让加载完成后立即播放,muted是自动播放被允许的前提,loop让短片段无限循环,playsinline对 iOS 很有必要,否则视频可能被强制全屏播放。路径方面建议把视频放到public目录下,这样/videos/food-intro.mp4是稳定静态路径;如果放到src/assets里,通过 import 引进来打包后会被改名,相对路径容易错。注意不要给视频文件起中文名,某些服务器会对中文路径转义,导致预览时找不到资源。
3.3 Tab 切换、列表渲染和表单校验的代码示例
TAB 是这类内容站的体力活模块:世界美食按地区切换,国内美食按菜系切换,本质都是点击标签改变当前激活项,再用激活项过滤列表。用 Vue3 组合式 API 写,核心代码很轻:
<script setup> import { ref, computed } from 'vue' const tabList = ['全部', '川菜', '粤菜', '本帮菜'] const activeKey = ref('全部') const foodData = ref({ '全部': [{ id: 1, name: '火锅' }, { id: 2, name: '烧鹅' }], '川菜': [{ id: 1, name: '火锅' }], '粤菜': [{ id: 2, name: '烧鹅' }], '本帮菜': [] }) const currentList = computed(() => foodData.value[activeKey.value] || []) </script> <template> <div class="tab-wrap"> <span v-for="tab in tabList" :key="tab" :class="['tab-item', { active: activeKey === tab }]" @click="activeKey = tab">{{ tab }}</span> </div> </template>这里activeKey是唯一的状态来源,点击标签只改它,列表由computed派生,不需要额外写更新函数。样式上如果要改 Tab 高亮效果,一般是在.tab-item.active的::after上做下划线或背景色;常见问题是下划线歪了,大多是.tab-item没有position: relative。表单部分,模板里的登录注册页一般用ref包表单对象,提交时先做非空和长度校验,再进入接口调用。例如:
const loginForm = reactive({ username: '', password: '' }) function handleLogin() { if (!loginForm.username.trim()) return if (loginForm.password.length < 6) return // 调用登录接口 }这里用reactive包装对象,模板里直接v-model绑定两个字段就行。需要提醒的是,前端校验只是体验层,后端接口必须再做一次校验,因为前端代码可以被用户直接修改。
4. 从 npm run dev 到构建部署:环境配置和问题排查
4.1 VSCode 打开项目后的启动步骤与依赖安装
很多新人拿到 Vue3 项目后,第一反应是双击index.html,结果只看到空白页。原因是 Vite 开发服务器会把 ES Module 转换后返回,直接用file://协议打开会报跨域和模块解析错误。正确做法是用 VSCode 打开项目根目录,在终端执行:
npm install npm run devnpm install会按package.json里的依赖生成node_modules。如果网络不稳定导致依赖安装到一半中断,可以删掉node_modules和package-lock.json重装,不要反复安装。启动成功后终端会输出Local: http://localhost:5173/,浏览器打开即可预览。如果你用的是 Windows PowerShell,执行 node 命令时可能遇到运行策略限制,报“无法加载文件”,这是系统脚本策略问题,不是项目问题,把终端切到 Command Prompt,或者在管理员 PowerShell 里执行Set-ExecutionPolicy RemoteSigned就能解决。
4.2 Vite 构建配置和部署子目录的 base 路径
本地预览没问题不代表部署没问题。Vite 默认构建时把资源路径写成/assets/xxx.js,如果站点部署在服务器根目录,这样做没问题;但放到类似http://ip/food的子目录下就会白屏,因为浏览器去找http://ip/assets/xxx.js而不是http://ip/food/assets/xxx.js。解决方法是改vite.config.js:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ base: '/food/', plugins: [vue()], build: { outDir: 'dist', assetsDir: 'static' } })base控制的是 index.html 里资源引用的前缀,改成子目录的实际路径后,dist 里的相对路径就正确了。部署时还要处理 Vue Router 的 history 模式:用户直接访问/food/detail/1,服务器会先去匹配磁盘文件,发现不存在直接返回 404。所以需要在 Nginx 里加一条回退规则:
server { listen 80; server_name example.com; root /var/www/food; index index.html; location /food/ { try_files $uri $uri/ /food/index.html; } }try_files $uri $uri/ /food/index.html的含义是:先找实际文件,找不到再找目录,再找不到就回退到index.html,由前端路由接管。这个配置几乎是 Vue3 部署的标配,记住它比记住 Vue 内部原理更实用。
4.3 常见报错与排查思路
整理几个我在跑同类模板时遇过的报错场景,遇到类似问题时可直接对号入座:
| 报错现象 | 可能原因 | 处理方式 |
|---|---|---|
| npm install 卡住不动 | 默认源不稳定 | 换镜像源:npm install --registry=https://registry.npmmirror.com |
| ERR_OSSL_EVP_UNSUPPORTED | Node 版本太高,与旧构建工具冲突 | NODE_OPTIONS=--openssl-legacy-provider或换 Node 16/18 |
| Cannot find module 'vue' | 依赖安装不完整 | 删 node_modules 重新安装 |
| 页面刷新后 404 | 路由 history 模式未配置回退 | Nginx 配 try_files 或改用 hash 模式 |
| 轮播不出现或样式错乱 | swiper css 引入顺序冲突 | 只保留 bundle 或只保留按需样式,不要混用 |
| Edge 中返回顶部按钮卡顿 | 滚动回调执行频率太高 | 用 requestAnimationFrame 包裹 scrollY 赋值 |
表中最后一条是浏览器兼容场景里常被提到的问题,其实根因不是项目代码,而是滚动事件在 Edge 中触发频率不稳定,用requestAnimationFrame做节流是最直接的修法。排查这类问题时,我习惯先看浏览器 Console 的红色错误,再按“资源找不到、依赖缺、版本不兼容”三个方向缩小范围。如果确认代码没问题,强制刷新一次 Ctrl+Shift+R,很多静态资源缓存问题就此消失。
5. 把模板改成商城或企业站:内容替换与接口接入的取舍
5.1 数据层替换:把页面里的硬编码内容抽出来
这类模板最大的学习价值,是让你看清内容型网站的骨架。改模板时我不建议直接在组件里搜图片链接替换,而是把所有页面里的硬编码数据抽到一个独立数据文件中,比如src/data/foods.js。抽出来后,后续接接口或做 vue3 商城,都只改数据来源,不用动组件结构。一个典型的数据文件是这样:
// src/data/foods.js export const bannerData = [ { id: 1, imgUrl: '/images/banner-1.jpg', title: '重庆火锅', link: '/food/1' }, { id: 2, imgUrl: '/images/banner-2.jpg', title: '广式早茶', link: '/food/2' } ] export const foodList = [ { id: 1, name: '麻辣火锅', area: '重庆', price: 88, category: '川菜' } ]组件里import { bannerData } from '@/data/foods'后直接赋值给响应式变量,未来接口返回的数据结构只要字段一致,替换成http请求即可。如果目标是商城,把foodList换成goodsList,卡片组件增加价格和加购按钮,就完成了从内容站到商城的第一步。需要额外注意的是,商城需要购物车状态,不要再给每个商品卡片单独维护一个加入数量,应该用 Pinia 管理全局购物车数据,这样做跨页面同步才一致。
5.2 登录注册页接真实接口时,token 处理不要写在组件里
登录注册页目前多半是本地校验通过后做一次模拟跳转,没有真实接口。接后端接口时,有人直接在handleLogin里写axios.post('/api/login'),然后把 token 塞进localStorage。这样页面一多,接口地址分散在各处,后端路径调整就得全局搜索。更好的做法是抽一个独立的 api 模块:
import axios from 'axios' const http = axios.create({ baseURL: '/api', timeout: 10000 }) http.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) export function login(data) { return http.post('/login', data) }在登录组件里调用login(form),成功后再把 token 交给 Pinia 或 localStorage。以后接入若依这类后台接口,只需要调整baseURL和请求头字段,页面组件不用大改。响应拦截器里还可以统一处理 401,比如在拿到未授权状态后清掉本地 token 并跳回登录页,这比在每个接口返回里单独判断要省事得多。
本文还有配套的精品资源,点击获取