☰
Vue3纯页面论坛信息管理系统实战:分页筛选、权限与本地存储
2026/9/30 0:47:48 网站建设 项目流程

做前端这七八年,我手上攒过不少练手项目,但真正被问得最多、也最愿意拿出来讲的,是一个看起来平平无奇的东西——论坛信息管理系统。它没有酷炫的动效,也没有三维场景,可每次有人问我"想练手该做什么",我还是会推荐它,尤其是那种不带后端服务、只做纯页面的版本。原因很实在:论坛这个场景几乎把前端日常要处理的事情全塞进去了——列表分页、条件筛选、表单校验、富文本编辑、评论嵌套、角色权限、本地状态持久化,一个都不少。而"纯页面"这三个字,又把它从"必须等后端接口"的被动里解放出来,你一个人、一台电脑、一个周末,就能跑通一条完整链路。这篇内容写给三类人:想找项目练手的前端新人、要准备作品集的求职者、以及习惯写服务端但想补一补页面能力的朋友。我会把项目怎么拆、数据从哪来、关键功能怎么写、哪些坑一定要绕开,全部摊开讲清楚,代码能直接抄。

1. 项目定位与技术选型:为什么"纯页面"反而是优势

1.1 "纯页面"到底指什么,边界在哪里

先把概念钉死,不然很容易做着做着就跑偏了。所谓纯页面的论坛信息管理系统,指的是整个项目不依赖任何真实后端服务,所有数据来源都是前端自己造的:静态 JSON、本地 JS 模块、Mock 拦截层,或者浏览器本地存储。它交付出去的形态是一套可以直接打开、点击、交互的界面,而不是一个需要npm run serve之后再配数据库、配环境的整套工程。

这个边界带来两个明显好处。第一是协作成本归零,你不用跟前端和后端来回对齐字段名、状态码、分页参数格式,想改一个字段名,全局搜索替换三秒钟搞定。第二是演示成本极低,面试的时候打开一个静态站点链接就能让对方上手点,比现场跑一套需要初始化数据库的工程靠谱得多。我见过太多人的作品集,评委点进去是一片 500 报错,项目再牛也没机会展示。

但边界也很清楚:别在纯页面项目里假装自己有服务端。有些人会硬写一个 Node 中间层,结果部署时又要配端口又要配转发,最后变成一个半成品。如果你确实想练全栈,那就大大方方承认这是全栈项目,不要挂在"纯页面"的名下。边界感清晰,项目才好收尾。

1.2 技术栈选型:Vue3 加 Vite 加 Element Plus 的取舍逻辑

选型这件事,我的判断标准只有一条:能不能在最短时间内把业务逻辑跑通,而不是把时间花在造轮子上。

层级选择主要理由
构建工具Vite冷启动秒级,热更新几乎无感,改样式不刷新页面
框架Vue 3 组合式 API逻辑聚合度高,一个功能的 ref、computed、watch 能写在一起
状态管理PiniaAPI 比 Vuex 简洁,天然支持 TS,调试面板友好
路由Vue Router 4动态路由和路由守卫成熟,权限控制好落地
组件库Element Plus表格、分页、表单校验、弹窗开箱即用,中文文档全
数据模拟本地模块 + 拦截层无需后端,同时保留真实请求的写法习惯

这里重点说为什么不选某些方案。不用 Tailwind 自己写全套 UI,是因为表格的固定列、排序、虚拟滚动这些细节,手写一次至少两天,练手项目的时间应该花在业务建模上。不用 React 不是 React 不好,而是 Element Plus 的生态在 Vue 侧更顺手,你要是已经很熟 Ant Design,换 React 完全没问题,逻辑是相通的。

还有一个容易被忽略的点:保留 axios 的调用形式。哪怕数据是本地造的,也建议把请求封装成request.get('/api/posts', params)的样子,然后在拦截层里把 URL 映射到本地数据。这样以后真接后端,只需要把拦截层删掉,业务代码一行都不用改。这个习惯我在实际项目里受益过很多次。

1.3 目录结构规划与模块划分

