まくろぐ
更新:
作成:

何をするか?

Google Analytics で Web サイトのアクセス解析を行うには、次のような感じのコードを各ページに埋め込む必要があります。

Google Analytics 用の埋め込みコード
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-ABCDE12345"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-ABCDE12345');
</script>

ここでは、Next.js が提供する Script コンポーネント (next/script) を使用して、Next.js アプリに同等のコードを埋め込む方法を説明します。 HTML 標準の script ではなく、Next.js の Script コンポーネントを使用することで、Next.js アプリのレンダリング処理に合わせて実行タイミングを最適化できます。

事前準備(Google Analitics でプロパティとデータストリームを追加する)

事前準備として、Google Analytics のサイトで、サイドバーの 管理 メニューから対象アプリ用に プロパティ を追加しておいてください。 さらに、そのプロパティに対して データストリーム を追加すると、Web サイトや Android アプリの利用状況を監視できるようになります。

/p/zycmw6f/img-001.png
図: プロパティにデータストリームを追加する

データストリームを追加すると、次のように 測定 ID が発行されます。 これが、前述の JavaScript コードで指定する ID になります。

/p/zycmw6f/img-002.png
図: 測定 ID が発行された

Next.js 用の Analytics コンポーネントを作成する

下記の Analytics.tsx は、Google Analytics 用のコードを埋め込むためのコンポーネントの実装例です。 Google Analytics の測定 ID は、ホスティングサーバー(例えば Vercel)の環境変数 NEXT_PUBLIC_ANALYTICS_ID で設定することを想定しています(自サイト専用のコンポーネントにするなら、ハードコーディングしちゃっても大丈夫です)。

components/common/Analytics.tsx
import { FC } from 'react'
import Script from 'next/script'

// サーバーの環境変数で Google Analytics の測定 ID を指定します
const ANALYTICS_ID = process.env.NEXT_PUBLIC_ANALYTICS_ID
// あるいはハードコーディングでも可
// const ANALYTICS_ID = 'G-ABCDE12345'

/** Google Analytics によるアクセス解析を行うためのコンポーネント */
export const Analytics: FC = () => {
  if (process.env.NODE_ENV !== 'production') {
    // 開発サーバー上での実行 (next dev) では何も出力しない
    return <></>
  }

  if (!ANALYTICS_ID) {
    console.warn('NEXT_PUBLIC_ANALYTICS_ID not defined')
    return <></>
  }

  return (
    <>
      <Script
        src="https://www.googletagmanager.com/gtag/js?id=${ANALYTICS_ID}"
        strategy="afterInteractive"
      />
      <Script id="google-analytics" strategy="afterInteractive">
        {`
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config', '${ANALYTICS_ID}');
        `}
      </Script>
    </>
  )
}

Script コンポーネントの strategy プロパティでは、読み込みタイミングを細かく制御できます(参考: Handling Scripts | Next.js)。 afterInteractive を指定すると、ページのハイドレーション処理が行われたタイミング(つまりユーザー操作が可能になったタイミング)で実行されます。 デフォルト値は afterInteractive なので、実はサンプルコード内の strategy プロパティの指定は省略することができます。

JavaScript コードをインラインで埋め込んでいる方の Script タグには、id プロパティを指定しておく必要があります。 この id は、Next.js が内部での最適化に使用します。 サンプルコードでは google-analytics と指定していますが、一意の文字列であれば何でも構いません。

Analytics コンポーネントを設置する

Analytics コンポーネントは、各ページで実行されるように pages/_app.tsx から呼び出すようにします。

pages/_app.tsx
import type { AppProps } from 'next/app'
import Head from 'next/head'
import { Analytics } from '../components/common/Analytics'

export default function MyApp({ Component, pageProps }: AppProps) {
  return (
    <>
      <Head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="initial-scale=1, width=device-width" />
        <title>Awesome website</title>
      </Head>
      <Analytics />
      <Component {...pageProps} />
    </>
  )
}

