Nuxt3+Vite+Vue3+TS构建仿爱彼迎网站:SSR实战与工程化解析
2026/9/14 11:54:36 网站建设 项目流程

简介:基于Vite、Vue3、TypeScript与Nuxt3技术栈的仿爱彼迎(Airbnb)网站源码,是一套面向中高级前端学习者的完整实践项目。它适合已经掌握Vue基础、希望理解组合式API与服务端渲染如何配合的开发者,通过源码可直观看到Vite构建配置、Nuxt3路由与页面组织、TypeScript类型约束在真实项目中的落地方式。压缩包内共42个文件,体积仅302KB,其中TypeScript文件负责类型声明与构建逻辑,SCSS文件用于管理全局及组件样式,Vue单文件组件承载页面与复用模块,JSON配置保存依赖与运行参数,png等图片资源用于完善界面视觉。已有1407人学习下载,代码量不大但工程结构清晰。从入口文件、路由表到语言工具函数,分层明确;pages、components、layouts等Nuxt3目录划分合理,整体可直接作为搭建预订或展示类站点的工程参考,通过阅读源码还能掌握多语言配置、自动导入组件、SCSS预处理器等实践技巧,对提升前端工程化能力颇有帮助。

1. 为什么仿爱彼迎网站要选 Nuxt3 + Vite + Vue3 + TS 这套组合

拿到“基于Vite+Vue3+TS+Nuxt3的仿爱彼迎网站源码.zip”,别急着解压跑 dev,先想清楚它解决了什么问题。爱彼迎这类站点,房源详情页必须能被搜索引擎收录,纯 Vue3 SPA 在 SSR 面前是软肋。Nuxt3 把 Vue3、Vite、TypeScript 焊在一起:开发走 Vite 按需编译,构建用 nitro 做服务端渲染,路由、数据请求、类型推导都有框架约定,不用自己拼一套 SSR 脚手架。

这套源码适合两类人读。一类是写过 Vue3 单页应用、想补 SSR 与全栈能力的前端;另一类是拿到源码后要二次开发、需要快速定位页面和接口分别改在哪里的工程师。前者建议按第 2、3 章的顺序读,后者可以直接跳到第 4、5 章的配置与验证部分。

标题里四个关键词对应四条主线:Vite 管构建与开发体验,Vue3 管组件模型,TS 管类型约束,Nuxt3 管服务端渲染与目录约定。这条链路理清了,zip 里的代码才读得动。

2. 用 nuxi 初始化 Nuxt3 + Vite + Vue3 + TS 工程:目录骨架与类型检查

虽然 zip 里已经是一套能跑的源码,我仍然建议先初始化一个干净工程做对照。源码经过拷贝、压缩和二次编辑之后,依赖版本和目录结构可能已经被动过,一个刚生成的工程能帮你快速区分哪些是框架默认行为、哪些是这个项目自己的定制。

2.1 初始化命令与版本控制:如何创建指定 Vite 版本的 Nuxt3 项目

# nuxi 是 Nuxt3 官方脚手架 npx nuxi@latest init airbnb-clone cd airbnb-clone npm install npm run dev

nuxi init 拉下来的是官方模板,里面一次性配好了 Vue3、TS 和 Vite 的联动,不需要手动安装 vite。较新的 Nuxt3 版本默认内置 Vite 5,package.json 里看不到 vite 依赖是正常现象,想确认实际装载的版本,在项目根目录执行 npm ls vite 看输出即可。这和“如何创建指定 vite 版本的项目”是同一枚硬币的两面:Vite 在这里是 Nuxt 的传递依赖,不要直接 npm install vite@6 去覆盖,那样大概率会和 Nuxt 内部的构建插件版本打架;确实要锁 Vite 版本,用 package.json 的 overrides 字段,并跑一遍完整的 build 回归。