很多练手项目死在结构混乱上——所有页面塞在一个 views 文件夹,所有请求写在组件里,改一个字段要翻五个文件。我习惯用下面这种按功能域切分的方式:

forum-admin/ ├── src/ │ ├── api/ # 请求封装,按模块拆分 │ │ ├── request.js │ │ ├── post.js │ │ └── user.js │ ├── mock/ # 模拟数据与拦截逻辑 │ │ ├── db.js # 内存数据库 + 本地存储读写 │ │ └── index.js # 请求路径映射 │ ├── stores/ # Pinia 状态 │ │ ├── user.js │ │ └── app.js │ ├── router/ │ │ └── index.js │ ├── components/ # 跨页面复用组件 │ │ ├── PostTable.vue │ │ └── CommentTree.vue │ ├── views/ # 页面级组件 │ │ ├── post/ │ │ ├── board/ │ │ └── login/ │ └── utils/ │ ├── storage.js │ └── permission.js

划分逻辑很简单:api 层负责"数据从哪来",stores 层负责"数据怎么共享",views 层负责"数据怎么展示"。三层之间单向依赖,页面不直接碰 mock,mock 不关心里面渲染成什么样。这种结构在项目膨胀到三四十个文件的时候,你依然能一眼定位问题在哪一层。别小看这一点,我接手过别人的练手项目,改一个分页参数要找二十分钟。

2. 数据层设计:没有后端,论坛的数据从哪来

2.1 用本地模块模拟真实接口的三种做法

数据模拟这块,从简单到复杂,我实际用过三种方案,各有适用场景。

第一种:静态 JSON 直读。建一个posts.json,需要的地方import进来。优点是零成本,缺点也明显——数据是只读的,发帖、删帖这些操作做不了,而且 JSON 一旦上千条,打包体积会很难看。这个方案只适合做纯展示的静态原型。

第二种:本地模块加 Promise 延迟。这是我最推荐的练手方案。数据放在一个 JS 模块里导出,读取时用setTimeout包一层模拟网络延迟,让 loading 状态有机会出现。

// mock/db.js const delay = (ms = 300) => new Promise((resolve) => setTimeout(resolve, ms)) let posts = [ { id: 1, boardId: 2, title: '新人报到,说说我入行第一年的踩坑记录', authorId: 1001, tags: ['经验分享', '新人'], views: 1263, likes: 48, status: 'published', createdAt: '2026-01-12 09:24:11' }, { id: 2, boardId: 2, title: '组件库按需引入后样式丢失,排查了两个小时', authorId: 1002, tags: ['疑难杂症'], views: 872, likes: 31, status: 'published', createdAt: '2026-01-14 20:05:37' } ] export async function queryPosts({ page = 1, size = 10, keyword = '', boardId = null, status = '' }) { await delay() let list = posts.slice() if (keyword) { const kw = keyword.trim().toLowerCase() list = list.filter((item) => item.title.toLowerCase().includes(kw)) } if (boardId) { list = list.filter((item) => item.boardId === boardId) } if (status) { list = list.filter((item) => item.status === status) } const total = list.length const start = (page - 1) * size return { total, list: list.slice(start, start + size) } }

注意这里筛选和分页的顺序:必须先筛选再分页,反过来的话页码会算错,这是新手最常见的逻辑错误之一。

第三种:拦截层方案。用 axios 拦截器或者连接本地数据映射,把request.get('/api/post/list')这样的请求转发到上面的queryPosts。写法上跟真实项目完全一致,后续迁移无痛。数据量大的时候,我还会把这层换成基于 Service Worker 的请求拦截,好处是所有请求都走真实的网络栈,Network 面板里能看到完整记录,演示时更有说服力。

2.2 用本地存储做数据持久化:让增删改查真的"生效"

内存里的数组有个致命问题:刷新页面就回到初始状态。你演示的时候刚发的帖子,一按 F5 就没了,体验非常割裂。解决办法是把数据落到浏览器本地存储里。