Next.js の Script 要素は Head 要素の外に記述しないと動作しないようなので注意してください。 これで、Web サイトのアクセス情報を Google Analytics のページで確認できるようになります。

٩(๑❛ᴗ❛๑)۶ わーぃ

関連記事

更新:
作成:

何をするか?

React のテキスト入力フォーム(input コンポーネントや mui の TextField など)で onChange イベントハンドラーを設定すると、入力テキストが変化したときに任意の処理を行うことができます。 ただ、onChange イベントは IME での日本語変換中にも呼び出されてしまう ので、おそらく、想定しているよりも多く呼び出されてしまいます。 インクリメンタルサーチなどで、入力のたびに API 呼び出しをしているようなケースでは、この振る舞いは抑制しなければいけません。

ここでは、IME が ON になっているとき(つまり日本語変換中)に、onChange イベントを無視する実装例を紹介します。 このあたりの実装は、ブラウザごとの微妙な振る舞いの差(特に Esc キーで IME 入力をキャンセルした場合など)を考えると、結構複雑だったりします。

実装してみる

React の input コンポーネントの onCompositionStartonCompositionEnd イベントハンドラーを設定すると、IME を ON/OFF したタイミングを知ることができます。 そのタイミングで、isImeOn のようなフラグを制御すれば、必要に応じて onChange イベントを無視できるようになります。

// import { FC, useRef, useState } from 'react'

const SearchBox: FC = () => {
  // 現在 IME ON(変換中)かどうかのフラグ
  const isImeOn = useRef(false)

  // 以前の入力テキスト(ブラウザごとの onChange の振る舞いの差異への対策)
  const [prevText, setPrevText] = useState('')

  // 入力テキストを処理する
  const handleChange = (text: string) => {
    if (prevText === text) return
    if (text === '') {
      // Chrome ではテキストクリア時に onCompositionEnd が呼ばれないことがある
      isImeOn.current = false
    } else if (isImeOn.current) {
      return // IME 変換中は何もしない
    }
    setPrevText(text)

    // ここで最新の入力値にもとづいて検索処理などを行う
    console.log(text)
  }

  return (
    <input
      id="search"
      type="search"
      onChange={(e) => handleChange(e.target.value)}
      onCompositionStart={() => {
        isImeOn.current = true // IME 入力中フラグを ON
      }}
      onCompositionEnd={(e) => {
        isImeOn.current = false // IME 入力中フラグを OFF
        handleChange((e.target as HTMLInputElement).value) // 入力確定したとき
      }}
    />
  )
}

ポイントは、onCompositionEnd ハンドラーの中で handleChange を明示的に呼び出しているところでしょうか。 これを入れておかないと、IME での変換を確定したときに、入力されたテキストを処理することができません(onChange は呼び出されるけど、IME 変換中とみなして処理をスキップしてしまうため)。

input コンポーネントの代わりに、mui の TextField コンポーネントを使う場合も、同様の props 設定で OK です。

<TextField
  id="search"
  type="search"
  fullWidth
  label="Search"
  variant="filled"
  onChange={(e) => handleChange(e.target.value)}
  onCompositionStart={() => {
    isImeOn.current = true // IME 入力中フラグを ON
  }}
  onCompositionEnd={(e) => {
    isImeOn.current = false // IME 入力中フラグを OFF
    handleChange((e.target as HTMLInputElement).value) // 入力確定したとき
  }}
/>

コンポーネント化する

実際に上記のようなコードを導入する場合は、IME まわりの処理を隠蔽する形でコンポーネント化してしまった方がよいでしょう。 次の SearchTextField コンポーネントは、props として onSearch ハンドラーを受け取ります。 このハンドラーは、適切なタイミングで呼び出される onChange ハンドラーとして使用することができます。

import { FC, useRef, useState } from 'react'
import { TextField } from '@mui/material'

interface Props {
  onSearch?: (text: string) => void
}

