文章

响应式系统对比深度解析

深入对比Vue3基于Proxy的响应式系统与React基于不可变数据的状态管理,从更新粒度、依赖追踪到性能权衡,揭示两大框架的核心设计哲学差异

响应式系统对比深度解析

一句话概括

Vue3的响应式系统基于Proxy的自动依赖追踪实现细粒度更新,而React通过不可变数据和显式setState触发从根节点开始的协调(Reconciliation)流程,两者代表了”自动追踪”与”显式声明”两种截然不同的响应式哲学。

背景与意义

前端框架的核心使命之一是解决”UI = f(state)”这个方程式——如何高效地将状态变化反映到用户界面上。Vue和React作为当前最主流的两大前端框架,对此给出了截然不同的答案。

Vue选择了”自动追踪”的路径:通过Proxy拦截属性的读写操作,自动建立依赖关系图,当数据变化时精确地更新依赖了该数据的组件。开发者只需修改数据,无需关心更新机制。

React则选择了”显式声明”的路径:每次状态变化都通过setStatedispatch显式触发,从根组件开始重新执行整个组件树的渲染函数,然后通过虚拟DOM Diff找出最小变更集。开发者需要遵循不可变数据的规则。

这两种方案各有优劣,理解它们的内部差异不仅是面试中的高频考点,更是选择技术栈和排查性能问题的关键基础。

概念与定义

响应式系统

响应式系统是指当程序中的某个值发生变化时,所有依赖该值的计算或副作用能自动重新执行的机制。

Vue3的响应式(基于Proxy)

Vue3通过reactive()ref()创建响应式对象。其核心机制是:

  • Proxy拦截:使用ES6的Proxy对象拦截属性的get/set操作
  • 依赖收集:在get操作时,将当前正在执行的副作用(如渲染函数、computed)注册为该属性的依赖
  • 派发更新:在set操作时,通知所有依赖了该属性的副作用重新执行

React的状态更新(基于不可变数据)

React通过useStateuseReducer等Hook管理状态。其核心机制是:

  • 不可变性:状态更新必须生成新的对象/数组(而非就地修改)
  • 显式触发:通过setState(newState)显式触发重新渲染
  • 协调(Reconciliation):从根组件开始,构建新的虚拟DOM树,与旧树进行比较,找出最小更新

最小示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Vue3 响应式
import { reactive, computed, watchEffect } from 'vue'

const state = reactive({ count: 0 })
const doubled = computed(() => state.count * 2)

watchEffect(() => {
  console.log(`当前值: ${state.count}, 翻倍: ${doubled.value}`)
})

state.count++ // 自动触发: "当前值: 1, 翻倍: 2"
state.count++ // 自动触发: "当前值: 2, 翻倍: 4"

// React 不可变数据
const [count, setCount] = useState(0)
const doubled = useMemo(() => count * 2, [count])

useEffect(() => {
  console.log(`当前值: ${count}, 翻倍: ${doubled}`)
}, [count])

setCount(c => c + 1) // 显式触发, React从根开始协调

核心知识点拆解

1. 依赖追踪机制对比

Vue3的自动追踪

Vue3的依赖追踪是一个精妙的”自动订阅”系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 简化的依赖追踪流程
let activeEffect: ReactiveEffect | null = null
const targetMap = new WeakMap<object, Map<string | symbol, Set<ReactiveEffect>>>()

// get拦截:收集依赖
function track(target: object, key: string | symbol) {
  if (!activeEffect) return

  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let deps = depsMap.get(key)
  if (!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }

  deps.add(activeEffect) // 当前副作用注册为key的依赖
}

// set拦截:触发更新
function trigger(target: object, key: string | symbol) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const deps = depsMap.get(key)
  if (deps) {
    deps.forEach(effect => effect()) // 通知所有依赖执行
  }
}

关键特性:依赖收集发生在运行时,只在副作用函数实际访问了属性时才建立依赖关系。如果某个条件分支没有执行到,分支内的属性不会被追踪。

React的显式声明

