在 react 中使用 rxjs

最近出于学习目的在临摹一个在线 ppt 的开源项目。原项目使用的是 vue3,我则打算用 react 来实现,同时玩一些暂时不会在工作中使用到的技术,比如 rxjs。本文用于记录在 react 中加入 rxjs 的过程,本人也是在学习过程中,因此以下仅为个人理解,并不代表最佳实践。

场景

每次点击”添加幻灯片”按钮,创建一张幻灯片,并创建一个历史快照,创建历史快照是一个 debounce 后的方法。即如果快速点击多次”添加幻灯片”, 也仅创建一个历史快照。

React 通常写法

import React from 'react'
import debounce from 'lodash/debounce'

const CreateSlideButton = () => {
  const createSnapshot = debounce(() => {
    console.log('create snapshot')
  }, 300)
  const createSlide = () => {
    console.log('create slide')
  }
  const handleClick = () => {
    createSlide()
    createSnapshot()
  }
  return (
    <div onClick={handleClick}>创建幻灯片</div>
  )
}

export default CreateSlideButton

改成 rxjs 实现

在 rxjs 里,数据 / 事件 是一个流的概念。比如我们点击按钮,每点击一次,那么这个事件流就多了一个点击事件。

我们需要做的就是在组件创建这么个事件流并且订阅,每当事件流有新的事件,那么就会执行我们的回调函数,当然别忘了在组件卸载的时候,需要取消订阅。

具体来说,通常我们会以 $ 结尾来表示这是一个流,订阅这个流实际上就是执行流的 subscribe 方法,subscribe 接收的第一个参数就是当事件流有新事件时执行的回调。而卸载则是指流的 unsubscribe 方法。

那么如何创建一个流呢?rxjs 提供了很多操作符,通常我们可以通过 fromEvent创建一个事件流

import { fromEvent } from 'rxjs';

const clickEvt$ = fromEvent(document, 'click');

但是 react 有自己的合成事件机制,显然上面的写法不太合适。我们需要自己来创建一个流,这个流可以接收 react 事件,同时这个流可以被订阅。我们称这样的流为 Subject。那么现在创建一个流可以这么写

import React, { useEffect } from "react";
import { Subject } from "rxjs";

const CreateSlideButton = () => {
  const clickEvt$ = new Subject();

  const handleClick = () => {
    clickEvt$.next();
  };
  return <div onClick={handleClick}>创建幻灯片</div>;
};

export default CreateSlideButton;

上述代码通过 const clickEvt$ = new Subject(); 创建了一个点击事件流,并且在每次 onClick 的时候,clickEvt$.next();给事件流推送一个事件。

但是这个写法明显有个问题,每次组件更新的时候都将创建一个新的 clickEvt$,这里我们用useMemo包起来

import React, { useMemo } from "react";
import { Subject } from "rxjs";

const CreateSlideButton = () => {
  const clickEvt$ = useMemo(() => new Subject(), []);

  const handleClick = () => {
    clickEvt$.next();
  };
  return <div onClick={handleClick}>创建幻灯片</div>;
};

export default CreateSlideButton;

接着我们需要在组件初始化时订阅这个事件流,并且在组件卸载时取消订阅这个事件流。

import React, { useEffect, useMemo } from "react";
import { Subject } from "rxjs";

const CreateSlideButton = () => {
  const clickEvt$ = useMemo(() => new Subject(), []);

  useEffect(() => {
    clickEvt$.subscribe(() => {
      console.log("click");
    });
    return () => clickEvt$.unsubscribe();
  }, [clickEvt$]);

  const handleClick = () => {
    clickEvt$.next();
  };
  return <div onClick={handleClick}>创建幻灯片</div>;
};

export default CreateSlideButton;

至此我们点击按钮后,将打印 “click”。

那么如何实现创建 幻灯片和历史快照自然就不言而喻了

import React, { useEffect, useMemo } from "react";
import { Subject } from "rxjs";
import debounce from "lodash/debounce";

