财神机器人

番摊机器人,顶尖机器人,数聊机器人,粮草机器人

粮草机器人 响应式原理 —— 数据变了,视图怎么知道?



无论是Vue、React还是现在前端各种状态管理库,核心都绕不开「响应式」——你改了数据,视图自动更新,不用你手动操作DOM。很多新手刚学的时候都会好奇:我就改了个JS变量,浏览器怎么知道要刷新视图?今天我们从最基础的原理开始,一步步讲透响应式的核心逻辑:数据变了,视图到底是怎么知道的?


一、响应式的核心本质:「依赖收集」+「触发更新」


不管是Vue2的Object.defineProperty还是Vue3的Proxy,甚至React的useState,响应式的核心逻辑其实就两步,非常好记:


依赖收集‌:哪个视图要用这个数据,就把这个视图的更新方法记下来

触发更新‌:数据一变,就把之前记下来的更新方法拿出来全部执行一遍,刷新视图


说白了就是「登记+通知」:你要用数据先登记好,我数据变了挨个叫你们来更新。我们从一个最简单的模拟实现入手,就能把这个逻辑讲清楚。


二、从零实现一个极简响应式:看懂核心逻辑


我们先不扯框架,就实现一个最基础的效果:修改数据,自动更新DOM,看看整个流程是怎么走通的。


第一步:定义一个存依赖的“笔记本”


首先我们需要一个地方存收集到的依赖,也就是谁要用我的数据,我记在小本本上:


javascript

// 全局依赖收集器:存每个数据对应的更新函数

class Dep {

  constructor() {

    // 存所有依赖的更新函数

    this.subscribers = new Set();

  }

  // 把当前要执行的更新函数加进来(依赖收集)

  depend() {

    if (currentUpdateFn) {

      this.subscribers.add(currentUpdateFn);

    }

  }

  // 数据变了,挨个通知更新(触发更新)

  notify() {

    this.subscribers.forEach(fn => fn());

  }

}


// 全局变量:当前正在要注册的更新函数,收集依赖的时候用

let currentUpdateFn = null;



这个Dep就是我们说的“小本本”,核心功能就是两个:收依赖、发通知。


第二步:把普通数据变成“可观测”的


我们要让数据变的时候能被检测到,就得给数据做个“包装”,当有人改数据的时候,我们能收到通知,这一步叫「数据劫持」。我们先拿最简单的Object.defineProperty来做演示:


javascript

// 把对象的属性变成响应式

function defineReactive(obj, key) {

  // 每个属性都有一个自己的Dep,存这个属性对应依赖

  const dep = new Dep();


  // 存当前属性的值

  let value = obj[key];


  // 劫持属性的get和set

  Object.defineProperty(obj, key, {

    get() {

      // 有人读这个属性的时候,收集当前的更新函数到依赖里

      dep.depend();

      return value;

    },

    set(newValue) {

      // 改数据的时候,如果值变了,就更新值,然后通知所有依赖更新

      if (newValue !== value) {

        value = newValue;

        dep.notify();

      }

    }

  })

}


// 把整个对象变成响应式

function reactive(obj) {

  for (let key in obj) {

    defineReactive(obj, key);

  }

  return obj;

}



这里有个关键细节:‌每个响应式属性都有自己独立的Dep‌——只有用到这个属性的视图,才会在这个属性的Dep里登记,改这个属性的时候,只会通知用到它的视图更新,不会瞎通知所有视图,节省性能。


第三步:注册更新函数,完成依赖收集


现在数据已经变成可观测的了,我们还要把视图的更新函数注册进去:


javascript

// 注册一个副作用函数(更新视图的函数)

function effect(updateFn) {

  // 先把当前更新函数存到全局,让get的时候能拿到

  currentUpdateFn = updateFn;

  // 执行一次更新函数,执行过程中会用到响应式数据,触发get,完成依赖收集

  updateFn();

  // 收集完了,清空全局,准备下一次收集

  currentUpdateFn = null;

}



我们来测试一下,整个流程走通:


html

<!-- 我们要更新的DOM -->

<div id="app"></div>

<script>

  // 定义一个响应式数据

  const data = reactive({ count: 0 });


  // 注册更新函数:数据变了就更新DOM

  effect(() => {

    document.getElementById('app').innerText = `当前计数:${data.count}`;

  });


  // 过两秒修改数据,看看会不会自动更新

  setTimeout(() => {

    data.count = 1;

    console.log('数据改了,视图自动更新了!');

  }, 2000);

</script>



打开页面你会看到,初始显示当前计数:0,两秒后自动变成当前计数:1——我们就只用改数据,视图自动更新了,一个最基础的响应式就跑通了!


整个流程我们捋一遍,你就懂了:


调用effect注册更新函数,把更新函数存到全局,然后执行更新函数

执行更新函数的时候,用到了data.count,触发get,把当前的更新函数加到data.count的Dep里,完成依赖收集

我们修改data.count,触发set,发现值变了,就调用Dep.notify,把之前收集的更新函数拿出来执行一遍

更新函数重新执行,把新的值渲染到DOM,视图更新完成


