拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

纯前端论坛信息管理系统:无需后端的前端项目实战

纯前端论坛信息管理系统:无需后端的前端项目实战

上周组里来了个实习生,问我有没有那种不用等后端联调、一个人就能从头撸到尾的 前端项目 练手。我直接把压箱底的一个东西丢给了他——论坛信息管理系统,而且是纯页面版本。所谓纯页面,就是整个系统只有前端,所有列表、详情、发帖、审核流程全靠本地 mock 数据和浏览器存储撑起来,不依赖任何真实接口,打开就能跑,改完就能看效果。它解决的问题特别实在:大多数人练手时卡在"没接口"这一步,写个表单都不知道往哪儿提交,于是项目永远停在静态首页;而纯页面方案把这个门槛直接拆掉,你依然要按真实业务的思路去设计数据流、分页、筛选、权限和状态流转,只是把网络那一层换成内存里的函数调用。这套东西适合三类人:刚学完框架基础想找个完整项目练手的新手、准备面试想拿一个能讲清楚细节的项目的求职者、以及想快速搭一个内部信息看板原型的老手。下面我按自己实际做这个项目时的顺序,把设计取舍、目录规划、核心页面实现、通用封装和踩过的坑全部摊开讲一遍。

1. 纯页面的论坛信息管理系统,先想清楚边界在哪

1.1 "纯页面"不是偷懒,而是一种刻意约束

很多人一听纯页面就觉得是"半成品",这是误解。真实项目里最耗时间的往往不是写页面,而是跟后端对字段、等接口、改联调环境。纯页面项目把这一层剥掉,等于给你一个可以完全掌控的实验场:字段你自己定,返回结构你自己设计,异常分支你自己造。你依然要回答和真实项目一模一样的问题——列表分页用什么参数传、删除之后是局部刷新还是全量重拉、编辑和新增能不能共用一个弹窗、用户角色怎么控制按钮显隐。这些问题在纯页面里思考一遍,等真接后端时你会发现自己几乎不需要重新设计。

我一般会把这类项目的边界划得很清楚:不做真实鉴权,登录只校验账号密码是否在本地用户表里;不做真实文件上传,图片转成 base64 或者 objectURL 存在内存里;不做服务端分页,所有分页排序筛选都在数组上算。但有一条必须认真做——数据层要像真的。我见过太多人把所有数据写死在页面组件的 data 里,结果加个删除功能就一地鸡毛,因为列表、详情、后台三处引用的根本不是同一份数据。所以哪怕只有前端,也要有一个统一的"数据源",这点后面会细说。

另一个容易忽略的边界是:纯页面项目要做到"刷新不丢"。浏览器一刷新数据全没了,体验上很像玩具,你也就失去了演示的欲望。用 localStorage 做持久化,成本极低,但项目的完成度会直接上一个档次。

1.2 技术选型:三条路的取舍

做这个项目前我试过三种组合,各自适用场景不一样,直接上表格更清楚。

方案上手成本适合场景主要短板
原生 HTML + CSS + JS低,但要自己造轮子只练 DOM 和事件,或者课程作业分页、弹窗、校验全手写,代码量爆炸
Vue 3 + Element Plus中,生态成熟想要完整 CRUD 体验、准备面试需要理解组合式 API 和响应式细节
React + Ant Design中偏高已经熟悉 React 体系的人状态管理方案多,容易选型纠结

我最终选的是Vue 3 + Element Plus + Vite,理由很直接:论坛系统百分之七十的工作量是表格、表单、弹窗、分页这四样,Element Plus 全给你封装好了,你能把精力放在业务逻辑而不是组件适配上。Vite 的冷启动和热更新快得离谱,改一行样式几乎感觉不到等待,这对一个需要反复调界面的项目太重要了。

还有一个细节值得说:不要一上来就上 Pinia 或 Vuex。这个项目的数据流其实很浅,一个模块级的 store 文件加几个 ref 就够了。过早引入全局状态管理,会让你在写第一行代码之前先纠结半天"这个状态该放哪"。我的做法是先全部用组合式函数组织,等某份数据被三个以上页面同时读写时,再把它提到 Pinia 里,这种"按需升级"的节奏反而更接近真实开发。

