手摸手带你彻底搞懂Vue的响应式原理

你是否好奇 Vue 为什么能在你修改变量时自动更新页面?

本文将手把手带你实现一个最简版的 Vue 响应式系统,从原理到代码,彻底讲透它的核心机制。

响应式系统的核心概念

什么是响应式

一句话概括就是:系统自动追踪数据的变化,并实时同步更新依赖该数据的视图或逻辑

大白话说就是:当你修改了变量,vue就会自动帮你更新页面,不用你手动改DOM

核心概念

在Vue中,它的本质是通过追踪数据和副作用函数之间的依赖关系,实现自动更新

那么它是怎么实现的呢?

依赖收集

当某个响应式变量被读取时,vue会记录当前是谁在用这个值,这个'谁'通常是一个副作用函数,也就是effect

javascript 复制代码
// 想象这个 effect 会订阅 count
effect(() => {
  console.log(count.value)
})

这段代码中effect传入的参数是一个函数,而这个函数中读取了count,那么,vue就会把这个函数,也就是副作用函数保存起来

当响应式变量的值发生变化时(如count.value++),Vue 会自动找到依赖它的所有副作用函数,并重新执行它们,从而更新视图或逻辑

这种机制就是发布订阅模式

  • 依赖收集 = 订阅
  • 触发更新 = 发布

触发更新

当响应式对象的某个属性被修改时,Vue会找到依赖它的所有effect,并依次调用他们。比如:

javascript 复制代码
count.value++    // 会触发上面订阅了 count 的 effect

响应式对象

Vue3中的reactive使用了es6的proxy来实现响应式代理

每当你对对象进行访问(get)或者修改(set)时,Vue 都能感知到这一行为,并自动进行依赖收集或触发更新

接下来我们来实现一个简单的响应式系统

最简实现

案例:

javascript 复制代码
const count = ref(0)

effect(() => {
  console.log(count.value)
})

setTimeout(() => {
  count.value++
}, 1000)

接下来我们依次实现refeffect,来成功让count变化时自动调用effect中的函数

effect实现

javascript 复制代码
// 定义一个全局对象,用来存储正在执行的副作用函数
let activeSub = null

class ReactiveEffect {
  constructor(public fn: Function) {}

  run() {
    // 将当前实例赋值给activeSub
    activeSub = this
    try {
      // 执行副作用函数
      return this.fn()
    } finally {
      // 将全局对象存储的副作用函数清空
      activeSub = null
    }
  }
}

function effect(fn) {
  const e = new ReactiveEffect(fn)

  e.run()
}

ref实现

这块代码比较复杂,我先把整体放出来,然后在分步骤解析

javascript 复制代码
class RefImpl {
  _value;   // 当前的值
  subs;     // 链表头,用来存副作用函数
  subsTail; // 链表尾

  constructor(value) {
    this._value = value
  }

  get value() {
    if (activeSub) {
      trackRef(this)
    }
    return this._value
  }  // 当访问 .value 时,Vue就会收集这个依赖,记录哪个副作用函数用到了
  set value(newValue) {
    this._value = newValue
    triggerRef(this)
  }  // 当 .value 改变时,系统就会调用 triggerRef 通知所有依赖它的副作用函数去重新执行
}

function ref(value) {
  return new RefImpl(value)
}

// 收集依赖
function trackRef(dep) {
  if (activeSub) {  // 如果当前有正在运行的 effect 就与这个 ref 建立关联
    link(dep, activeSub)
  }
}

// 触发ref关联的依赖
function triggerRef(dep) {
  if (dep.subs) {  // 如果有关联的副作用函数便执行
    propagate(dep.subs)
  }
}

function link(dep, sub) {
  // 定义一个依赖关系的双向链表节点的结构
  let newLink = {
    sub,                // 订阅者,即副作用函数(表示谁依赖了这个ref)
    nextSub: undefined, // 链表中的下一个订阅者(副作用函数)
    prevSub: undefined, // 链表中的上一个订阅者
  }

  // 判断当前的 dep 是不是第一次被订阅(即 subsTail 为 null)
  if (dep.subsTail) {
    dep.subsTail.nextSub = newLink  // 将尾节点的nextSub指向新的节点(插入到尾部)
    newLink.prevSub = dep.subsTail  // 将这个新节点的prevSub指向原来的尾节点
    dep.subsTail = newLink          // 将原来的尾节点指向新节点
  } else {
    // 第一次被订阅,将头和尾都指向这个新节点
    dep.subs = newLink
    dep.subsTail = newLink
  }
}

function propagate(subs) {
  let link = subs          // 拿到链表头
  const queuedEffect = []  // 定义一个数组用来存储需要重新执行的副作用函数
  while (link) {           // 如果有值就去将副作用函数取出来,存入到数组
    const sub = link.sub
    queuedEffect.push(link.sub) // 将副作用函数追加到数组中
    link = link.nextSub         // 赋值为下一个链表
  }
  // 依次调用
  queuedEffect.forEach(effect => effect.run())
}

首先说一下什么是链表?