/**
 * インクリメンタルサーチ用に `TextField` を拡張したコンポーネントです。
 *
 * ユーザーの入力に応じて検索 API を呼び出す場合は、`onSearch` ハンドラーを使用します。
 * `onSearch` は、`TextField` の `onChange` よりも適切なタイミングで呼び出されます。
 * 例えば、`onChange` は IME 変換中にも呼び出されてしまいますが、
 * `onSearch` はユーザーが入力を確定したときにしか呼び出されないようになっています。
 */
export const SearchTextField: FC<Props> = ({ onSearch }) => {
  // 現在 IME ON(変換中)かどうかのフラグ
  const isImeOn = useRef(false)

  // 以前の入力テキスト(ブラウザによって onChange の振る舞いが微妙に異なるための対策)
  const [prevText, setPrevText] = useState('')

  // 入力テキストを処理する
  const handleChange = (text: string) => {
    if (prevText === text) return
    if (text === '') {
      // Chrome ではテキストクリア時に onCompositionEnd が呼ばれないことがある
      isImeOn.current = false
    } else if (isImeOn.current) {
      return // IME 変換中は何もしない
    }
    setPrevText(text)

    // ここで任意のコールバック関数を呼び出す
    onSearch?.(text)
  }

  return (
    <TextField
      id="search"
      type="search"
      fullWidth
      label="Search"
      variant="filled"
      onChange={(e) => handleChange(e.target.value)}
      onCompositionStart={() => {
        isImeOn.current = true // IME 入力中フラグを ON
      }}
      onCompositionEnd={(e) => {
        isImeOn.current = false // IME 入力中フラグを OFF
        handleChange((e.target as HTMLInputElement).value) // 入力確定したとき
      }}
    />
  )
}

このコンポーネントを使用すると、前述の実装を次のように簡潔に記述できます。

// import { FC } from 'react'
// import { SearchTextField } from '../components/SearchTextField'

const SearchBox: FC = () => {
  return <SearchTextField onSearch={(text) => console.log(text)} />
}

関連記事

更新:
作成:

Meilisearch とは?

Meilisearch は、簡単に使えることをポリシーとして作られた OSS のサーチエンジンで、これを利用すると、アプリの フロントエンドに高速な検索機能 を組み込むことができます。 個人や中小企業でも手軽に導入でき、柔軟な設定により様々な用途に対応できます。 Meilisearch には次のような特徴があります。

  • 高速な応答(検索結果は 50 ミリ秒以内に得られる。ただし、1,000 万件以上の大規模なデータは想定していない)
  • インクリメンタルサーチ(入力するたびにプレフィックスで絞り込んで結果を返す)
  • タイポ入力補正(検索語句を多少間違えて入力してもそれっぽい単語で検索してくれる)
  • プレースホルダー検索(キーワードを入力せずにカテゴリーなどで結果を絞り込む)
  • カスタムランキング処理(ranking rules による検索結果のソート)
  • 検索結果のハイライト表示(入力キーワードに一致した位置がわかる)
  • 日本語を含む各種言語に対応(形態素解析エンジンに Lindera を採用)
  • 同義語の定義

Meilisearch は 簡単であること を哲学としているので、類似の検索フレームワークに比べて全体的にシンプルな構成になっています。 Elasticsearch などでは大袈裟すぎるというニーズにマッチするかもしれません。 JSON 形式のドキュメントを REST API で登録してインデックスを作成するだけで、REST API による検索が可能になります。 最終的にはフロントエンドから直接 REST API を呼び出すように UI を作り込んでいく必要がありますが、プレビュー用の Web サーバー(ダッシュボード UI)が内蔵されているので、コーディングなしでインクリメンタルサーチを試すことができます。

https://docs.meilisearch.com/search_preview/no_documents.png
図: Meilisearch ダッシュボード

似たような検索エンジンとしては、Algolia という SaaS があります。 Meilisearch は Algolia にインスパイアされて作られているので、アルゴリズム等は似ていますが、オープンソース であるという大きな違いがあります(Algolia は個人で使うには高すぎるというのもあります…)。

