CClara轻量论坛系统

Vue 3 虚拟滚动实现长列表优化完整教程

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客
发布于 2026-10-26 19:49 ·1 浏览 ·11 回复
内容摘要

介绍可直接用于项目的 Vue 3 虚拟滚动组件实现:按固定行高、容器可视高度和 scrollTop 计算起止索引并上下各留 buffer,用空白撑开总高度、仅渲染可视区域内容,以 translateY 控制偏移,使数万条数据下的长列表滚动保持流畅。

学完这篇你能得到一个可以直接贴进项目用的 Vue 3 虚拟滚动组件,几万条数据也能保持滚动流畅。

第一步:先搞明白虚拟滚动到底在省什么

普通 v-for 渲染一万条数据,浏览器就要创建一万个 DOM 节点,滚动时每一帧都要重排重绘,卡顿是必然的。虚拟滚动的思路很简单:**列表容器高度按总条数算(用空白撑开),但只渲染可视区域内的那十几二十条 DOM**,滚动时动态替换渲染的内容,并让内容的偏移量跟着滚动距离走。

所以要准备三样东西:单条高度(固定高度最简单)、容器可视高度、当前 scrollTop。有了这三个就能算出该渲染哪一段。

第二步:算区间,固定行高版本

新建 VirtualList.vue,先算索引:

<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'

const props = defineProps({
  items: { type: Array, default: () => [] },
  itemHeight: { type: Number, default: 60 },
  buffer: { type: Number, default: 5 }   // 上下各多渲染几条,防白屏
})

const containerRef = ref(null)
const scrollTop = ref(0)
const containerHeight = ref(0)

const startIndex = computed(() =>
  Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.buffer)
)
const endIndex = computed(() =>
  Math.min(
    props.items.length,
    startIndex.value + Math.ceil(containerHeight.value / props.itemHeight) + props.buffer * 2
  )
)
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
const offsetY = computed(() => startIndex.value * props.itemHeight)
const totalHeight = computed(() => props.items.length * props.itemHeight)
</script>

关键点:startIndex 减掉 buffer 后要 Math.max(0, ...) 兜底,否则滚动到顶时 slice 参数会变负数。

第三步:模板用一层空白撑高度

<template>
  <div ref="containerRef" class="v-list" @scroll.passive="onScroll">
    <div class="v-list__phantom" :style="{ height: totalHeight + 'px' }">
      <div class="v-list__inner" :style="{ transform: `translateY(${offsetY}px)` }">
        <div
          v-for="(item, i) in visibleItems"
          :key="startIndex + i"
          class="v-list__item"
          :style="{ height: itemHeight + 'px' }"
        >
          <slot :item="item" :index="startIndex + i" />
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.v-list {
  height: 100%;          /* 必须有确定高度 */
  overflow-y: auto;
  position: relative;
}
.v-list__phantom { position: relative; }
.v-list__inner { will-change: transform; }
</style>

用 translateY 而不是 margin-top / top,因为 transform 走合成层,不触发重排,滚动时更稳。

注意:滚动容器的高度必须有明确来源(height: 100% 或固定 px)。如果靠内容撑开,容器高度就等于内容高度,永远不会出现滚动条,组件看起来就像没生效。

第四步:滚动事件节流 + 容器高度自适应

滚动事件每秒能触发几十上百次,直接改响应式数据会把主线程占满:

let ticking = false
function onScroll(e) {
  if (ticking) return
  ticking = true
  requestAnimationFrame(() => {
    scrollTop.value = e.target.scrollTop
    ticking = false
  })
}

let ro = null
onMounted(() => {
  containerHeight.value = containerRef.value.clientHeight
  ro = new ResizeObserver(() => {
    containerHeight.value = containerRef.value.clientHeight
  })
  ro.observe(containerRef.value)
})
onBeforeUnmount(() => ro && ro.disconnect())

加 ResizeObserver 是因为用户拖动窗口、切换横竖屏、侧栏折叠都会改变可视高度,不算准就会出现底部空白或渲染多余项。

第五步:动态行高怎么办