const CreateSlideButton = () => {
  // 创建点击事件流
  const clickEvt$ = useMemo(() => new Subject(), []);
  const [createSnapshot, createSlide] = useMemo(
    () => [
      debounce(() => {
        console.log("create snapshot");
      }, 300),
      () => {
        console.log("create slide");
      }
    ],
    []
  );

  useEffect(() => {
    // 订阅
    clickEvt$.subscribe(() => {
      createSlide();
      createSnapshot();
    });
    // 取消订阅
    return () => clickEvt$.unsubscribe();
  }, [clickEvt$, createSnapshot, createSlide]);

  const handleClick = () => {
    // 推送点击事件
    clickEvt$.next();
  };
  return <div onClick={handleClick}>创建幻灯片</div>;
};

export default CreateSlideButton;

还有可以改造的地方么?

既然我们接受了 frp 的编程思想,是否可以这么思考,我们每次点击,就产生了点击事件流,而点击事件的回调中,我们又派发了创建历史快照这个事件?那么创建历史快照这个事件应该也有自己的流!

从这个角度我们用同样的方法改造一下代码

import React, { useEffect, useMemo } from "react";
import { Subject } from "rxjs";
import debounce from "lodash/debounce";

const CreateSlideButton = () => {
  const clickEvt$ = useMemo(() => new Subject(), []);
  // 初始化 创建快照事件流
  const createSnapshotEvt$ = useMemo(() => new Subject(), []);

  const [createSnapshot, createSlide] = useMemo(
    () => [
      debounce(() => {
        console.log("create snapshot");
      }, 300),
      () => {
        console.log("create slide");
      }
    ],
    []
  );

  useEffect(() => {
    createSnapshotEvt$.subscribe(() => {
      createSnapshot();
    });
    return () => createSnapshotEvt$.unsubcribe()
  }, [createSnapshotEvt$, createSnapshot]);

  useEffect(() => {
    clickEvt$.subscribe(() => {
      createSlide();
      // 推送创建快照事件
      createSnapshotEvt$.next();
    });
    return () => clickEvt$.unsubscribe();
  }, [clickEvt$, createSnapshotEvt$, createSlide]);

  const handleClick = () => {
    clickEvt$.next();
  };
  return <div onClick={handleClick}>创建幻灯片</div>;
};

export default CreateSlideButton;

实际上 createSlide 也可以单独创建事件流,这里不赘述。那么上面写法有什么好处呢?

好处就是 rxjs 提供了大量的操作符,其中就包括了 debounceTime。因此我们可以这么写

import React, { useEffect, useMemo } from "react";
import { Subject } from "rxjs";
import { debounceTime } from "rxjs/operators";

const CreateSlideButton = () => {
  const clickEvt$ = useMemo(() => new Subject(), []);
  const createSnapshotEvt$ = useMemo(() => new Subject(), []);

  const [createSnapshot, createSlide] = useMemo(
    () => [
      () => {
        console.log("create snapshot");
      },
      () => {
        console.log("create slide");
      }
    ],
    []
  );

  useEffect(() => {
    // 通过 .pipe 对事件流进行 debounce 操作
    createSnapshotEvt$.pipe(debounceTime(300)).subscribe(() => {
      createSnapshot();
    });
    return () => createSnapshotEvt$.unsubcribe();
  }, [createSnapshotEvt$, createSnapshot]);

  useEffect(() => {
    clickEvt$.subscribe(() => {
      createSlide();
      createSnapshotEvt$.next();
    });
    return () => clickEvt$.unsubscribe();
  }, [clickEvt$, createSnapshotEvt$, createSlide]);

  const handleClick = () => {
    clickEvt$.next();
  };
  return <div onClick={handleClick}>创建幻灯片</div>;
};

export default CreateSlideButton;

可以看到我们不再需要 lodash 的 debounce 对 createSnapshot 进行操作,而是对事件流进行 debounce 操作。

踩过的坑

上面举出的例子里 observableobserver 很简单,就直接 console.log 就完事儿了。然而在实际的开发场景中,确并非如此简单。上面的代码太长了,因此重新举一个例子

import React, { useEffect, useState } from 'react'
import { of } from 'rxjs'

