QRコード関連のWebツール、外部サービスに頼らず自前で組もうとすると意外と詰まりどころが多い。生成は割と素直だが、読み取り側はカメラ権限・フレーム取得・ライフサイクル管理まで絡んできて、サンプルコードをそのまま貼っただけでは動かなかったり、コンポーネントを離れてもカメラが回り続けたりする。
ぱんだツールズに「QRコード生成」と「QRコード読み取り」を実装するにあたって、最終的にたどり着いた構成と、途中でハマった点を整理する。全部ブラウザ内完結で、入力テキストや画像・カメラ映像はサーバーに一切送らない。
何を作ったか
ぱんだツールズの中の2ツール:
- QRコード生成: テキストやURLを入力 → Canvas に QR を描画 → PNG ダウンロード
- QRコード読み取り: 画像ファイル or カメラ映像から QR をデコード(URL は別タブで開ける)
どちらもサーバー処理なし、Cloudflare Pages の静的配信で動く。
技術スタック:
-
qrcode(生成)— 1.5.4 -
jsqr(デコード)— 1.4.0 -
getUserMedia API(カメラ) - Canvas 2D /
requestAnimationFrame
依存はこの2ライブラリだけ。WASMもネイティブも要らない。
生成側:qrcode の toCanvas で済む
生成は本当にあっさりしている。QRCode.toCanvas(canvas, text, options) を呼べばその場で描画される。
import QRCode from 'qrcode'
const canvas = canvasRef.current!
await QRCode.toCanvas(canvas, text, { width: size, margin: 2 })
await new Promise<void>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
setDownloadBlob(blob)
resolve()
} else {
reject(new Error('PNG変換に失敗しました'))
}
}, 'image/png')
})
ポイントは2つだけ。
-
marginを 2 以上にする: 0 や 1 だとスマホカメラが認識率を落とす。ISO/IEC 18004 では「クワイエットゾーン」として最低4モジュールが規定されていて、qrcodeのデフォルトもそれに準拠して4。ただし4だと余白がデカすぎて印刷時に見栄えが悪い。スキャナ実装が寛容なので、規格上は非準拠寄りだが2くらいが実用上の落としどころ。 -
toBlobを Promise でラップする: そのままだと callback 地獄になる。Blob にしておくとURL.createObjectURLでダウンロードリンクが作れて楽。
ファイル形式は PNG にしている。SVG 出力もできるが、印刷物向けの用途が多いので PNG の方がトラブルが少ない。
サイズは 256 / 512 / 1024 px の3択にした。Web 用は 256、名刺・チラシ印刷は 1024 が目安。
読み取り側:ImageData → jsQR が基本
jsQR の API はシンプルで、ImageData を渡すと QR コードが見つかった場合に {data, location, ...} を返す。
import jsQR from 'jsqr'
export function decodeQrFromImageData(imageData: ImageData): QrDecodeResult | null {
const code = jsQR(imageData.data, imageData.width, imageData.height)
if (!code) return null
return {
data: code.data,
isUrl: /^https?:\/\//i.test(code.data),
}
}
問題は どうやって ImageData を取り出すか。画像ファイルからもカメラからも直接は取れないので、必ず Canvas を経由する。
画像ファイルから取り出す
FileReader → Image → Canvas の3段経由。
const reader = new FileReader()
reader.onload = (e) => {
const src = e.target?.result as string
const img = new Image()
img.onload = () => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const decoded = decodeQrFromImageData(imageData)
// ...
}
img.src = src
}
reader.readAsDataURL(file)
ここで「URL.createObjectURL(file) で blob URL を作って Canvas に貼ればいいじゃん」と思うかもしれないが、iOS Safari で blob URL から Image を作ると一部のフォーマット(HEIC等)でロード失敗するので、確実なのは readAsDataURL で Data URL 化する方法。HEIC は事前に JPG/PNG への変換が必要(ぱんだツールズ側では別ツールに案内している)。
カメラ読み取りはライフサイクル管理がキモ
ここが一番ハマった。getUserMedia で取った MediaStream は 明示的に止めないとずっと回り続ける。コンポーネントがアンマウントされても、ブラウザ的には「カメラ使用中」のインジケータがそのまま残る。
最終的にこの形に落ち着いた:
const streamRef = useRef<MediaStream | null>(null)
const rafRef = useRef<number | null>(null)
const stopCamera = useCallback(() => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop())
streamRef.current = null
}
setCameraState('idle')
}, [])
useEffect(() => {
if (mode !== 'camera') stopCamera()
return () => stopCamera() // unmount 時も必ず止める
}, [mode, stopCamera])
3箇所で stopCamera を呼んでいる:
- モード切り替え時(画像ファイル ↔ カメラ)
- コンポーネント unmount 時
- QR デコード成功時(自動停止)
useRef で stream と RAF ハンドルを保持しないと、再レンダー時に「最初の stream を止め忘れたまま新しい stream が走り出す」現象が起きる。useState だと state 更新で再レンダーが発生して参照が古くなるリスクがあるので ref が安全。
facingMode の罠
getUserMedia({ video: { facingMode: 'environment' } }) を指定すると、スマホでは背面カメラが優先される。PC には背面カメラが無いので Chrome は「最も近いもの」を返す(=Webカメラ)。これは仕様上 OK。
ただし facingMode: { exact: 'environment' } と書くと PC で OverconstrainedError が出て起動失敗する。exact を付けない方が無難。
スキャンループは requestAnimationFrame で十分
video 要素のフレームを Canvas に描画 → getImageData → jsQR、を毎フレームやる:
const scanFromCamera = useCallback(() => {
const video = videoRef.current
const canvas = canvasRef.current
if (!video || !canvas || video.readyState < video.HAVE_ENOUGH_DATA) {
rafRef.current = requestAnimationFrame(scanFromCamera)
return
}
const ctx = canvas.getContext('2d')!
canvas.width = video.videoWidth
canvas.height = video.videoHeight
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const decoded = decodeQrFromImageData(imageData)
if (decoded) {
setResult(decoded.data)
stopCamera()
return
}
rafRef.current = requestAnimationFrame(scanFromCamera)
}, [stopCamera])
setInterval で 100ms ごと、みたいな書き方もできるが RAF の方がブラウザのフレーム描画と同期するので CPU が無駄に食わない。バッテリー消費にも優しい。
video.readyState < HAVE_ENOUGH_DATA のガードは重要。video の準備ができる前に getImageData するとサイズが 0 で例外が出る。
エラーハンドリングはユーザー向け文言を分ける
getUserMedia は失敗時に DOMException を投げる。エラー名で原因が分かるので、ユーザー向けに言い換える:
} catch (err: unknown) {
if (err instanceof DOMException) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setError('カメラへのアクセスが許可されていません。ブラウザの設定からカメラを許可してください。')
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
setError('カメラが見つかりませんでした。')
} else {
setError('カメラの起動に失敗しました。別のアプリがカメラを使用中の可能性があります。')
}
}
}
「カメラが起動できません」だけ出すと、ユーザーは権限の問題なのかデバイスの問題なのか切り分けられない。ユーザーが取れる次のアクションが変わるエラーは別文言にするべき。
HTTPS必須は静的サイトでも変わらない
getUserMedia は secure context(HTTPS or localhost)でしか動かない。ぱんだツールズは Cloudflare Pages 配信なので HTTPS が標準で効いている。ローカル開発時も localhost は secure context 扱いなので問題ないが、自前のステージング環境を IP 直で見ると動かないので注意。
iframe 内で動かす場合は allow="camera" 属性が必要。Embed 用途を想定するなら最初から忘れずに付ける。
学び
- ライブラリ選定の軽さは正義: jsQR と qrcode はどちらも依存ゼロの軽量ライブラリで、初動が早く、Cloudflare Pages のキャッシュとも相性がいい
-
Canvas は ImageData の橋渡しとして必要: 画像処理系はだいたい Canvas 経由になる。流用できるユーティリティを
src/lib/qr/に切り出しておくと他ツールでも使える - カメラ系は ref と effect のクリーンアップが本体: 「動く」と「止まる」のセットで設計しないと、ユーザーのデバイスのカメラランプが点きっぱなしになる事故が起きる
まとめ
QR コード関連のツールは、生成は qrcode を1行で、読み取りは ImageData → jsQR の固定パターンで押さえれば大抵いける。難しいのはカメラのライフサイクル管理と、exact を付けると壊れるなどの小さな罠。
URL が読めたら別タブで開くショートカットを付けると体験が大きく良くなる。フィッシング懸念があるので、URL を直接開く前に必ず一度表示してユーザーに確認させる仕様にしている。
ぱんだツールズ ではこの他にも PDF・画像・CSV・テキスト処理など、ブラウザ完結の無料ツールを 80 本以上公開している。全部登録不要・サーバー送信なしで使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。