1.3 这套项目真正能沉淀下来的能力

做完一遍,你会实打实拿到这些东西:一是响应式数据的更新心智模型,知道为什么改了数组某一项页面不刷新、为什么直接赋值对象会丢响应;二是列表页的通用套路,搜索、筛选、分页、排序、多选、批量删除这一整套,几乎每个后台系统都要重写一遍,写熟了就是肌肉记忆;三是表单校验与弹窗复用的抽象能力,新增和编辑共用一个表单组件,是前端从"能写"到"写得好"的分水岭。

顺便说一句,这个项目在面试里非常好用。面试官问"你做过什么项目",很多人只能说"跟着教程做了一个商城",一听就是模板。而你说"我做了一个纯前端的论坛信息管理系统,接口层是自己写的 mock 路由,分页筛选都在本地算,数据用 localStorage 持久化",话题立刻就落到工程细节上了,接下来他八成会问你分页和搜索联动怎么处理、删除后怎么保持分页正确。这些问题我在第 3、5 节都给了答案。

2. 整体架构与目录规划:先把地基画出来

2.1 页面清单与路由表设计

动手前先列页面清单,这一步千万别省。我一开始脑子里只有"列表页"和"详情页"两个概念,写着写着发现发帖要单独一页、后台要按用户和帖子分两个 tab、登录要独立路由,最后返工重构路由,白干两小时。后来我固定了一套清单模板,先写下来再写代码:

  • 公开区:登录页/login、帖子列表/posts、帖子详情/posts/:id、发帖/posts/create、个人中心/profile
  • 管理区:帖子管理/admin/posts、用户管理/admin/users、分类管理/admin/categories
  • 兜底:404 页面,以及一个重定向到 404 的通配路由

路由设计上有两个坑我踩过。第一,详情页用动态参数而不是查询参数,/posts/1001比/posts?id=1001更符合直觉,也方便你做面包屑和页面标题。第二,管理区统一用一个带meta.requiresAuth的父路由,子路由继承守卫,不用在每个页面上重复判断登录态。

// src/router/index.js const routes = [ { path: '/login', name: 'Login', component: () => import('@/views/Login.vue') }, { path: '/posts', name: 'PostList', component: () => import('@/views/posts/PostList.vue') }, { path: '/posts/:id', name: 'PostDetail', component: () => import('@/views/posts/PostDetail.vue') }, { path: '/admin', component: () => import('@/layouts/AdminLayout.vue'), meta: { requiresAuth: true, role: 'admin' }, children: [ { path: 'posts', name: 'AdminPosts', component: () => import('@/views/admin/PostManage.vue') }, { path: 'users', name: 'AdminUsers', component: () => import('@/views/admin/UserManage.vue') } ] }, { path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/NotFound.vue') } ]

路由懒加载一定加上,哪怕项目小。原因不是性能——项目几十个页面根本压不垮打包体积——而是为了让你习惯"按路由切分代码块"的写法,等以后接手大项目时不会手生。

2.2 数据层:mock 数据 + localStorage 双层结构

这是整个项目最核心的设计。我的方案是分三层:种子数据、持久层、业务接口。

种子数据就是一份写死的初始数组,包含帖子、用户、分类、评论四张表,字段设计得尽量完整,比如帖子要有id、title、content、categoryId、authorId、status、views、likes、createTime、updateTime。这些字段不是为了好看,而是每加一个你就要在列表里多渲染一列,逼着你去处理日期格式化、状态映射、长文本截断这些真实问题。

持久层负责把内存数据同步到 localStorage。首次进站时检测有没有存档,没有就写入种子数据,有就读取。这里有个细节:种子数据要带版本号,比如forum_db_v1,将来你改了字段结构,直接换成 v2,老数据自动作废重写,不用写迁移逻辑。