提示:Nuxt3 需要 Node 18 以上,建议直接用 20 或 22 LTS。环境配置阶段踩得最多的就是 Node 版本过低导致 nuxi 命令直接报错,而不是项目代码本身的问题。

npm run dev 启动后,Vite 的 HMR 只覆盖 pages、components、composables 这些业务目录;改 nuxt.config.ts 会触发整个 dev server 重启,这不是配置写错,是框架设计如此。开发服务器默认监听 3000 端口,被占用时会自动顺延到 3001。

2.2 目录结构对照:仿爱彼迎的页面、API、组件各放哪

目录或文件职责仿爱彼迎里的对应内容
pages/文件即路由index.vue 首页、search.vue 搜索页、listings/[id].vue 房源详情
components/自动导入的组件ListingCard.vue、DateRangePicker.vue、MapView.vue
composables/自动导入的组合式函数useSearchFilters、useWishlist
server/api/Nitro 服务端接口listings.get.ts 返回房源列表
middleware/路由守卫auth.ts 未登录跳转登录页
plugins/启动时执行的插件地图 SDK、埋点 SDK 初始化
nuxt.config.ts唯一的配置入口Vite、TS、routeRules 都在这里改

pages 目录是理解这套源码的第一把钥匙。search.vue 对应 /search,listings/[id].vue 对应 /listings/:id,加一层 listings/city.vue 就成了 /listings/tokyo,文件结构就是 URL 结构。components 和 composables 目录下的文件不需要手动 import,文件名即使用名,比如 components/cards/ListingCard.vue 在模板里写作 CardsListingCard,目录层级会转成驼峰前缀,拼错了工具不会报错,渲染出来却是空白。

app.vue 是根组件,里面通常会放一个 NuxtPage;需要全站统一的顶部导航和页脚,就在布局文件里套 NuxtLayout。二次开发时先顺着 app.vue 找布局,再顺着 pages 找业务页面,比从 package.json 猜代码组织方式要快得多。

2.3 tsconfig 与 vue-tsc:.nuxt 目录里的类型是怎么生成的

Nuxt3 项目根目录的 tsconfig.json 只是一个占位文件,真正被 IDE 和 vue-tsc 使用的配置由框架生成,写在 .nuxt/tsconfig.json 里。直接在根目录 tsconfig.json 加 compilerOptions 大概率会被覆盖,正确入口是 nuxt.config.ts 的 typescript 字段。

// nuxt.config.ts export default defineNuxtConfig({ typescript: { strict: true, typeCheck: true, // dev 和 build 时都跑 vue-tsc,多花时间换类型安全 tsConfig: { compilerOptions: { types: ["node"] // 让 server/api 里可以使用 Node 全局变量 } } } })

这段配置里,strict 控制 TS 严格模式;typeCheck 打开后每次 dev 启动和 build 都会执行 vue-tsc 全量检查,项目大了启动会明显变慢,个人开发可以先关掉,只在 CI 里用 npx nuxi typecheck 显式执行。tsConfig 的语义是合并进生成的配置,而不是替换它,所以你可以追加 types、paths 等选项,但不要指望通过它删掉框架预置的内容。

注意:.nuxt 目录要写进 .gitignore。CI 环境里第一次跑类型检查之前先执行 npx nuxi prepare,否则会直接报 Cannot find module '#imports',很多人误以为是依赖没装好,其实是生成的类型文件还没落地。

3. 仿爱彼迎搜索与详情页:URL 状态、useFetch 取数与 Pinia 边界

仿爱彼迎的主流程是首页 → 搜索结果 → 房源详情 → 收藏与预订。SSR 场景下处理顺序很重要:先把 URL 里的状态定下来,再发数据请求,最后才轮到组件内部的临时状态,顺序反了就会出现首屏白屏、回退丢状态这类问题。

3.1 搜索结果页:筛选条件与 URL 查询参数的双向同步