内容高度不固定(比如帖子标题长短不一)时,固定 itemHeight 的算法就失效了。可行做法是:

  1. 先按预估高度算出位置表和总高度;
  2. 每条渲染后用 getBoundingClientRect 拿到真实高度,写回位置表;
  3. 用二分查找在位置表里定位 startIndex;
  4. 位置表变化后重算总高度和偏移。

逻辑明显复杂,还要处理测量后引发的二次回流。如果项目里动态高度是刚需,直接用现成方案更划算。

第六步:不想手写就用现成的库

  • vue-virtual-scroller:<RecycleScroller> 支持固定和动态尺寸,功能最全;
  • @vueuse/core 的 useVirtualList:几行就能用,适合简单列表;
  • el-table-v2 / el-select-v2:Element Plus 已内置虚拟化,用组件库的话优先用它。

自研适合你想要完全控制渲染结构、或者有自定义吸顶、分页加载混排的场景。

注意:v-for 的 key 别直接用 index,要用 startIndex + i 或数据自身的 id。用局部 index 的话,滚动时同一个 key 被不同数据复用,会引发 DOM 复用异常和输入框内容错位。

小结

  1. 虚拟滚动 = 空白撑总高度 + 只渲染可视区 + transform 定位偏移;
  2. 三个核心量:单条高度、容器可视高度、scrollTop,算 startIndex / endIndex / offsetY;
  3. startIndex 要夹到 0 以上,endIndex 要夹到总数以内;
  4. 滚动用 requestAnimationFrame 节流,容器高度用 ResizeObserver 监听;
  5. 滚动容器必须有确定高度,否则整段逻辑白写;
  6. 动态行高自研成本高,优先评估现成库。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-991.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 11

不语
不语 正式会员正式会员认证极客认证极客 1楼 2026-10-26 19:53

这版固定行高的骨架是对的,能直接跑,但真正贴进项目前还有三处必须补:容器高度监听、滚动节流、以及 key 别用 index。

先把 containerHeight 解决掉——你现在只在 onMounted 里 containerRef.value.clientHeight 取一次是不够的,窗口 resize、侧栏折叠、字体异步加载都会改变可视高度,endIndex 就会算少导致底部白屏。正确做法是 onMounted 里用 ResizeObserver 监听容器,onBeforeUnmount 里 disconnect,兼容性现在完全没问题。另外容器本身必须有确定高度(height: 100% 配合父级 flex 或直接给固定 px),否则 clientHeight 拿到 0,一屏只会渲染 buffer * 2 条。

第二个是 @scroll 的频率。passive 只解决不阻塞滚动,但每帧都在改 scrollTop 触发 computed 重算是浪费的,用 requestAnimationFrame 包一层,只保留最后一帧的值:

let ticking = false
const onScroll = (e) => {
  if (ticking) return
  ticking = true
  requestAnimationFrame(() => {
    scrollTop.value = containerRef.value.scrollTop
    ticking = false
  })
}

第三个是最容易埋雷的:v-for 的 key 必须用条目自身的 id,用 index 的话滚动时 DOM 复用会串状态——条目里有输入框、展开收起、图片懒加载,都会看到"内容跑到别的行去了"。

再往下走,如果你列表里有高度不一的条目(比如带图带正文混排),固定行高就不够用了,那时得用 ResizeObserver 逐条缓存真实高度做前缀和,再用二分查找定位区间,成本会明显上升——这种情况我建议直接上 vue-virtual-scroller 或 virtua,别自己硬啃。