// src/utils/storage.js const KEY = 'forum_db_v1' export function loadDB(seed) { const raw = localStorage.getItem(KEY) if (!raw) { localStorage.setItem(KEY, JSON.stringify(seed)) return JSON.parse(JSON.stringify(seed)) } try { return JSON.parse(raw) } catch (e) { localStorage.setItem(KEY, JSON.stringify(seed)) return JSON.parse(JSON.stringify(seed)) } } export function saveDB(db) { localStorage.setItem(KEY, JSON.stringify(db)) } export function resetDB(seed) { localStorage.setItem(KEY, JSON.stringify(seed)) }

resetDB这个函数看着不起眼,实际特别有用。你反复测试增删改之后,数据会变得乱七八糟,页面上全是"测试123"这种脏标题,演示的时候很难看。在设置里放一个"恢复初始数据"按钮,一键回到干净状态,代价只有三行代码。

业务接口层就是第 4 节要讲的 mock 路由,它对外暴露的形态和真实的 axios 请求完全一致,都是返回 Promise。这一层做对了,将来把 mock 换成真实接口,只需要删掉一个适配器文件,页面代码一行不动。

2.3 目录结构:让模块边界一眼可见

目录结构我反复调整过三次,最终稳定成这样:

src/ ├── api/ # 业务接口,一个领域一个文件 ├── mock/ # 种子数据 + mock 路由表 ├── utils/ # storage、日期格式化、防抖、脱敏 ├── hooks/ # useTable、useDialog、useAuth ├── components/ # 通用组件,如 PostCard、CommentItem ├── layouts/ # 布局壳,前台布局和管理后台布局 ├── views/ # 页面 ├── router/ └── styles/

这里最值得说的是hooks目录。很多人做练手项目时会把分页逻辑复制粘贴到每个列表页,五六个页面就是五份几乎相同的代码,改一个 bug 要改五处。抽成useTable之后,一个列表页的逻辑能压到三十行以内,这个抽象过程本身就是最有价值的学习内容。后面第 4 节我会把useTable的完整实现给出来。

layouts目录也别省。前台需要一个顶部导航加内容区的布局,后台需要一个侧边菜单加面包屑的布局,两套壳子分开写,页面组件里就不用管导航栏了,清爽很多。

3. 核心页面实操拆解

3.1 帖子列表页:搜索、筛选、分页的三方联动

列表页是整个系统的门面,也是坑最多的地方。先说一个最基本的问题:搜索输入该不该立即触发请求。如果你直接在input事件里调接口,用户每敲一个字就查一次,在真实项目里会把后端打崩,在纯页面项目里则会让界面疯狂闪动。正确做法是加防抖,300 毫秒是个经验值,够快也不至于卡顿。

// src/hooks/useDebounce.js import { ref, watch } from 'vue' export function useDebounce(source, delay = 300) { const target = ref(source.value) let timer = null watch(source, (v) => { clearTimeout(timer) timer = setTimeout(() => { target.value = v }, delay) }) return target }

第二个坑是搜索时必须把页码重置为 1。这个逻辑特别容易漏:你在第 5 页搜"vue",结果本地过滤后总共只有 3 条数据,却还在请求第 5 页,页面直接空白。我一开始就是把page.current和关键词分开监听,结果用户搜完看到空列表,以为是没数据。后来改成统一监听一个条件对象,只要除页码外的条件变化就重置页码:

const query = reactive({ keyword: '', categoryId: '', status: '', current: 1, size: 10 }) const debouncedKeyword = useDebounce(toRef(query, 'keyword')) watch([debouncedKeyword, () => query.categoryId, () => query.status], () => { query.current = 1 fetchList() }) watch(() => query.current, fetchList)

第三个细节是本地分页的写法。纯页面项目里所有数据都在数组里,分页就是slice:

const filtered = allPosts .filter(p => !query.keyword || p.title.includes(query.keyword)) .filter(p => !query.categoryId || p.categoryId === query.categoryId) .sort((a, b) => b.createTime - a.createTime) total.value = filtered.length list.value = filtered.slice((query.current - 1) * query.size, query.current * query.size)

注意filter返回的是新数组,原数组不会被破坏,这点很关键。如果你直接对原数组做sort,会发现排序之后持久化到 localStorage 的顺序也变了,刷新页面后帖子顺序莫名其妙。养成"先复制再操作"的习惯,[...arr].sort()比arr.sort()安全得多。