React不追踪依赖关系,而是要求开发者通过useMemo/useCallback的依赖数组手动声明依赖:

1
2
3
4
5
// React模式下,开发者需要自己维护依赖数组
const filtered = useMemo(
  () => items.filter(item => item.category === selectedCategory),
  [items, selectedCategory] // 手动声明
)

React 19的React Compiler会在编译时自动推断依赖关系,弥补这个差距,但其能力仍然受限于编译时分析的边界——无法处理动态属性访问、条件分支等运行时信息。

2. 更新粒度对比

Vue3的组件级细粒度更新

在Vue3中,当一个响应式属性变化时,只有直接依赖了该属性的组件会重新渲染:

1
2
3
4
5
6
7
8
9
state.count 变化
  ↓
查找依赖了 state.count 的所有副作用
  ↓
只有组件A重新渲染(组件B和C不受影响)
  ↓
组件A内的模板编译后的更新函数执行
  ↓
精确更新DOM中与 state.count 相关的节点

这意味着Vue3不需要虚拟DOM Diff就能确定哪些DOM节点需要更新——细粒度到”属性级别”。

React的子树级协调

在React中,setState触发的是从setState所在组件开始的整个子树的协调

1
2
3
4
5
6
7
8
9
setCount(newCount) 在组件A中调用
  ↓
组件A重新渲染(调用组件函数)
  ↓
组件A返回的新虚拟DOM
  ↓
与旧的虚拟DOM进行Diff
  ↓
找出需要更新的实际DOM节点

React的re-render粒度是”组件级”的(至少是整个组件函数重新执行),然后通过虚拟DOM Diff找出实际变化。这就是为什么React需要useMemo、React.memo等优化手段的原因。

3. 运行时 vs 编译时的权衡

维度Vue3(运行时追踪)React(协调)
依赖建立运行时自动收集手动声明/编译时推断
更新触发细粒度到属性从组件开始协调
是否需要虚拟DOM不需要(可跳过)必需(Diff基础)
性能优化成本低(默认就足够好)中到高(需要手动memo)
动态数据友好度高(运行时追中)低(依赖数组不易管理动态key)

实战案例:大型表格组件对比

Vue3版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<template>
  <table>
    <thead>
      <tr>
        <th v-for="col in columns" :key="col.key">
          {{ col.label }}
          <button @click="sortBy(col.key)">排序</button>
        </th>
      </tr>
    </thead>
    <tbody>
      <TableRow
        v-for="row in sortedRows"
        :key="row.id"
        :row="row"
        :columns="columns"
        :editing-cell="editingCell"
        @edit-start="startEdit"
        @edit-end="endEdit"
      />
    </tbody>
  </table>
</template>

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

const props = defineProps({
  rows: Array,
  columns: Array,
})

// 响应式状态
const sortKey = ref('')
const sortOrder = ref('asc')
const editingCell = reactive({ rowId: null, colKey: null })

// Vue3的computed自动追踪依赖
const sortedRows = computed(() => {
  if (!sortKey.value) return props.rows

  return [...props.rows].sort((a, b) => {
    const valA = a[sortKey.value]
    const valB = b[sortKey.value]
    const modifier = sortOrder.value === 'asc' ? 1 : -1
    return valA > valB ? modifier : valA < valB ? -modifier : 0
  })
})

function sortBy(key) {
  if (sortKey.value === key) {
    sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
  } else {
    sortKey.value = key
    sortOrder.value = 'asc'
  }
}

function startEdit(rowId, colKey) {
  editingCell.rowId = rowId
  editingCell.colKey = colKey
}

function endEdit() {
  editingCell.rowId = null
  editingCell.colKey = null
}
</script>

React版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import React, { useState, useMemo, useCallback, memo } from 'react'

type CellPosition = { rowId: string | null; colKey: string | null }

