🦔

ReactでQRコードを表示したり読み取ったり

2023/12/01に公開

アプリ内でQRコードをしようする機会があったので備忘録と復習のためにまとめることにした。
実装する機会はそこまで多くないと思うが、いざ実装するときのため。
はじめは全てライブラリで実装しようと思ったが、iPhoneでの問題があったりでスキャン部分だけjsQRを使用して実装することにした。

準備

  • QRコード表示
  • 読み取り(カメラ)
  • 読み取り(画像)

環境

  • Next14(を使っているが細かくコンポーネントを分けないのでクライアントコンポーネントで作っちゃう)
  • React
  • typescript
  • tailwind

ライブラリ

qrcode.react(表示)
https://www.npmjs.com/package/qrcode.react
jsQR(読み取り)
https://github.com/cozmo/jsQR
react-dropzone(一応対応)
https://react-dropzone.js.org/

実装

ライブラリのインストール

pnpm add jsqr qrcode.react react-dropzone

QRコードの表示

まずは簡単な方から。

QRCodeSVGをインポートしてvalueに文字列を入力するだけでQRコードを作成してくれる簡単なライブラリ。

app/page.tsx
import { NextPage } from 'next'
import { QRCodeSVG } from 'qrcode.react'

const Home: NextPage = () => {
  return (
    <main className='flex min-h-screen flex-col items-center justify-between p-24'>
      <div className='aspect-square h-72 w-72 rounded-md bg-white p-4'>
        <QRCodeSVG value={`http://localhost:3000/result`} size={224} />
      </div>
    </main>
  )
}
export default Home

今回は読み取り→遷移という流れにしたいのでURLを入れておく。
あとはサイズをちょこちょこ直す。
これで完成。簡単。

読み取り(カメラ)

QRコードを使用できるアプリでよく見る、カメラで読み取る機能の実装。

PCのインカメなので表示隠ししています
スキャン後。携帯に先程のQRコードの画像を写してスキャンさせた。

src/components/QrReader.tsx
'use client'
import jsQR from 'jsqr'
import Link from 'next/link'
import React, { useRef, useState, useEffect, FC } from 'react'

type Props = {}
const QrCodeScanner: FC<Props> = () => {
  const videoRef = useRef<HTMLVideoElement>(null)
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const [result, setResult] = useState('')
  const [error, setError] = useState('')

  useEffect(() => {
    const constraints = {
      video: {
        facingMode: 'environment',
        width: { ideal: 300 },
        height: { ideal: 300 },
      },
    }

    // デバイスのカメラにアクセスする
    navigator.mediaDevices
      .getUserMedia(constraints)
      .then((stream) => {
        // デバイスのカメラにアクセスすることに成功したら、video要素にストリームをセットする
        if (videoRef.current) {
          videoRef.current.srcObject = stream
          videoRef.current.play()
          scanQrCode()
        }
      })
      .catch((err) => console.error('Error accessing media devices:', err))

    const currentVideoRef = videoRef.current

    // コンポーネントがアンマウントされたら、カメラのストリームを停止する
    return () => {
      if (currentVideoRef && currentVideoRef.srcObject) {
        const stream = currentVideoRef.srcObject as MediaStream
        const tracks = stream.getTracks()
        tracks.forEach((track) => track.stop())
      }
    }
  }, [])

  const scanQrCode = () => {
    const canvas = canvasRef.current
    const video = videoRef.current
    if (canvas && video) {
      const ctx = canvas.getContext('2d')
      if (ctx) {
        // カメラの映像をcanvasに描画する
        ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
        // QRコードをスキャンする
        const qrCodeData = jsQR(imageData.data, imageData.width, imageData.height)
        if (qrCodeData) {
          // スキャンされた内容を確認する
          if (qrCodeData.data !== 'http://localhost:3000/result') {
            setError('対応していないQRコードです')
            setTimeout(scanQrCode, 100) // スキャンの頻度を制限
            return
          }
          setResult(qrCodeData.data)
          return
        }
        setTimeout(scanQrCode, 100)
      }
    }
  }

  return (
    <div>
      {!result && (
        <div className='flex justify-center'>
          <div className='relative h-[300px] w-[300px]'>
            <video ref={videoRef} autoPlay playsInline className='absolute left-0 top-0 -z-50 h-[300px] w-[300px]' />
            <canvas ref={canvasRef} width='300' height='300' className='absolute left-0 top-0' />
          </div>
        </div>
      )}
      {result && (
        <div className='flex justify-center'>
          <Link href={result}>
            <button>push</button>
          </Link>
        </div>
      )}
      {error && <p className='text-center text-xs text-red-500'>{error}</p>}
    </div>
  )
}

export default QrCodeScanner

何をしているか簡単に説明すると
カメラ機能
useEffectの中身。

  1. navigator.mediaDevices.getUserMediaでメディアデバイスのアクセス許可を取る
  2. 許可された場合にvideoに指定しているrefのsrcObjectにストリームを指定してvideoで再生する
  3. クリーンアップ時にはsrcObjectのトラックを停止してリソースの解放とカメラの使用の終了をする

という流れ。
読み取り
scanQrCodeの中身。

  1. キャンバスの2Dレンダリングコンテキスト(ctx)を取得
  2. ビデオのスナップショットをキャンバスに表示
  3. ctx.getImageDataでピクセルデータを取得
  4. jsQR関数で画像データからQRコードをスキャン
  5. QRコードのデータがあるときにスキャンされた内容が正しければresultにセットする
  6. QRコードのデータがなかったり内容に間違いがあれば、エラーを表示して100ミリ秒後にscanQrCode関数を再度実行してスキャンを継続

