理解 reactive 响应式数据原理

前言

「响应式数据」在前端框架中被广泛使用,如我们熟知的 Vue 响应式数据劫持React 生态 - 状态管理 Mobx

这得益于 ES6 Proxy 对数据的 "拦截" 特性。外界对该对象的 访问和修改 ,都必须先通过这层拦截,其核心是借助 Proxy 的 get()set() 方法,实现一些想要的逻辑处理。

基于这个特性,我们来实现一个简易版 reactive 来理解「响应式数据」原理。

一、observable 创建响应式对象

observable() 函数用于创建一个响应式对象,它接收一个目标对象,返回创建后的响应式对象。

  1. 使用示例:
js 复制代码
const { observable, autorun, batch, define, toJS } = reactive;

const object = {
  name: '小明',
  school: {
    name: '清华大学',
  }
}
const obs = observable(object);

autorun(() => {
  console.log(obs.name); // 消费响应式数据
});

obs.name = '小红'; // 修改响应式数据值

示例中调用 observable 基于 object 原始对象创建了一个响应式对象,在 autorun(下文介绍) 注册的函数中消费了响应式数据。

autorun() 函数有一个特点:默认会执行一次传入的函数(tracker),若其中 消费 了响应式数据,在响应式数据值发生变化时,会再次执行。

而响应式原理就是:数据变化时,通知消费它的函数去重新执行

在示例中代码输出如下:

js 复制代码
小明
小红
  1. 代码实现:

observable 接收一个原始对象,其内部会基于原始对象创建一个响应式对象并进行返回,它的实现如下:

js 复制代码
const observable = (target) => {
  return createObservable(null, null, target, false);
}

observable.shallow = (target) => {
  return createObservable(null, null, target, true);
}

observable深度劫持 创建一个响应式对象,在这里我们扩展一个 observable.shallow,用于创建 浅劫持 响应式对象,也就是只会对目标对象的第一级属性操作响应。

它们都是执行 createObservable() 创建,区别在于传递给此函数的第四参数,为 true 代表 浅劫持

createObservable() 方法四个参数分别指:

  • 第一参数 target,要进行数据劫持的父级对象,在这里传入的对象属于最顶级,所以值为 null;
  • 第二参数 key,在父级对象下所使用的 key,同理这里值为 null;
  • 第三参数 value,要深度劫持的目标对象,
  • 第四参数 shallow,是否对 目标对象 进行浅劫持。
js 复制代码
const ProxyRaw = new WeakMap(); // 所有 Proxy 集合(key 是 proxy,值为 target)
const RawProxy = new WeakMap(); // 深观察的 Proxy 集合(key 是 target,值为 proxy)
const RawShallowProxy  = new WeakMap(); // 浅观察的 Proxy 集合(key 是 target,值为 proxy)

const createObservable = (
  target, // 父级对象 
  key, // 对应 key
  value, // 要观察的值
  shallow, // 是否浅观察(只处理一层)
) => {
  // 只对 对象 进行劫持
  if (typeof value !== 'object') return value;
  // 如果 value 已经是一个响应式对象,直接返回
  if (ProxyRaw.get(value)) return value;

  // 这里判断是否属于浅劫持,发生在深度递归处理时。如果父劫持对象的类型属于一个浅劫持,则不对当前对象做劫持处理。
  // (如果一开始不理解这里,可以先跳过)
  if (target) {
    // target 可能是普通对象,也可能是 Proxy 实例,先考虑 Proxy 实例
    const parentRaw = ProxyRaw.get(target) || target;
    const isShallowParent = RawShallowProxy.get(parentRaw);
    if (isShallowParent) return value;
  }
  
  // 创建 Proxy
  return createProxy(value, shallow);
}

const createProxy = (target, shallow) => {
  // 创建响应式对象
  const proxy = new Proxy(target, handlers); // 核心处理在 handlers
  ProxyRaw.set(proxy, target);
  (shallow ? RawShallowProxy : RawProxy).set(target, proxy);
  return proxy;
}

createObservable 中会先做一些条件判断确认是否要对数据进行劫持,若满足条件进入 createProxy 方法中执行 new Proxy(target, handlers) 创建一个响应式对象并进行返回,handlers 是一个对象,提供了 getset 方法处理劫持逻辑。

现在,一个响应式数据创建完成。到这里我们可以得到以下信息:

  1. 当对响应式数据值进行访问或修改时,真正的处理逻辑在 handlers 提供的 getset 方法中;
  2. 深度劫持并不会在 observable 这里深度递归去完成,其实发生在对属性操作时触发的劫持 getset 方法中(后面就理解了);

在看数据劫持 getset 的实现之前,我们先来看下 autorun 做了什么事情,之后结合 observableautorun 一起来看这两个方法的具体实现。