// React.memo包裹子组件避免不必要的重渲染
const TableRow = memo(function TableRow({
  row,
  columns,
  editingCell,
  onEditStart,
  onEditEnd,
}: {
  row: Record<string, any>
  columns: Array<{ key: string; label: string }>
  editingCell: CellPosition
  onEditStart: (rowId: string, colKey: string) => void
  onEditEnd: () => void
}) {
  return (
    <tr>
      {columns.map(col => (
        <td
          key={col.key}
          onClick={() => onEditStart(row.id, col.key)}
          className={
            editingCell.rowId === row.id && editingCell.colKey === col.key
              ? 'editing'
              : ''
          }
        >
          {row[col.key]}
        </td>
      ))}
    </tr>
  )
})

export default function DataTable({
  rows,
  columns,
}: {
  rows: Array<Record<string, any>>
  columns: Array<{ key: string; label: string }>
}) {
  const [sortKey, setSortKey] = useState('')
  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
  const [editingCell, setEditingCell] = useState<CellPosition>({
    rowId: null,
    colKey: null,
  })

  // 手动声明依赖
  const sortedRows = useMemo(() => {
    if (!sortKey) return rows
    return [...rows].sort((a, b) => {
      const valA = a[sortKey]
      const valB = b[sortKey]
      const modifier = sortOrder === 'asc' ? 1 : -1
      return valA > valB ? modifier : valA < valB ? -modifier : 0
    })
  }, [rows, sortKey, sortOrder])

  const handleSort = useCallback((key: string) => {
    setSortKey(prev => {
      if (prev === key) {
        setSortOrder(o => (o === 'asc' ? 'desc' : 'asc'))
        return prev
      }
      setSortOrder('asc')
      return key
    })
  }, [])

  const handleEditStart = useCallback(
    (rowId: string, colKey: string) =>
      setEditingCell({ rowId, colKey }),
    []
  )

  const handleEditEnd = useCallback(
    () => setEditingCell({ rowId: null, colKey: null }),
    []
  )

  return (
    <table>
      <thead>
        <tr>
          {columns.map(col => (
            <th key={col.key}>
              {col.label}
              <button onClick={() => handleSort(col.key)}>排序</button>
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {sortedRows.map(row => (
          <TableRow
            key={row.id}
            row={row}
            columns={columns}
            editingCell={editingCell}
            onEditStart={handleEditStart}
            onEditEnd={handleEditEnd}
          />
        ))}
      </tbody>
    </table>
  )
}

关键差异分析

场景Vue3React
排序时行数据变化只有依赖了新排序结果的组件更新需要memo防止不相关行重渲染
编辑状态变化精确更新编辑态相关的类名整个表格组件重渲染,靠Diff找出变化
新增列自动追踪新属性需要使用展开运算符不可变更新

在大型表格(1000+行)场景中,Vue3的细粒度更新优势明显——表头排序按钮点击时,React需要手动添加memo来防止所有行重渲染,而Vue3默认只更新依赖了排序结果的计算属性。

底层原理

1. Vue3的依赖追踪全流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Vue3核心源码简化
function createReactiveObject(target, handler) {
  // 使用Proxy代理
  return new Proxy(target, handler)
}

const baseHandlers = {
  get(target, key, receiver) {
    // 依赖收集
    track(target, key)

    const value = Reflect.get(target, key, receiver)

    // 如果值还是对象,递归代理
    if (value !== null && typeof value === 'object') {
      return reactive(value)
    }

    return value
  },

  set(target, key, value, receiver) {
    const oldValue = target[key]
    const result = Reflect.set(target, key, value, receiver)

    // 值发生变化时才触发更新
    if (hasChanged(value, oldValue)) {
      trigger(target, key)
    }

    return result
  },

  deleteProperty(target, key) {
    const hadKey = Object.prototype.hasOwnProperty.call(target, key)
    const result = Reflect.deleteProperty(target, key)

    if (hadKey) {
      trigger(target, key)
    }

    return result
  },
}

依赖收集的关键数据结构是三层嵌套的WeakMap/Map/Set:

1
2
3
4
5
targetMap (WeakMap<object, Map>)
  └── target1 → depsMap (Map<string | symbol, Set>)
        ├── 'name' → Set<ReactiveEffect> [effectA, effectB]
        ├── 'age'  → Set<ReactiveEffect> [effectA]
        └── Symbol(sym) → Set<ReactiveEffect> [effectC]

2. React的协调(Reconciliation)流程

React的更新流程可以概括为以下阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
setState(newState)
  ↓
1. 调度(Scheduler)
   - 确定更新优先级(lane)
   - 加入更新队列
  ↓
2. Render阶段(可中断)
   - 构建新的Fiber树(WIP Tree)
   - 执行组件函数,生成新的虚拟DOM
   - Diff比较,标记需要更新的节点(effectTag)
   - 这个过程可以被高优先级更新打断
  ↓
3. Commit阶段(不可中断)
   - 根据effectTag执行真实的DOM操作
   - 运行useLayoutEffect
   - 等待浏览器paint后运行useEffect

React Diff算法核心

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 简化的React Diff
function reconcileChildren(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: ReactNodeList,
): Fiber | null {
  // 只比较同一层级,不跨层级比较
  if (currentFirstChild === null) {
    // 首次渲染:创建所有Fiber节点
    return mountChildFibers(returnFiber, newChildren)
  } else {
    // 更新:Diff比较
    return reconcileChildFibers(returnFiber, currentFirstChild, newChildren)
  }
}

React Diff的三大假设:

  1. 只比较同层:跨层级移动视为销毁+重建
  2. 类型不同则重建:div变成span代表完全不同的子树
  3. key稳定标识:通过key追踪节点在数组中的位置

3. 更新路径长度对比

一条数据变化到达DOM的路径长度对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Vue3:
  state.count = 3
    → trigger('count')
    → 执行effect(组件渲染函数)
    → 直接更新DOM节点(跳过虚拟DOM)
    路径长度:3步

React:
  setCount(3)
    → scheduleUpdateOnFiber
    → render阶段(执行组件,生成虚拟DOM)
    → diff比较(找出变化)
    → commit阶段(DOM操作)
    路径长度:5步

Vue3的路径更短,因为不需要虚拟DOM Diff这一步。但React的虚拟DOM带来了两个关键优势:

  1. 平台无关性:虚拟DOM可以渲染到DOM之外(React Native等)
  2. 声明式编程:开发者不用关心”如何更新”,只关心”状态是什么”

高频面试题解析

面试题1:Vue2的defineProperty和Vue3的Proxy有什么区别?

问题:为什么Vue3要用Proxy替换Vue2的Object.defineProperty?

答案:Object.defineProperty存在几个根本性缺陷:

  1. 无法检测新增属性:defineProperty必须在对象初始化时就定义好所有需要响应的属性。Vue2通过Vue.set()来曲线救国
  2. 无法检测删除属性:删除属性不会触发setter。Vue2也没有官方API处理
  3. 数组索引问题:Vue2通过重写数组方法(push/pop等)来模拟数组响应式,但无法检测通过索引直接修改元素
  4. 性能开销:递归遍历对象所有的属性并逐一用defineProperty改写,初始化成本高
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Vue2中defineProperty的缺陷
const obj = { a: 1 }
Object.defineProperty(obj, 'a', {
  get() { /* 依赖收集 */ },
  set(v) { /* 触发更新 */ },
})

// ❌ 无法处理新增属性
obj.b = 2 // 不会触发更新

// ❌ 无法处理数组索引修改
const arr = [1, 2, 3]
arr[0] = 99 // 不会触发更新

// === Vue3 Proxy ===
const proxy = new Proxy({ a: 1 }, {
  get(target, key) { /* 依赖收集(可处理任意key) */ },
  set(target, key, value) { /* 触发更新(新增属性也OK) */ },
})

// ✅ 新增属性自动响应式
proxy.b = 2 // 触发set handler

// ✅ 数组索引修改自动响应式
const arrProxy = new Proxy([1, 2, 3], { /* 同样的handler */ })
arrProxy[0] = 99 // 触发set handler

面试题2:Vue的响应式是否比React性能更好?

问题:Vue的细粒度响应式默认性能比React好,这是否意味着Vue整体性能更优?

答案:不能简单下结论。两种系统在不同场景下有各自的性能特征:

Vue3优势场景

  • 大型列表的局部更新:只更新变化的行,不需要memo优化
  • 频繁的细粒度属性更新:实时仪表盘、股票行情等
  • 深层嵌套对象的单属性更新:直接修改深层属性,自动追踪

React优势场景

  • 大型应用的首屏加载:SSR + streaming + Suspense架构更成熟
  • 高度动态的UI:频繁的节点插入/删除/排序(React Diff优化充分)
  • 跨平台需求:React Native、React VR等

一个更准确的认知是:Vue3默认性能好的门槛更低,不需要额外的优化知识就能写出高性能应用;而React的上限更高,在正确的优化策略下(memo、useMemo、虚拟化列表等)可以达到与Vue3媲美甚至超越的性能。

对于大多数业务应用,两者在性能上的差距可以忽略不计,真正影响选择的应该是生态、团队技术栈和开发体验。

面试题3:能否将Vue的响应式数据用在React中?

问题:能否直接将Vue的reactive对象传入React组件使用?

答案:技术上可以,但不推荐,而且需要额外处理。核心问题在于更新机制的兼容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { reactive } from 'vue'

// React组件中使用Vue响应式数据
function ReactComponent() {
  const state = useMemo(() => reactive({ count: 0 }), [])

  // 必须强制Vue的响应式更新映射到React的渲染
  const [, forceUpdate] = useReducer(x => x + 1, 0)

  useEffect(() => {
    // 手动建立桥梁:Vue响应式 → React渲染
    const effect = watchEffect(() => {
      // 访问响应式属性,触发Vue的依赖收集
      console.log('count:', state.count)
      // 强制React重新渲染
      forceUpdate()
    })
    return () => effect.stop()
  }, [state])

  return <div>{state.count}</div>
}

这种方法存在几个问题:

  1. 两次更新:Vue修改数据和React渲染是两次独立的调度,存在时序问题
  2. 内存泄漏风险:需要在组件卸载时正确清理Vue的响应式依赖
  3. 并发模式不兼容:Vue的响应式更新不会遵循React的并发调度优先级

这实际上反映了两个框架的设计哲学冲突:Vue的”数据驱动更新”和React的”UI=f(state)”>的声明式模型,在底层机制上无法平滑互操作。组件共享的最佳实践是通过统一的store(如Pinia/Zustand)进行通信,而非跨框架使用响应式系统。

总结与扩展

Vue3和React的响应式系统代表了两种根本不同的设计哲学:

哲学维度Vue3React
数据模型可变(mutable)不可变(immutable)
更新触发自动追踪显式声明
更新粒度属性级组件级(+协调)
心智模型“修改数据→自动更新”“创建新数据→声明更新”
初始化成本建立Proxy代理构建Fiber树
运行时开销依赖收集虚拟DOM Diff

扩展思考

未来的趋势是两种哲学在某种程度上相互借鉴。React 19的React Compiler试图通过在编译时自动推导依赖关系来减少手动memoization的负担——这本质上是向”自动追踪”靠拢。而Vue3也在从另一方面借鉴React——Vapor Mode(无虚拟DOM)尝试在保持响应式的基础上借鉴React的编译时优化思想。

真正有趣的是Signal(信号)的兴起——Solid.js、Preact Signals、Angular 17的Signal都采用了类似的底层机制。Signal本质上将响应式从框架层面下沉为语言级别的基础设施,这与Vue3的ref/reactive有异曲同工之妙,但Signals的推拉结合(push-pull)机制在理论上比Vue3的纯push机制更高效。

无论框架如何演进,理解响应式系统的本质——”如何高效地将数据变化映射到UI变化”——永远是前端工程师的核心能力。

本文由作者按照 CC BY 4.0 进行授权