手摸手带你彻底搞懂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)

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

相关推荐
三原1 小时前
7000块帮朋友做了2个小程序加一个后台管理系统,值不值?
前端·vue.js·微信小程序
白仑色1 小时前
完整 Spring Boot + Vue 登录系统
vue.js·spring boot·后端
阳火锅2 小时前
Vue 开发者的外挂工具:配置一个 JSON,自动造出一整套页面!
javascript·vue.js·面试
G_whang3 小时前
jenkins部署前端vue项目使用Docker+Jenkinsfile方式
前端·vue.js·jenkins
荔枝荔枝荔枝3 小时前
【Vue源码学习】Vue新手友好!为什么vue2 this能够直接获取到data和methods中的属性?
vue.js·源码
寻觅~流光3 小时前
封装---统一封装处理页面标题
开发语言·前端·javascript·vue.js·typescript·前端框架·vue
恰薯条的屑海鸥4 小时前
前端进阶之路-从传统前端到VUE-JS(第五期-路由应用)
前端·javascript·vue.js·学习·前端框架
wangpq4 小时前
Echart饼图自动轮播效果封装
javascript·vue.js
真夜4 小时前
记录van-rate组件输入图片打包后无效问题
前端·vue.js·typescript