简介:这份基于 Vue3 与 Element Plus 搭建的仿小红书 Web 端网页源码,定位为前端实战练习与快速复刻内容社区界面的工具包,适合有一定 HTML/CSS 基础、正在学习 Vue 生态的开发者。压缩包内共 36 个文件,包含 12 个 Vue 组件、9 个 JavaScript 逻辑文件、4 个 CSS 样式文件、4 个 JSON 配置等高可读性代码,压缩后体积仅 99KB,工程目录规范易上手。项目完整实现了小红书核心交互:主页瀑布流卡片布局与点击放大预览、个人详情页的收藏/发布/点赞列表展示、卡片式登录注册界面及表单校验、发布模块支持上传图片并自动生成预览图,几乎覆盖了内容社区 Web 端的典型交互场景。目前已有 465 人学习浏览,既能作为课程设计或作品集参考,也适合用 Element Plus 快速验证交互想法。直接运行构建命令即可查看整套页面效果,是学习组件化拆分、路由组织和状态管理的便捷样例。
1. 仿小红书前端设计,为什么选 Vue3 + Element Plus 而不是从零写样式
小红书前端最显眼的两个特征是双列瀑布流笔记流和清爽的卡片式视觉。把它拆开看,真正需要自己实现的部分是布局算法、图片加载策略和路由状态,底层的按钮、输入框、上传组件、弹窗完全可以用 Element Plus 撑起来。Vue3 的组合式 API 在处理列表数据、滚动加载和组件通信时比选项式 API 更直接,配合<script setup>写仿站项目,代码量能压到很低的水平。
这篇文章讲的是「如何从零搭出一份可运行的仿小红书前端源代码」。你拿到的不只是一堆组件拼装,还包括项目初始化、按需引入 Element Plus、双列瀑布流、搜索联想、发布页表单校验、滚动分页这些完整链路。适合两类人:一类是刚学完 Vue3 基础,想用完整项目验证自己水平的初学者;另一类是准备前端面试,需要一套能讲清楚原理的实战代码。核心结论先放在这里:仿小红书不靠 CSS 魔法,靠的是数据结构设计和组件边界划分。
2. 用 Vite 搭建 Vue3 项目并接入 Element Plus:环境与骨架
2.1 Vue3 安装及环境配置:最小项目初始化
常见做法是用 Vite 创建 Vue3 项目,它比 webpack 冷启动快,配合<script setup>写起来也顺手。Node 版本建议 18 以上,Vite 5 之后对版本的要求更明确,低于 16 的旧环境会直接报错。
npm create vite@latest xhs-like -- --template vue cd xhs-like npm install npm install element-plus @element-plus/icons-vue npm install vue-router@4 pinia说明:--template vue生成的是 Vue3 + Vite 的官方模板,自带App.vue、main.js和基础目录。Element Plus 的图标包@element-plus/icons-vue需要单独安装,因为它不随主包发布。vue-router@4和pinia是 Vue3 生态对应的版本,pinia 已经取代 Vuex 成为 Vue3 的推荐状态管理方案。
装完之后打开main.js,把 Element Plus 完整注册进去。完整引入的代价是打包体积偏大,约 700KB 的 JS 增量,但开发阶段调试省心,后面再按需优化。
import { createApp } from 'vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import zhCn from 'element-plus/es/locale/lang/zh-cn' import App from './App.vue' import router from './router' import { createPinia } from 'pinia' const app = createApp(App) app.use(ElementPlus, { locale: zhCn }) app.use(router) app.use(createPinia()) app.mount('#app')代码逻辑说明:app.use(ElementPlus, { locale: zhCn })传入中文语言包,否则分页器、日期选择器等内置文案显示英文。app.use(createPinia())在入口注册,组件里通过useStore()调用,不需要额外配置模块化方案。
如果在意包体积,推荐生产环境换成按需自动引入,用 unplugin 系列插件:
npm install -D unplugin-auto-import unplugin-vue-components然后在vite.config.js里配置:
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()] }) ] })逻辑说明:AutoImport 负责ElMessage、ElMessageBox这类函数式组件的自动导入,Components 负责el-button、el-input这类模板组件的按需加载。配完之后不需要在 main.js 里use(ElementPlus),代码里没写到的组件不会进打包结果。
2.2 仿小红书页面的目录结构:按页面划分而非按组件类型
很多 Vue3 项目从后台管理系统模板起步,目录习惯是components/下堆几十个文件。仿小红书这种内容型页面,我一般按页面模块组织,因为瀑布流、详情、发布三个页面之间是平级关系,各自内部的子组件只服务自身。
src/ api/ # 接口请求封装,仿站用 mock 数据 note.js router/ index.js stores/ # pinia 状态 note.js views/ home/ # 首页:搜索栏 + 瀑布流 HomeView.vue NoteCard.vue detail/ # 笔记详情 DetailView.vue CommentItem.vue publish/ # 发布页 PublishView.vue mock/ notes.json App.vue main.js路由配置用路由懒加载,把三个页面的 chunk 拆开,首屏只加载首页:
import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', name: 'home', component: () => import('../views/home/HomeView.vue') }, { path: '/note/:id', name: 'detail', component: () => import('../views/detail/DetailView.vue') }, { path: '/publish', name: 'publish', component: () => import('../views/publish/PublishView.vue') } ] }) export default router注意:createWebHistory依赖服务端配置 fallback,本地 dev 环境没问题,部署到 Nginx 时需要添加try_files $uri $uri/ /index.html;,否则刷新/note/123会 404。这是仿站项目部署时最常见的坑。
2.3 Element Plus 菜单与布局:仿小红书不套后台框架
小红书桌面端的典型布局是左侧窄栏导航加右侧内容区。Element Plus 的el-menu通常配合后台管理的侧边栏使用,直接搬过来会很「后台」。仿小红书时要改的是菜单外观:取消边框、让菜单项图标和文字左对齐、hover 背景色改浅灰。
<template> <el-container class="app-layout"> <el-aside width="180px" class="side-nav"> <div class="logo">仿小红书</div> <el-menu :default-active="activeMenu" class="menu" @select="onSelect"> <el-menu-item index="home"> <el-icon><HomeFilled /></el-icon><span>首页</span> </el-menu-item> <el-menu-item index="publish"> <el-icon><Plus /></el-icon><span>发布笔记</span> </el-menu-item> </el-menu> </el-aside> <el-main class="content"> <router-view /> </el-main> </el-container> </template>el-menu默认有右侧竖线边框和选中态高亮,仿小红书需要覆盖它:
.menu { border-right: none; --el-menu-item-height: 44px; } .menu .el-menu-item { border-radius: 8px; margin: 4px 8px; } .menu .el-menu-item.is-active { background-color: #f2f2f2; color: #181818; }说明:is-active是 Element Plus 暴露的状态类名,选中项底色改成浅灰就接近小红书的视觉了。整体布局用el-container撑开,el-main默认带内边距,记得清掉,否则内容区上下左右会有空白。菜单选中态也可以结合路由做:监听route.path计算activeMenu,实现侧边导航和 tab 标签页的联动效果,这和 element-plus 菜单结合 tab 一起使用的后台场景同理,只是这里没有多标签页需求。
3. 双列瀑布流与笔记卡片:仿小红书信息流的关键实现
3.1 瀑布流方案选型:CSS columns 与 JS 分列对比
小红书最核心的视觉元素是双列瀑布流:左右两列卡片高度参差不齐,视觉上像瀑布落下。实现上有两种主流方案,面试时也常被问到,这里对比清楚。
| 维度 | CSS columns 方案 | JS 分列方案 |
|---|---|---|
| 最小实现代码 | 4 行 CSS | 约 40 行 JS |
| 填充顺序 | 纵向先填满第一列 | 横向左右交替 |
| 动态追加数据 | 自动回流,无需干预 | 需手动计算两列高度 |
| 与懒加载配合 | 滚动位置计算略麻烦 | 位置可控度更高 |
| 浏览器兼容 | 现代浏览器均支持 | 无兼容问题 |
CSS columns 的实现极短,用column-count指定列数,子元素加break-inside: avoid防止卡片被拦腰截断:
.note-waterfall { column-count: 2; column-gap: 12px; } .note-item { break-inside: avoid; margin-bottom: 12px; }JS 分列方案的原理是记录两列的当前高度,新卡片插入较矮的那一列,代码可控也更接近小红书真实的数据分发逻辑:
const leftColumn = ref([]) const rightColumn = ref([]) const leftHeight = ref(0) const rightHeight = ref(0) function appendToColumn(note, estimatedHeight) { if (leftHeight.value <= rightHeight.value) { leftColumn.value.push(note) leftHeight.value += estimatedHeight } else { rightColumn.value.push(note) rightHeight.value += estimatedHeight } }逻辑说明:estimatedHeight是图片宽高比预估出的卡片高度,无法精确知道,所以 JS 分列方案通常要配合 ResizeObserver 修正实测高度。仿站练手用 CSS columns 足够,但如果你在准备 vue3 面试题,JS 分列方案的「为什么能减少列差」值得多讲几句:它通过累加估算高度近似平衡两列,真实渲染后列差不超过一张卡片的高度。
3.2 用 Element Plus 组件组装笔记卡片:el-image 加 el-avatar
小红书的每张卡片包含封面图、标题、作者头像和点赞数。Element Plus 的el-image自带懒加载和占位图,比原生img更适合信息流。完整组件代码如下:
<script setup> defineProps({ note: { type: Object, required: true } }) defineEmits(['select']) const formatCount = (n) => { if (n >= 10000) return (n / 10000).toFixed(1) + '万' return String(n) } </script> <template> <div class="note-card" @click="$emit('select', note.id)"> <el-image :src="note.cover" :lazy="true" fit="cover" class="cover" :preview-src-list="[note.cover]" /> <p class="title">{{ note.title }}</p> <div class="meta"> <el-avatar :size="20" :src="note.authorAvatar" /> <span class="author">{{ note.authorName }}</span> <span class="likes"> <el-icon><Pointer /></el-icon> {{ formatCount(note.likes) }} </span> </div> </div> </template>代码说明:el-image的lazy属性基于 IntersectionObserver 实现,进入视口才开始加载图片,是信息流性能的关键。preview-src-list传入图片地址数组,点击封面可以调出 Element Plus 自带的图片预览层,这比手写弹窗省事。el-avatar指定size为数字时按像素渲染,src加载失败时显示首字符占位。这里的formatCount是纯函数,放在<script setup>顶部与逻辑分开,方便单元测试。
卡片封面高度不能写死,因为瀑布流的精髓就是每张卡片高度不同。常见做法是给封面一个基础高度,再用object-fit: cover让图片裁切填满:
.cover { width: 100%; height: 240px; border-radius: 8px; background-color: #f0f0f0; /* 图片加载前的占位底色 */ }如果你有每张图片的宽高比数据,可以用aspect-ratio属性动态设置高度,减少页面回流。封装一个计算属性返回height样式即可。
3.3 模拟数据与分页加载:从 mock JSON 到滚动加载
仿站项目没有真实后端,src 源代码里通常放一份 mock 数据模拟接口。封装一个api/note.js:
import notesData from '../mock/notes.json' const PAGE_SIZE = 10 export function fetchNotes(page) { return new Promise((resolve) => { setTimeout(() => { const start = (page - 1) * PAGE_SIZE const list = notesData.slice(start, start + PAGE_SIZE) resolve({ list, hasMore: start + PAGE_SIZE < notesData.length }) }, 300) }) } export function fetchNoteById(id) { return new Promise((resolve) => { const note = notesData.find(n => n.id === id) setTimeout(() => resolve(note), 150) }) }setTimeout模拟网络延迟,让 loading 状态有实际展示效果,也能顺便验证v-loading指令的反馈。
首页的滚动加载用IntersectionObserver监听列表底部的哨兵元素,这是当前前端开发里最主流的做法,不阻塞主线程:
<script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' import { fetchNotes } from '../../api/note' import { useNoteStore } from '../../stores/note' const sentinel = ref(null) const list = ref([]) const page = ref(1) const hasMore = ref(true) const loading = ref(false) let observer = null async function loadMore() { if (loading.value || !hasMore.value) return loading.value = true const res = await fetchNotes(page.value) list.value.push(...res.list) hasMore.value = res.hasMore page.value += 1 loading.value = false } onMounted(() => { loadMore() observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) loadMore() }, { rootMargin: '200px' }) observer.observe(sentinel.value) }) onBeforeUnmount(() => { observer && observer.disconnect() }) </script>代码逻辑说明:observer 创建在onMounted里,因为sentinel.value在模板渲染完成后才有值。rootMargin: '200px'表示哨兵元素进入视口前 200px 就触发加载,提前量可以避免用户看到加载状态。onBeforeUnmount里断开 observer 是必要的,否则路由切换后 observer 仍然持有旧 DOM 引用,可能造成内存泄漏,这是 Vue3 项目里比较隐蔽的性能问题。
用 Pinia 管理笔记状态,详情页和首页共享同一份数据,避免重复请求:
import { defineStore } from 'pinia' export const useNoteStore = defineStore('note', { state: () => ({ notes: [], noteMap: {} }), actions: { addNotes(list) { this.notes.push(...list) list.forEach(n => { this.noteMap[n.id] = n }) }, getNoteById(id) { return this.noteMap[id] } } })用noteMap做 id 到笔记对象的映射,比每次find遍历 O(n) 要快。数据量到几千条时这个差距就很明显了。很多前端初学者会把状态一股脑塞进组件里,导致详情页刷新后数据丢失,用 Pinia 统一管理是 Vue3 项目的标准解法。
4. 搜索、详情与发布页:把「仿小红书」落到交互细节上
4.1 顶部搜索栏与前端联想:el-select 加远程搜索
小红书首页顶部是搜索框,输入关键词出现联想列表。Element Plus 的el-select开启filterable和remote就能实现远程搜索,但仿站的联想数据来自前端 mock,逻辑是在本地数组里过滤:
<script setup> import { ref } from 'vue' import { fetchSearchSuggestions } from '../../api/search' const keyword = ref('') const suggestions = ref([]) const loading = ref(false) async function onSearch(query) { if (!query) return loading.value = true suggestions.value = await fetchSearchSuggestions(query) loading.value = false } </script> <template> <el-select v-model="keyword" filterable remote placeholder="搜索笔记" :remote-method="onSearch" :loading="loading" class="search-box" > <el-option v-for="item in suggestions" :key="item.id" :label="item.title" :value="item.id" /> </el-select> </template>参数说明:remote-method在输入时触发,接收当前输入值作为参数;:loading控制下拉框内的加载动画。这里有个小坑:el-select的v-model绑定的是选中项的value,即笔记 id,而不是关键词文本。如果选中后要跳转详情页,watch 这个值跳路由即可;如果想保留文本展示,需要把value和label分开处理。
联想接口的 mock 就是简单的数组过滤:
const allNotes = [...笔记数据] export function fetchSearchSuggestions(keyword) { return new Promise((resolve) => { const result = allNotes .filter(n => n.title.includes(keyword) || n.tags.some(t => t.includes(keyword))) .slice(0, 8) setTimeout(() => resolve(result), 200) }) }slice(0, 8)限制联想条数,接口层就控制数据量而不是渲染层过滤,这是前后端协作的常识。搜索完成后回填关键词,把列表数据源切换成搜索接口的返回值,这个切换逻辑放在 Pinia 里用一个searchText字段控制比较干净。
4.2 详情页路由传参:用 computed 从 store 取数据
从瀑布流卡片点击进入详情,路由定义是/note/:id。DetailView 里通过route.params.id读取参数,配合 store 里的getNoteById拿到笔记数据:
<script setup> import { useRoute } from 'vue-router' import { computed } from 'vue' import { useNoteStore } from '../../stores/note' const route = useRoute() const noteStore = useNoteStore() const note = computed(() => noteStore.getNoteById(route.params.id) ) </script>computed的响应式依赖是route.params.id,路由切换时自动重新计算,不需要手动 watch,这是 Vue3 组合式 API 比选项式 API 直观的地方。如果用户直接刷新详情页,store 里没有数据,需要调用fetchNoteById兜底,常见做法是在onMounted里判断:
onMounted(() => { if (!note.value) { fetchNoteById(route.params.id).then(data => { noteStore.addNotes([data]) }) } })详情页的布局可以仿照小红书桌面端:左侧大图画廊,右侧标题、作者信息和评论区。评论区用el-divider分隔评论流,每条评论项抽成独立组件CommentItem.vue,接收commentprop 并渲染嵌套回复。这里保持了上一章定义的组件边界:子组件只负责展示,不自己拉数据。
评论区展开收起可以用el-collapse,但小红书原版是「展开全部评论」的单项操作,用简单的v-if加一个按钮更贴切。Vue3 的v-model在这里不需要,一个布尔值showAllComments就够。
4.3 发布页的 el-upload 与表单校验:上传控件的配置细节
小红书发布笔记的核心是图集上传加标题正文填写。Element Plus 的el-upload支持list-type="picture-card"的卡片式上传,配合v-model:file-list双向绑定已选文件:
<script setup> import { ref, reactive } from 'vue' import { ElMessage } from 'element-plus' const fileList = ref([]) const formRef = ref(null) const form = reactive({ title: '', content: '', tags: [] }) const rules = { title: [ { required: true, message: '请填写笔记标题', trigger: 'blur' }, { min: 2, max: 20, message: '标题长度 2 到 20 个字符', trigger: 'blur' } ], content: [ { required: true, message: '请填写正文内容', trigger: 'blur' } ] } const beforeUpload = (file) => { const isImage = file.type.startsWith('image/') if (!isImage) { ElMessage.error('只能上传图片文件') return false } return true } async function onSubmit() { await formRef.value.validate() ElMessage.success('发布成功') // 这里拼接 FormData 提交给后端 } </script> <template> <el-form ref="formRef" :model="form" :rules="rules" label-position="top"> <el-form-item label="标题" prop="title"> <el-input v-model="form.title" maxlength="20" show-word-limit /> </el-form-item> <el-form-item label="正文" prop="content"> <el-input v-model="form.content" type="textarea" :rows="6" /> </el-form-item> <el-form-item label="图片"> <el-upload v-model:file-list="fileList" list-type="picture-card" :auto-upload="false" :limit="9" :before-upload="beforeUpload" accept="image/*" > <el-icon><Plus /></el-icon> </el-upload> </el-form-item> <el-button type="primary" @click="onSubmit">发布</el-button> </el-form> </template>参数说明:v-model:file-list绑定的是上传文件数组,每项包含name、url、status等字段;:auto-upload="false"关闭自动上传,把所有文件统一在提交时处理,这在仿站 mock 场景里更顺手。:limit="9"限制最多 9 张,超过上限后上传入口自动隐藏,但不会给提示,需要监听on-exceed回调补一个 ElMessage。rules里的trigger字段决定校验时机,blur 适合输入框,change 适合下拉和上传组件。
表单校验通过后发布按钮会触发formRef.value.validate(),它返回一个 Promise,校验失败时 reject 并自动在表单项下方渲染错误文案。把await放在el-button的 click 事件里是最直接的异步处理方式。注意到beforeUpload返回false时文件不会进入fileList,这个拦截比上传后再删更干净。
5. 仿小红书前端源代码的踩坑记录与调优技巧
5.1 图片加载闪烁:占位尺寸与 el-image 的配合
信息流最常见的视觉问题是大图加载完成时卡片突然跳动,整列布局被顶开。根源是图片没有预留空间。推荐的解法是后端在接口里返回图片的宽高比,前端用aspect-ratio占位:
<el-image :src="note.cover" :style="{ aspectRatio: note.width / note.height }" fit="cover" :lazy="true" />aspectRatio是浏览器原生支持的 CSS 属性,Vue 的:style绑定会把它转成aspect-ratio: 0.75这样的形式。图片加载前,容器按比例占位,加载完成后不会撑动布局。如果接口拿不到宽高比,退而求其次是固定一个高度,用object-fit: cover裁切,代价是部分图片显示不全。注意el-image在加载失败时会显示插槽内容,建议放一个默认灰色块,而不是让区域塌陷,否则瀑布流底部会出现空洞。
5.2 列表性能与滚动体验:节流、卸载与虚拟滚动
当瀑布流持续加载到几百条数据时,DOM 节点数量会让滚动掉帧。三个层面的处理顺序很重要。第一层,接口返回的数据先做字段裁剪,只保留渲染需要的字段,减少 Vue3 响应式代理的开销。第二层,卡片里不用监听滚动事件的组件,el-image的懒加载本身由 IntersectionObserver 驱动,不需要额外节流。第三层,数据量真的很大时上虚拟滚动,但 Element Plus 官方没有提供虚拟列表组件,引入el-table的虚拟滚动又太重,更常见的是用 vue-virtual-scroller 或者手动实现「只渲染可视区域前后 N 条」。
一个免费的优化是给列表外层加content-visibility: auto:
.note-item { content-visibility: auto; contain-intrinsic-size: 320px; }content-visibility: auto让浏览器跳过屏幕外元素的渲染,contain-intrinsic-size提供占位高度避免滚动条抖动。这一行 CSS 能省掉很大一部分首屏渲染耗时,副作用是页面内查找功能可能失效,仿站场景基本不受影响。
5.3 响应式断点:手机单列到桌面三列
小红书移动端是单列大图,桌面端是双列,宽屏可以扩展到三列。断点用 CSS 媒体查询配合column-count切换:
| 屏幕宽度 | 列数 | 布局说明 |
|---|---|---|
| < 600px | 1 | 移动端全宽大图 |
| 600px ~ 1200px | 2 | 小红书经典双列 |
| > 1200px | 3 | 宽屏下提高信息密度 |
.note-waterfall { column-count: 1; column-gap: 12px; } @media (min-width: 600px) { .note-waterfall { column-count: 2; } } @media (min-width: 1200px) { .note-waterfall { column-count: 3; } }移动端的适配还需要处理顶部搜索栏,el-select在小屏下宽度设为 100%,侧边栏隐藏,导航移到底部或者用el-drawer收纳。调试时在 Chrome DevTools 的设备模拟里逐个宽度检查,重点看瀑布流列切换瞬间卡片是否掉到错误列,这通常和过渡动画的 transform 残留有关,给.note-item加transform: translateZ(0)可以规避。
最后提一个和 Element Plus 相关的隐藏陷阱:按需引入模式下,函数式组件如ElMessage、ElNotification的样式不会自动加载,需要手动引入它们对应的 CSS 文件:
import { ElMessage } from 'element-plus' import 'element-plus/es/components/message/style/css'漏掉这行的话,发布成功提示会出现一个没有任何样式的裸文本弹层。把这段代码放在入口文件的 Element Plus 配置旁边,仿站源代码里的所有提示类组件就都正常了。整份代码跑起来之后,用npm run build检查产物,首屏 JS 应该控制在 300KB 以内,超过就要排查是不是忘开 unplugin 的按需引入。
本文还有配套的精品资源,点击获取