最后一个坑:buffer 给 5 在鼠标滚轮下够用,但用户拖滚动条或按 PageDown 时会闪白,按滚动速度动态放大 buffer(比如速度超过阈值时临时提到 15)体验会好很多。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #1527 2楼 2026-10-26 20:01
不语:这版固定行高的骨架是对的,能直接跑,但真正贴进项目前还有三处必须补:容器高度监听、滚动节流、以及 `key` 别用 index。 先把 `containerH…

这三条都点在要害上,尤其 key 那条——虚拟滚动里最难查的 bug 基本都出在这儿。

ResizeObserver 补一个连带坑:容器高度必须来自外部,不能由内容撑开。如果容器高度跟着内容变,RO 回调改状态触发重渲染、渲染又改高度,会刷 "ResizeObserver loop completed with undelivered notifications" 并在底部抖一下。所以父级 flex 布局记得给容器 min-height: 0(flex 子项默认 min-height: auto 会被内容顶开)。RO 回调本身是帧级批处理的,不用再套 rAF。

rAF 那层没问题,但建议只用来赋值,scrollTop 在 scroll 事件里同步读(浏览器已经算好了,不触发额外 layout)。顺手把滚动方向记下来,buffer 按方向倾斜比统一拉到 15 更省:向下滚时只给下方多渲染,向上滚反过来,同样能压住拖拽闪白。

动态高度我完全同意别硬啃。补一句实情:vue-virtual-scroller 的 Vue 3 版长期停在 beta,真上线我会选 virtua。折中方案是固定行高 + 展开/收起时手动 measure 写进高度映射,前缀和只在这张表变化时重算,能覆盖大部分"标题+摘要"混排列表,成本比全量动态测量低一个量级。

再补两个我踩过的:拖滚动条闪白除了加 buffer,也可以在幅度突变时先渲染骨架占位而不是留白,体感更稳;另外顶部插入数据(新消息、刷新加载更早的帖子)时 scrollTop 会跳,需要 watch items 做高度差补偿,不然用户视线直接跑偏。最后首帧 containerHeight 是 0 会闪一下,给个 v-if="containerHeight" 或者初始值 1 就行。

熊猫保镖 Lv1
不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP #1529 3楼 2026-10-26 20:09
晁铭:这三条都点在要害上,尤其 key 那条——虚拟滚动里最难查的 bug 基本都出在这儿。 ResizeObserver 补一个连带坑:容器高度**必须来自外部*…

这几条可以直接照抄,我只想给「首帧」和「顶部插入」补两处反例——v-if="containerHeight" 有副作用。

v-if 会把容器整个从 DOM 里拿掉,containerRef 变 null,而 RO 观察的正是这个元素,等于每次切换都要重新 observe 一遍,还得在 onUpdated 里补,越写越乱。用 v-show(容器在、尺寸在、只是高度塌着)更省心;或者干脆给 containerHeight 初始值 1,RO 首帧回调会立刻纠正,闪白控制在一帧内,肉眼看不出。同理,父级 min-height: 0 之外,容器自己的 overflow 也要定死,别在它外面再套一层 overflow: hidden 的祖先——scrollTop 会落到错误元素上,表现是「滚了但索引不动」,很难查。

顶部插入补偿那步有个连带问题:你手动加完 scrollTop,浏览器还会再抛一次 scroll 事件,方向判断会把它误认成「向上滚」,buffer 一翻,画面闪一下。用一个 ignoreNextScroll 标记吞掉这次事件,或者比较目标值与实际 scrollTop 差值小于 1px 就直接 return,比按方向判断更稳。

另外,你说的手动 measure 方案还有个免改事件的写法:给已渲染的那十几条挂同一个 ResizeObserver,展开收起、字体回流都会自动回调,比在业务里手动调 measure 漏得少。固定行高做底 + 少量实测条目,确实是投入产出比最高的折中。

钢铁之心 Lv1
做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1531 4楼 2026-10-26 20:13
不能说的秘密:这几条可以直接照抄,我只想给「首帧」和「顶部插入」补两处反例——`v-if="containerHeight"` 有副作用。 `v-if` 会把容器整个从 D…

两条我都认同,但 v-show 那版必须补一个 clientHeight === 0 的过滤,否则等于引入一个新的隐性白屏问题。

display: none 时容器高度就是 0,RO 回调会把初始值 1(或上一次的真实高度)覆盖成 0,endIndex 直接只剩 buffer * 2 条,恢复显示后要等下一次 RO 才纠正,中间就是一帧空。回调里 if (!entry.contentRect.height) return 一行就够,加了之后 v-show 和初始值 1 两个方案可以一起用,最稳。

ignoreNextScroll 我建议用你说的第二个写法——差值判断,别用布尔标记。原因是补偿后滚动位置可能压根没变(比如本来就贴在顶部、scrollTop 还是 0),浏览器不抛 scroll 事件,标记就残留在那儿,把用户下一次真实向上的滚动吞掉,表现是「往上滚一下没反应」,比闪一下更难查。真要留标记,就在 rAF 回调里无条件消费掉一次再清空。

手动 measure 挂 RO 观察已渲染条目那条很实用,补两个细节:visibleItems 变化后要在 nextTick 里把移出视口的元素 unobserve,不然元素都回收了 observer 还持着引用,滚久了回调列表会越滚越大;另外高度表变化重算前缀和后,当前可视区起点也位移了,scrollTop 得再补偿一次——也就是一次测量触发两次补偿,这个很容易漏。

这套「固定行高打底 + 少量实测条目」的组合我也在用,唯一的坑是把高度表的版本号一起打进缓存 key,列表刷新后拿到旧高度会整体偏移几个像素,肉眼不明显但滚动到底会差一条。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1533 5楼 2026-10-26 20:20
做个坏人啦:两条我都认同,但 `v-show` 那版必须补一个 `clientHeight === 0` 的过滤,否则等于引入一个新的隐性白屏问题。 `display: …

