Vue3+Element Plus仿小红书前端实战:从瀑布流到性能优化
2026/9/14 1:40:14 网站建设 项目流程

简介:这是基于Vue 3与Element Plus构建的仿小红书Web端前端源码包,适合有Vue基础的前端学习者与初中级开发者,用于练习组件化架构、响应式布局与完整业务交互。资源共36个文件,包括12个Vue页面组件、9个JavaScript逻辑文件、4个CSS样式文件及若干JSON配置等,压缩包仅99KB,src目录下按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 脚手架创建项目,先定 TypeScript

npm create vite@latest xhs-clone -- --template vue-ts cd xhs-clone npm install npm install element-plus @element-plus/icons-vue pinia vue-router

vue-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-imageel-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-buttonel-tabs这类组件,AutoImport负责ElMessageElMessageBox这类 API 函数。两者配合后,代码里直接写<el-tabs>ElMessage.success(),Vite 编译阶段会把对应的组件和样式自动打进产物,不用手动import组件、也不用引全局 CSS。

提示:编辑器对自动导入的变量报 undefined 时,给AutoImport({ dts: true })生成类型声明,或在tsconfig.jsontypes里加上对应声明文件路径。

自动导入只处理组件和 API 的引入,CSS 变量仍然在项目里手动控制。想改主题色,可以在全局样式里覆盖 CSS 变量,也可以引入 SCSS 源码走$colors配置,第 5 章会给 CSS 变量方案。

2.3 仿小红书项目的目录规划

按职责拆目录,避免页面组件越写越臃肿:

目录/文件职责典型内容
src/api/接口请求封装note.ts里放getFeedgetNoteDetail
src/components/跨页面通用组件NoteCard.vueWaterfall.vue
src/layouts/页面外壳HomeLayout.vue含顶部搜索栏和底部 Tab
src/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 class="home-page"> <header class="home-page__header"> <el-input v-model="keyword" placeholder="搜索笔记" :prefix-icon="Search" clearable /> </header> <el-tabs v-model="activeTab" class="home-page__tabs" @tab-change="onTabChange"> <el-tab-pane label="发现" name="discover" /> <el-tab-pane label="关注" name="follow" /> <el-tab-pane label="附近" name="nearby" /> </el-tabs> <Waterfall :notes="visibleNotes" :loading="loading" /> </div> </template> <script setup lang="ts"> 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 模拟接口 } </script>

v-model绑定的name值决定当前显示哪个分栏,@tab-change在切换后触发数据加载。三个分栏数据源独立,computed根据activeTab派生当前列表,避免在模板里写一串三元表达式。

面试里常被追问的「element-plus 菜单结合 tab 一起使用」,本质是让两个组件共享同一个响应式变量。比如个人主页左侧el-menu、右侧el-tabsel-menudefault-active指向activeTabselect事件里给它赋值;el-tabsv-model也绑定activeTab。要注意el-menuindex规定是字符串,el-tabsname也建议统一成字符串 key,否则会出现数字和字符串类型错位导致选中态失效。

3.2 瀑布流:JS 均分配列,而不是 CSS columns

双列瀑布流有两种常见实现,差别肉眼可见:

方案填充顺序适合场景
CSScolumns先竖排填满左列,再填右列纯图库、照片墙
JS 均分 + CSS Grid按数组顺序先左后右信息流、带标题卡片

CSScolumns的问题在于顺序不友好:第 1、3、5 条数据全在左列,用户读到右侧时已经隔了三条内容。信息流要求视觉顺序和数组顺序一致,所以用 JS 按索引交替分发到两列:

<template> <div class="waterfall"> <div v-for="(column, colIndex) in columnList" :key="colIndex" class="waterfall__column"> <NoteCard v-for="note in column" :key="note.id" :note="note" /> </div> </div> </template> <script setup lang="ts"> 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; } </style>

columnListcomputed,源数据变化时自动重算两列分配。用 Grid 双列而不是绝对定位,省掉手动计算每张卡片高度的逻辑,卡片高度不一致时 Flex 列会自动撑开。8px 的间距接近小红书信息流的紧凑感,想要宽松可以调成12px

