图秀主页
56.67M · 2026-02-04
最近重构了我的个人项目 bbab.net,这是一个面向数字创作者的资源导航站 。
项目一开始跑得很顺畅,但随着收录的工具突破 500+,前端渲染开始卡顿,后端 MongoDB 的 CPU 也在频繁飙升。如果是简单的 CRUD 项目,可能挂个 Nginx 就完事了,但导航站的特殊性在于:读多写少、列表长、对响应速度极其敏感。
这次重构,我把重点放在了前端渲染性能和后端 I/O 模型上。下面聊聊我踩过的坑和解决方案。
Vue 3 的 reactivity 系统虽然比 Vue 2 快,但在处理长列表时,如果不加控制,依然会造成掉帧。
最初的逻辑很简单:父组件 fetch 所有分类数据,通过 props 传给子组件,子组件 v-for 渲染。
// 糟糕的实践
// 父组件一次请求拉取几百条数据
const resources = ref([])
onMounted(async () => {
resources.value = await api.getAllResources() // 数据量大,JSON解析慢
})
这导致两个问题:
对于导航站,用户其实只能看到屏幕内的那几个工具。所以,我引入了虚拟滚动逻辑,只渲染视口内的 DOM。
为了不引入沉重的第三方库(如 vue-virtual-scroller),我写了一个轻量级的 Hook:
// hooks/useVirtualList.js
import { ref, computed, onMounted, onUnmounted } from 'vue'
export function useVirtualList(list, itemHeight = 100) {
const containerRef = ref(null)
const scrollTop = ref(0)
const screenHeight = ref(0)
const visibleCount = computed(() => Math.ceil(screenHeight.value / itemHeight) + 2)
const startIndex = computed(() => Math.floor(scrollTop.value / itemHeight))
const endIndex = computed(() => Math.min(startIndex.value + visibleCount.value, list.value.length))
// 可见区域的数据
const visibleData = computed(() => {
return list.value.slice(startIndex.value, endIndex.value)
})
// 偏移量,制造“滚动”的假象
const offsetY = computed(() => startIndex.value * itemHeight)
const totalHeight = computed(() => list.value.length * itemHeight)
const handleScroll = (e) => {
scrollTop.value = e.target.scrollTop
}
return { containerRef, visibleData, offsetY, totalHeight, handleScroll }
}
在组件中使用:
<template>
<div class="list-container" ref="containerRef" @scroll="handleScroll" style="height: 100vh; overflow-y: auto;">
<!-- 占位 div,撑开滚动条高度 -->
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<!-- 实际渲染的列表 -->
<div
v-for="item in visibleData"
:key="item._id"
class="list-item"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<ResourceCard :data="item" />
</div>
</div>
</div>
</template>
效果:DOM 节点从几百个瞬间降到了 10 个左右,滚动丝般顺滑。
导航站有一个核心指标:点击统计。
最初的实现是“即点即更”:
// 阻塞式写入
router.post('/click/:id', async (req, res) => {
await Resource.updateOne({ _id: req.params.id }, { $inc: { clicks: 1 } })
res.json({ ok: 1 })
})
这在低并发下没问题,但如果某个链接突然火了,几百个并发请求会瞬间打满 MongoDB 的连接数,导致主业务(列表查询)变慢。
利用 Node.js 单线程非阻塞 I/O 的特性,我们将“写入操作”与“业务响应”解耦。
// utils/clickQueue.js
const queue = []
let isProcessing = false
// 定时器:每 1 秒刷一次盘
setInterval(async () => {
if (queue.length === 0 || isProcessing) return
isProcessing = true
const bulkOps = queue.map(id => ({
updateOne: {
filter: { _id: id },
update: { $inc: { clicks: 1 } }
}
}))
try {
// MongoDB BulkWrite 操作效率极高
await Resource.bulkWrite(bulkOps)
queue.length = 0 // 清空队列
} catch (err) {
console.error('Batch write failed:', err)
} finally {
isProcessing = false
}
}, 1000)
// 导出一个简单的推入函数
module.exports = (id) => {
if (id) queue.push(id)
}
API 接口改造:
收益:接口响应时间从 20ms-100ms 降低到了 1ms-2ms,用户体验几乎无感知。
之前的 Vite 配置是把所有 SVG 导入为一个组件库,这导致首屏 JS 包多了 50KB。
现在改成了动态组件:
<script setup>
const props = defineProps(['name'])
// 运行时动态导入,Vite 会自动分包
const icon = defineAsyncComponent(() => import(`../assets/icons/${props.name}.svg`))
</script>
<template>
<Suspense>
<component :is="icon" />
</Suspense>
</template>
对于分类列表查询,我们要确保查询是 Covered Query(索引覆盖)。
// Schema 索引设计
ResourceSchema.index({ category: 1, status: 1, createdAt: -1 })
查询时只 select 需要的字段:
// 只查需要的字段,不查 description 这种大字段
.find({ category, status: 'active' })
.select('name url icon clicks')
重构后的bbab.net/,在首屏加载时间(FCP)和数据库 CPU 占用上都有数量级的优化。
做个人项目最容易陷入“堆功能”的误区,其实把一个简单的列表页性能压榨到极致,反而更有成就感。如果你也在折腾 Vue3 或者 Node.js 的性能优化,欢迎在评论区交流!