二、autorun 监听响应式数据

从上面示例代码可以看出 autorun 起到了 监听 响应式数据的作用,它有以下特点:

  1. 接收一个函数(叫 tracker),在 tracker 中可以去消费响应式数据;
  2. 默认会将 tracker 执行一次(目的是让 响应式数据 能够收集到它,用于后续数据变更时的通知);
  3. 消费的响应式数据发生变化时重新执行 tracker;
  4. 此外 autorun 会返回一个函数(销毁函数),用于取消对响应式数据的监听。

基于上述特点,autorun 的实现如下:

js 复制代码
const ReactionStack = []; // 记录 autorun 参数 tracker

const autorun = (ticker) => {
  const reaction = () => {
    if (typeof tracker !== 'function') return;
    if (ReactionStack.indexOf(tracker) === -1) {
      try {
        ReactionStack.push(reaction); // 1. 入栈
        tracker(); // 2. 执行函数去 消费 响应式数据
      } finally {
        ReactionStack.pop(); // 3. 出栈
      }
    }
  }
  reaction(); // 最开始执行一次
  return () => { 
    // return 的函数用于销毁(结合 get、set 实现一起看便于理解)
    // 将 reaction 从观察列表中移除
    if (reaction._reactionsSet) {
      reaction._reactionsSet.forEach(reactionsMap => {
        reactionsMap.forEach(reactions => reactions.delete(reaction));
      });
      delete reaction._reactionsSet;
    }
  }
}

这里为 ticker 包装了一层 reaction(反应),注意看这里的执行顺序:

  1. 执行 tracker 前先将 reaction 存储到 ReactionStack 中;
  2. 执行 tracker;
  3. reactionReactionStack 中移除。

这样做的目的是:在执行第二步 tracker 时,若内部消费了响应式数据,则可以在 ReactionStack 中拿到 reaction 和响应式数据进行关联(收集),当响应式数据发生修改时就可以执行收集到的 reaction 实现通知。

下面我们来看 收集 reaction 的逻辑。

三、劫持的具体实现:get 与 set

1、get() 收集 reaction

当在 autorun tracker 中消费响应式数据时,会触发 Proxy get() 方法,主要做的事情是:是收集订阅者 reaction,并且对消费数据尝试深度劫持:

js 复制代码
const handlers = {
  // 参数:目标对象、属性名、proxy 实例本身(可选)
  get: function(target, key, receiver) {
    const result = target[key];
    // 收集使用此变量的 reaction
    const reaction = ReactionStack[ReactionStack.length - 1];
    reaction && addReactionsMapToReaction(reaction, addRawReactionsMap(target, key, reaction));
    // 尝试深度观察,满足条件,要 return Proxy
    return createObservable(target, key, result);
  },
  
  set: function(target, key, value, receiver) {
    // ...
  }
}

addRawReactionsMap 方法起到收集 reaction 的作用,与所消费的响应式数据进行关联,为后面响应式数据更新时,对收集到的 reaction 进行一一执行(下面 set() 的逻辑)

js 复制代码
const RawReactionsMap = new WeakMap(); // 记录每一个 target 对象,它下面所有属性,保存的 reactions 集合

// 为 target[key] reactions 集合,添加 reaction
const addRawReactionsMap = (target, key, reaction) => {
  const reactionsMap = RawReactionsMap.get(target); // 获取 target Map 属性集合
  if (reactionsMap) {
    const reactions = reactionsMap.get(key); // 获取 target[key] Set reaction 集合
    if (reactions) {
      reactions.add(reaction);
    } else {
      reactionsMap.set(key, new Set([reaction]));
    }
    return reactionsMap;
  } else {
    const reactionsMap = new Map([
      [key, new Set([reaction])], // Set 可以去重
    ]);
    RawReactionsMap.set(target, reactionsMap);
    return reactionsMap;
  }
}

addReactionsMapToReaction 则是反向处理,将响应式数据通过属性指针记录在 reaction 中,用于销毁 autorun

js 复制代码
// 将 reactionsMap 绑定在 reaction 函数属性指针上(autorun 销毁时使用)
const addReactionsMapToReaction = (reaction, reactionsMap) => {
  reaction._reactionsSet = reaction._reactionsSet || new Set();
  reaction._reactionsSet.add(reactionsMap);
  return reaction._reactionsSet;
}

2、set() 通知 reaction

当修改响应式数据时,会触发 set() 方法,主要做的事情是:执行 get() 时收集到的 reaction

