本文最初发布于 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/
评论