という流れ。
あとはresultがあればボタン表示してーなどの表示を調整。
今回は読み取り後にボタンを表示させているが、ここで遷移させたりなど読み取ったあとにやりたい処理を書けばいい。

問題発生と解決方法

始めはvideo要素をhidden(display: none)で隠して実装していた(カメラの表示に必要なのは1つなので)が、

iPhoneのsafariで起動したら動かない……!(正確に言うと一回目のキャプチャで映像が止まってしまう)
またかと。いつもいつもiOS、safariで問題が起きるんですよね。
そこで調べたところ

  • video要素はdisplay: nonevisibilty: hiddenで隠してはいけない
  • video要素にautoPlayplaysInlineを指定する

videocanvasの後ろに隠す実装にしたら解決できた。

参考
https://github.com/cozmo/jsQR/issues/185#issuecomment-700789578

読み取り(画像)

カメラが有るときは画像選択もできるということで。

スキャン後(下のやつ)。

カメラとだいたいやっていることは同じ。カメラではなく画像にしただけ。

src/components/QrReaderImage.tsx
'use client'
import jsQR from 'jsqr'
import Link from 'next/link'
import { FC, useState } from 'react'
import { useDropzone } from 'react-dropzone'

type Props = {}
const QrReaderImage: FC<Props> = () => {
  const [result, setResult] = useState<string | undefined>()
  const [error, setError] = useState<string | undefined>()

  const onDrop = async (files: File[]) => {
    if (files.length < 1) return setError('ファイルを選択されます')
    const file = files[0]
    // 画像の判定
    if (!file.type.includes('image')) return setError('画像じゃありません')
    if (file) {
      const reader = new FileReader()
      reader.onload = (e: ProgressEvent<FileReader>) => {
        if (!e.target) return
        const img = new Image()
        img.onload = () => {
          // キャンバス作成
          const canvas = document.createElement('canvas')
          canvas.width = img.width
          canvas.height = img.height
          const ctx = canvas.getContext('2d')
          if (ctx) {
            // キャンバスに画像を描画
            ctx.drawImage(img, 0, 0)
            const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
            // QRコードをスキャン
            const qrCodeData = jsQR(imageData.data, imageData.width, imageData.height)
            if (!qrCodeData) {
              setResult(undefined)
              setError('QRコードを読み込めませんでした')
              return
            }
            // スキャンされた内容の確認
            if (qrCodeData.data !== 'http://localhost:3000/result') {
              setResult(undefined)
              setError('対応していないQRコードです')
              return
            }

            setResult(qrCodeData.data)
          }
        }
        img.src = e.target.result as string
      }
      reader.readAsDataURL(file)
    }
  }
  const { getRootProps, getInputProps } = useDropzone({ onDrop, multiple: false })

  return (
    <>
      {!result && (
        <div className='h-72 w-72 bg-white rounded-sm border border-dashed border-gray-600' {...getRootProps()}>
          <input {...getInputProps()} />
          {error && <p className='text-center text-xs text-red-500'>{error}</p>}
        </div>
      )}
      {result && (
        <div className='flex justify-center text-black'>
          {' '}
          <Link href={result}>
            <button>push</button>
          </Link>
        </div>
      )}
    </>
  )
}

export default QrReaderImage

何をしているか
画像の取得
react-dropzoneで実装。
ドラッグアンドドロップでも画像選択できますよ。に対応。
読み取り
onDropの中身。

  1. 画像を取得
  2. reader.readAsDataURLreader.onloadを発火させる
  3. 画像の読み込みが終わったらcanvas要素を処理内で作成
  4. 画像をキャンバスに表示
  5. jsQR関数で画像データからQRコードをスキャン
  6. QRコードのデータがあるときにスキャンされた内容が正しければresultにセットする

画像の取得先が違うだけでQRコードを読み取る箇所についてはほぼ同じ。

これで完成。
ちなみにpageの最終コード。

app/page.tsx
import QrCodeScanner from '~/components/QrReader'
import { NextPage } from 'next'
import { QRCodeSVG } from 'qrcode.react'
import QrReaderImage from '~/components/QrReaderImage'

const Home: NextPage = () => {
  return (
    <main className='flex min-h-screen flex-col items-center justify-between p-24'>
      <div className='aspect-square h-72 w-72 rounded-md bg-white p-4'>
        <QRCodeSVG value={`http://localhost:3000/result`} size={224} />
      </div>
      <p className='mt-8 text-center text-sm text-gray-500'>QRコードをスキャン</p>
      <div className='bg-white p-4 rounded-md'>
        <QrCodeScanner />
      </div>
      <p className='mt-8 text-center text-sm text-gray-500'>QRコードをスキャン</p>
      <div className='bg-white p-4 rounded-md'>
        <QrReaderImage />
      </div>
    </main>
  )
}
export default Home

まとめ

スキャンに関する部分は同じ流れなので、QRコードの画像取得さえできればだいたい対応できる。
ただミソとなる部分はやっぱり画像の取得なのでそこは要件に合わせて実装する必要がある。
パフォーマンス面の調整など必要になると思うが、スキャン前、スキャン後を自作にする分エラー処理なんかも直感的に書きやすくなるので、フルでライブラリ使わずに作っても案外悪くないなという印象だった。

Discussion