提示:本地分页时不要用splice去截取原数组来"实现分页",那会把数据真的删掉。这是新手非常容易犯的错误,我在 review 别人代码时见过不止一次。

列表页的渲染我还做了个小优化:标题超过两行就用 CSS 的-webkit-line-clamp截断并加省略号,鼠标悬停用title属性显示完整标题。比在 JS 里手动slice字符串优雅得多,也不会把中文截成半个字。

3.2 发帖页:富文本、草稿与图片预览

发帖页看起来简单,其实是交互最密集的一页。我先说一个取舍:到底要不要引入富文本编辑器。成熟的富文本库体积动辄几百 KB,配置项一大堆,对这个项目来说是杀鸡用牛刀。我的方案是 Markdown 输入加实时预览,左边 textarea,右边渲染结果,总共不到一百行代码,还顺带练了正则和字符串处理。

图片处理是这一页的重点。用户选中图片后,用FileReader读成 base64 直接塞进内容里,这样刷新页面图片还在(因为 base64 存在 localStorage 里),不需要任何后端。但这里有两个必须注意的点:

  • 限制文件大小。base64 会比原图膨胀约 33%,一张 2MB 的图存进 localStorage 就是 2.7MB,而 localStorage 总容量一般只有 5MB 左右,存三四张图就爆了,之后所有写入操作都会抛异常。我的做法是限制单张 300KB 以内,超出就提示用户压缩。
  • 及时释放 objectURL。如果你用URL.createObjectURL做预览,用完一定要URL.revokeObjectURL,否则内存会持续增长。虽然纯页面项目里这个泄漏量不大,但这是必须养成的习惯。
function onFileChange(e) { const file = e.target.files[0] if (!file) return if (file.size > 300 * 1024) { ElMessage.warning('图片请控制在 300KB 以内') return } const reader = new FileReader() reader.onload = () => { previewUrl.value = reader.result form.content += `\n![图片](${reader.result})\n` } reader.readAsDataURL(file) }

草稿功能也值得做。用户在编辑框里输入时,每隔几秒把内容写进sessionStorage,页面意外刷新后能恢复。这个功能在真实系统里是标配,实现成本却很低,是个性价比极高的加分项。记住用sessionStorage而不是localStorage,草稿属于会话级数据,关掉标签页就该清掉。

提交时的校验也不能马虎。标题必填且有长度限制、分类必选、内容不能全空白。Element Plus 的el-form校验规则能覆盖大部分场景,但正则校验一定要自己测边界,比如标题长度用max: 50而不是手写正则,中文一个字符算一个长度,max的表现符合直觉。

3.3 帖子详情与评论区:渲染安全与楼层设计

详情页有两件事必须想明白:内容怎么渲染,以及评论怎么组织。

内容渲染这块,很多人图省事直接用v-html。纯页面项目里内容都是自己造的,看似没有 XSS 风险,但我依然不建议直接用v-html,原因有两个:一是养成坏习惯,将来接真实用户输入时会出事;二是v-html里的样式是全局的,用户输入的<style>标签会影响整个页面。如果确实要渲染 HTML,至少加一层白名单过滤。

我选择的方案是自己写一个极简的 Markdown 渲染函数,只支持标题、粗体、代码块、链接和图片这几类,用正则逐条替换,输出前把<>这类字符转义掉。代码量大概六十行,比引入整个 Markdown 库轻,还能完全掌控输出内容。

评论区的楼层设计是个有意思的取舍。一级评论加二级回复,还是无限嵌套?无限嵌套在数据结构和缩进渲染上都麻烦,移动端屏幕宽度也不够。我的做法是两层结构:顶层评论平铺,回复挂在父评论的children数组里,渲染时用缩进加左侧竖线区分。这样数据结构清晰,渲染逻辑也简单,用户视觉上完全能看懂层级关系。

浏览量统计也有小技巧。用户进详情页时给views加一,但如果你在onMounted里直接加,每次刷新都会加,开发阶段你自己调试十次就变成 100 多浏览了。我的处理是用sessionStorage记录当前会话已经看过的帖子 id 列表,同一个会话只加一次,这个小细节能让数据看起来真实不少。