// pages/search.vue const route = useRoute() const router = useRouter() const filters = reactive({ city: (route.query.city as string) || '东京', checkIn: (route.query.checkIn as string) || '', checkOut: (route.query.checkOut as string) || '', guests: Number(route.query.guests ?? 2) }) // 浏览器前进/后退时 URL 变化,要回写到筛选表单 watch( () => route.query, (q) => { filters.city = (q.city as string) || '东京' filters.guests = Number(q.guests ?? 2) } ) function applyFilters() { router.push({ path: '/search', query: { ...filters } }) }

把筛选条件放进 query 而不是组件内部 state,换来三个能力:首屏 SSR 直接按 URL 取数,搜索结果的链接可以分享给同事且还原同一屏内容,浏览器后退按钮能回到上一次筛选结果。watch 里的回写最容易漏,漏掉的症状是“从详情页返回搜索页时,筛选框的值和 URL 对不上”,排查时先对比 route.query 和 filters 的内容。

URL 状态定下来之后,数据请求就变成对 URL 的投影,这也是搞懂这套仿爱彼迎源码的关键视角:页面不再自己记忆“当前搜的是什么”,而是永远问 route.query 要答案。

3.2 useFetch 与 server/api:一套带 TS 类型的 mock 数据层

// server/api/listings.get.ts —— 开发阶段当 mock 接口用 interface Listing { id: number city: string title: string price: number } const mockListings: Listing[] = [ { id: 1, city: 'tokyo', title: '代代木公园旁一居室', price: 680 }, { id: 2, city: 'kyoto', title: '京都町屋整栋', price: 980 } ] export default defineEventHandler(async (event) => { const { city } = getQuery(event) // 模拟 300ms 网络延迟,方便观察页面 loading 状态 await new Promise((resolve) => setTimeout(resolve, 300)) if (!city) return mockListings return mockListings.filter((item) => item.city === city) })
<!-- pages/search.vue --> <script setup lang="ts"> const route = useRoute() // 页面级组件允许顶层 await,SSR 阶段会把数据直接注入 HTML const { data: listings, pending, error } = await useFetch<Listing[]>( '/api/listings', { query: { city: () => route.query.city || '' } } ) </script> <template> <div v-if="pending">加载中</div> <div v-else-if="error">接口异常</div> <ListingCard v-for="item in listings" :key="item.id" :listing="item" /> </template>

defineEventHandler 里的 getQuery 负责解析 URL 查询参数,返回普通对象,城市筛选直接交给 filter 过滤。useFetch 是这段代码的核心,它相当于 useAsyncData 加 $fetch 的合并封装,服务端渲染时会把数据序列化进 payload,浏览器端 hydration 阶段直接复用,不会发出第二次请求。

三种取数工具按场景选:

API适用场景说明
useFetch页面和组件的首屏取数SSR 去重、自动注入 payload
useAsyncData取数逻辑不是单纯 fetch要先把多个接口结果合并再返回时用
$fetch事件回调里的临时请求提交预订、点赞、收藏时用

有个细节值得注意:useFetch 的 query 参数支持传函数,写成 city: () => route.query.city 可以让它在依赖变化时自动重新请求,这是很实用但经常被忽略的参数行为。mock 阶段把接口放在 server/api 的另一个好处是类型可以共用,真实后端就绪后只替换 handler 内部实现,页面里的 useFetch 调用几乎不用动。

地图组件在仿爱彼迎里绕不开,但它是典型的浏览器环境依赖组件。地图 SDK 直接用在 SSR 页面里会在服务端阶段报 window is not defined,常见做法是用 组件包一层,或者动态导入时声明 ssr: false,让地图只走客户端渲染。

3.3 状态拆分:useState、Pinia 与 vue3 computed 的配合方式

SSR 下状态管理最怕两件事:服务端和客户端状态不一致导致 hydration 报错,以及多个页面重复请求同一份数据。Nuxt3 自带的 useState 会把值随 payload 序列化到客户端,跨组件共享简单状态足够用;需要跨页面持久、带业务动作、想看 devtools 时间线的状态,上 Pinia。