export default function App() {
  const [count, setCount] = useState(0)

  const sum = (val) => setCount(count + val)

  useEffect(() => {
    const subscription = of(1, 2, 3).subscribe(sum)
    return () => subscription.unsubscribe()
  }, [])

  return <div>{count}</div>
}

上面这个例子,我的本意是希望订阅一个 of 流,并且将产出的值相加后展示出来,也就是说我预期是能打印出 0 + 1 + 2 + 3 = 6。但一旦如果按上面的写法,输出将会是 3。如果有开启 eslint-plugin-react-hooks,eslint 会提示 React Hook useEffect has a missing dependency: 'sum'. Either include it or remove the dependency array。实际上就是因为我们没有正确去配置 useEffect 的依赖,导致 sum 中获取到的 count 一直是 0,因此最终输出为 3。那我们稍加改进

import React, { useEffect, useState, useCallback } from 'react'
import { of } from 'rxjs'

export default function App() {
  const [count, setCount] = useState(0)

  const sum = useCallback((val) => setCount(count + val), [count])

  useEffect(() => {
    const subscription = of(1, 2, 3).subscribe(sum)
    return () => subscription.unsubscribe()
  }, [sum])

  return <div>{count}</div>
}

这次我们给 useEffect 添加了 sum 作为依赖,并且给 sum 包裹了 useCallback。这个时候,打开页面发现,页面上的数字不断增加。原因是我们每次 setCount 之后,count 更新导致 sum 更新,同时也导致不断执行 useEffect 中的方法,即不断的 subscribeunsubscribe

react 为 setState 提供了一种函数回调的方式,即可以通过 setCount(count => count + val)这样的形式来获取旧值,那么代码可以改成

import React, { useEffect, useState, useCallback } from 'react'
import { of } from 'rxjs'

export default function App() {
  const [count, setCount] = useState(0)

	const sum = useCallback((val) => setCount((count) => count + val), [])

  useEffect(() => {
    const subscription = of(1, 2, 3).subscribe(sum)
    return () => subscription.unsubscribe()
  }, [sum])

  return <div>{count}</div>
}

这一次页面显示了预期的 6

经过上面的改写,我们获取到了预期的结果,之后我一度考虑是否能通过将 sum 保存到 ref 中,从而将 sum 从 依赖项中移除呢?

import React, { useEffect, useState, useCallback, useRef } from 'react'
import { of } from 'rxjs'

export default function App() {
  const [count, setCount] = useState(0)

  const sum = useCallback((val) => setCount((count) => count + val), [])

  const latestSum = useRef(sum)
  useEffect(() => {
    latestSum.current = sum
  })

  useEffect(() => {
    const subscription = of(1, 2, 3).subscribe(latestSum.current)
    return () => subscription.unsubscribe()
  }, [])

  return <div>{count}</div>
}

很遗憾,上面的写法是能输出预期结果,然而实际上这么写却并非正确。因为上述的例子只是恰好 sum 不会变更,从而只需要 subscribe 一次。假设 sum 会变更,那么 subscribe 订阅的将一直是初始化时的 sum,而非最新的 sum。将上述代码改成

import React, { useEffect, useState, useCallback, useRef } from 'react'
import { of } from 'rxjs'

export default function App() {
  const [count, setCount] = useState(0)

  const sum = useCallback((val) => setCount(count + val), [count])

  const latestSum = useRef(sum)
  useEffect(() => {
    latestSum.current = sum
  })

  useEffect(() => {
    const subscription = of(1, 2, 3).subscribe(latestSum.current)
    return () => subscription.unsubscribe()
  }, [])

  return <div>{count}</div>
}

页面将会输出 3,即 latestSum.current 中的 count 一直是 0。

总结

可以看到对于原代码而言,rxjs 的实现反而多了很多代码,不过上面的例子只是给到一个启发与入门的作用,让我们从函数响应式编程的角度去思考如何组织代码,而往往在更复杂的场景才能真正展示 rxjs 的威力,是否要使用 rxjs 需要根据具体的场景来考量。