3.4 后台管理页:批量操作、状态流转与权限模拟

后台页面才是真正考验工程能力的地方。帖子管理页我做了这几件事:表格多选、批量下架、批量删除、单条状态切换、按状态和分类筛选、以及操作列的编辑和查看。

批量操作最容易出的 bug 是"跨页选择"。Element Plus 的el-table默认在切换页码时会清空选中项,这符合多数场景的预期。但如果你希望用户跨页勾选后再批量操作,就得用row-key配合reserve-selection。我建议新手阶段保持默认行为,先理解"选择状态和页面数据的生命周期绑定"这件事,再去研究跨页选择的实现。

状态流转要有约束。帖子有草稿、已发布、已下架三种状态,不是所有状态之间都能随意切换。我的规则是:草稿可以发布,已发布可以下架,已下架可以重新发布但不能直接改回草稿。用一张小表格把合法流转列出来,代码里用一个canTransfer(from, to)函数做判断,按钮根据结果决定是否可点。这种"把业务规则前置"的写法,比在点击事件里写一堆if清晰得多。

权限模拟是另一个重点。本地用户表里给每个用户一个role字段,admin和user两种就够。路由守卫拦一层,页面内的按钮再拦一层——普通用户看不到"删除"按钮,直接输入管理页地址也会被重定向。这里要强调:纯前端的权限控制只是演示性质,改一下 localStorage 就能绕过,它教给你的价值在于"权限判断该放在哪一层"这个思考方式,而不是安全性本身。

4. 通用能力封装:把重复代码抽干净

4.1 mock 路由:让接口层和真实项目长得一样

我不喜欢在页面里直接操作数据数组,因为那样将来换真实后端时要改的地方太多。我的做法是写一个极简的 mock 适配器,用方法 + 路径作为 key 注册处理函数,对外暴露的调用方式和 axios 几乎一致。

// src/mock/index.js import { loadDB, saveDB, seed } from '@/utils/storage' import { postRoutes } from './routes/post' const db = loadDB(seed) const routes = { ...postRoutes(db, saveDB) } const delay = (ms) => new Promise(r => setTimeout(r, ms)) export async function mockRequest({ url, method = 'GET', params = {}, data = {} }) { await delay(200 + Math.random() * 200) const key = `${method.toUpperCase()} ${url}` const handler = routes[key] if (!handler) { return Promise.reject(new Error(`未匹配到 mock 路由: ${key}`)) } return handler({ params, data }) }

注意那个随机延迟。固定延迟会让所有请求同时返回,看起来假;加上 100 到 200 毫秒的抖动,加载状态、骨架屏这些效果才有真实感,你也有机会去处理 loading 时序问题。

路由注册长这样,看起来朴素但足够用:

// src/mock/routes/post.js export function postRoutes(db, save) { return { 'GET /api/posts': ({ params }) => { const { keyword = '', categoryId = '', page = 1, size = 10 } = params const list = db.posts .filter(p => !keyword || p.title.includes(keyword)) .filter(p => !categoryId || p.categoryId === categoryId) .sort((a, b) => b.createTime - a.createTime) return { code: 0, data: { list: list.slice((page - 1) * size, page * size), total: list.length } } }, 'POST /api/posts': ({ data }) => { const id = Date.now() db.posts.unshift({ ...data, id, views: 0, createTime: Date.now() }) save(db) return { code: 0, data: { id } } } } }

统一的返回结构{ code, data }很重要。它逼着你在页面里处理"请求成功但业务失败"这种情况,而不是无脑then里拿数据。真实项目里这种错误码判断写不好,线上很容易出问题。

4.2 useTable:一个 Hook 干掉五个列表页的重复代码

这是我抽得最值的一个 Hook。它把分页、加载状态、查询条件、数据获取全部收进去,列表页只需要提供取数函数。

// src/hooks/useTable.js import { reactive, ref, watch } from 'vue' export function useTable(fetcher, defaultQuery = {}) { const list = ref([]) const loading = ref(false) const total = ref(0) const query = reactive({ page: 1, size: 10, ...defaultQuery }) async function load() { loading.value = true try { const res = await fetcher({ ...query }) list.value = res.data.list total.value = res.data.total } finally { loading.value = false } } function search() { query.page = 1 load() } watch(() => query.page, load) return { list, loading, total, query, load, search } }

