在 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 操作。
踩过的坑
上面举出的例子里 observable 的 observer 很简单,就直接 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 中的方法,即不断的 subscribe 与 unsubscribe。
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 需要根据具体的场景来考量。