// utils/storage.js const PREFIX = 'forum_db_' export function save(key, value) { try { window.localStorage.setItem(PREFIX + key, JSON.stringify(value)) return true } catch (err) { console.warn('本地写入失败,可能是超出容量限制', err) return false } } export function load(key, fallback = null) { const raw = window.localStorage.getItem(PREFIX + key) if (!raw) return fallback try { return JSON.parse(raw) } catch (err) { console.warn('本地数据解析失败,已丢弃', err) return fallback } }

然后在db.js里做一次初始化:启动时先尝试从本地读,读不到就用种子数据,之后每次写操作都同步落盘。这样整个系统就具备了"记忆"。

注意:本地存储单个域名通常有 5MB 左右的上限,不同浏览器略有差异。一旦你在帖子里塞图片的 base64 字符串,很容易就撑爆,然后写入静默失败,用户以为发布成功其实数据丢了。图片一律存成 Blob URL 或者只存缩略图字符串,绝对不要把原图转 base64 塞进去。

还有一个实操细节:写入时机要比对差异。别每次任何一个字段变都全量写一遍,我一般只在增删改这三个动作后面调用保存函数,列表滚动、搜索关键词这些纯视图状态不落盘。否则一个页面滚动几十次就触发几十次 JSON 序列化,卡顿肉眼可见。

2.3 数据结构设计:帖子、板块、用户、评论的关系

论坛业务的核心是四张"表",关系理清楚了,后面的页面写起来就是顺水推舟。

实体关键字段与其他实体的关系
板块 boardid、name、slug、description、sort一对多关联帖子
帖子 postid、boardId、title、content、authorId、tags、status、views、likes属于一个板块,属于一个用户,拥有多条评论
评论 commentid、postId、parentId、authorId、content、createdAt通过 parentId 自关联形成树
用户 userid、name、avatar、role、signature一对多关联帖子和评论

这里有两个设计选择值得说清楚。

第一,标签用数组还是关联表。真实后端一般会拆成标签表和关联表,但在纯页面项目里,帖子对象直接挂一个tags: string[]就够了。好处是渲染时不用做二次查询,坏处是标签重命名要遍历所有帖子。练手阶段,简单优先。

第二,评论为什么用 parentId 而不是 children 嵌套。扁平化存储 +parentId指向父评论,是更稳妥的做法。嵌套结构在新增一条深层回复时要层层查找父节点,写起来啰嗦;扁平结构只需要 append 一条记录,渲染时在内存里组装成树就行。下面这段组装逻辑我几乎每个项目都在用:

export function buildCommentTree(list) { const map = new Map() const roots = [] list.forEach((item) => { map.set(item.id, { ...item, children: [] }) }) map.forEach((node) => { if (node.parentId && map.has(node.parentId)) { map.get(node.parentId).children.push(node) } else { roots.push(node) } }) return roots }

用一次遍历建索引,再一次遍历挂载,时间复杂度是线性的。如果写成递归查找父节点,一千条评论能把页面卡住好几秒,这个差距在做数据量测试的时候非常明显。

3. 核心功能实现:从帖子列表到详情页的完整链路

3.1 帖子列表:分页、筛选、排序怎么落地

列表页是整个系统的门面,也是最容易做得"能用但不好用"的地方。我的经验是,筛选条件必须同步到地址栏。原因有三:用户刷新页面不丢状态,可以直接把链接发给别人,浏览器前进后退符合预期。

// views/post/PostList.vue 片段 import { ref, computed, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { queryPosts } from '@/api/post' const route = useRoute() const router = useRouter() const loading = ref(false) const list = ref([]) const total = ref(0) const query = ref({ page: Number(route.query.page) || 1, size: Number(route.query.size) || 10, keyword: route.query.keyword || '', boardId: route.query.boardId ? Number(route.query.boardId) : null, status: route.query.status || '' }) async function fetchList() { loading.value = true try { const res = await queryPosts(query.value) list.value = res.list total.value = res.total } finally { loading.value = false } } watch( query, (val) => { router.replace({ query: { ...val, page: String(val.page) } }) fetchList() }, { deep: true, immediate: true } )