js 复制代码
const handlers = {
  get: function(target, key, receiver) {
    // ...
  },
  set: function(target, key, value, receiver) {
    const oldValue = target[key];
    const newValue = createObservable(target, key, value);
    if (oldValue !== newValue) {
      // 更新值,并且执行 reactions
      target[key] = newValue;
      const runReactions = (() => {
        const reactions = RawReactionsMap.get(target)?.get(key);
        reactions && reactions.forEach(reaction => {
          reaction();
        });
      })();
    }
    return true;
  }
}

至此,一个 observable 响应式数据与 autorun 结合就实现了数据更新,消费该数据的 tracker 重新执行。

四、batch 批量更新数据

我们先来看一个例子,一个 autorun tracker 中消费了多个响应式数据值,当在同一时刻对它们进行更新时,tracker 会被执行多次。

js 复制代码
const obs = observable({});
autorun(() => {
  console.log(obs.a, obs.b, obs.c);
});
obs.a = 'a';
obs.b = 'b';
obs.c = 'c';

// 输出结果:
undefined undefined undefined
a undefined undefined
a b undefined
a b c

可见这并不是我们想要的,这个场景我们期望:同一时刻更新 autorun tracker 中消费的响应式数据,仅更新一次

将更新逻辑放在 batch() 中处理可以实现。

js 复制代码
batch(() => {
  obs.a = 'a';
  obs.b = 'b';
  obs.c = 'c';
});

// 输出结果:
undefined undefined undefined
a b c

