
简介这是基于Vue 3与Element Plus构建的仿小红书Web端前端源码包适合有Vue基础的前端学习者与初中级开发者用于练习组件化架构、响应式布局与完整业务交互。资源共36个文件包括12个Vue页面组件、9个JavaScript逻辑文件、4个CSS样式文件及若干JSON配置等压缩包仅99KBsrc目录下按apis、components、router、stores、views等模块划分便于对照阅读。主页采用瀑布流卡片展示点击卡片可放大预览个人详情页整合收藏、发帖与点赞数据登录注册为卡片式设计并包含完整表单校验发布模块支持上传图片、填写信息后生成预览图基本还原小红书核心交互。目前已有465人学习下载适合作为仿站练手、课程设计或前端面试作品参考。1. 仿小红书前端为什么选择 Vue3 加 Element Plus双列瀑布流、红色爱心、底部标签栏——小红书视觉辨识度很高但前端并未开源面试作品常有人复刻。市面上的仿小红书项目多停在 Vue2 或原生 JS现在看Vue3 组合式 API 更适合管瀑布流这类高频更新状态Element Plus 再把表单、弹窗、消息提示补齐。下面从工程初始化讲起依次拆解首页瀑布流、笔记详情与发布表单再到性能优化覆盖一个可运行的仿小红书前端最小闭环。适合刚学完 Vue3、准备作品集的人也适合想了解 Element Plus 在 C 端页面怎么落地的一线开发者。仿的是交互与视觉形态配图和品牌元素请替换成自有素材。2. Vue3 工程初始化Element Plus 按需引入的配置方式一个仿小红书项目能不能跑得轻第一步不是写组件而是把工程依赖配好。Element Plus 全量引入会让首包多出几百 KB 的组件与样式代码按需引入是必须做的一步也正好对应 vue3 环境配置里最常被忽略的部分。这一章给出完整的初始化命令、Vite 配置和目录规划。2.1 用 Vite 脚手架创建项目先定 TypeScriptnpm create vitelatest xhs-clone -- --template vue-ts cd xhs-clone npm install npm install element-plus element-plus/icons-vue pinia vue-routervue-ts模板自带 TypeScript 和vitejs/plugin-vue省去手工搭编译器的时间。element-plus是组件库本体element-plus/icons-vue提供图标pinia用于管理点赞、收藏这类跨页面状态vue-router负责首页、详情、发布三个页面的跳转。若本机还没装 Node先装 LTS 版本再跑上面四行命令Node 18 之后的环境配置基本就是这一套。创建完成后我习惯先删掉模板自带的HelloWorld.vue和默认样式避免示例代码干扰后面的目录规划。随后启动npm run dev浏览器能打开 Vite 默认页就说明环境通了再继续接 Element Plus。2.2 unplugin 自动导入用多少组件打包多少全量引入最简单但在这种 C 端仿站项目里完全不划算import ElementPlus from element-plus import element-plus/dist/index.css app.use(ElementPlus)这种方式会把全部组件和样式注册进应用即使只用了el-image、el-form等十来个组件。改为按需引入需要加两个 Vite 插件// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] })Components负责模板里出现的el-button、el-tabs这类组件AutoImport负责ElMessage、ElMessageBox这类 API 函数。两者配合后代码里直接写el-tabs和ElMessage.success()Vite 编译阶段会把对应的组件和样式自动打进产物不用手动import组件、也不用引全局 CSS。提示编辑器对自动导入的变量报 undefined 时给AutoImport({ dts: true })生成类型声明或在tsconfig.json的types里加上对应声明文件路径。自动导入只处理组件和 API 的引入CSS 变量仍然在项目里手动控制。想改主题色可以在全局样式里覆盖 CSS 变量也可以引入 SCSS 源码走$colors配置第 5 章会给 CSS 变量方案。2.3 仿小红书项目的目录规划按职责拆目录避免页面组件越写越臃肿目录/文件职责典型内容src/api/接口请求封装note.ts里放getFeed、getNoteDetailsrc/components/跨页面通用组件NoteCard.vue、Waterfall.vuesrc/layouts/页面外壳HomeLayout.vue含顶部搜索栏和底部 Tabsrc/views/路由页面首页、详情页、发布页src/stores/Pinia 状态点赞、收藏、关注关系src/router/路由表动态参数/:id、滚动行为src/utils/纯函数工具数字格式化、防抖src/styles/全局样式颜色变量、reset单独拆layouts的原因是小红书详情页没有底部标签栏发布页也没有只有首页套着底部 Tab。把带 Tab 的骨架放在HomeLayout.vue里首页作它的子路由详情页和发布页独立成顶层路由路由表会干净很多。项目里第二层再出现跨业务的通用组件时建议在components/下按feature/或common/细分不要在views里塞一堆局部组件。3. 仿小红书首页瀑布流与笔记卡片的完整实现首页是整个仿小红书项目里信息量最大的页面顶部搜索栏、分类 Tab、双列信息流。Element Plus 在这个页面的价值不在于某个组件多惊艳而是几个组件组合起来恰好能表达社区产品的高频交互。这一章把首页拆成三段来实现Tab 切换、瀑布流、卡片封装。3.1 用 el-tabs 切「发现 / 关注」和 el-menu 联动怎么写小红书首页顶部是「发现 / 关注 / 附近」三个入口Element Plus 里最贴合的组件是el-tabs。把每个分栏的标题去掉下划线样式再配一个el-input搜索框视觉上就接近社区首页的骨架template div classhome-page header classhome-page__header el-input v-modelkeyword placeholder搜索笔记 :prefix-iconSearch clearable / /header el-tabs v-modelactiveTab classhome-page__tabs tab-changeonTabChange el-tab-pane label发现 namediscover / el-tab-pane label关注 namefollow / el-tab-pane label附近 namenearby / /el-tabs Waterfall :notesvisibleNotes :loadingloading / /div /template script setup langts import { computed, ref } from vue import { Search } from element-plus/icons-vue const activeTab ref(discover) const keyword ref() const feedMap { discover: [] as Note[], follow: [] as Note[], nearby: [] as Note[] } const visibleNotes computed(() feedMap[activeTab.value]) function onTabChange() { // 切分栏时重新拉数据这里用 setTimeout 模拟接口 } /scriptv-model绑定的name值决定当前显示哪个分栏tab-change在切换后触发数据加载。三个分栏数据源独立computed根据activeTab派生当前列表避免在模板里写一串三元表达式。面试里常被追问的「element-plus 菜单结合 tab 一起使用」本质是让两个组件共享同一个响应式变量。比如个人主页左侧el-menu、右侧el-tabsel-menu的default-active指向activeTabselect事件里给它赋值el-tabs的v-model也绑定activeTab。要注意el-menu的index规定是字符串el-tabs的name也建议统一成字符串 key否则会出现数字和字符串类型错位导致选中态失效。3.2 瀑布流JS 均分配列而不是 CSS columns双列瀑布流有两种常见实现差别肉眼可见方案填充顺序适合场景CSScolumns先竖排填满左列再填右列纯图库、照片墙JS 均分 CSS Grid按数组顺序先左后右信息流、带标题卡片CSScolumns的问题在于顺序不友好第 1、3、5 条数据全在左列用户读到右侧时已经隔了三条内容。信息流要求视觉顺序和数组顺序一致所以用 JS 按索引交替分发到两列template div classwaterfall div v-for(column, colIndex) in columnList :keycolIndex classwaterfall__column NoteCard v-fornote in column :keynote.id :notenote / /div /div /template script setup langts import { computed } from vue import NoteCard from ./NoteCard.vue const props defineProps{ notes: Note[] }() const columnList computed(() { const left: Note[] [] const right: Note[] [] props.notes.forEach((note, index) { if (index % 2 0) left.push(note) else right.push(note) }) return [left, right] }) /script style scoped .waterfall { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 8px; } .waterfall__column { display: flex; flex-direction: column; gap: 8px; } /stylecolumnList是computed源数据变化时自动重算两列分配。用 Grid 双列而不是绝对定位省掉手动计算每张卡片高度的逻辑卡片高度不一致时 Flex 列会自动撑开。8px 的间距接近小红书信息流的紧凑感想要宽松可以调成12px。当面时提到瀑布流可以补一句这个方案只解决双列分发不解决「左右列高度差太大」的问题。真实社区产品会做高度预估后短边优先插入那就是vue-virtual-scroller那类虚拟列表的范畴仿站阶段不需要上。3.3 封装 NoteCard封面比例、图片懒加载、点赞数格式化卡片是信息流里复用率最高的组件封装时把「点击跳详情」「图片懒加载」「数字格式化」都做进组件内部调用方只传一个note对象template div classnote-card clickrouter.push(/note/${note.id}) el-image classnote-card__cover :srcnote.cover fitcover :lazytrue / p classnote-card__title{{ note.title }}/p div classnote-card__meta span classnote-card__author{{ note.author }}/span span classnote-card__likes el-iconStar //el-icon {{ formatCount(note.likes) }} /span /div /div /template script setup langts import { useRouter } from vue-router import { Star } from element-plus/icons-vue interface Note { id: string cover: string title: string author: string likes: number } const props defineProps{ note: Note }() const router useRouter() function formatCount(count: number): string { return count 10000 ? (count / 10000).toFixed(1) w : String(count) } /script style scoped .note-card__cover { width: 100%; aspect-ratio: 3 / 4; border-radius: 8px; } .note-card__title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 14px; line-height: 1.4; } .note-card__meta { display: flex; justify-content: space-between; font-size: 12px; color: #666; } /styleel-image的lazy属性开的是组件内置懒加载滚动到视口附近才请求图片首屏只加载可见的几张封面这是信息流性能的关键。aspect-ratio: 3 / 4把封面固定成小红书常见的 3:4 竖图比例object-fit: cover由fitcover兜底避免图片变形。标题用两行截断超过部分显示省略号防止卡片高度参差过大。formatCount把 12000 显示成1.2w这种细节在这份源代码里占比不大但很影响观感。4. 仿小红书笔记详情页与发布表单路由、状态与校验首页负责引流详情页负责承载正文发布页负责生产内容。这一章把三个页面之间的路由关系、跨页面的交互状态、以及发布表单的校验规则一次讲完。4.1 路由配置详情页动态参数与滚动恢复详情页的 URL 带笔记 ID路由表里用:id占位同时配置scrollBehavior让用户从详情页返回首页时能回到原来的滚动位置// src/router/index.ts import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), scrollBehavior(to, from, savedPosition) { return savedPosition ?? { top: 0 } }, routes: [ { path: /, component: () import(/layouts/HomeLayout.vue), children: [{ path: , name: home, component: () import(/views/HomeView.vue) }] }, { path: /note/:id, name: note-detail, component: () import(/views/NoteDetailView.vue) }, { path: /publish, name: publish, component: () import(/views/PublishView.vue) } ] })首页挂在HomeLayout下底部 Tab 由 Layout 统一渲染详情页不套 Layout所以它是顶层路由。savedPosition是浏览器返回时自动记录的坐标有值就恢复原位没有就滚到顶部如果不配这个函数Vue Router 默认行为是每次跳转都回到顶部用户在信息流里点进详情再返回时会丢掉刚才浏览的位置。详情页里通过useRoute()拿参数注意route.params.id的类型是string | string[]先转成字符串再拼请求参数。页面内轮播大图可以用el-image的预览能力实现preview-src-list传入图片数组放大看大图由组件内置完成el-image classdetail__image :srcnote.images[activeIndex] :preview-src-listnote.images :initial-indexactiveIndex fitcontain /initial-index决定点击预览时从哪张图开始看配合缩略图点击事件更新activeIndex就实现了点哪张看哪张的交互。4.2 用 Pinia 管理点赞与收藏状态跨页面共享点赞/收藏状态最忌讳放在组件内部详情页点了赞返回首页列表时卡片上的红心状态对不上因为两个页面各自维护了一份数据。把状态提升到 Pinia store列表页和详情页读的是同一份数据// src/stores/interaction.ts import { defineStore } from pinia export const useInteractionStore defineStore(interaction, { state: () ({ likedIds: [] as string[], collectedIds: [] as string[] }), getters: { isLiked: (state) (id: string) state.likedIds.includes(id), isCollected: (state) (id: string) state.collectedIds.includes(id) }, actions: { toggleLike(id: string) { const index this.likedIds.indexOf(id) if (index -1) this.likedIds.splice(index, 1) else this.likedIds.push(id) }, toggleCollect(id: string) { const index this.collectedIds.indexOf(id) if (index -1) this.collectedIds.splice(index, 1) else this.collectedIds.push(id) } } })getter 返回函数是为了在模板里按笔记 ID 查询interactionStore.isLiked(note.id)。注意 getter 里不能直接写箭头函数返回Set再在模板里.has()虽然能跑但响应式追踪在部分边界场景会丢更新。数组加includes是最稳的写法。详情页里点赞按钮的显示值用computed派生const liked computed(() interactionStore.isLiked(route.params.id as string))点击按钮时调用toggleLikestore 更新后首页卡片上同一个 ID 的红心状态同步变化不需要重新请求接口。4.3 发布表单el-form 的 rules 与自定义校验发布页是 Element Plus 发挥最充分的地方。表单包含标题、正文、标签、图片四类字段用el-form的model和rules声明式校验script setup langts import { reactive, ref } from vue import { ElMessage, type FormInstance, type FormRules } from element-plus const formRef refFormInstance() const form reactive({ title: , content: , tags: [] as string[], images: [] as string[] }) const rules: FormRules { title: [ { required: true, message: 标题不能为空, trigger: blur }, { min: 4, max: 30, message: 标题长度需要在 4 到 30 字之间, trigger: blur } ], content: [{ required: true, message: 正文不能为空, trigger: blur }], images: [{ type: array, required: true, min: 1, message: 至少上传一张图片, trigger: change }] } async function submit() { const valid await formRef.value?.validate().catch(() false) if (!valid) return ElMessage.success(发布成功示例流程未接后端) } /script各字段的校验规则如下重点看trigger的差异字段校验规则trigger说明titlerequiredmin/maxblur失焦时校验长度 4~30contentrequiredblur正文非空即可imagestype: arraymin: 1change图集至少一张添加图片时触发trigger决定校验时机blur是输入框失焦时校验change是值变化时校验。图集是数组用change才能在图片上传完成后立刻提示而不是等用户点别的地方。validate()返回 Promise校验失败会 reject所以用.catch(() false)兜住避免submit函数里出现未捕获的 Promise 异常。图片上传用el-upload的list-typepicture-card在on-success回调里把返回的 URL 推进form.images样式上就是小红书发布页那排方形缩略图。标签输入用el-tag加el-input的经典组合回车新增、点击关闭删除这部分逻辑和 Element Plus 官方示例一致直接抄配置改字段名即可。5. 仿小红书前端的上线前优化骨架屏、触底加载与主题色页面能跑通只是第一步。把这三件事做完这份仿小红书源代码才算有拿出手的质量。5.1 首屏骨架屏用 el-skeleton 承接加载态接口没返回时空白页面体验很差。用el-skeleton配合animated属性渲染加载占位数据到达后自动切换成真实内容el-skeleton :loadingloading animated template #template div classskeleton-grid el-skeleton-item v-fori in 6 :keyi variantimage styleheight: 180px / /div /template template #default Waterfall :notesnotes / /template /el-skeletonvariantimage生成图片形状的灰色块和卡片封面比例保持一致时观感最自然。注意骨架屏要和真实布局同构真实页面是双列骨架也应是双列否则加载完成后布局跳动明显。5.2 触底加载一个 IntersectionObserver 搞定监听滚动事件再做节流是常见做法但更省事的是在列表底部放一个哨兵元素用IntersectionObserver观察它是否进入视口// src/composables/useInfiniteScroll.ts import { onMounted, onUnmounted, ref } from vue export function useInfiniteScroll(loadMore: () void) { const sentinel refHTMLElement | null(null) const observer new IntersectionObserver((entries) { if (entries[0].isIntersecting) loadMore() }) onMounted(() { if (sentinel.value) observer.observe(sentinel.value) }) onUnmounted(() observer.disconnect()) return sentinel }在Waterfall底部放一个div refsentinel styleheight: 1px /进入视口就触发loadMore。比滚动事件好在不用自己算scrollTop clientHeight是否触底也不用 throttle。注意loadMore内部要做防重正在请求时设置loading标志否则 Observer 在低配置机器上可能连续触发多次请求。5.3 主题色变量把 Element Plus 从蓝改成小红书红Element Plus 默认主色是#409EFF和社区产品的调性差太远。主色通过 CSS 变量覆盖不用重新编译 SCSS:root { --el-color-primary: #ff2442; --el-color-primary-light-3: #ff5c70; --el-color-primary-light-5: #ff8a99; --el-color-primary-light-7: #ffb8c1; --el-color-primary-light-8: #ffd0d6; --el-color-primary-light-9: #ffe8eb; --el-color-primary-dark-2: #e01e3a; }光改--el-color-primary不够按钮 hover、active、信息条背景依赖的是-light-3到-light-9这组渐变色不覆盖的话鼠标悬停时还会露出蓝色系。-dark-2是按压色通常取主色加深一档。这段变量放在全局样式文件里必须在 Element Plus 组件样式之后生效项目里一般放在src/styles/variables.css并在main.ts引入。改完启动项目打开浏览器开发者工具的 Elements 面板检查计算样式里搜--el-color-primary能找到#ff2442那行才算覆盖成功。本文还有配套的精品资源点击获取