这段代码里有一个坑必须点出来:搜索框不能每次输入都发请求。虽然现在是本地数据没网络开销,但筛选加计算在数据量大时依然会卡,而且真实项目里这就是典型的接口风暴。正确做法是加防抖,三百毫秒足够。

let timer = null function onKeywordInput(val) { if (timer) clearTimeout(timer) timer = setTimeout(() => { query.value.keyword = val query.value.page = 1 }, 300) }

搜索时把页码重置为 1 是必须的,否则你在第 8 页搜一个只有两条结果的词,页面会变成空白,用户第一反应是"系统坏了"。

排序和分页的交互也有讲究。切换排序字段时,页码同样要归 1;切换每页条数时,最合理的做法是按当前第一条数据的序号去反推新页码,让用户视觉上不跳走。这个细节做不做,体验差距很大。

3.2 发帖与编辑:表单校验和草稿保存的细节

发帖表单看着简单,实际上藏着不少细节。我用 Element Plus 的表单组件配合自定义校验规则,这一块可以直接抄。

const rules = { title: [ { required: true, message: '标题不能为空', trigger: 'blur' }, { min: 4, max: 60, message: '标题长度需在 4 到 60 个字符之间', trigger: 'blur' }, { validator: (rule, value, callback) => { if (/[<>]/.test(value)) { callback(new Error('标题不能包含尖括号')) } else { callback() } }, trigger: 'blur' } ], boardId: [{ required: true, message: '请选择所属板块', trigger: 'change' }], content: [ { required: true, message: '正文不能为空', trigger: 'blur' }, { min: 10, message: '正文至少 10 个字符', trigger: 'blur' } ] }

长度限制必须前后端一致,但纯页面项目里只有前端,所以这里其实是在练你的产品思维——标题 60 个字是在列表页一行的展示极限,超过就会被截断成省略号;正文最少 10 个字是为了拦住"顶""沙发"这种毫无信息量的内容。规则背后要有理由,不是随便填个数。

草稿保存值得单独讲。用户写了八百字,手滑点了关闭,全没了,这是最伤人的体验。我的做法是监听表单数据,做防抖后写入本地存储,进入页面时检测到草稿就弹提示。

