本文最初發布於 Orizens 博客,經原作者 Oren Farhi 授權,由 InfoQ 中文站翻譯並分享。
我為 ReadM(https://readm.netlify.app/)創建了 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"的右側,是一個帶有白色「播放」圖標的黑色橢圓形。
針對 useRecorder() 的音頻錄製功能,我發現了一個不錯的軟體包,可以抽象並簡化錄音操作:
mic-recorder-to-mp3(https://www.npmjs.com/package/mic-recorder-to-mp3)
由於使用了這個模塊,我的 hook 的代碼變得非常短。但它也簡化了自己的構建塊。
const [audio, setAudio] = useState<File>()
const [player, setPlayer] = useState<HTMLAudioElement>()
const recorderInstance = useRef<MicRecorder>(() => undefined)
start() 函數使用一個新的錄製實例來更新 recorderInstance。這個實例是用來停止錄製的函數。我決定使用 useEffect() 和 Observables,將構造函數的返回值用作 destroy/cancel 功能(請注意,我正在檢查這裡是否支持錄製,後文具體介紹):const start = () => {
if (supportsRecordingWithSpeech) recorderInstance.current = record()
}
const stop = async () => {
if (supportsRecordingWithSpeech) {
const { file, audioPlayer } = await recorderInstance.current()
setAudio(file)
setPlayer(audioPlayer)
}
}
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,
}
}
隨著 ReadM 的發展,我更深入地嘗試了在 JavaScript 中的函數式編程。
由於 ReadM 利用了 Redux 來編寫 record() 函數,因此我導入了 redux 的 compose():import { compose } from "redux"
compose() 函數接受任意數量的參數。這些參數必須是函數。compose() 從 最後 一個參數開始依次調用這些函數(pipe 也會執行相同的操作,但會從第一個參數開始)。
每個函數的結果將傳遞到下一個函數。由函數的最終目標來決定返回值是什麼——這就實現了某種「可連結性」,所以可以與 compose() 序列一起使用。
使用 record() 時,首先運行的是 setupMic(),然後一個接一個地調用函數,同時接收後者的返回值。
const record = compose(
attachStopRecording,
startRecording,
setupMic
)
function setupMic() {
return new MicRecorder({
bitRate: 128,
})
}
function startRecording(recorder: MicRecorder) {
recorder.start()
return recorder
}
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,這款程序能通過實時反饋樹立兒童閱讀和講出英語的信心(更多語種正在開發中):
https://readm.netlify.app/
我會基於 ReadM 的開發經驗,撰寫更多有用的文章。
Oren Farhi 是前端工程師和 JS 顧問。他的作品包括 ReadM、Echoes Player、ngx-infinite-scroll 等。他撰寫了《Angular 和 NgRx 的響應式編程》一書。這裡是他的開源項目列表:
https://github.com/orizens
https://orizens.com/about