
简介这是一份基于ViteVue3TypeScriptNuxt3技术栈的仿爱彼迎网站前端项目源码面向Vue/Nuxt进阶学习者也适合准备做全栈或SSR项目的开发者参考。项目融合了最新构建工具、组合式API、静态类型系统与服务器端渲染按Nuxt标准结构组织页面、组件、布局与路由。压缩包共含42个文件以ts、vue、scss、json为主覆盖类型声明、页面逻辑、样式定制与项目配置整体大小约302KB轻量且便于逐文件阅读。已有1407人学习下载。通过该项目源码可以直接启动调试观察热更新和SSR效果并理解TypeScript在Vue3组合式API中的用法、Nuxt路由和配置文件的编写思路可在此基础上扩展成完整住宿预订应用。1. 为什么这份仿 Airbnb 源码值得拆开先看解开12-Airbnb-master压缩包第一眼看到的是vite.config.ts、src/views、src/router这类标准的 ViteVue3 工程文件和印象里 Nuxt3 项目的pages/目录完全不同。多数人拿到手会疑惑这到底是不是一份能跑的 Nuxt3 源码我一开始也踩了这个直觉陷阱跑通一遍才明白Nuxt3 的底层构建引擎就是 Vite两者实际上是叠加而不是互斥的。这套代码把 Vite、Vue3、TypeScript 和 Nuxt3 塞进了同一个仿 Airbnb 预订场景里适合已经会写 Vue3 组件、但没把 SSR、服务端预取、hydration 概念串起来的前端开发者。跟着配一遍环境你能同时看清 Nuxt 控制的路由、Nitro 提供的接口能力和 Vite 构建层之间的关系。2. Vite 引擎之下Nuxt3 TS 工程目录与双配置文件2.1 为什么 Nuxt3 要把 Vite 放到底层Nuxt3 不再像 Nuxt2 那样默认依赖 Webpack而是在开发环境使用 Vite 的依赖预构建和 HMR在生产构建使用 Vite 打包。这意味着你在 Vite 建立的 Vue3 项目里积累的import.meta.env、defineConfig、public静态资源规则在 Nuxt3 中仍然有效只是入口命令从vite换成了nuxt。对于 Airbnb 这类包含房型列表、日历、搜索筛选的交互页面Vite 的秒级热更新能把过去 Webpack 全量编译的等待时间压缩到几百毫秒。但也正因为这样不少人会误以为 Nuxt3 项目里必须保留自己的vite.config.ts才能生效。实际上 Nuxt3 读取的是nuxt.config.ts中的vite字段。压缩包里那些vite.config.ts、tsconfig.node.json大多数情况是 Vue Router SPA 时代留下的配置文件或者是 IDE 插件用于识别 Vite 服务的。若你让 Nuxt 和 Vite 插件同时接管同一份.vue文件就会出现组件重复编译或者代理冲突这类难排查的问题。2.2 环境准备Node 版本、包管理器与初始化命令先把基础环境提到目标版本Node 建议 18 以上npm 9 或者 pnpm 7压缩包里自带yarn.lock说明原工程用 yarn也可以用pnpm install重建依赖。这里有一个容易被忽略的问题如果package.json里写的是nuxt3.x 的 devDependencies而你的 Node 版本低于 16.10nuxi命令可能直接报Cannot find module node:path这是 Node 模块前缀兼容问题升级 Node 比替换包更省事。从零复刻一个同类项目最常见的做法是使用 Nuxt 官方脚手架npx nuxi init airbnb-clone cd airbnb-clone npm install npm run dev如果想把源码结构保持成压缩包里的样子也就是把页面、组件、工具都放进src/需要改nuxt.config.ts。配置文件的关键在于srcDir见下面代码// nuxt.config.ts export default defineNuxtConfig({ ssr: true, srcDir: src, // 告诉 Nuxt 应用代码在 src 目录下 css: [~/assets/css/main.css], vite: { server: { fs: { strict: false } // 开发时允许读取工作区外的文件 } }, typescript: { strict: true, shim: false // 关闭 .vue shim让 IDE 用 Volar 做类型推导 } })这段配置里srcDir把src/app.vue作为应用入口css加载全局样式文件vite.server.fs.strict控制开发服务器是否只能访问项目目录内的文件当你用 monorepo 或符号链接管理公共资源时需要放开它typescript.shim关闭后.vue文件的类型来自 Volar而不是默认的vue/shims.d.ts。这几点都直接决定你能不能看到带完整类型提示的组件代码。2.3 Vite 与 Nuxt 两套配置文件怎么共存回到项目里那几个老文件tsconfig.node.json负责 Vite 配置使用的 Node 环境类型auto-imports.d.ts和components.d.ts是自动导入插件生成的声明文件。如果新项目不需要它们可以从工程中移除但为了兼容既有组件扫描保留也无妨。需要注意的冲突点有三个。第一不要在vite.config.ts中注册vitejs/plugin-vue之后再通过 Nuxt 启动应用Nuxt3 内置了 Vue 插件重复注册会让构建出现 deprecation 提示。第二tsconfig.json的include不要把.nuxt排除掉否则useFetch、useRoute的类型会全部变成 any。第三包管理器版本不一致会导致 Vite 依赖树分裂安装完依赖后可以执行npm ls vite看当前解析到的 Vite 版本如果存在两个大版本在package.json里统一overrides或resolutions后重装。提示如果运行npm run dev提示找不到vite通常不是真正缺失模块而是 Nuxt 内部依赖与你单独安装的 Vite 版本错位。删除根目录node_modules/vite和 lockfile按 Nuxt 自动生成的依赖重新安装再启动开发服务器即可。3. Nuxt3 路由与 useAsyncData把 SPA 优势迁移到 SEO 场景3.1 文件即路由从 views 迁移到 pagesNuxt3 的页面路由由pages目录自动生成views里的 .vue 文件如果不放进pages它们不会获得独立 URL。源码中保留src/views通常是因为早期 SPA 把页面组件放在那里迁移时可以直接把views/Home.vue移动到pages/index.vue。例如创建房型详情页时目录结构如下src/pages/rooms/[id].vue参数id对应/rooms/123的123。在页面组件里可以直接用useRoute读取参数并用useFetch请求详情template div RoomDetail v-ifroom :roomroom / LoadingState v-else / /div /template script setup langts const route useRoute() // useFetch 在服务端执行并把结果序列化到客户端 const { data: room, error } await useFetchRoomDetail( /api/rooms/${route.params.id} ) if (error.value) { throw createError({ statusCode: 404, message: 房型不存在, fatal: true }) } useHead({ title: computed(() room.value?.title ?? 房源详情), meta: [ { name: description, content: computed(() room.value?.summary ?? Airbnb 房型详情) } ] }) /scriptuseFetch的返回值是 ref 对象所以在模板中room是响应式的。throw createError在 SSR 时会直接让 Nuxt 渲染一个 404 页面避免把error泄露给用户。这里的RoomDetail和LoadingState都来自src/components不需要手动 import。3.2 服务端数据预取useAsyncData 与 useFetch 的选择在 Vue Router SPA 中onMounted发请求再渲染是常见写法但到了 SSR 场景首屏 HTML 在服务端已经输出完毕等浏览器再发请求会白白增加一次往返。Nuxt3 推荐在script setup里直接调用useAsyncData或useFetch让数据在服务端先准备好。下表是几个方法的选择依据方法请求时机使用场景useFetchSSR 和客户端导航时都执行普通 JSON 接口比如房型列表useAsyncDataSSR 和客户端导航时执行但需自配请求封装第三方 SDK 调用$fetch只在调用时执行点击收藏、提交预订等用户事件useLazyFetch不阻塞导航客户端异步加载地图兴趣点、评价分页简单来说页面首屏数据用useFetch组件的次要数据用useLazyFetch用户操作后主动发起的请求用$fetch。如果遇到useFetch与路由参数联动的情况可以把route.params.id传给useFetch的 keys 数组让数据随参数变化重新获取。3.3 用 Nitro 提供接口server/api 下的房型数据Nuxt3 自带 Nitro 服务端可以在不单独启动 Express 的情况下写后端接口。我们在server/api下创建一个房型列表接口// server/api/rooms.get.ts export default defineEventHandler((event) { const { city 上海, page 1 } getQuery(event) const rooms [ { id: 1, title: 老洋房整租, price: 680, city: 上海 }, { id: 2, title: 外滩景观公寓, price: 1240, city: 上海 }, { id: 3, title: 梧桐区民宿, price: 520, city: 上海 } ] // 按城市过滤并模拟分页返回 const filtered rooms.filter((room) room.city city) return { list: filtered.slice((Number(page) - 1) * 10, Number(page) * 10), total: filtered.length } })defineEventHandler接收event参数getQuery从 URL 中取出查询参数。这样一个 GET 请求http://localhost:3000/api/rooms?city上海即可拿到 JSON。页面中请求接口时建议直接用字符串路径const { data: result } await useFetch(/api/rooms, { query: { city: route.query.city, page: route.query.page } })让 Nuxt 处理相对路径的好处是开发和生产环境都会自动指向当前站点的域名避免额外配置 VITE_API_BASE。如果接口返回 404先检查server/api是否放在项目根目录而不是src内部放在src/server需要额外配置 nitro 的srcDir对初学者来说没必要。4. Vue3 TS 实现搜索与预订Composition API 与类型约束4.1 数据结构先行先定义 SearchQuery 再写组件在仿 Airbnb 站点里最核心的业务交互是搜索和预订。搜索条件包括地点、入住日期、退房日期、人数如果再叠加价格区间、房源类型参数很容易散落在各个ref里。用 TypeScript 先把结构定义出来能让页面的状态有一张“结构图”。// src/api/types.ts export interface SearchQuery { location: string checkIn: string checkOut: string guests: number } export interface RoomDto { id: number title: string price: number city: string image: string }SearchQuery多一个字段所有调用组件的地方都会得到类型错误这是推进项目重构时最好的提示。比 UI 界面先写类型能避免“字段名在组件之间复制粘贴导致不匹配”的隐性 bug。4.2 用 useSearch 组合式函数统一表单逻辑Vue3 的 Composition API 适合把一组相关的搜索逻辑抽成一个组合式函数。下面这个useSearch负责维护搜索表单状态// src/composables/useSearch.ts import { computed, ref, watch } from vue export function useSearch(defaultQuery: SearchQuery) { const keyword ref(defaultQuery.location) const checkIn ref(defaultQuery.checkIn) const checkOut ref(defaultQuery.checkOut) const guests refnumber(defaultQuery.guests) const disabledDates computed(() { // 简单示例checkOut 不能早于 checkIn return checkIn.value checkOut.value checkOut.value checkIn.value }) const canSubmit computed(() { return keyword.value.trim().length 0 !disabledDates.value }) async function submit() { if (!canSubmit.value) return const query: SearchQuery { location: keyword.value, checkIn: checkIn.value, checkOut: checkOut.value, guests: guests.value } // $fetch 在服务端与客户端都能运行 return await $fetchRoomDto[](/api/search, { method: POST, body: query }) } watch(keyword, (value) { // 输入防抖常用于地点自动补全 if (value.trim()) { console.log(fetch suggestions for, value) } }) return { keyword, checkIn, checkOut, guests, disabledDates, canSubmit, submit } }computed在 Vue3 中根据依赖的 ref 做缓存只有checkIn或checkOut变化时才会重新计算所以disabledDates不会成为性能瓶颈。guests显式标注为number避免在 input 的事件中把它变成字符串。这里也体现了 Nuxt3 和 Vue3 的组合优势$fetch是 Nuxt 提供的全局请求方法返回的数据类型可以直接承载RoomDto[]不用像 axios 那样包一层.data。4.3 把列表页与详情页串起来搜索表单提交后得到的结果通常进入列表页。列表页可以用一个refRoomDto[]保存结果再通过路由参数传递筛选条件const router useRouter() const { keyword, submit } useSearch({ location: 上海, checkIn: , checkOut: , guests: 2 }) async function handleSearch() { const result await submit() router.push({ path: /rooms, query: { location: keyword.value, checkIn: checkIn.value } }) }把搜索条件放到 URL query 中比直接用 Pinia 存状态更利于分享链接和 SSR 初始化。用户粘贴链接给朋友时朋友看到的搜索结果和原页面一致这是 Airbnb 这类站点应该有的体验。4.4 引入 Pinia 管理全局态时的注意点搜索条件也可以放进 Pinia但仅用于跨页面同步。安装后注册模块npm install pinia pinia/nuxt在nuxt.config.ts里加入export default defineNuxtConfig({ modules: [pinia/nuxt] // 模块自动注册 store 目录 })Pinia 在 Nuxt3 中的使用方式与 Vue3 SPA 基本一致但要注意服务端渲染时每个请求都必须有独立的 store不能把 store 实例放在模块顶层。所以组合式函数内部要用defineStore的工厂函数返回而不是直接导出useStore()的返回值。这一点在并发请求时最容易暴露具体表现是不同用户的状态互相污染。5. 自动导入、路径别名与多语言拆解 src 目录的工程细节5.1 自动导入的边界components.d.ts 与 auto-imports.d.ts压缩包中可见的components.d.ts、auto-imports.d.ts在 Vite 生态里通常由 unplugin 生成。Nuxt3 内置了自动导入机制扫描src/components和src/composables目录生成.nuxt/types/components.d.ts。所以你在组件里可以直接使用SearchBar而不需要 import文件越多这个机制越省事但也会带来一个隐蔽的问题同名组件覆盖。Nuxt 解析组件时以目录深度优先同名时深处组件会覆盖浅处。如果src/components/search/SearchBar.vue与src/components/SearchBar.vue同时存在运行到哪个取决于文件系统扫描顺序容易在不同平台上表现不一致。解决的方法很直接给关键组件显式 import或者在文件名上增加模块前缀例如HomeSearchBar.vue、RoomsSearchBar.vue。5.2 环境变量的迁移从 VITE_ 到 NUXT_在env.d.ts里看到ImportMetaEnv声明变是 Vite 项目遗留的东西。Nuxt3 虽然兼容import.meta.env.VITE_*但生产构建时更推荐用 runtimeConfig因为只有以NUXT_开头的环境变量才会被序列化到客户端。常见做法NUXT_PUBLIC_API_BASEhttps://api.example.com在代码里获取const config useRuntimeConfig() console.log(config.public.apiBase)如果直接用import.meta.env.VITE_API_BASE开发环境正常但执行nuxt build后客户端代码里可能变成undefined。遇到“开发正常、生产接口为空”的问题时先去检查环境变量前缀有没有写错。5.3 多语言目录 language 的接入Airbnb 类站点的国际化通常有 zh-CN 和 en-US 两套文案src/language下放 JSON 是最常见的组织方式。配合nuxtjs/i18n模块// nuxt.config.ts export default defineNuxtConfig({ modules: [nuxtjs/i18n], i18n: { defaultLocale: zh-CN, locales: [ { code: zh-CN, name: 简体中文 }, { code: en-US, name: English } ], strategy: prefix_except_default // 默认语言不显示前缀 } })配置后用$t(home.title)渲染文案。要注意prefix_except_default策略下切换语言会改变 URL 前缀如果同时启用了useHead里的 canonical URL需要让useLocalePath参与链接生成否则搜索引擎会认为两个 URL 是重复页面。另外SSR 环境下读取语言偏好不能直接访问localStorage可以从 cookie 里读。5.4 路由共存与自定义场景src/router里的东西如果直接运行Nuxt3 会忽略它。原因在于 Nuxt3 在内部已经生成了基于文件系统路由的 Vue Router 实例你再去createRouter只会创建另一个 router 实例二者互不接管。想要自定义路由行为应该利用pages目录下的definePageMeta中间件或者在server/middleware中写服务端逻辑。比如旧链接/room?id123要重定向到/rooms/123一个更合适的位置是server/middleware/url.tsexport default defineEventHandler((event) { const url getRequestURL(event) if (url.pathname /room url.searchParams.get(id)) { return sendRedirect(event, /rooms/${url.searchParams.get(id)}) } })这样旧链接不经过前端路由也不会在 404 页面里闪一下。类似需求优先考虑服务端中间件逻辑落点清晰后续迁移也方便。6. 收尾技巧用 routeRules 提升详情页性能并排查水合警告6.1 用 routeRules 给详情页做 SWR详情页是访问量最高、数据变化最慢的页面。在nuxt.config.ts里加一段规则即可让 Nitro 对/rooms/**做增量静态缓存export default defineNuxtConfig({ routeRules: { /rooms/**: { swr: 60 } // 60 秒内直接返回缓存 HTML } })swr: 60表示 60 秒内的所有请求直接读缓存60 秒后由第一个请求触发重新渲染并把新页面缓存下来。它比纯静态页更适合房源这类时效性数据不需要在每次价格变动时重新发布站点。如果某个房间被下架可以在下架接口里调用 Nitro 的缓存失效逻辑或者把该页面标记为 prerender 并持续刷新。6.2 定位水合警告的流程Vue 控制台出现Hydration completed but contains mismatches时多数与“客户端多渲染了某个元素”有关。先把渲染逻辑里直接调用Date.now()、Math.random()的地方去掉改为在onMounted中设置const now ref() onMounted(() { now.value new Date().toISOString() })然后打开浏览器查看接口返回的 HTML对比服务端与客户端渲染结果。最常见的差异是文本内容里多了一个空格、emoji 或空格实体。把差异行所在组件的template拆小二分定位到具体节点效率会高很多。6.3 部署时只带上 .output项目跑顺之后部署时不需要上传全部源码。nuxt build之后会生成.output目录里面包含应有的服务端入口。启动命令是node .output/server/index.mjs再用 Nginx 反向代理到对应端口。这里给你一个最小配置的思路location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; }Nginx 不需要处理 SPA fallback因为 Nuxt 的服务端会处理路由。如果想进一步提升静态资源命中率可以给/_nuxt/目录加上长缓存头但不要对 HTML 设置长缓存否则routeRules的swr会失去意义。这样运营一段时间后查看访问日志里的 cache 命中情况就能确认 route 规则是否真正生效。本文还有配套的精品资源点击获取