最近重构了我的个人项目 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解析慢
}) 

这导致两个问题:

  1. 首屏加载体积大。
  2. 任何微小的状态变更(如暗黑模式切换),都会触发 Diff 算法遍历整个巨大的虚拟 DOM 树。

优化:虚拟滚动 + 组件懒加载

对于导航站,用户其实只能看到屏幕内的那几个工具。所以,我引入了虚拟滚动逻辑,只渲染视口内的 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 个左右,滚动丝般顺滑。

二、 后端:不要让数据库阻塞你的 Node

导航站有一个核心指标:点击统计。
最初的实现是“即点即更”:

//  阻塞式写入
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,用户体验几乎无感知。

三、 另外两个小细节

1. SVG 图标按需加载

之前的 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> 

2. MongoDB 索引覆盖

对于分类列表查询,我们要确保查询是 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 的性能优化,欢迎在评论区交流!

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com