用的时候长这样:

const { list, loading, total, query, load, search } = useTable(fetchPostList, { keyword: '' }) onMounted(load)

页面里只剩模板和少量交互逻辑。这个抽象教会我一件事:重复出现第三次的代码就该被抽走,出现第二次时先忍着,因为过早抽象可能会抽错方向。

4.3 路由守卫与登录态模拟

登录态的模拟要把握一个度。我用的是最朴素的方案:登录成功后往 localStorage 写一个 token 字符串和用户信息对象,守卫里检查 token 是否存在。不要用 JWT 那套东西,纯页面项目里没有密钥,写了也是自欺欺人。

// src/router/guard.js router.beforeEach((to) => { const token = localStorage.getItem('forum_token') if (to.meta.requiresAuth && !token) { return { name: 'Login', query: { redirect: to.fullPath } } } if (to.name === 'Login' && token) { return { name: 'PostList' } } })

注意redirect参数的处理。用户直接访问管理页被弹到登录页,登录成功后应该回到管理页而不是首页,这个体验细节很多教程都不讲,但用户是会明显感知到的。

5. 常见问题与排查技巧实录

5.1 状态相关的坑

改了数据页面不刷新。这是 Vue 里最经典的问题。原因是 Vue 3 的响应式基于 Proxy,如果你用索引直接赋值一个不存在的属性,或者替换整个对象引用,都可能丢响应。解决办法是用reactive包装的对象操作,或者干脆重新赋值整个数组:list.value = [...list.value]。我在做批量下架时就遇到过,修改了数组内每个对象的status字段但视图纹丝不动,最后发现是后端返回的数据被Object.freeze了——虽然纯页面项目里不会有这种情况,但理解这个机制很有必要。

多个页面共享数据不同步。列表页删了一条帖子,详情页还显示着;后台改了状态,前台列表没变。根本原因是每个页面各自load了一份数据副本。我的解法是把数据源统一放在一个模块级的 store 里,所有增删改都走同一份数据,页面通过计算属性派生。当你发现"同一个数据被三个地方独立维护"时,就该重构了。

5.2 富文本与图片的坑

现象可能原因解决办法
保存时报错 QuotaExceededError图片 base64 撑爆 localStorage限制图片大小,或改用 IndexedDB
图片预览正常但刷新后消失用了 objectURL 且未持久化改用 FileReader 转 base64
内存持续增长objectURL 未释放用完调用 revokeObjectURL
渲染内容后页面样式错乱v-html 注入了 style 标签白名单过滤,或改用自研渲染

QuotaExceededError这个异常一定要捕获。很多人不写 try catch,结果存数据时静默失败,用户以为提交成功了,刷新一看全没了。我的处理是在saveDB里包一层 catch,失败时提示"本地存储空间不足,请清理历史数据",同时提供一个清空按钮。

5.3 表格分页与批量选择的坑

分页相关的 bug 集中在三处:搜索后没重置页码、删除后当前页数据为空却没有自动回退、以及size改变时页码没重置。第三点尤其隐蔽——用户从每页 10 条切到每页 50 条,当前还在第 5 页,实际数据只有 2 页,表格空白。解决办法是监听size变化时把page强制设为 1,el-pagination的size-change事件里加这一行就行。

批量选择还有个坑是选中项在数据刷新后失效。你勾了三条准备删除,结果中途刷新了列表,选中状态还在但对应的行数据已经变了。稳妥的做法是每次数据加载完成后调用tableRef.value.clearSelection()清空选中,让用户重新勾选。

5.4 构建与部署的坑

打包之后白屏,九成是资源路径问题。默认构建产物用的是绝对路径/assets/xxx.js,如果你部署在子目录下,全都 404。解决方法是把vite.config.js里的base改成'./'。这个坑我每次部署新项目都要踩一遍,因为本地开发时用 dev server 完全看不出来。