Meilisearch サーバーを起動する

Docker Hub に getmeili/meilisearch というイメージが公開されているので、これを使えば簡単に Meilisearch のサーバーを立ち上げることができます。

meilisearch サーバーの起動
docker run --rm -it -p 7700:7700 \
    -v $(pwd)/meili_data:/meili_data \
    getmeili/meilisearch:v0.30
  • docker run のオプションの意味:
    • --rm … コンテナを停止したときに、コンテナを自動で削除します。
    • -it … ターミナルとコンテナの標準入出力を連動させます。
    • -p 7700:7700localhost:7700 へのアクセスをコンテナの 7700 番ポート(Meilisearch サービス)へ転送します。
    • -v $(pwd)/meili_data:/meili_data … カレントディレクトリの meili_data ディレクトリを、Meilisearch のデータ格納先として使用します。ディレクトリが存在しない場合は、自動で作成されます。

Meilisearch はデフォルトで development モード (--env=development) で起動し、プレビュー用の UI を提供します。 コンテナが起動したら、ブラウザで http://localhost:7700/ にアクセスすると、Meilisearch のダッシュボードを表示できます。

プライマリーフィールド

Meilisearch を使い始めるときは、最初に プライマリーフィールド の概念を理解しておく必要があります。

Meilisearch に登録するデータのことを ドキュメント と呼びますが、各ドキュメントには必ず、プライマリーフィールドと呼ばれる一意の ID フィールドが必要です。 逆に言うと、これ以外のフィールドの値は、どれだけ重複していても大丈夫です。 同一のドキュメント ID を持つドキュメントを登録しようとすると、古いドキュメントは上書きされます。

/p/bo8q8p7/img-001.drawio.svg
図: Meilisearch のプライマリフィールド関連用語

多くの場合、プライマリーフィールドのキー名(プライマリーキー)は、id といった名前ですが、インデックスを作成するときに任意のキー名を指定することができます。 キー名を省略すると、1 つ目のドキュメントから id を含む名前のキーが自動的に選択されます(例: idbookIdtodo-id)。

インデックスにドキュメントを登録するとき、そのフィールドにプライマリーキーが見つからない場合は登録に失敗します。 ドキュメント ID が不正な形式の場合も登録に失敗します。 ドキュメント ID は、整数値、あるいは、英数字 (a-zA-Z0-9)、ハイフン (-)、アンダースコア (_) のみで構成された文字列である必要があります。

ドキュメント ID の形式
"id": 12345      // Good
"id": "a39NfE2"  // Good
"id": "x-12_34"  // Good

"id": 0.12345    // NG(浮動小数点数はダメ)
"id": "df/3 #@"  // NG(不正な記号やスペース)

インデックスの作成とドキュメントの追加

Meilisearch で検索を行えるようにするには、インデックスを作成して、そこに JSON 形式のドキュメントを投入していく必要があります。 それぞれの作業を独立して行うこともできますし、ドキュメントを追加するときに同時にインデックスを作成してしまうこともできます。 Meilisearch の操作方法としては、各種言語用のライブラリが提供する API を使う方法と、REST API を直接呼び出す方法があります。 ここでは curl コマンドで直接 REST API を呼び出して、インデックスの作成とドキュメントの追加をしてみます。

あるインデックスに対してドキュメントを追加するには、/indexes/<インデックス名>/documents というエンドポイントに POST メソッドで JSON 形式のデータを送ります。 次の例では、books という名前のインデックスを作成し、そこに 3 つの書籍情報(ドキュメント)を登録しています。

ドキュメントの登録(同時に books インデックスを生成)
curl -X POST 'http://localhost:7700/indexes/books/documents?primaryKey=id' \
    -H 'Content-Type: application/json' \
    --data-binary '[
        { "id": 1, "title": "みんなの Meili", "author": "名無し" },
        { "id": 2, "title": "Meili かわいい", "author": "John Doe" },
        { "id": 3, "title": "Best of Meili", "author": "Maku" }
    ]'