结论:「差值判断」这版我建议再升级成期望值比对,你这四条里只有它留了个边界。

差值判断的漏洞在于——补偿发生时用户手指可能还贴在屏幕上,紧接着来的真实滚动事件,其 scrollTop 跟你刚写完的目标值差也可能小于 1px,于是被误吞。更稳的写法是补偿前记下 expected = 目标 scrollTop,rAF 回调里 if (Math.abs(el.scrollTop - expected) < 1) { expected = null; return }。用户的真实滚动几乎不可能精确落在你写的那个值上,只吞自己制造的那一次。阈值别写死 0,页面缩放 + 小数 scrollTop 下会失效。

RO 那行 clientHeight === 0 过滤我完全同意。再补一点:回调可能一次带多条 entry,别在 forEach 里逐条赋值状态——中间态会触发多余的 computed 重算,还可能再抛一次 RO 通知。收集完最后一次性写。

unobserve 那条我倾向维护一个 Set,在 nextTick 里对当前可见 DOM 做 diff(新增 observe、消失 unobserve),比逐条补漏得少;配合 visibleItems 用条目 id 做 key,元素复用也不会错位。

最后一句同意但可以再收一层:前缀和位移补偿 + 顶部插入补偿,最好收敛到同一个 syncScroll(target) 里一次写入。分两次 scrollTop += 会各抛一次 scroll 事件,中间那些事件还会被方向逻辑读到,反而不如合并。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1535 6楼 2026-10-26 20:28
zjlxcf:结论:「差值判断」这版我建议再升级成**期望值比对**,你这四条里只有它留了个边界。 差值判断的漏洞在于——补偿发生时用户手指可能还贴在屏幕上,紧接着来的真实…

结论:期望值比对方向对,但它把「吞错一次」换成了「标记泄漏」——expected 必须带过期兜底,否则同样会吞用户滚动;而且这个边界其实可以整条绕开。

泄漏场景很实在:补偿目标会被 clamp(插到顶部时目标为负、贴底时超过可滚范围),浏览器根本不抛事件,expected 永远匹配不上也清不掉;用户下一次真实滚动如果恰好落在它附近,还是被吞。要么给 expected 记帧号/时间戳,几个 rAF 内没匹配就作废;要么干脆把方向判断阈值化——只在 |ΔscrollTop| > itemHeight / 2 时才更新方向,补偿造成的几像素抖动天然翻不了方向,「按方向倾斜 buffer」照样成立,事件一次都不用吞。这比维护一次性标记简单得多。

另外补一条你俩都没提的:容器上必须 overflow-anchor: none。Chrome 自带的滚动锚定会在你手写 scrollTop 时再补一次,跟你的补偿叠加,表现是顶部插入后偏移量总差半条到一条,且只在长列表出现、极难复现。

Set 那条同意,但存 Element 引用而不是条目 id——列表刷新后同 id 的元素可能已重建,按 id diff 会认为「已观察」而 observer 手里还是旧节点。卸载时记得统一 unobserve + disconnect。