// stores/wishlist.ts —— 收藏夹跨页面共享,用 Pinia export const useWishlistStore = defineStore('wishlist', () => { const ids = ref<number[]>([]) const count = computed(() => ids.value.length) function toggle(id: number) { const idx = ids.value.indexOf(id) idx >= 0 ? ids.value.splice(idx, 1) : ids.value.push(id) } return { ids, count, toggle } })

defineStore 用 setup 写法直接复用 ref 和 computed,比 options 写法少一层字符串 key,类型推导也更好。从 Vue2 转过来的同学不要在 Pinia 里找 mutations,action 里直接改 state 就是标准做法。使用前需要在 nuxt.config.ts 的 modules 里注册 @pinia/nuxt,store 文件放在 stores/ 目录即可自动导入。

判断依据很直接:这个状态刷新页面丢了也没关系,就用 useState 或者干脆放 URL;刷新后必须恢复的,比如收藏夹、最近浏览,就交给 Pinia 再做持久化。wishlist 里的 count 用 computed 派生而不是单独维护一个计数器,是因为它和 ids 是同一份数据的不同投影,存两份早晚会不一致。

4. Vite 构建配置与 TS 类型检查:Nuxt3 里的高频报错处理

项目能跑通之后,真正的维护成本会转移到构建配置和类型报错上。这两个区域的改动入口都在 nuxt.config.ts 和项目根目录的 types/ 文件夹里,改错了地方要么不生效,要么被框架生成的配置覆盖。

4.1 nuxt.config.ts 里的 Vite 配置:optimizeDeps、构建目标与 base 路径

