列表渲染性能优化的框架级对决:React memo 与 Vue v-memo 的底层差异与选型决策
前端列表渲染是性能事故的高发区。同样是 10000 条数据,React 和 Vue 面对的问题看起来一样,但解法却截然不同——这背后是两套完全不同的响应式哲学和编译策略。
本文不会重复「用虚拟列表」这种正确废话,而是从 框架运行时机制 出发,拆解为什么看似相同的优化手段在两个框架里效果天差地别。
一、先看问题:同一个场景,两套痛感
// React — 典型的性能陷阱
function NameList({ names }: { names: string[] }) {
const [filter, setFilter] = useState('')
const filtered = names.filter(n => n.includes(filter))
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
{filtered.map(name => (
<NameItem key={name} name={name} /> {/* 每次 Render 都重建 */}
))}
</div>
)
}
const NameItem = ({ name }: { name: string }) => {
const [selected, setSelected] = useState(false)
return (
<div onClick={() => setSelected(!selected)}>
{selected ? '✅' : '⬜'} {name}
</div>
)
}
<!-- Vue — 同样的问题,不同的根源 -->
<template>
<div>
<input v-model="filter" />
<NameItem
v-for="name in filteredList"
:key="name"
:name="name"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const filter = ref('')
const names = ref<string[]>([...]) // 10000 条
const filteredList = computed(() =>
names.value.filter(n => n.includes(filter.value))
)
</script>
React 的痛:输入框每敲一个字符,整个列表跟着 render,NameItem 全部重新协调(Reconciliation),即使数据没变。
Vue 的痛:看起来没问题——Vue 默认精准更新,但 computed 返回的是全新数组引用,v-for 会把所有 <NameItem> 都标记为「需要 patch」,Virtual DOM diff 成本仍然存在。
二、React 的武器:memo + useMemo 的组合拳
2.1 入门级:React.memo 浅比较
const NameItem = React.memo(({ name }: { name: string }) => {
const [selected, setSelected] = useState(false)
return (
<div onClick={() => setSelected(!selected)}>
{selected ? '✅' : '⬜'} {name}
</div>
)
})
效果:当 name props 相同时,跳过 Render。
局限性:如果父组件每次传给 NameItem 的是新创建的函数或对象引用,React.memo 形同虚设:
// ❌ 反模式:每次都传递新函数引用,memo 失效
{filtered.map(name => (
<NameItem
key={name}
name={name}
onSelect={(n) => handleSelect(n)} // 每次 render 创建新函数!
style={{ padding: 8 }} // 每次 render 创建新对象!
/>
))}
2.2 进阶级:useCallback + useMemo 引用稳定
// ✅ 正确姿势:稳定引用
function NameList({ names }: { names: string[] }) {
const [filter, setFilter] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const filtered = useMemo(
() => names.filter(n => n.includes(filter)),
[names, filter]
)
const handleSelect = useCallback((name: string) => {
setSelected(prev => {
const next = new Set(prev)
next.has(name) ? next.delete(name) : next.add(name)
return next
})
}, []) // 永远稳定
const itemStyle = useMemo(() => ({ padding: 8 }), []) // 永远稳定
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
{filtered.map(name => (
<NameItem
key={name}
name={name}
selected={selected.has(name)}
onSelect={handleSelect}
style={itemStyle}
/>
))}
</div>
)
}
const NameItem = React.memo(({ name, selected, onSelect, style }: Props) => {
return (
<div onClick={() => onSelect(name)} style={style}>
{selected ? '✅' : '⬜'} {name}
</div>
)
})
关键认知:React 的性能优化本质是「用 memo 编码契约」——你显式告诉 React「这个组件在什么条件下不更新」。这给了你精确控制权,但代价是 memo/useCallback/useMemo 三板斧的维护负担。
2.3 React 编译器(React Compiler)能救我们吗?
React 19 的编译器可以自动注入 memo,但它不是银弹:
// React Compiler 自动生成等价代码:
// 原本你需要手写 useMemo/useCallback/memo,现在是自动的
但编译器不能改写你的数据流逻辑。如果 filtered 每次返回新引用、handleSelect 每次创建新闭包,编译器也只能在它能力范围内做 memo,仍然逃不开「新引用导致子组件重渲染」的问题。真正的解还是不可变数据流的正确建模。
三、Vue 的武器:编译器加持的细粒度更新
3.1 Vue 的默认行为已经比 React 优化很多——但还不够
Vue 3 的响应式系统会追踪到属性级别的依赖。但 v-for 在 computed 重新计算时,Vue 的 diff 算法仍然需要遍历新旧 VNode:
<!-- 10000 条数据只改了 1 条 → diff 仍然遍历 10000 个 VNode -->
3.2 v-memo:Vue 3.2+ 的精准跳过
<template>
<div>
<input v-model="filter" />
<NameItem
v-for="name in filteredList"
:key="name"
:name="name"
:selected="selectedSet.has(name)"
v-memo="[name, selectedSet.has(name)]"
@select="toggleSelect(name)"
/>
</div>
</template>
v-memo 的工作原理:当依赖数组 [name, selectedSet.has(name)] 的值与上次渲染时完全一致,Vue 直接复用整棵 VNode 子树,连 diff 都跳过。
这比 React.memo 更底层:React.memo 跳过了组件 Render 函数调用,但仍然需要走协调(Reconciliation)去比较返回的 JSX;而 v-memo 直接跳过整个子树的 diff。
3.3 实战:大规模列表的状态管理
<script setup lang="ts">
import { ref, computed, shallowRef, triggerRef } from 'vue'
const filter = ref('')
const names = ref<string[]>(generateLargeList(10000))
const selectedIds = ref(new Set<number>())
// ✅ 关键优化:shallowRef 避免深层响应式开销
const items = shallowRef(
names.value.map((name, i) => ({ id: i, name, selected: false }))
)
const filteredList = computed(() => {
const f = filter.value.toLowerCase()
return f
? items.value.filter(item => item.name.toLowerCase().includes(f))
: items.value
})
function toggleSelect(id: number) {
const item = items.value[id]
item.selected = !item.selected
triggerRef(items) // shallowRef 需要手动触发
}
</script>
<template>
<input v-model="filter" placeholder="搜索..." />
<div class="list">
<div
v-for="item in filteredList"
:key="item.id"
v-memo="[item.selected]"
@click="toggleSelect(item.id)"
:class="{ active: item.selected }"
>
{{ item.selected ? '✅' : '⬜' }} {{ item.name }}
</div>
</div>
</template>
效果:选中一个 item 时,只有那一个 div 被重新 patch,其余 9999 个完全复用。
四、Benchmark:数据说话
使用 Chrome Performance Profiler,在 MacBook Pro M1 上测试 10000 条数据的列表,过滤输入触发重渲染,取 5 次平均值:
| 方案 | 首次渲染 | 过滤输入(Scripting) | 单项选中(Scripting) |
|---|---|---|---|
| React 无优化 | 420ms | 185ms | 178ms |
| React memo + useCallback | 435ms | 42ms | 38ms |
| React Compiler (自动 memo) | 430ms | 40ms | 36ms |
| Vue 无优化 | 210ms | 95ms | 82ms |
| Vue + v-memo | 215ms | 28ms | 6ms |
| Vue + v-memo + shallowRef | 208ms | 24ms | 5ms |
结论:
- Vue 基线性能就比 React 好(模板编译 + 细粒度响应式),首次渲染快近一倍
- React 优化后能达到「足够好」的水平,但代码复杂度的代价明显
- Vue 的
v-memo在单项更新的场景下显著优于 React.memo,因为跳过了 diff 本身
五、框架之外:通用最佳实践
无论 React 还是 Vue,以下原则都有效:
5.1 虚拟列表是终极解(但别急着用)
对于真正需要渲染 10000+ DOM 节点的场景(如聊天消息、日志流),虚拟列表是唯一解:
// react-virtuoso 示例
<Virtuoso
data={filteredNames}
itemContent={(index, name) => <NameItem name={name} />}
/>
但虚拟列表会破坏原生 Ctrl+F、无障碍导航等浏览器能力。如果列表项简单(纯文本、不带交互),在 5000 条以内用 DOM 渲染 + 框架优化完全够用。
5.2 列表项的「身份」比「值」更重要
// ❌ 用 index 做 key — 排序/过滤时状态错乱
{items.map((item, i) => <Item key={i} {...item} />)}
// ✅ 用稳定 id 做 key
{items.map(item => <Item key={item.id} {...item} />)}
5.3 避免在渲染循环中做数据转换
// ❌ 在 map 里做计算
{names.map(name => {
const upper = name.toUpperCase() // 每次都算
const avatar = generateAvatar(name) // 每次都算
return <Item key={name} display={upper} avatar={avatar} />
})}
// ✅ 提前计算好,map 里只做渲染
const displayData = useMemo(() =>
names.map(name => ({
name,
upper: name.toUpperCase(),
avatar: generateAvatar(name),
})),
[names]
)
return displayData.map(d => <Item key={d.name} {...d} />)
5.4 状态上移 vs 状态下沉
// ❌ 所有状态放在顶层,任何变动触发全量重渲染
function Parent() {
const [items, setItems] = useState([...])
return <List items={items} onUpdate={setItems} />
}
// ✅ 把局部状态下沉到子组件
function Parent() {
const items = useItems() // 只读
return <List items={items} />
}
const Item = memo(({ item }) => {
const [expanded, setExpanded] = useState(false) // ✅ 局部状态不牵连父级
return <div onClick={() => setExpanded(!expanded)}>{item.name}</div>
})
六、选型决策矩阵
| 场景 | React 方案 | Vue 方案 |
|---|---|---|
| 简单列表(< 500 条) | 无需优化 | 无需优化 |
| 中规模列表 + 高频交互 | memo + useMemo + useCallback | v-memo + shallowRef |
| 超大规模(> 5000 条) | 虚拟列表 + React Compiler | 虚拟列表 + v-memo |
| 团队新人多 | React Compiler(降低心智负担) | Vue 默认行为(开箱即用) |
| 需要极致粒度控制 | 手动 memo(精确但繁琐) | v-memo + 定制 diff 策略 |
七、总结
React 和 Vue 列表渲染优化走了完全不同的路:
- React:给你一个精简内核,让你自己用
memo/useMemo/useCallback搭建优化体系。灵活但心智负担重。 - Vue:编译器替你做了大部分优化,
v-memo是更底层、更高效的跳过手段。省心但定制性稍弱。
两者没有绝对的优劣,关键在于你是否理解自己框架的运行时。一个不理解 Virtual DOM diff 的 React 开发者,写再多 memo 也是徒劳;一个不理解响应式依赖追踪的 Vue 开发者,遇到性能瓶颈只会卡住。
框架是工具,性能优化靠的是你对底层机制的理解——这篇文章的价值不在于告诉你在哪个框架上用哪个 API,而是帮你建立跨框架的性能思维能力。
测试代码仓库:benchmark-repo
评论区
登录 后参与评论