本文最初发布于 Orizens 博客,经原作者Oren Farhi授权,由 InfoQ 中文站翻译并分享。
在本文中我决定走技术路线,分享我编写自定义 hooks 和集成某些函数式编程策略的经验。本文介绍了一个自定义 hook:useRecorder()。
“useRecorder()”规范
我为ReadM™创建了 useRecorder(),ReadM™是一款免费且易用的阅读 Web 应用,它可以激励孩子们通过实时反馈来练习、学习、阅读和讲出英语,并提供了很好的体验。
这个 hook 的功能是提供一个录制器:
用法
我设计的 useRecorder() hook 是与段落组件一起使用的——这个段落组件由 3 个组件组成:分别是一个 Speaker、一个 Speech Tester 和一个 Recorder Button。Recorder Button 实际上是一个简单的圆形按钮,一旦用户读出了句子并得到了反馈,它就会出现。这样,用户点击录制按钮就可以重听自己最后一次录音。
上面的描述是在下面这段代码中实现的(我删除了一些实际代码来简化文章):
export function Paragraph({ text, ...props }: ParagraphProps) {
const { start, stop, player } = useRecorder()
const handleEndResult = () => {
stop()
}
const handleStart = result => {
start()
}
return (
<section>
<Speaker
text={text}
disable={isReading}
verified={speechResult}
highlight={verified}
speed={speed}
/>
<SpeechTester onStart={handleStart} onResult={handleEndResult} />
<ButtonIcon
icon="play-circle"
title="Listen to your voice"
onClick={playRecording}
/>
</section>
)
}
复制代码
ReadM™ recorder 显示在图中第一句话“the power of your subconscious mind"的右侧,是一个带有白色“播放”图标的黑色椭圆形。
可重用的 React 自定义 Hook:实现
针对 useRecorder()的音频录制功能,我发现了一个不错的软件包,可以抽象并简化录音操作:mic-recorder-to-mp3。
由于使用了这个模块,我的 hook 的代码变得非常短。但它也简化了自己的构建块。
我创建了两个状态分别用来保存音频和播放器。
const [audio, setAudio] = useState<File>()
const [player, setPlayer] = useState<HTMLAudioElement>()
复制代码
为了缓存每个实例的 recorder,我使用了一个 ref:
const recorderInstance = useRef<MicRecorder>(() => undefined)
复制代码
start()函数使用一个新的录制实例来更新 recorderInstance。这个实例是用来停止录制的函数。我决定使用 useEffect()和 Observables,将构造函数的返回值用作 destroy/cancel 功能(请注意,我正在检查这里是否支持录制,后文具体介绍):
const start = () => {
if (supportsRecordingWithSpeech) recorderInstance.current = record()
}
复制代码
record()函数是三个函数的函数式组合,本节中将具体介绍。
接下来,async stop()函数返回对 Blob 音频文件的引用,以及一个可在任何给定时间播放音频的音频播放器实例。这些保存在这个 hook 开始的状态之内。
const stop = async () => {
if (supportsRecordingWithSpeech) {
const { file, audioPlayer } = await recorderInstance.current()
setAudio(file)
setPlayer(audioPlayer)
}
}
复制代码
目前为止,Android 中还无法通过 WebAPI 录制语音。我正在使用 navigator 的 userAgent 对象来确定代码是在移动平台还是 Android 平台上运行。为了避免这个 hook 错误,**start()和 stop()**都会在运行之前执行检查。
const supportsRecordingWithSpeech =
navigator.userAgent.match(/(mobile)|(android)/im) === null
export function useRecorder() {
const [audio, setAudio] = useState<File>()
const [player, setPlayer] = useState<HTMLAudioElement>()
const recorderInstance = useRef<MicRecorder>(() => undefined)
const start = () => {
if (supportsRecordingWithSpeech) recorderInstance.current = record()
}
const stop = async () => {
if (supportsRecordingWithSpeech) {
const { file, audioPlayer } = await recorderInstance.current()
setAudio(file)
setPlayer(audioPlayer)
}
}
return {
start,
stop,
audio,
player,
}
}
复制代码
函数式 Javascript:创建一个 Recorder
随着ReadM™的发展,我更深入地尝试了在 JavaScript 中的函数式编程。
由于ReadM™利用了 Redux 来编写 record()函数,因此我导入了 redux 的 compose():
import { compose } from "redux"
复制代码
**compose()函数接受任意数量的参数。这些参数必须是函数。compose()从最后一个参数开始依次调用这些函数(pipe 也会执行相同的操作,但会从第一个参数开始)。每个函数的结果将传递到下一个函数。由函数的最终目标来决定返回值是什么——这就实现了某种“可链接性”,所以可以与 compose()**序列一起使用。
使用 record()时,首先运行的是 setupMic(),然后一个接一个地调用函数,同时接收后者的返回值。
const record = compose(
attachStopRecording,
startRecording,
setupMic
)
复制代码
setupMic()创建 recorder 的新实例并返回它:
function setupMic() {
return new MicRecorder({
bitRate: 128,
})
}
复制代码
接下来,以 recorder 实例作为参数调用 startRecording(recorder)。它也返回 recorder。虽说这个函数只是在更广泛的上下文中调用 start(),但它允许执行与启动音频有关的其他逻辑或其他一些操作:
function startRecording(recorder: MicRecorder) {
recorder.start()
return recorder
}
复制代码
最后,使用相同的 recorder 实例作为参数调用 attachStopRecording(recorder)。此函数返回一个新函数——recorder 的 stop()功能,该函数返回文件(blob 缓冲区)和加载了此文件的音频播放器实例。
汇总在一起:
function setupMic() {
return new MicRecorder({
bitRate: 128,
})
}
function startRecording(recorder: MicRecorder) {
recorder.start()
return recorder
}
function attachStopRecording(recorder: MicRecorder) {
return () =>
recorder
.stop()
.getMp3()
.then(([buffer, blob]) => {
const file = new File(buffer, "reading.mp3", {
type: blob.type,
lastModified: Date.now(),
})
const audioPlayer = new Audio(URL.createObjectURL(file))
return { file, audioPlayer }
})
.catch(e => {
console.error(`Something went wrong with the recording ${e}`)
})
}
const record = compose(
attachStopRecording,
startRecording,
setupMic
)
复制代码
如果你喜欢箭头函数,则代码将变为:
const setupMic = () => new MicRecorder({ bitRate: 128 })
const startRecording = (recorder: MicRecorder) => recorder.start() && recorder
const attachStopRecording = (recorder: MicRecorder) => () =>
recorder
.stop()
.getMp3()
.then(([buffer, blob]) => {
const file = new File(buffer, "reading.mp3", {
type: blob.type,
lastModified: Date.now(),
})
const audioPlayer = new Audio(URL.createObjectURL(file))
return { file, audioPlayer }
})
.catch(e => {
console.error(`Something went wrong with the recording ${e}`)
})
const record = compose(
attachStopRecording,
startRecording,
setupMic
)
复制代码
函数式编程的好处
在开发过程中,我一直在问一个问题:它能给我带来什么好处?
首先,我从几个函数开始来编写和创建功能,并确保它们以某种方式链接在一起,让“链”得以正常运转。这些函数可重用于其他目的——我可能在其他场景中用它们实现其他操作或功能。
测试变得更加模块化,更加精确,并与可自我操作的单元隔离开来。每个单元的职责变得更小,只需测试一个简单任务即可。
总的来说,我很满意最后的结果。写出来的代码小巧、简单且易于维护。几个月后再回来看这段代码,我也可以很快地阅读并理解它。
进一步改善
我一直在思考如何改进现有代码。可以将一些可选配置添加到这个 hooks 的函数签名中,例如:结果文件名、录制比特率、不同的文件类型等。
我们可以进一步提高实现的响应性,并创建单个“activate()”函数来使**start()和 stop()**函数作为 effects,让前者触发这两个操作。
请查看我们的革命性应用ReadM™,这款程序能通过实时反馈树立儿童阅读和讲出英语的信心(更多语种正在开发中)。
我会基于ReadM™的开发经验,撰写更多有用的文章。
作者介绍
Oren Farhi是前端工程师和 JS 顾问。他的作品包括ReadM™、Echoes Player、ngx-infinite-scroll等。他撰写了《Angular和NgRx的响应式编程》一书。这里是他的开源项目列表。
原文链接:https://orizens.com/blog/how-to-functional-programming-with-custom-react-hooks/
评论