export default defineNuxtConfig({ vite: { optimizeDeps: { // 把大依赖预构建,避免 dev 启动后反复重新优化导致卡顿 include: ['dayjs', 'lodash-es'] }, build: { target: 'es2020', // 现代浏览器基线,产物体积更小 cssCodeSplit: true // 按页面切分 CSS,详情页样式不进列表页 } }, app: { baseURL: '/airbnb/' // 部署到子路径时配这里,而不是 vite.base } })

optimizeDeps 只在 dev 模式生效,作用是依赖预构建。每次新页面引入一个体积较大的库时,dev 都会明显停顿一下并打印 new dependencies optimized 提示,把这个库加进 include 数组就能消掉停顿。build.target 决定产物的语法基线,es2020 覆盖了 Vue3 支持的现代浏览器,产物比 es2015 小一截;需要兼容老浏览器再降级,但要做好 polyfill 体积上涨的心理准备。

base 路径是高频误解点。在 Vite 单页项目里配 vite.base 就能改资源前缀,但 Nuxt3 的页面路由和静态资源都以 app.baseURL 为准,只改 vite.base 会出现“首页能打开,刷新子路由 404”的经典症状。子路径部署的正确做法是配 app.baseURL,并确保网关注入该前缀后把请求转发给 node 进程。

4.2 vue-tsc 高频报错与处理:从报错现场到根因

报错现场根因处理方式
Cannot find module '#imports'.nuxt 类型文件未生成或缺失执行 npx nuxi prepare 后重试
route.query.city 的类型是 string / string[] / undefined 联合体useRoute 的 query 类型设计如此先断言:const city = (route.query.city as string) ?? ''
第三方包没有任何类型声明包本身没带 .d.ts在 types/ 目录写 declare module 'xxx' 补类型
useFetch 拿到的 data 类型不是接口结构泛型没有显式声明给 useFetch 补完整泛型参数

第三个报错在仿爱彼迎项目里最常出现在地图和日期选择类库上。declare module 只需要补自己用到的导出,不用完整复刻整个包的类型,保证业务代码不飘红即可,这是务实的选择。

useFetch 的泛型问题值得展开,因为接口经常在外面包一层统一结构:

// 后端返回 { code: 0, data: [...] },泛型要写完整的包裹结构 const { data } = await useFetch<{ code: number; data: Listing[] }>( '/api/listings' ) const listings = computed(() => data.value?.data ?? [])

不写泛型时 data.value 会被推导成 any,模板里写什么都编译通过,运行时才发现字段取错。补上泛型后 computed 的推导链就通了,data.value 可能为空的情况用空数组兜底,模板里直接 v-for listings 不会再报类型错误。

4.3 dev 阶段接口转发:用 nitro.devProxy 而不是 vite.server.proxy

模拟数据阶段可以只用 server/api,但联调真实后端时,最省事的方案是让开发服务器把 /api 前缀的请求转到后端服务。

export default defineNuxtConfig({ nitro: { devProxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

这里有个容易踩的坑:Nuxt3 的 dev server 由 nitro 把持,/api 路径默认先被 nitro 接管。如果沿用 Vite 时代的习惯配置 vite.server.proxy,请求到 nitro 这一层就被拦住了,转发配置根本不生效。正确入口是 nitro.devProxy,它只在开发模式生效,生产构建完全不受影响。生产环境要做同样的事,对应的是 routeRules 里的转发规则,或者直接交给 nginx 处理。

注意:devProxy 和 server/api 同时存在时,server/api 的路由优先级更高。想临时切回 mock,把 devProxy 配置注释掉即可,两套数据源切换成本很低。

5. 上线前验证与缓存:curl 检查 SSR、SWR 与 nginx 转发

5.1 用 curl 验证首屏 HTML 真的渲染出了房源

npm run build node .output/server/index.mjs # 另开一个终端 curl -s http://localhost:3000/listings/1 | grep -o '代代木公园旁一居室'

grep 不到房源标题时,第一反应是服务端数据请求失败,而不是模板写错。SSR 页面里服务端错误经常被吞掉,浏览器里看到的是白屏或 loading 卡死,curl 拿到的 HTML 是最直接的证据。常见失败原因有两个:server/api 里用了相对路径读文件,构建后目录结构变化导致寻址失败;useFetch 的 query 函数里访问了 window 之类的浏览器对象。

5.2 详情页用 routeRules 的 SWR,而不是纯静态渲染

export default defineNuxtConfig({ routeRules: { '/listings/**': { swr: 3600 }, '/listings/**/og.png': { cache: { maxAge: 86400 } } } })

房源详情页热度高、内容相对静态,但价格和可订状态会变。swr: 3600 的含义是:缓存一小时,过期后不阻塞请求,先把旧页面返回给用户,同时在后台生成新页面替换缓存,这是爱彼迎同款业务场景最合适的折中。验证方式很简单:连续 curl 两次同一个 URL,第二次响应头里 x-nitro-cache 从 MISS 变成 HIT 就说明缓存生效;如果套了 CDN,先确认 CDN 没有剥掉这个响应头。

5.3 nginx 只做转发,不要直接 serve .output

很多从 Vue2 静态部署转过来的同学习惯把 dist 丢给 nginx,这个习惯照搬到 Nuxt3 会出问题。.output 目录里既有 node 服务代码也有静态资源,正确姿势是让 nginx 把请求转发到 3000 端口的 node 进程,静态资源单独按目录缓存。

location / { proxy_pass http://127.0.0.1:3000; } location /_nuxt/ { alias /opt/airbnb/.output/public/_nuxt/; expires 30d; }

上线验收时盯两个地方:一是 nginx 的错误日志里有没有 502,有就说明 node 进程没起来或者端口不对;二是用 curl -I 看响应头里的 x-nitro-cache 字段,确认详情页确实命中了 SWR 缓存,同时检查 HTML 里静态资源的指纹文件名,确保浏览器加载的是最新构建产物,而不是旧缓存。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询