理解 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 了解更详细的使用。

相关推荐
蜗牛快跑2137 分钟前
面向对象编程 vs 函数式编程
前端·函数式编程·面向对象编程
Dread_lxy8 分钟前
vue 依赖注入(Provide、Inject )和混入(mixins)
前端·javascript·vue.js
涔溪1 小时前
Ecmascript(ES)标准
前端·elasticsearch·ecmascript
榴莲千丞1 小时前
第8章利用CSS制作导航菜单
前端·css
奔跑草-1 小时前
【前端】深入浅出 - TypeScript 的详细讲解
前端·javascript·react.js·typescript
羡与1 小时前
echarts-gl 3D柱状图配置
前端·javascript·echarts
guokanglun1 小时前
CSS样式实现3D效果
前端·css·3d
咔咔库奇1 小时前
ES6进阶知识一
前端·ecmascript·es6
前端郭德纲1 小时前
浏览器是加载ES6模块的?
javascript·算法
JerryXZR2 小时前
JavaScript核心编程 - 原型链 作用域 与 执行上下文
开发语言·javascript·原型模式