batch 表示 批量 的意思,批量更新的原理是:启用 batch 时将执行上下文标记为批量更新,在执行到 Proxy set() 方法时先不通知 reaction而是收集起来,等批量更新完成后统一执行一次reaction`。

js 复制代码
// 标记正在进行 batch 批量处理(标记执行上下文为 batch)
const BatchCount = { value: 0 }; 
// 存储 batch 需要批量执行的 reactions。Set 在这里可用于去重
const PendingReactions = new Set(); 

const batch = (fn) => {
  // 1. 标记进入 batch 批量处理机制
  BatchCount.value ++;
  // 2. 执行 fn
  fn();
  // 3. 退出 batch 批量处理
  BatchCount.value --;
  // 执行 PendingReactions
  if (BatchCount.value === 0) {
    PendingReactions.forEach(reaction => {
      reaction();
    });
  }
}

对应的 Proxy set() 要判断一下是否处于 batch 上下文:

js 复制代码
const handlers = {
  get: function(target, key, receiver) {
    // ...
  },
  set: function(target, key, value, receiver) {
    const oldValue = target[key];
    const newValue = createObservable(target, key, value);
    if (oldValue !== newValue) {
      // 更新值,并且执行 reactions
      target[key] = newValue;
      const runReactions = (() => {
        const reactions = RawReactionsMap.get(target)?.get(key);
        reactions && reactions.forEach(reaction => {
 -         // reaction();
          // 处于 batch 批量处理机制下,将 reaction 加入到 Pending 集合中
 +        if (BatchCount.value > 0) {
 +          PendingReactions.add(reaction);
 +        } else {
 +          reaction();
 +        }
        });
      })();
    }
    return true;
  }
}

五、toJS 转换为原始对象

用于深度递归将一个 observable 对象转换成普通 JS 对象

js 复制代码
const toJS = (values) => {
  const weakset = new WeakMap(); // WeakSet 的成员只能是对象,便于后续深度解析
  // 递归函数
  const _toJS = (values) => {
    if (weakset.has(values)) return values; // 避免死循环

    if (isObservable()) {
      if (Array.isArray(values)) {
        weakset.add(values);
        const res = [];
        values.forEach(item => res.push(_toJS(item)));
        weakset.delete(values);
        return res;
      } else if (Object.prototype.toString.call(values) === '[object Object]') {
        weakset.add(values);
        const res = {};
        for (const key in values) {
          res[key] = _toJS(values[key]);
        }
        weakset.delete(values);
        return res;
      }
    }
    
    return values;
  }
  return _toJS(values);
}

六、扩展 - 集成在 React 中使用

从上面信息我们知道:响应式数据发生修改时可以通知到 消费 它的 函数。假如用在 React 中会怎样呢?

我们期望在响应式数据发生变化时,所消费它的 React 组件 能够进行更新重渲染,类似于一些 状态库 ,但它更精准:仅在组件内所消费的数据发生变化时进行更新(按需更新)。

1、Tracker

要实现这个需求,需要借助一个新的 API Tracker,它的作用与 autorun 类似用于 消费及订阅更新,但实现上有所不同。

它接收一个 scheduler 作为数据更新时执行的通知函数,而消费响应式数据则是放在了 Tracker.track() 中,这样就做到了 数据消费 和 监听更新 的功能分离。

放在 React 组件中可以理解为:组件 render 类比为 track() 消费数据,监听更新要做的事情仅是 forceUpdate() 重渲染。

Tracker 是作为接入在 React/Vue 的依赖追踪工具。

我们先看代码示例:

js 复制代码
const obs = observable({
  name: '初始值',
})

// Tracker 的参数时一个 scheduler
const tracker = new Tracker(() => {
  // 3、数据变化,执行特定的逻辑
  console.log('执行特定逻辑,比如 forceUpdate()');
})

// Tracker 实例提供一个 track,与 autorun 的 track 作用一致
tracker.track(() => {
  // 1、消费响应式数据
  console.log(obs.name);
})

// 2、修改响应式数据
obs.name = '更新值';

再来看 Tracker 代码实现:

js 复制代码
class Tracker {
  constructor(scheduler) {
    // _scheduler 用作 更新 执行函数
    this.track._scheduler = scheduler;
  }
  // track 用作消费
  track = (tracker) => {
    if (typeof tracker !== 'function') return;
    let result;
    try {
      ReactionStack.push(this.track); // 1. 入栈
      result = tracker(); // 2. 执行函数去 消费 响应式数据
    } finally {
      ReactionStack.pop(); // 3. 出栈
    }
    return result;
  }
}

可以看到 track 的实现与 autorun reaction 相似,不过这里需要做下兼容处理:在 Proxy set()、batch 中对 Tracker 执行其 _scheduler

js 复制代码
const handlers = {
  get: function(target, key, receiver) {
    // ...
  },
  set: function(target, key, value, receiver) {
    const oldValue = target[key];
    const newValue = createObservable(target, key, value);
    if (oldValue !== newValue) {
      // 更新值,并且执行 reactions
      target[key] = newValue;
      const runReactions = (() => {
        const reactions = RawReactionsMap.get(target)?.get(key);
        reactions && reactions.forEach(reaction => {
          if (BatchCount.value > 0) {
            PendingReactions.add(reaction);
+         } else if (typeof reaction._scheduler === 'function') {
+           reaction._scheduler(); // Tracker
          } else {
            reaction();
          }
        });
      })();
    }
    return true;
  }
}

const batch = (fn) => {
  // 1. 标记进入 batch 批量处理机制
  BatchCount.value ++;
  // 2. 执行 fn
  fn();
  // 3. 退出 batch 批量处理
  BatchCount.value --;
  // 执行 PendingReactions
  if (BatchCount.value === 0) {
    PendingReactions.forEach(reaction => {
+     if (typeof reaction._scheduler === 'function') {
+       reaction._scheduler();
+     } else {
+       reaction();
      }
    });
  }
}

2、observer

observer 是连接 React 组件响应式数据 实现依赖追踪的桥梁。

借助 Tracker 的特性,

  1. 将组件的 render 逻辑作为 Tracker.track() 去消费响应式数据;
  2. 提供组件的 forceUpdate 作为 Tracker scheduler() ,被用于消费数据更新的处理函数。

这样,当消费的数据发生更新时,便会执行组件的 forceUpdate 进行重渲染,以此来达到 数据更新视图同步更新 的目的。

js 复制代码
// reactive-react.jsx
import { useReducer, useRef } from 'react';
import { Tracker } from './reactive';

const observer = (FunctionComponent) => {
  const wrappedComponent = (props) => {
    const [, forceUpdate] = useReducer(v => v + 1, 0);
    // forceUpdate 作为 scheduler
    const trackerRef = useRef(new Tracker(forceUpdate));
    // 组件渲染去消费响应式数据
    return trackerRef.current.track(() => FunctionComponent(props));
  }
  return wrappedComponent;
}

export default observer;

一个简单的 React 代码示例:

jsx 复制代码
import React from 'react';
import observer from './reactive-react';
import { observable } from './reactive';

const state = observable({
  title: 'reactive',
  content: '响应式数据'
});

const Head = observer(() => {
  console.log('head render.');
  return <div>
    {state.title}, 
    <input value={state.title} onChange={e => state.title = e.target.value} />
  </div>;
});

const Content = observer(() => {
  console.log('content render.');
  return <div>
    {state.content}, 
    <textarea value={state.content} onChange={e => state.content = e.target.value} />
  </div>;
});

const App = () => {
  console.log('app render.');
  return <div>
    <Head name="head" />
    <Content name="content" />
  </div>
}

export default App;

文末

感谢阅读。理解响应式原理,对今后实现业务需求能够起到一定的高效帮助。

本文 reactive 实现参考于 Formily @formily/reactive,还有一些其他的 API 可供使用,可查看 @formily/reactive 了解更详细的使用。

相关推荐
AlienZHOU5 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
刺客码8 小时前
Layui 表格固定列行高错位问题解决方案
前端框架·layui·jquery·web
Captaincc8 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师10 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen10 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒11 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow11 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81811 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007811 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明11 小时前
第 6 章 组件进阶
前端·vue.js