另一个是路由模式。createWebHistory的路径好看,但部署到静态服务器后,直接访问/posts/1001会 404,因为服务器找不到这个物理文件。纯页面项目我建议直接用createWebHashHistory,地址栏多个#不好看,但零配置、绝对不白屏,对练手项目来说省心得多。

// src/router/index.js const router = createRouter({ history: createWebHashHistory(), routes, scrollBehavior() { return { top: 0 } } })

6. 上线前的性能与体验收尾

6.1 长列表渲染别硬扛

帖子多起来之后(我测试时造了 500 条),列表如果一次渲染全部,滚动会明显掉帧。纯页面项目里最省事的解法是做虚拟滚动,或者干脆在本地分页的基础上再限制单页最大 50 条。Element Plus 有el-table-v2支持虚拟滚动,但换组件成本不低。我的建议是先量一下——500 条以内基本无感,超过 1000 条再考虑优化,别为了想象中的性能问题提前折腾。

真正值得做的优化是减少不必要的计算。列表里的分类名、作者名都需要从 id 映射过来,如果每条都遍历一遍分类数组去find,500 条就是 500 次遍历。提前建一个Map做索引,map.get(id)是常数时间,改起来只要几行:

const categoryMap = computed(() => new Map(categories.value.map(c => [c.id, c]))) const getCategoryName = (id) => categoryMap.value.get(id)?.name || '未分类'

6.2 数据初始化与演示模式

上线演示前一定要准备一个"演示模式"。我第一次给同事展示时,页面上全是"测试测试""asdf"这类脏数据,尴尬得不行。后来加了两个按钮:一个是"填充演示数据",一键生成 60 条标题规范、分类分布合理的帖子;另一个是"恢复初始状态",清空所有本地存储回到种子数据。

演示数据的生成也有讲究,标题要像真的,我写了一个简单的模板拼接:

const topics = ['组件通信', '响应式原理', '打包优化', 'TypeScript 实践', '状态管理选型'] const verbs = ['踩坑记录', '实践总结', '疑问求助', '方案对比'] const title = `${topics[i % topics.length]}:一份${verbs[i % verbs.length]}`

生成的数据带正确的时间戳、浏览量和分类,看起来就是一个真实运行中的论坛,展示效果完全不同。

7. 我在这个项目里最想告诉你的几件事

7.1 关于"抄作业"的正确姿势

前面给了不少代码片段,但我建议你别直接复制粘贴跑通就完事。这个项目最大的价值在于那些需要你自己做决定的地方:分页用本地还是 mock 接口里做、状态用 Pinia 还是组合式函数、富文本用现成库还是自己写。我的选择有我的理由,你的场景可能完全不同。真正学到东西的时刻,是你按自己的思路写完,跑起来发现有问题,回头琢磨"为什么会这样"的那几分钟。

7.2 遇到的坑比代码更值钱

我在这上面栽得最惨的一次是 localStorage 存爆。当时为了测试,我往帖子里塞了几张大图,结果整个应用的所有写操作全部静默失败,删帖删不掉、发帖发不出,排查了半天才发现是存储超限。从那以后我养成了两个习惯:所有本地存储写入都包 try catch,以及所有涉及用户输入体积的地方都加限制。这些习惯后来在真实项目里救我很多次,因为线上环境的坑,往往就是本地环境里被你忽略的那些边界。

7.3 这个项目的下一步可以怎么长

如果做完基础版本还意犹未尽,有几个方向可以试。一是把 mock 层换成 Service Worker 拦截,让网络请求走真实的 fetch,这样你就能在 DevTools 的 Network 面板里看到完整的请求链路,体验和真实项目几乎一样。二是加上按需加载的分页缓存,用户翻回上一页时不重新计算。三是把 permissions 做细,做成角色加操作的二维矩阵,用指令的方式控制按钮级权限。四是可以试试把整个数据层换成 IndexedDB,容量上限从 5MB 提到几百 MB,图片就能正常存了。

这几个方向我都试过前两个,Service Worker 那套上手成本比想象中低,Network 面板能看到请求的那一刻,你会觉得这个"纯页面"项目突然就有了完整的工程质感。至于后面两个,等你的数据量真的撑不住现有方案时再动手也不迟,别为了技术而技术。

返回列表