import { watch, onMounted } from 'vue' import { save, load } from '@/utils/storage' const DRAFT_KEY = 'post_draft' watch( form, (val) => { if (draftTimer) clearTimeout(draftTimer) draftTimer = setTimeout(() => { if (val.title || val.content) { save(DRAFT_KEY, { ...val, savedAt: Date.now() }) } }, 800) }, { deep: true } ) onMounted(() => { const draft = load(DRAFT_KEY) if (draft && draft.title) { // 这里不要用原生 confirm,体验割裂,用组件库的确认框 showRestoreDialog(draft) } })

发布成功后一定要清掉草稿,不然下次进来又问一遍恢复,用户会烦。

实操心得:富文本编辑器的内容回填,一定要在编辑器实例 ready 之后再调用设置方法。我踩过一次坑——在 created 里就赋值,编辑器还没挂载,内容直接丢了,控制台一点报错都没有,排查了很久才发现是时序问题。

3.3 详情页与评论树:递归组件写法与性能注意点

评论树是论坛的灵魂,也是递归组件的经典应用场景。Vue 3 里组件可以通过文件名自动自引用,写起来很清爽。

<!-- components/CommentTree.vue --> <template> <div class="comment-node"> <div class="comment-head"> <span class="author">{{ node.authorName }}</span> <span class="time">{{ node.createdAt }}</span> <button class="reply-btn" @click="onReply(node.id)">回复</button> </div> <p class="comment-body">{{ node.content }}</p> <div v-if="node.children && node.children.length" class="comment-children"> <CommentTree v-for="child in node.children" :key="child.id" :node="child" @reply="onReply" /> </div> </div> </template> <script setup> defineProps({ node: { type: Object, required: true } }) const emit = defineEmits(['reply']) function onReply(id) { emit('reply', id) } </script> <style scoped> .comment-children { padding-left: 24px; border-left: 2px solid #ebeef5; } </style>

这段代码有几个工程化的点。第一,事件逐层向上抛,不要在子组件里直接调 store,否则组件复用性会断掉。第二,缩进层级要有上限,我一般限制到 5 层,再深的回复自动拍平,视觉上不再继续缩进。原因是屏幕上每层缩进 24 像素,五层就吃掉 120 像素,手机端根本没法看。

性能方面,评论超过 300 条的时候,整棵树一次性渲染会明显掉帧。两个优化手段:一是默认只展开前两层,深层用"展开更多"按钮控制;二是如果确实要全部渲染,给每条评论的节点用v-memo缓存,避免父组件状态变化导致整棵树重渲染。我实测过,八百条评论的页面,加v-memo之后从滚动卡顿变成基本流畅。

页面详情区还有个小细节:阅读量自增要防抖并且只算一次。简单做法是在本地存储里记一个已读帖子 id 列表,进详情页时判断,没读过才加一。虽然纯页面项目里这个数字不影响什么,但这个思路在真实项目里能防止刷量,值得养成习惯。

4. 权限与状态管理:纯前端也能做出"像样"的权限体系

4.1 用 Pinia 管理登录态与角色

很多人觉得没有后端就没有权限可言,其实不对。前端权限的核心是控制界面可见性和入口可用性,至于安全性,那是后端的事。在演示项目里把权限做出来,能让作品集的说服力上一个台阶。

// stores/user.js import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { load, save } from '@/utils/storage' export const useUserStore = defineStore('user', () => { const token = ref(load('token', '')) const profile = ref(load('profile', null)) const role = computed(() => profile.value?.role || 'guest') const isLogin = computed(() => Boolean(token.value)) const canManagePost = computed(() => ['admin', 'moderator'].includes(role.value)) function login(payload) { token.value = payload.token profile.value = payload.profile save('token', token.value) save('profile', profile.value) } function logout() { token.value = '' profile.value = null window.localStorage.removeItem('forum_db_token') window.localStorage.removeItem('forum_db_profile') } return { token, profile, role, isLogin, canManagePost, login, logout } })

这里我特意用了setup风格的 store,因为组合式写法和页面里的逻辑风格一致,读代码不用切换思维模式。角色设计我一般分四档:admin能管所有内容,moderator只管帖子和评论,user只能操作自己的内容,guest只能看。

判断"能不能操作某条数据"时,条件不能只写角色,还要拼上归属关系:

function canEdit(post, user) { if (!user.isLogin) return false if (user.role === 'admin') return true if (user.role === 'moderator') return true return post.authorId === user.profile.id }

这个函数建议放在utils/permission.js里统一维护,别散落在各个组件。权限逻辑一旦分散,改规则的时候必然漏掉几处,线上就会出现"某个入口还能点进去"的尴尬情况。

4.2 路由守卫与按钮级权限的轻量实现

路由守卫负责拦人,自定义指令负责藏按钮,这两层配合起来就够用了。

// router/index.js router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.meta.requiresAuth && !userStore.isLogin) { next({ name: 'login', query: { redirect: to.fullPath } }) return } if (to.meta.roles && !to.meta.roles.includes(userStore.role)) { next({ name: 'forbidden' }) return } next() })

按钮级权限用指令实现,写法干净,模板里一眼能看懂:

// utils/permission.js import { useUserStore } from '@/stores/user' export const permission = { mounted(el, binding) { const userStore = useUserStore() const required = binding.value const allowed = Array.isArray(required) ? required.includes(userStore.role) : required === userStore.role if (!allowed) { el.parentNode && el.parentNode.removeChild(el) } } }
<el-button v-permission="['admin', 'moderator']" type="danger">批量删除</el-button>

注意:指令里我选择直接移除 DOM,而不是设置display: none。原因是隐藏元素在开发者工具里改一下样式就能点,虽然前端拦不住铁了心的人,但至少不要给人留下"改一行样式就能进来"的印象。这类细节在面试里很加分。

另外记得处理权限变更后的路由刷新。用户退出登录时,如果当前停留在管理页面,要主动跳回首页或者登录页,否则会看到一堆报错弹窗。我的做法是在退出方法里直接router.replace('/'),简单粗暴但绝不出错。

5. 布局自适应的实现细节(大屏方案)

5.1 rem 与 vw 方案的对比和落地

论坛后台在大屏上展示的机会其实不少,比如运营大屏、展厅演示,这时候固定像素布局就会显得拥挤或者空旷。主流的适配方案有三种,我做个横向对比。

方案原理优点缺点
固定 px不做适配开发简单,像素级还原大屏上元素过小,小屏溢出
rem + 动态根字号JS 按屏幕宽度设置 html 字号兼容性好,改造成本低需要额外脚本,首屏可能闪一下
vw + clamp全部用视口单位并夹取值纯 CSS,无脚本依赖部分老组件库样式需要覆盖

我目前更倾向vw 加 clamp 混用,因为不用额外脚本,配合clamp()可以做区间限制,既能在小屏上保持可用,又不会在大屏上失控。给个实际的写法:

.forum-layout { --page-padding: clamp(12px, 1.2vw, 32px); --card-radius: clamp(6px, 0.5vw, 12px); padding: var(--page-padding); gap: clamp(8px, 0.8vw, 20px); } .post-title { font-size: clamp(15px, 1.05vw, 20px); line-height: 1.6; }

用 CSS 变量兜住整套间距和字号,改一处全局生效。这个习惯我是从设计规范做得比较正规的项目里学来的,比在每个组件里写死数字靠谱太多。

如果项目里已经大量使用 px,那退回到 rem 方案更省事。核心就是动态设置根字号:

const BASE_WIDTH = 1920 const BASE_SIZE = 16 function setRootFontSize() { const width = Math.min(window.innerWidth, 2560) const scale = width / BASE_WIDTH document.documentElement.style.fontSize = `${BASE_SIZE * scale}px` } setRootFontSize() window.addEventListener('resize', () => { if (resizeTimer) clearTimeout(resizeTimer) resizeTimer = setTimeout(setRootFontSize, 150) })

加上上限 2560 是为了防止超宽屏出现字号夸张的情况,这个细节很少有人提,但实际用 4K 屏演示时特别明显。

5.2 表格与列表在大屏下的展示优化

Element Plus 的表格在窄屏下会横向滚动,在大屏下又会留下一大片空白,两边都不好看。我的处理方式是按屏宽切换信息密度,而不是让表格无限拉伸。

具体做法是给表格列配置做成响应式的计算属性:

import { computed } from 'vue' import { useWindowSize } from '@vueuse/core' const { width } = useWindowSize() const columns = computed(() => { const base = [ { prop: 'title', label: '标题', minWidth: 260, showOverflowTooltip: true }, { prop: 'authorName', label: '作者', width: 120 }, { prop: 'views', label: '浏览', width: 90, sortable: true }, { prop: 'createdAt', label: '发布时间', width: 170, sortable: true } ] if (width.value >= 1600) { base.splice(2, 0, { prop: 'boardName', label: '所属板块', width: 140 }) base.push({ prop: 'likes', label: '点赞', width: 90, sortable: true }) } if (width.value >= 2000) { base.push({ prop: 'tags', label: '标签', minWidth: 180 }) } return base })

思路很直白:小屏保核心,大屏加信息。用户在大屏上本来就希望能一次看到更多内容,你把表格拉宽而列数不变,反而浪费了空间。这个技巧在数据看板类项目里同样适用。

还有个容易被忽略的点:大屏下点击区域会显得过于紧凑。行高、按钮尺寸、间距都需要按比例放大。我一般会把表格的size属性做动态绑定,宽度超过 1600 时切成large,视觉上更舒展。

6. 常见问题与排查实录

6.1 问题速查表

下面这些是我在做这类项目时反复遇到的问题,整理成表方便快速定位。

现象大概率原因处理方式
按需引入后组件样式全丢函数式组件的样式没被自动引入手动引入对应样式文件,或改用完整引入
本地数据刷新后重置只在内存里改,没写回本地存储在增删改后调用保存函数
删除最后一页数据后页面空白页码越界,未做页码纠正删除后判断当前页是否还有数据,没有则页码减一
富文本内容粘贴后样式错乱外部样式和内联属性一起被带进来粘贴时做标签白名单过滤,只保留基础标签
列表滚动卡顿一次性渲染全部节点分页或虚拟滚动,深层评论默认折叠
搜索输入触发频繁计算没有防抖,每次输入都刷新加 300ms 防抖并在搜索时重置页码
中文输入法导致搜索提前触发未处理输入法合成事件监听合成事件或使用组件库自带防抖
退出登录后页面报错当前路由仍停留在权限页面登出时主动跳转首页

6.2 我踩过的几个坑和对应的解法

第一个坑:页码越界。我在第 5 页删掉了最后一条数据,列表变成空白,用户一脸茫然。原因是筛选后的总数从 41 变成 40,第 5 页只剩下 0 条。解决办法是在删除成功后重新计算总页数,如果当前页大于总页数,就把页码往前挪一位再刷新。这段逻辑我抽成了一个公共函数,所有带分页的列表都复用:

function normalizePage(current, total, size) { const maxPage = Math.max(1, Math.ceil(total / size)) return Math.min(current, maxPage) }

第二个坑:深拷贝不彻底。编辑帖子时我把列表里的对象直接赋给了表单,用户改了半天点取消,结果列表里那条数据也变了。原因是对象引用是同一个。这里必须做深拷贝,简单场景用structuredClone,兼容性有顾虑就用JSON.parse(JSON.stringify())。这个问题在新手项目里出现频率极高,而且演示时特别容易尴尬——你改完取消,标题居然变了。

第三个坑:递归组件的 key 用索引。我一开始图省事写:key="index",结果删除某条评论后,后面的评论内容整体错位,看起来像是删错了。原因就是索引 key 在列表变动时无法正确复用节点。递归组件里 key 必须用唯一 id,这条铁律没有例外。

第四个坑:开发时数据正常,打包后空白。排查半小时发现是路由用了 history 模式,本地直接打开index.html时路径匹配不上。如果这个项目是要以文件形式交付的,记得把路由切成 hash 模式,或者老老实实配一个静态服务。这种问题在交付场景里非常常见,提前想好交付形态能省很多事。

6.3 关于这个项目还能怎么往下走

纯页面版本做扎实之后,扩展方向其实挺多的,我说几个我认为性价比最高的。

一是加导出功能,把帖子列表导出成表格文件。这一块的难点不在生成文件,而在中文编码和大量数据的分批写入,做过一次之后你对二进制流和编码会有更实在的理解。二是加国际化,把界面文案抽成语言包,切换时观察哪些内容是硬编码的,这个过程能帮你养成"文案不写死在模板里"的习惯。三是把数据层抽成独立包,让 mock 层可以一键切换到真实接口,这样这套代码既是练手项目,也能直接作为真实项目的前端骨架。

如果时间只够做一件事,我建议是把搜索、分页、筛选这三个功能打磨到极致。它们看着最普通,但真正能体现一个人对边界情况的理解程度——空状态怎么展示、加载中怎么反馈、错误了怎么恢复、条件冲突了怎么处理。这些细节堆起来,才是作品集里最打动人的部分。

最后分享一个我一直在用的习惯:每做完一个功能,就在浏览器里从头到尾点一遍,专门去点那些"不该点"的地方——空表单直接提交、快速连点两次删除、搜一个不存在的关键词、把筛选条件全选上。这几个动作每次都能帮我揪出一两个问题,比写单元测试还立竿见影。这套论坛系统的代码我是从一个空白目录一点点敲出来的,中间重写了两次数据层,最后一次才把本地存储和内存数据的关系理顺。你要是照着做,大概也会经历同样的过程,别急,坑踩过一遍,这些东西就真的长在你身上了。

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

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

立即咨询