前述の通り、id というキーを自動的にプライマリーキーとして採用してくれますが、上記のように primaryKey=id でキー名を明示することもできます。 次のように処理内容が出力されれば成功です。

実行結果(の出力)
{"taskUid":3,"indexUid":"books","status":"enqueued","type":"documentAdditionOrUpdate","enqueuedAt":"2023-01-24T07:02:22.4346468Z"}

多数のドキュメントを登録したいときは、次のような JSON ファイルとしてデータを用意しておき、

books.json
[
  { "id": 1, "title": "みんなの Meili", "author": "名無し" },
  { "id": 2, "title": "Meili かわいい", "author": "John Doe" },
  { "id": 3, "title": "Best of Meili", "author": "Maku" }
]

次のように Meilisearch のインデックスに登録します(ファイル名の前に @ プレフィックスを付けます)。

books インデックスに JSON ファイルのドキュメントを登録
curl -X POST 'http://localhost:7700/indexes/books/documents?primaryKey=id' \
    -H 'Content-Type: application/json' --data-binary @books.json

Meilisearch サービスに現在登録されているインデックスの一覧を確認するには、/indexes というエンドポイントに GET リクエストを投げます。

インデックスの一覧を取得(出力は整形してます)
$ curl -X GET http://localhost:7700/indexes?limit=3
{
  "results": [
    {
      "uid": "books",
      "createdAt": "2023-01-24T06:47:13.0555429Z",
      "updatedAt": "2023-01-24T07:10:13.2373666Z",
      "primaryKey": "id"
    }
  ],
  "offset": 0,
  "limit": 3,
  "total": 1
}

正しく、books というインデックスが作成されているようです。 まだインデックスは 1 つしか作成していないので、total フィールドの値は 1 になっています。

ドキュメントを取得する

すべてのドキュメントを取得する

/indexes/{index_uid}/documents に GET 要求を送ると、すべてのドキュメントを取得できます。 ただし、limit パラメーターのデフォルト値は 20 です。

最大 3 つのドキュメントを取得
$ curl http://localhost:7700/indexes/books/documents?limit=3

/indexes/{index_uid}/documents/{document_id} に GET 要求を送ると、指定したドキュメント ID に一致するドキュメントを取得できます。

ドキュメント ID = 1 のドキュメントを取得
$ curl http://localhost:7700/indexes/books/documents/1

ドキュメントを検索する

/indexes/{index_uid}/search に GET あるいは POST 要求を送ると、任意の検索文字列(q パラメーター)でドキュメントを検索できます。 この API が Meilisearch による検索のキモとなる API です。 API キーを使用したアクセス制御を行う場合は、POST メソッドの方を使う必要があります。 なので、実運用では POST を使うことになりますが、ここではお試しのため GET メソッドを使っています。

「Meili」で検索
$ curl http://localhost:7700/indexes/books/search?q=Meili

ちょっと綴りを間違えて、「Meilli」と入力してもうまく検索してくれます。 (*>ω<)bグッ

日本語で検索する場合は、検索文字列を URL エンコードしておく必要があるかもしれません。

「かわいい」で検索
$ curl http://localhost:7700/indexes/books/search?q=%E3%81%8B%E3%82%8F%E3%81%84%E3%81%84

他にも様々なクエリパラメーターが用意されていて、ページングやソート方法などを指定できるようになっています。 詳細は Search API のドキュメント を参照してください。

ダッシュボードで確認する

インデックスに登録されたドキュメントは、http://localhost:7700 で表示されるダッシュボード (Mini Dashboard) からも検索できるようになっています。

/p/bo8q8p7/img-002.png
図: Mini Dashboard で books インデックスを検索

うまくいきました! ٩(๑❛ᴗ❛๑)۶ わーぃ

メニュー

まくろぐ
サイトマップまくへのメッセージ