整个过程就是这么简单,核心就是「谁读数据我存谁,谁改数据我通知谁」。


三、Vue2和Vue3的响应式差异:为什么Vue3要换Proxy?


知道了核心原理,我们再看框架的实现,其实就是在这个基础上加了更多细节,Vue2和Vue3最核心的差异就是数据劫持的方式不一样:


Vue2:Object.defineProperty 的坑


Vue2用的就是我们上面写的Object.defineProperty,这个方案虽然能用,但是有两个天生的坑:


监听不到对象属性的新增和删除‌:Object.defineProperty只能劫持已经存在的属性,你给对象新增一个属性,这个属性一开始没被劫持,所以改了不会触发更新,Vue2才搞出个$setAPI来解决这个问题

监听不到数组的索引和长度修改‌:因为性能问题,Vue2一开始没有对数组的索引做劫持,所以你写arr = 123不能触发更新,也得用$set,而且push、pop这些原生数组方法也要做一层包装才能劫持

如果是深层嵌套对象,要递归遍历所有属性做劫持,初始化的时候性能开销比较大


所以Vue2里才有那么多“为什么我改了数据视图不更新”的问题,本质就是Object.defineProperty的天生限制。


Vue3:Proxy 解决了这些坑


Vue3换成了ES6的Proxy,直接代理整个对象,不需要遍历每个属性做劫持,天生解决了Vue2的问题:


Proxy代理的是整个对象,不管你新增属性还是删除属性,都能触发get/set,不需要额外的API

数组的索引修改、长度修改都能正常劫持,不用特殊处理

只有访问到深层属性的时候,才会对深层属性做代理,懒加载,初始化性能比递归劫持好很多


我们用Proxy重写一下defineReactive,就能看出区别:


javascript

function reactive(obj) {

  return new Proxy(obj, {

    get(target, key) {

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

      // 如果是对象,再做一层代理,实现懒劫持

      if (typeof value === 'object' && value !== null) {

        return reactive(value);

      }

      // 依赖收集和之前一样

      dep.depend();

      return value;

    },

    set(target, key, newValue) {

      Reflect.set(target, key, newValue);

      // 触发更新

      dep.notify();

      return true;

    },

    deleteProperty(target, key) {

      delete target[key];

      dep.notify();

      return true;

    }

  })

}



可以看到,Proxy天生支持新增删除属性,懒加载深层对象,比Object.defineProperty好用太多,这就是为什么Vue3要换方案。


四、React的响应式:为什么和Vue不一样?


React没有用Vue这种“数据劫持+依赖收集”的响应式,而是用了另一种思路:状态改变 -> 重新渲染,核心原理其实是useState/useReducer触发的组件重渲染:


jsx

const [count, setCount] = useState(0);

// 你调用setCount修改数据,React就会自动让这个组件重新渲染,用新的count生成新的DOM,完成视图更新



React的思路其实更直接:我不做细粒度的依赖收集,你改了哪个状态,我就把这个状态对应的组件重新渲染一遍,虽然看起来比Vue的细粒度更新笨一点,但是逻辑简单,配合React的fiber调度,性能也不差。


现在React也有了useSyncExternalStore这些API,也能做细粒度的响应式更新,本质思路还是和我们之前说的一样:依赖收集+触发更新,只是把更新粒度从属性变成了组件。


五、常见的响应式问题解答

1. 为什么有时候改了数据,视图不更新?


无论是Vue还是React,本质都是没触发依赖更新:


Vue2:新增对象属性、直接改数组长度,没触发劫持,自然不会通知更新,用$set就好

Vue3:如果是响应式对象的解构,解构之后的变量变成普通值了,不是响应式的,改了不会触发更新

React:你直接改了state对象,没调用setState,React自然不知道你改了,不会重新渲染

2. 为什么不要在计算属性里修改数据?


计算属性本质是一个带缓存的副作用函数,计算属性里读数据会收集依赖,如果你在里面改数据,会触发当前的依赖更新,然后更新又会触发计算,循环递归,直接栈溢出。


3. 浅响应和深响应有什么区别?


Vue3里的shallowReactive就是浅响应,只有对象的第一层是响应式的,深层属性不会做代理,改深层属性不会触发更新,好处是性能快,适合大对象场景,reactive默认是深响应,所有层级都是响应式的。


六、总结


其实响应式说穿了就是一个非常简单的设计模式:观察者模式,核心逻辑就是三句话:


把数据包装成可观测的,能检测到什么时候被读、什么时候被改

读数据的时候,把用到这个数据的更新函数收集起来(依赖收集)

改数据的时候,把所有收集到的更新函数拿出来执行一遍(触发更新)


Vue2用Object.defineProperty做数据劫持,解决了响应式问题但是有新增属性、数组的坑;Vue3用Proxy解决了这些坑,性能更好;React换了个思路,用状态触发组件重渲染,本质都是实现「数据变了,视图自动更新」的效果。


现在你再改数据的时候,就知道视图到底是怎么知道数据变了——本质就是我们提前把依赖登记好,数据一变挨个发通知而已。


Powered By Z-BlogPHP 1.7.3

财神机器人,顶尖机器人,数聊机器人,粮草机器人