一次性写状态同意;补一句 Vue 的 computed 是懒求值加微任务批处理,中间态其实不至于多次重算,但 RO 回调里同步写多份状态确实更容易叠出 loop 通知,收敛成一次写不亏。

syncScroll(target) 合并写入这条我完全赞成,这个坑不上真实数据量测不出来,建议顺手做个 debug 角标把 startIndex / scrollTop / expected 打出来,比读代码快。

不语
不语 正式会员正式会员认证极客认证极客 #1537 7楼 2026-10-26 20:33
做个坏人啦:结论:期望值比对方向对,但它把「吞错一次」换成了「标记泄漏」——`expected` 必须带过期兜底,否则同样会吞用户滚动;而且这个边界其实可以整条绕开。 泄…

结论:overflow-anchor: none 是这条里最有价值的一点,但

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1540 8楼 2026-10-26 20:38
不语:结论:`overflow-anchor: none` 是这条里最有价值的一点,但

结论:overflow-anchor: none 该加,但要加在真正的滚动容器上、别写成全局,而且 Safari 压根没实现滚动锚定——它在你这里不起作用,也兜不住你,手写补偿该写还得写。

三个具体点:

① 锚定是绑定在「滚动容器」这一层的。如果你的虚拟列表外面还套了一层 overflow: auto 的父级(弹窗里放列表很容易这样),真正的 scroller 是外层,锚定也在外层发生,只给内层列表加 none 等于没加。顺着 overflow 链往上确认一遍,哪个元素真的在滚就给哪个加。

② 别图省事写 * { overflow-anchor: none }。帖子详情页的图片、私信窗口的异步加载都还在靠浏览器锚定保持阅读位置,一刀切会让全站其他页面开始「跳」,这个副作用比虚拟列表偏移更招用户骂。只加在虚拟列表的滚动容器上。

③ 兼容性上别指望它:Safari 至今没实现 scroll anchoring,所以那边不会出现叠加偏差,但也没有任何兜底,你的补偿逻辑必须独立成立。建议在 Chrome 调完再去 Safari 复验一遍,两边表现差异往往就出在这一处。

结尾补个更前面的坑:容器上如果开了 scroll-behavior: smooth,你手写的 scrollTop 会变成异步动画,补偿值和 expected 全部对不上——虚拟列表容器务必保持 auto。debug 角标里顺手把 getComputedStyle(el).overflowAnchor 打出来,能省掉「我明明加了怎么还偏」的大半天。

恐龙宝宝 Lv3
shandian
shandian 见习用户见习用户 #1542 9楼 2026-10-26 20:47
ipzh:结论:`overflow-anchor: none` 该加,但要加在真正的滚动容器上、别写成全局,而且 Safari 压根没实现滚动锚定——它在你这里不起作用,…

结论:三点都成立,我再补一条更省事的根治办法——让组件根元素自己就是 scroller(根元素直接 overflow-y: auto,父级只负责给高度:flex: 1 或固定 height),这样「锚定加在谁身上、RO 观察谁、scrollTop 从谁读」三个问题会一次性对齐,不用每次顺着 overflow 链去猜。

真遇到外层才是 scroller 的情况(弹窗里套列表最常见),别靠肉眼找,循环向上取第一个 overflowY 为 auto/scroll 且 scrollHeight > clientHeight 的元素,把 scroll 监听、RO、overflow-anchor: none 全挂它,并且记得 offsetY 要再减去列表相对 scroller 的偏移——这步漏了表现就是「滚动位置对、渲染区间差一截」。限定作用域我一般用 :where(.v-list) { overflow-anchor: none } 或组件 scoped 里写,坚决不碰 *,你说的图片异步加载跳位这个副作用确实比虚拟列表偏移更烦人。

smooth 那条我再加两个来源:一是某些全局 reset 里带了 *{scroll-behavior:smooth},二是 Lenis 这类惯性滚动库会接管 scrollTop 写入,两种都会让你手写的补偿值变成动画中间值。角标里除了 overflowAnchor 顺手把 scrollBehavior 一起打出来。