链表是一种基础的数据结构,用于按顺序存储元素,通过节点之间的指针连接来组织数据

我们为什么用链表而不是数组?来看下这两者的简单对比:

特性 数组(Array) 链表(当前用的结构)
添加依赖(effect) O(1) or O(n) ✅ O(1)(直接接到尾部)
删除依赖(effect) ❌ O(n) ✅ O(1)(通过指针)
遍历执行所有依赖 O(n) O(n)
实现复杂度 简单 略高

链表的设计可以让我们更高效的添加和移除副作用函数依赖

接下来我们看看在我们的代码中链表结构是怎么设计的:

javascript 复制代码
let newLink = {
  sub,                // 订阅者,即副作用函数 effect 实例(表示谁依赖了这个ref)
  nextSub: undefined, // 下一个
  prevSub: undefined, // 上一个
}

它用来存储与当前响应式数据关联的副作用函数effect实例,sub是我们调用link函数时传递过来的全局定义的一个变量,用来存储正在执行的副作用函数effect实例,这一块的代码在effect实现那里

接下来看这段代码,我将每行都写上了注释,想必应该很清楚了

javascript 复制代码
// 判断当前的 dep 是否已有订阅者(即是否已经建立过依赖)
if (dep.subsTail) {
  // 已经有订阅者,追加到链表尾部

  // 1. 让当前尾节点的 nextSub 指向新节点(连接尾部)
  dep.subsTail.nextSub = newLink

  // 2. 让新节点的 prevSub 指向旧尾部(形成双向链表)
  newLink.prevSub = dep.subsTail

  // 3. 更新 dep.subsTail,标记新的尾节点
  dep.subsTail = newLink
} else {
  // 没有任何订阅者,这是第一次收集依赖

  // 1. 让 subs(链表头)指向新节点
  dep.subs = newLink

  // 2. 同时 subsTail(链表尾)也指向它(链表中只有一个节点)
  dep.subsTail = newLink
}

dep也就是调用link函数时传递过来的ref实例,在RefImpl构造函数中,我们定义了subssubsTail,分别用于存储链表的头部和尾部

这样的话我们在修改ref响应式数据时就可以从自己身上拿到subs然后一路nextSub下去,就可以拿到所有关联的副作用函数effect实例啦

然后我们把这些函数全部执行一遍就可以了

也就是这一段代码

javascript 复制代码
function propagate(subs) {
  let link = subs                 // 1. 从链表头开始遍历
  const queuedEffect = []         // 2. 用一个数组缓存所有需要触发的副作用函数

  while (link) {                  // 3. 遍历链表,直到 null
    const sub = link.sub           // 当前节点中的副作用函数
    queuedEffect.push(sub)         // 加入执行队列
    link = link.nextSub            // 移动到下一个节点
  }

  // 4. 依次执行所有副作用函数(即 effect.run())
  queuedEffect.forEach(effect => effect.run())
}

这样一个简易的响应式系统就完成啦

javascript 复制代码
const count = ref(0)

effect(() => {
  console.log(count.value)
})

setTimeout(() => {
  count.value++
}, 1000)
  1. () => {console.log(count.value)}执行
  2. 触发countget
  3. 收集依赖执行link函数
  4. 一秒后执行count.value++
  5. 触发countset
  6. count的值++
  7. 执行propagate
  8. 拿到count实例中的subs,遍历执行收集的副作用函数
  9. 控制台再次打印count.value

总结

虽然这个响应式系统已经能自动追踪数据变化并更新副作用函数,但它仍然是一个极简版本,存在不少限制,比如:

  • 不支持嵌套 effect
  • 不支持复用 effect
  • 没有调度器(scheduler)

这些问题将在后续文章中继续优化和完善,欢迎关注 🌟

相关推荐
知识分享小能手2 小时前
Vue3 学习教程,从入门到精通,Vue 3 + Tailwind CSS 全面知识点与案例详解(31)
前端·javascript·css·vue.js·学习·typescript·vue3
柑橘乌云_4 小时前
vue中如何在父组件监听子组件的生命周期
前端·javascript·vue.js
小白的代码日记7 小时前
Springboot-vue 地图展现
前端·javascript·vue.js
kymjs张涛9 小时前
零一开源|前沿技术周刊 #11
前端·javascript·vue.js
anyup9 小时前
🚀 2025 最推荐的 uni-app 技术栈:unibest + uView Pro 高效开发全攻略
前端·vue.js·uni-app
掘金019 小时前
🚀 Vue 中使用 `@vueuse/core` 终极指南:从入门到精通
vue.js
掘金019 小时前
🔥 Vue 开发者的“外挂”库: 让你秒变超级赛亚人!🔥
javascript·vue.js·前端框架
北辰浮光10 小时前
[Element-plus]动态设置组件的语言
javascript·vue.js·elementui
李大玄10 小时前
一套通用的 JS 复制功能(保留/去掉换行,兼容 PC/移动端/微信)
前端·javascript·vue.js
russo_zhang10 小时前
【Nuxt】一行代码实现网站流量的实施监控与统计
vue.js·nuxt.js