当面时提到瀑布流,可以补一句:这个方案只解决双列分发,不解决「左右列高度差太大」的问题。真实社区产品会做高度预估后短边优先插入,那就是vue-virtual-scroller那类虚拟列表的范畴,仿站阶段不需要上。

3.3 封装 NoteCard:封面比例、图片懒加载、点赞数格式化

卡片是信息流里复用率最高的组件,封装时把「点击跳详情」「图片懒加载」「数字格式化」都做进组件内部,调用方只传一个note对象:

<template> <div class="note-card" @click="router.push(`/note/${note.id}`)"> <el-image class="note-card__cover" :src="note.cover" fit="cover" :lazy="true" /> <p class="note-card__title">{{ note.title }}</p> <div class="note-card__meta"> <span class="note-card__author">{{ note.author }}</span> <span class="note-card__likes"> <el-icon><Star /></el-icon> {{ formatCount(note.likes) }} </span> </div> </div> </template> <script setup lang="ts"> 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; } </style>

el-imagelazy属性开的是组件内置懒加载,滚动到视口附近才请求图片,首屏只加载可见的几张封面,这是信息流性能的关键。aspect-ratio: 3 / 4把封面固定成小红书常见的 3:4 竖图比例,object-fit: coverfit="cover"兜底,避免图片变形。标题用两行截断,超过部分显示省略号,防止卡片高度参差过大。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 class="detail__image" :src="note.images[activeIndex]" :preview-src-list="note.images" :initial-index="activeIndex" fit="contain" />

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))

点击按钮时调用toggleLike,store 更新后,首页卡片上同一个 ID 的红心状态同步变化,不需要重新请求接口。

4.3 发布表单:el-form 的 rules 与自定义校验

发布页是 Element Plus 发挥最充分的地方。表单包含标题、正文、标签、图片四类字段,用el-formmodelrules声明式校验:

<script setup lang="ts"> import { reactive, ref } from 'vue' import { ElMessage, type FormInstance, type FormRules } from 'element-plus' const formRef = ref<FormInstance>() 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说明
titlerequired+min/maxblur失焦时校验,长度 4~30
contentrequiredblur正文非空即可
imagestype: 'array'+min: 1change图集至少一张,添加图片时触发

trigger决定校验时机:blur是输入框失焦时校验,change是值变化时校验。图集是数组,用change才能在图片上传完成后立刻提示,而不是等用户点别的地方。validate()返回 Promise,校验失败会 reject,所以用.catch(() => false)兜住,避免submit函数里出现未捕获的 Promise 异常。

图片上传用el-uploadlist-type="picture-card",在on-success回调里把返回的 URL 推进form.images,样式上就是小红书发布页那排方形缩略图。标签输入用el-tagel-input的经典组合,回车新增、点击关闭删除,这部分逻辑和 Element Plus 官方示例一致,直接抄配置改字段名即可。

5. 仿小红书前端的上线前优化:骨架屏、触底加载与主题色

页面能跑通只是第一步。把这三件事做完,这份仿小红书源代码才算有拿出手的质量。

5.1 首屏骨架屏:用 el-skeleton 承接加载态

接口没返回时,空白页面体验很差。用el-skeleton配合animated属性渲染加载占位,数据到达后自动切换成真实内容:

<el-skeleton :loading="loading" animated> <template #template> <div class="skeleton-grid"> <el-skeleton-item v-for="i in 6" :key="i" variant="image" style="height: 180px" /> </div> </template> <template #default> <Waterfall :notes="notes" /> </template> </el-skeleton>

variant="image"生成图片形状的灰色块,和卡片封面比例保持一致时观感最自然。注意骨架屏要和真实布局同构,真实页面是双列,骨架也应是双列,否则加载完成后布局跳动明显。

5.2 触底加载:一个 IntersectionObserver 搞定

监听滚动事件再做节流是常见做法,但更省事的是在列表底部放一个哨兵元素,用IntersectionObserver观察它是否进入视口:

// src/composables/useInfiniteScroll.ts import { onMounted, onUnmounted, ref } from 'vue' export function useInfiniteScroll(loadMore: () => void) { const sentinel = ref<HTMLElement | 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 ref="sentinel" style="height: 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那行才算覆盖成功。

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

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

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

立即咨询