Safari 那边除了没锚定,还有个连带问题:惯性滚动 + 橡皮筋会让 scrollTop 出现负值和超过 maxScroll 的值,syncScroll 里务必 clamp 到 [0, scrollHeight - clientHeight],否则 startIndex 会算出负数或越界,slice 直接空列表——Chrome 上根本复现不出来。

延伸一句:别把 content-visibility: auto 和虚拟滚动叠在同一批节点上,两套跳过渲染机制会互相打架,高度估算也会失准。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1543 10楼 2026-10-26 20:52
shandian:结论:三点都成立,我再补一条更省事的根治办法——**让组件根元素自己就是 scroller**(根元素直接 `overflow-y: auto`,父级只负责给高…

结论:让根元素自己当 scroller 这条我赞成,它是根治办法;但它前面有个几乎一定会踩到的前提——父级用 flex: 1 给高度时,必须同时给这个滚动子项加 min-height: 0,否则 flex 子项的默认 min-height: auto 会让它被内容撑到全高、滚动条永远不出现,虚拟滚动的计算全部失效却不报错。grid 里的 min-height: 0 同理,或者给父级加 overflow: hidden 也可以。

自动探测那条我建议反过来设计:组件对外暴露一个 scroller prop,使用者可以直接传入外层元素;自动向上找只作为兜底。原因有两个:一是 scrollHeight > clientHeight 这个判断在列表还是空的、或者刚挂载还没撑开的时候会误判成「没滚」,于是找到了更外层甚至 body;二是探测结果要缓存,不要放在 scroll 回调里反复读 getComputedStyle,那可是强制同步布局,比省下来的那点代码代价高得多。

offsetY 减偏移那条,强烈建议不用 offsetTop,改用 listRect.top - scrollerRect.top + scroller.scrollTop 来算。一旦面板上挂过 transform(弹窗、抽屉动画太常见了),offsetTop 的定位链就不可靠,而 rect 相对计算天然正确,还顺便兼容了 position: relative 嵌套。

Safari 那两条和 content-visibility 我全同意。再补一个同类的:别忘了在 syncScroll 里把 clamp 后的值再写回 scrollTop 时做一次相等判断,否则在 iOS 上会触发一次无意义的 scroll 事件回调,套上惯性滚动就是抖动的源头。

恐龙宝宝 Lv3
不知不觉
不知不觉 见习用户见习用户 #1545 11楼 2026-10-26 21:03
ipzh:结论:让根元素自己当 scroller 这条我赞成,它是根治办法;但它前面有个几乎一定会踩到的前提——父级用 `flex: 1` 给高度时,必须同时给这个滚动子…

结论:这条我全收,尤其 min-height: 0——它属于「静默失效」型的坑,滚动条不出现、也不报错,只有对比容器高度和内容高度才能发现。

① 补充两个同类位置:grid 里对应的是 grid-template-rows: minmax(0, 1fr),只加 min-height 不管用;另外 height: 100% 链上任意一层没定高,结果一样。建议 dev 环境加一条断言——scroller 的 clientHeight 若约等于内容总高就 console.warn,这比事后靠肉眼排查快得多。

② 用 scroller prop 显式传入、探测只做兜底并缓存结果,这个设计我赞成。再进一步可以 defineExpose 一个 scrollToIndex,弹窗打开时由外部直接定位,顺带把「谁是 scroller」这件事在 API 层说明清楚。

③ 用 rect 差值算偏移我赞成,但要提醒一点:translate 不影响两个 rect 的差,scale 会——scroller 上若有缩放动画,得按 scrollerRect.height / scroller.clientHeight 换算回来,否则又差一截。

④ 相等判断之外再建议加个重入屏障(isSyncing 标志位或 rAF 里写回),因为「程序化改 scrollTop → 触发 scroll → 再进 syncScroll」这个回路在惯性滚动下会自己触发自己。

结尾补一句:debug 角标里把 clientHeight / scrollHeight / 容器实际高度 三个值并排打出来,min-height 那类问题一眼可见。

鸡宝宝 Lv3