snd.devをReactで使ってみた
(追記: SEについてのポエムを最後に書いてしまいました。)
電通の土屋さんとスターリーワークスさんが snd.dev というUI向けのサウンドライブラリを出したと聞いたので早速つくっているアプリで試してみた。
npmとCDNとどちらもあるのでHTML/VanillaJSで手軽に使うこともReactなどのプロダクトにも導入しやすい。
ここではReact/Next.jsでの組み込んでみた内容をまとめてみる。
プロジェクト構成
- TypeScript 4.5.5
- React 17.0.2
- Next 12.0.8
- Chakra 1.8.1
※UIはChakraを採用していますが他のものでも応用できるように書いています。
インストール方法
コンソールなどで package.json
に追加する。
npm i snd-lib
基本的な使い方
- Sndインスタンスを作る
- サウンドアセットをロードする
- Sndインスタンスのメソッドを呼ぶ
const snd = new Snd(/* options */)
snd.load(Snd.KITS.SND01)
document.getElementById("button").addEventListener("click", () => {
snd.playButton()
})
初期化時に自動的にロードさせるオプションもある。
const snd = new Snd({
preloadSoundKit: Snd.KITS.SND01 // or Snd.KITS.SND02
})
なお、iOSなどのモバイル端末では音を再生するときに制約があり、ユーザーのアクションをトリガーに再生しないと勝手に音を鳴らすことができないため、通例として何かしらのユーザー操作で最初の1回を無音で鳴らしておくというハックが必要ですが、それはSndコンストラクタ内でよしなにやってくれるのでモバイルでも問題なく使えます。
素敵。
Reactで使いやすくする
SndインスタンスをContext/Providerで流す
おなじSnd
インスタンスを使いまわして一度だけアセットをロードしたいので初期化処理をContext.Provider
に集めて利用側はContext
を通じてインスタンスを受け取って使うだけにします。
はじめに値の型がSnd
となるContext
をつくります。
ここでは初期値の型を雑にアサーションしてしまってますがプロジェクトの作りに応じてよしなに変えてください。
export const SoundContext = createContext<Snd>({} as Snd)
Snd
の知識を隠蔽するなら必要なメソッドや操作のみ渡すのも良いと思います。
type ISoundContext = {
play: (key:string) => void
stop: (key:string) => void
mute: () => void
unmute: () => void
}
export const SoundContext = createContext<ISoundContext>({} as ISoundContext)
Provider
はインスタンス生成時に渡すオプション(SndOptions
)をそのまま受け取れるようにしました。
type IProps = {
options?: SndOptions
}
export const SoundContextProvider: VFC<PropsWithChildren<IProps>> = ({
options,
children,
}) => {
const [snd, setSnd] = useState<Snd>()
const load = useCallback(async () => {
await snd?.load(Snd.KITS.SND01)
}, [snd])
useEffect(() => {
setSnd(new Snd(options))
}, [options])
useEffect(() => {
// preloadSoundKitがある場合はコンストラクタで自動的にロードされるのでloadメソッドを呼ばない
if (options?.preloadSoundKit) return
void load()
}, [options?.preloadSoundKit, load])
if (!snd) return <>{children}</>
return <SoundContext.Provider value={snd}>{children}</SoundContext.Provider>
}
ちなみにSnd
クラス本体以外の型は、パッケージ内の定義ファイルから読めば使えます。
import { SndOptions, PlayOptions } from 'snd-lib/dist/snd'
Next.jsなので pages/_app.tsx
で先程の初期化を担う SoundContextProvider
をインポートしてページコンポーネントを囲みます。
※実プロジェクトでは他にもいろいろ なんとかProvider
があるけど省略します。
const App: VFC<AppProps> = ({ Component, pageProps }) => (
<SoundContextProvider options={{ preloadSoundKit: Snd.KITS.SND02 }}>
<ChakraProvider>
<Component {...pageProps} />
</ChakraProvider>
</SoundContextProvider>
)
export default App
Reactコンポーネントで使う
これで下層のコンポーネントでは Context
経由で Snd
インスタンスを受け取って使えます。
const snd = useContext(SoundContext)
ただ、ボタンなどのたくさんある全ての要素で毎度インポートとuseContext()
書くのが辛そうだったので、UIライブラリに依存しないHooksを作り、それをUIライブラリと紐付けた汎用的なUIコンポーネントを作ります。
useClickSound()
を実装します。
HooksとしてはonClick()
というマウスイベントハンドラを含むpropsを受け取って、onClick()
にサウンドを鳴らす処理を挟み込んで他のpropsはそのまま戻す感じのI/Fにします。
import { useContext, useCallback, MouseEventHandler } from 'react'
import { SoundContext } from 'views/features/sounds/Context'
export const useClickSound = <T extends { onClick?: MouseEventHandler }>({
onClick,
...props
}: T) => {
const snd = useContext(SoundContext)
const onClickHandler: MouseEventHandler = useCallback(
(e) => {
snd.playButton()
onClick?.(e)
},
[onClick, snd],
)
return {
...props,
onClick: onClickHandler,
}
}
これをお使いのUIライブラリの汎用パーツとして組み込んで利用します。
Chakra の場合の実装は次のようになりました。
ボタンのpropsを受け取って useClickSound()
を通した値を Button
に渡します。
このときrefを転送できるようにします。(Chakraの場合、ReactのではなくてChakraのforwardRef()
を使います。)
import {
Button,
ButtonProps,
forwardRef,
IconButtonProps,
} from '@chakra-ui/react'
import useClickSound from 'views/features/sounds/useClickSound'
export const SoundButton = forwardRef((props: ButtonProps | IconButtonProps, ref) => (
<Button {...useClickSound(props)} ref={ref} />
))
こうすることで、onClick()
含め、もとのボタンpropsを透過的に渡すだけなので、もともとのButton
コンポーネントと使用感はまったく同じです。
const SomeComponent = () => {
const clickHandler = useCallback(() => {
// Do something.
}, [])
return (
<SoundButton size="lg" onClick={clickHandler}>ボタン</SoundButton>
)
}
as
プロパティで IconButton
などに変更することもできます。
もうちょっと汎用的にできそうな気もしますが関係ないところで時間がかかるのでこの辺で妥協しました。
<SoundButton as={IconButton} aria-label="ボタン" icon={<SomeIcon />} />
追記
as
プロパティでコンポーネントを変更したときにうまく他のプロパティが反映されないことがあるので、Button
とIconButton
の切り替え程度であればそれぞれ素直にラップしたものを用意したほうが良いかもです。
うまく汎用化できる方は上記のような形で設計すると良いと思います。
export const SoundButton = forwardRef((props: ButtonProps, ref) => (
<Button {...useClickSound(props)} ref={ref} />
))
export const SoundIconButton = forwardRef((props: IconButtonProps, ref) => (
<IconButton {...useClickSound(props)} ref={ref} />
))
これで既存のプロジェクトですでに組み込まれたボタンがあってもコンポーネントを置き換えていくだけで、ほぼ違和感なく組み込みができると思います。
よいサウンドライフを。
余談
短絡的にクールなSEを入れること自体がUXをもたらすという誤解がないようにしたいです。
「インタラクションデザイン」として触っている感触の一端を担うのがSEの役割であり、
UIのフィードバックが適切になされていなければ作業者の自己満足に終わってしまいます。
Webにおいてはアプリっぽいものと広告ぽいものと雑誌媒体ぽいものでいろんな文脈があります。
UIにおけるSEは、もそもそOSやネイティブのアプリのUIとの連続性において適度な一貫性を保つのに有用と思いますが、雑誌とか病院のサイトとかがガチャガチャSEがなるのは必要性に疑問もあります。
そういうUI的な文脈とかシンタックスを理解しているほうが良いし、
SEを効果的に入れるには、インターフェイスとの対話において視覚以外のフィードバックとして必然性や意図を持つことと、かつ適切な音質音量音色で視覚的なトンマナと一貫性を担保することが重要です。
(ユーザーを妨げないとかは視覚的な演出もふくめて大前提にしています。)
商用もしくは公共性の高いサイトでは、音を出せない環境で想定される場合への配慮(そもそも鳴らさない、事前にアテンションを入れる、サウンドのON/OFFを選択できるなど)をしたりSEを入れることで体験が向上することを目指すのがUX(におけるUIの立場)と思います。
音が聞こえないと理解できない用途も避けるべきです。
なくても成り立つんだけど、あることでグッとよくなる(といいな)という思いがこのライブラリの配布には込められていると思います。
snd.devを立ち上げた方たちのそういう哲学的なところもセットで広がるといいな、と思います。
Discussion
使ってみたかったので大変助かります!
Next,jsで試してみたのですが、挙動は成功せずでして、よろしければ上記コードを書いているfile名を記載いただけないでしょうか?
このようにどこに記載されたのが拝見し、再度試行してみたいです!