🦁

NextJS App Router/Prisma/RSC/Server Actionsを使用してAIアプリを構築する

2024/09/26に公開

初めに

この記事では、NextJSの最新機能であるapp router、RSCとserver actions、型安全なデータベース操作ライブラリprisma、およびvercelプラットフォームで新しく導入されたStorage(無料枠付きDB)、さらにはOPENAIのAPIを利用してメンテナンス費用が不要(規模が小さい場合)な外国人向けの日本語学習アプリケーションを作成する方法について説明します。

このアプリは、分からない日本語を入力するとAIが翻訳結果や読み方をカード形式で表示します。

このアプリを再度開いて、前回のデータをカード形式で表示して復習できます。

カードの中で、原文、翻訳結果と読み以外は、録音機能と再生機能もついています。

その後、特に重要な表現が気になる場合は、それを選択して単語帳に追加することもできます。


追加した後、単語帳画面に見えます。


大体そういう感じのアプリなんです、プロジェクトのアドレスはこちらです:https://github.com/chenbj5515/japanese-memory-nextjs15
直接体験もできます:
https://japanese-memory-nextjs15.vercel.app/latest

ディレクトリ構成

├── prisma
│   └── schema.prisma           // テーブル定義、データベースへの接続方法。
└── src
    ├── auth.ts                 // authに関連する主要なロジック
    ├── middleware.ts           // authのミドルウェア
    ├── prisma.ts               // prismaのインスタンスを作成
    ├── app
    │   ├── api
    │   │   └── auth
    │   │       └── [...nextauth]
    │   │           └── route.ts        // auth関連のリクエストを処理します
    │   ├── page
    │   │   ├── _components             // このページ専用のコンポーネント、「_」で始まる場合、アクセス可能なリソースとして扱われません。
    │   │   ├── _server-actions         // このページ専用のserver-actions、「_」で始まる場合、アクセス可能なリソースとして扱われません。
    │   │   ├── page.tsx                
    │   │   └── loading.tsx             // この画面専用のローディング
    │   ├── client-layout.tsx           // ReduxのProviderなどクライエント環境が必要なレーアウト
    │   ├── globals.css                 
    │   ├── layout.tsx                  
    │   ├── loading.tsx                 // 全体的loading,専用のものがない場合、これが有効になります。
    │   └── page.tsx                    // 入り口
    ├── components
    │   └── component
    │       ├── hooks
    │       │   └── index.ts            // コンポーネント専用のhook
    │       ├── server-actions
    │       │   └── index.ts            // コンポーネント専用のserver-actions
    │       └── index.ts                
    ├── hooks
    │   ├── events.ts                   // イベント関係のhook
    │   ├── index.ts                    // 全体のhook
    │   └── utils.ts                    // 基本カスタマイズhook
    ├── server-actions
    │   └── index.ts                    // AIに質問するなど全体のserver-actions
    ├── store
    │   ├── index.ts                    // storeをエクスポート
    │   └── state-slice.ts              // 状態初期値、reducerなどを定義しているslice
    ├── types
    │   └── index.d.ts                  // 特定のライブラリの型定義を修正または補完
    └── utils
        └── index.ts                    // プロジェクト全体で使用可能なツールメソッド

データベース接続と基本操作

Vercel Storage

Vercel Storageは、Vercelプラットフォームで新しく導入されたDBサービスです。利用を開始すれば無料枠付きのDBを所有できます。
POSTGRES_PRISMA_URLが提供されます、それは持っていれば、PostgreSQL付きの任意の端末でDBにアクセスできます。実はVercelプラットフォームでは、端末も提供されるため、直接利用できるからPostgreSQLを自分でインストールする手間が省かれます。

RSC

RSCはReact Server Componentです、RSC自体は単なるコンポーネントですが、実際にはRSCの使用はレンダリングと開発方法全体の最適化です。

RSCを使用しているの開発も一種のSSRであり、ただ以前とは異なるSSRです。以前のSSRをハイドレーションSSRと呼び、RSCを使用したSSRをRSC SSRと呼んでいます。

RSCの中にやっていることは、ハイドレーションSSRのgetServerSidePropsにやっていることは大体同じです。基本的には、DBをアクセスして、データを取得します。

しかし両者には本質的な違いがあります。SSRではデータが取得された後にクライアントとサーバーの両方でレンダリングされるコンポーネントに渡されます、サーバー側は何イベントもない文字列形式のHTMLを返して、ユーザーは速くデータを見ることができますが、まだインタラクションは一切できません。JSの読み込みが完了すると、クライアント側で再度正常にレンダリングされ、既存のサーバー側レンダリング結果とクライアント側レンダリング結果を照合し、対応関係を見つけて、インタラクティブできないHTMLにイベントや状態が付与されるようにして、インタラクティブになります。このプロセスは「ハイドレーション」と呼ばれます。

RSCの場合、RSCとクライアントレンダリングのコンポーネントは明らかに分けています。コンポーネントツリーのトップレベルにのみ、マークされていないものがRSCです。一方、クライアントレンダリングされるコンポーネントにはすべて"use client"というマークが付けられます。サーバーコンポーネントの下にクライアントコンポーネントが配置可能ですが、逆ならダメです。

RSCでは要素にイベントをバインドすることはできませんが、formを利用することで、基本的なページのインタラクションも実現できます。だからページが簡単の場合、RSCだけで完成させることができます。

src/app/page.tsx
import { saveUserData } from "./server-actions";

export default async function Home() {

  return (
    <>
      <form action={saveUserData}>
        <input type="text" name="username" placeholder="Username" />
        <input type="email" name="email" placeholder="Email" />
        <button type="submit">Submit</button>
      </form>
    </>

  );
}
src/app/server-actions/index.ts
"use server"
export async function saveUserData(formData: any) {
    const username = formData.get("username");
    const email = formData.get("email");
    await db.saveUser({ username, email });
}

もちろん、一般的に画面はそんなに単純ではありません。基本的にはフォームのインタラクションよりも複雑です。そのため、RSCはデータを取得し、それをCC(クライアントコンポーネント)に渡してレンダリングすることが最も一般的な使用方法です。

これは、画面をレンダリングするにはJSファイルの読み込みが完了する必要があることを意味します。実際、前のバージョンのSSRよりも少し遅くなります。以前はどんなJSの読み込みも待つ必要がなく、直接HTMLをレンダリングできました。

ただし、RSC+CCの利点はより多いかもしれません:

  1. コンポーネントトリー全体にハイドレーション必要はありません、この部分の時間を節約することができ、インタラクティブになる時間はより早いです。その上、ハイドレーションエラーについて心配する必要はもうありません。
    P.S. 具体の行動は確かに以前のSSRとは全く異なっているでしょうが、公式ドキュメントではまだRSCとCCを組み合わせるプロセスを「ハイドレーション」と呼んでいるようです。しかし、個人的にはこれはもうハイドレーションとは言えないと思います。
  2. HTMLが返される前に何も表示されない白い画面を避けます、ローディングとスケルトン画面を表示することができます。(loading.tsx)
  3. RSCとCCが明確に区別され、考えもより簡単になりました。
  4. CCの状態を保ったまま、RSCも含めてコンポーネントツリー全体を再度更新することができます。(以前のSSRでは、getServerSidePropsは画面が完全にリフレッシュされると再度実行されますが、単にルーティングが更新された場合は再実行されません。)
src/app/cc.tsx
"use client"
import React from "react";
import { useRouter } from 'next/navigation';

export default function CC(props: any) {
    const {message} = props;
    const router = useRouter();
    // countの状態はルートがリフレッシュされる際にも保存できます。
    const [count, setCount] = React.useState(0);

    return (
        <div>
            count: {count}
            <div onClick={() => setCount(count + 1)}>add count</div>
            <div onClick={() => router.refresh()}>refresh route</div>
            client compnent: {message}
        </div>
    );
}
src/app/page.tsx
import CC from "./cc";
export default async function RSC() {
  const message = Math.random();

  return (
    <div>
      RSC Date:{Date.now()}
      <CC message={message} />
    </div>
  );
}

Prisma

では、具体的にRSCの中でどうやってDBからデータを取得しますか?型セーフのDB操作ライブラリーPrismaがおすすめです。

型セーフのライブラリはたくさんありますが、Prismaの実装はかなり洗練されています。

まず、ローカルでPrismaをインストールします:npm i -D prisma。その後、schema.prismaっていう設定ファイルを作成します:

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("POSTGRES_PRISMA_URL")
}

model memo_card {
  id                 String      @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  translation        String
  create_time        DateTime    @db.Timestamptz(6)
  update_time        DateTime    @updatedAt @db.Timestamptz(6)
  record_file_path   String?
  original_text      String?
  review_times       Int?        @default(0)
  user_id            String      @default("chenbj")
  kana_pronunciation String?
  word_card          word_card[] @relation("MemoToWord")
}

model articles {
  id          String   @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  content     String
  create_time DateTime @db.Timestamptz(6)
  user_id     String?
  tags        String?
  title       String?
}

model user {
  user_id     String    @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  email       String    @db.VarChar(255)
  name        String?   @db.VarChar(255)
  image       String?   @db.VarChar(255)
  create_time DateTime? @default(now()) @db.Timestamptz(6)
  update_time DateTime? @default(now()) @db.Timestamptz(6)
  github_id   String?   @db.VarChar(255)
}

model word_card {
  id           String    @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  word         String
  meaning      String
  create_time  DateTime  @db.Timestamptz(6)
  user_id      String
  review_times Int       @default(0)
  memo_card_id String    @db.Uuid
  memo_card    memo_card @relation("MemoToWord", fields: [memo_card_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "fk_memo_card")
}

上のPOSTGRES_PRISMA_URLはDBに接続必要な環境変数です、Vercelプラットフォームに書いてあります。

下はテーブルの定義です。一般的に、まずSQLを実行してデータベースにテーブルを追加し、問題がなければnpx prisma db pullコマンドを実行して、DBからテーブル定義をschema.prismaファイルに自動的に同期します。このコマンドはデフォルトで.envファイルから環境変数を取得します。他のファイルに記述している場合は、dotenv-cliをインストールし、どのファイルから環境変数を指定するか明示する必要があります。
npm install -g dotenv-cli
dotenv -e .env.local -- npx prisma db pull

また、他人の既存DBのテーブルをコピーしたい場合は、schema.prisma設定ファイルが役立ちます。単純にschema.prismaをコピーし、環境変数を設定してからnpx prisma db pushを実行することで、テーブルをあなたのDBに追加できます。

上記の作業が完了したら、npx prisma generateを実行して、DBテーブルで定義されたタイプを持つライブラリを生成します。

これで、設定は完了しました。次は、DBデータのアクセスです。まずは、Prismaインスタンスの作成です。DB接続は設定ファイルに書いてありますから、これは簡単です:

src/prisma.ts
import { PrismaClient } from '@prisma/client'

export const prisma = new PrismaClient();

いま、このprismaインスタンスを利用して、DBデータのアクセスは可能です:

src/app/random/page.tsx
import React from "react"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { prisma } from "@/prisma"
import { MemoCards, LocalCards, InputBox, WordCardAdder } from "@/components";

export default async function Home() {
    const session = await auth()
    if (!session?.userId) {
        redirect("/api/auth/signin")
    }

    const count = await prisma.memo_card.count();

    const randomOffset = Math.floor(Math.random() * (count - 20));

    const memoCards = await prisma.memo_card.findMany({
        skip: randomOffset,
        take: 20,
    });

    return (
        <>
            <div className="pb-[86px]">
                <MemoCards memoCardsInitial={memoCards} />
                <LocalCards />
            </div>
            <div className="fixed z-[12] width-80-680 left-[50%] -translate-x-1/2 bottom-2 h-[50px] w-[100%]">
                <InputBox />
            </div>
            <WordCardAdder />
        </>
    )
}

上記の例では、私たちがTSコードは何も書いてないでも、memoCards変数には、テーブルに対応する型も付いています。

それに、テーブルのデータ型に基づいて拡張したい場合は、このタイプを取得することもできます。例えばPrisma.memo_cardGetPayload<{}>はmemo_cardテーブルの型です。

Server Actions

Server ActionsはNextJSの最新機能で、本質的には、通常のインターフェースと同様にネットワークリクエストなんですが、開発体験としては、クラウドファンクションに似ていますし、めちゃくちゃ簡単です。

さまざまな紹介文章でServer Actionsはformと一緒に使用されることがよくありますが、実際には両者を一緒に使用する必要性はありません。よく一緒に現れる理由は、おそらくformがRSCがインタラクティブになりたいのときの重要な手段であるからです。

しかし、実際には、CC(クライアントコンポーネント)も完全にServer Actionsを利用できます。

さらに言えば、大量の同時リクエストがない場合、単に基本的なDB操作を行う管理システムにおいては、APIやGraphqlを書くことは面倒で収益性が低いな無駄な作業です。Server Actionsを直接使用すると、コードがかなりに簡潔になります。

src/app/word-cards/server-actions/update-review-time.ts
"use server"
import { auth } from "@/auth";
import { prisma } from "@/prisma";

export async function updateReviewTimes(id: string) {
    const session = await auth();

    const updatedMemoCard = await prisma.memo_card.updateMany({
        where: {
            id: id,
            user_id: session?.userId
        },
        data: {
            review_times: {
                increment: 1
            },
        },
    });

    return JSON.stringify(updatedMemoCard);
}
src/app/word-cards/word-cards.tsx
async function handleRecognizeClick(id: string) {
    // ...UI update logic
    // DB update
    await updateReviewTimes(id);
}

上記はServer Acitonsを使用した方法ですが、これまでのどんな方法よりもはるかに簡潔です。

しかし、ストリームを利用したい場合は?実は、Server Actionsはストリームもサポートしています。下記は、開発者がServer Actionsを使ってAIインターフェースを呼び出す際に、最も一般的に遭遇するストリームを使用した例です。

src/server-actions/index.ts
'use server';

import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { createStreamableValue } from 'ai/rsc';

export async function askAI(input: string) {
    const stream = createStreamableValue('');

    (async () => {
        const { textStream } = await streamText({
            model: openai('gpt-4-turbo'),
            prompt: input,
        });

        for await (const delta of textStream) {
            stream.update(delta);
        }

        stream.done();
    })();

    return { output: stream.value };
}
src/components/word-adder/index.tsx
import { readStreamableValue } from 'ai/rsc';
import { askAI } from "@/server-actions";

async function handleSomeEvent() {
    const { output } = await askAI(`これは日本語の単語またはフレーズです:${selected}、それを中国語に翻訳してください、気をつけて原文とか余分な言葉を出さないで、翻訳結果だけを出してください。`);
    for await (const delta of readStreamableValue(output)) {
        if (delta) {
            // delataはAI APIから出力される文字列の断片であり、順番に組み合わせると完全な結果が得られます。
        }
    }
}

ログインと認証

こちらはAuthJSを選定しました。ちなみに、ドキュメントサイトには専用AIが搭載されており、めっちゃ便利です。ChatGPTではAuthJS関係の情報が古い可能性があり、専用AIを利用してください。

ログインと認証の実現することは簡単です。

まずはauth.tsの作成:

src/auth.ts
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
 
export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [GitHub],
})

こちらの「signIn」はログイン機能です。カスタマイズされたログイン画面で使用できますが、通常はAuthJSが提供するログインページを使用すれば十分です。

しかし、「AuthJSが提供するログインページ」はどこですか?「/api/auth/signin」です。ログインしてない状況を検知すれば、そっちにリダイレクトして、AuthJS提供するページが見えます:

src/app/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"

export default async function RootPage() {
  const session = await auth()

  if (session) {
    redirect("/latest")
  } else {
    redirect("/api/auth/signin")
  }
}

もちろん、「/api/auth/signin」っていうルートはまだ書いてないです。今から実装します、実際はこっちもすごく簡単です。

api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlers

[...nextauth]は動的ローティングで、ここでは多くのルーティングリクエストを処理しますが、もちろんsigninも含まれます。

すべてのリクエスト処理ロジックはAuthJSが私たちの代わりに行ってくれるので、簡潔ですが、何をしているかはよくわかりません。でも実はこっちのロジックは簡単です。

最初に「/api/auth/signin」リクエストを受信し、ログインしていない状態を検知した場合は、ログインボタンが含まれるページを表示します。上記のプロバイダーにはGitHubしか記載されてないため、GitHubログインボタンのみを表示します。他の登録方式も欲しい場合、プロバイダーに追加してください。

しかし、現在の状況ではGithubに送信されたログインリクエストは受け入れられません。Github開発者設定画面で、アプリを作成することは必要です。作成し、Homepage URLとAuthorization callback URLを入力し、AUTH_GITHUB_SECRETとAUTH_GITHUB_IDを取得して、環境設定ファイルに記述したら、ログインリクエストが受け付けられます。ログインすると、AuthJSは画面を上記設定したAuthorization callback URLにリダイレクトします。

これから、任意のサーバー環境で承認が可能です。

// 任意のサーバー側環境
import { auth } from "@/auth"

const session = await auth()

現在考えていることは、クライアントでの承認方法についてかもしれませんが、一般的にすべてのページはRSCでデータを取得するので、認証もそこで行えばよく、クライアント側で再度認証する必要はあまりありません。でも確かに、クライアントでユーザー情報を取得することは必要の可能性があります。これはまだできません。しかし、ユーザー情報を取得する前に、それは何を明確にするは重要です。GithubやGoogleなどのプラットフォームはユーザーIDを提供しますが、それをそのまま識別子として使用することもできます。ただし、メール登録シナリオやユーザー管理などを考慮すると、通常は独自のユーザーテーブルを作成してユーザーを管理します。

したがって、ユーザーをデータベースに登録するロジックとユーザーIDをセッションに追加するロジックはどこに書けばよいですか?

AuthJSはcallbacksを提供しています。

src/auth.ts
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import { prisma } from "@/prisma"

export const findUserByPlatformID = async (platform_id: string) => {
  const user = await prisma.user.findFirst({
    where: {
      github_id: platform_id,
    },
  });
  return user;
};

export const createUserInDatabase = async (
  email: string,
  platform_id: string,
  name?: string | null,
  image?: string | null,
) => {
  const newUser = await prisma.user.create({
    data: {
      email,
      name,
      image,
      create_time: new Date(),
      update_time: new Date(),
      github_id: platform_id,
    },
  });
  return newUser;
};

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [GitHub],
  callbacks: {
    async signIn({ user, account, profile, email, credentials }) {
      const user_mail = user.email;
      const github_id = profile?.id?.toString();
      if (!user_mail || !github_id) return false;
      const dbUser = await findUserByPlatformID(github_id);
      if (!dbUser) {
        // 未登録の場合、登録する
        await createUserInDatabase(user_mail, github_id, user.name, user.image);
      }
      return true; // 登録を許せる
    },
    async jwt({ token, account, profile }) {
      const github_id = profile?.id?.toString();

      // user_idをtokenに追加する
      if (github_id) {

        const dbUser = await findUserByPlatformID(github_id);

        token.user_id = dbUser?.user_id;
      }

      return token;
    },

    async session({ session, token }) {
      const userId = token.user_id;
      if (typeof userId === "string") {
        session.userId = userId;
      }
      // user_idをトークンからセッションに追加する
      return session;
    },
  }
})

ここにはjwtとsessionは紹介されてない概念です。

JWTはよく使用されてるトークン形式であり、その特徴はユーザー情報を含んでいることです。通常、ログインや認証ライブラリを使用する際にJWTが使用され、開発者がコード管理を行う必要はなく、ライブラリがすべて処理してくれます。一般的にJWTはクライアントでhttp-onlyのcookieっていう形式で存在します。http-onlyであるため、ブラウザのみがアクセスでき、JavaScriptではアクセスできないので、XSS攻撃を防ぐことができます。しかし、CSRF攻撃は依然として防げません。一般的に、機密性の高い操作には、開発者が自らトークンを生成し、検証する必要があります。私のサイトにはそのような機密操作はないため、議論はここまでです。

sessionはログイン後、クライアントもアクセスできるこのセッションの情報ですが、一般的には権限管理が複雑なシステム以外では、ただログイン後のユーザー情報となります。セッションは通常、サーバーに保存されます。クライアントのcookieにJWTがすでにある場合、クエストを送信してセッションを取得できます。AuthJSの場合、デフォルトでは、セッションにはuserIDは含まれていませんので、上記のコードに追加する必要があります。

全体的なプロセスはこれです:

  1. GitHubなどのプラットフォームでの正常な認証。
  2. signIn callbackがトリガーされます、プラットフォームのIDに基づいて登録しているかどうかをチェックします。してないの場合は、ユーザーIDを生成して登録します。
  3. ユーザーの画面は、callback URLにリダイレクトされ、その結果サーバーにリクエストが送信されます。
  4. リクエスト途中、jwt callbackがトリガーされます、ユーザーIDをJWTに記述します。HTTPレスポンスヘッダーのSet-Cookie指示により、JWTがhttp-onlyのクッキーとしてクライアントに返されます。
  5. クライアントは、Set-Cookie指示に従って、JWTをhttp-onlyのクッキーとしてローカルに保存します。
  6. クライアントは、他の画面をアクセスします。この画面のクライアントコンポーネントにuseSessionを使用して、ユーザーIDを持ちのセッションを取得します。useSessionの実装は単なるリクエストです。JWTのクッキーがすでに保存されているため、このリクエストには自動的にJWTを添付されます。したがって、サーバーはセッションへのアクセスを許可します。

キャッシュ処理(性能最適化)

最適化の目標はすべてのルートが瞬時に表示されますし、DB更新後にページを訪れると新しいデータを取得できることです。
prefetchとrevalidatePath利用して、上記の目標が達成しました。
具体的な対応策がこの文章で記述しています:
https://zenn.dev/chenbj/articles/48c4055e4d25e5

セキュリティ観点

RSCを利用しているプロジェクトでは、RSCからCCに渡すPropsに情報漏洩の可能性がある。何か漏洩してはならない情報が含まれていないか確認する必要があります。
これはRSCの場合特別なセキュリティ問題です、Server Actionsここでもセキュリティの問題が発生する可能性がありますが、普通のリクエストによって引き起こされる問題とはあまり変わりません。
完全的な視点は以下に記述しています:
https://zenn.dev/chenbj/articles/d86169e4f223a2

エラー処理とユーザー行動ログの報告・可視化

施工中

状態管理

他の場合では、新しくて開発を簡単にする技術が好きですが、状態管理に関しては結構古いと面倒くなReduxを選択しました。

状態は重要なので、少し手間がかかっても考えを含めてコードを実装する方が良いと思います。

Reduxでは、状態を直接変更することはできず、代わりにアクションを使用して修正する必要があります。また、状態の変更は純粋な関数である必要があり、副作用を含んではいけません。

ただ、一般的には全体の状態が複雑でないことが多く、良い例を見つけるのは難しいです。

だから、今回の例はReduxじゃなくて、useReducerを利用して複雑なローカル状態の管理を説明します。

最初に説明した通り、カード内の気になる表現を選択し、AIが提供する翻訳結果を取得して単語帳に追加できます。

このプロセスを、状態、イベント、アクション、および副作用に分けて分析しましょう。

まず、具体的な状態に含めるべき変数を考えてみましょう。一番目は現在状態の名前、これは私個人的に好きなやり方です、現在の状態一体何がちゃんと書いておけば、ロジックを理解するのに役立つだけでなく、副作用を処理する際にも不可欠です。ポップアップは、選択した文字の近くに表示されるように位置を決めるために、leftとtopの2つの変数が必要です。最後は、選択した文字です、選択されたテキストが存在する場合のみ、ポップアップが表示されます。

だから、最初の状態はこれです:

{
    state: "initial",
    left: -100,
    top: -100,
    selectedText: ""
}

「select」アクションが発生すると、アクションのペーロードにはleft, top, selectedTextパラメータが含まれます、そのあと、状態の名前は「selected」に変更して、left, top, selectedTextもパラメータの値に更新します。

{
    state: "selected",
    left,
    top,
    selectedText
}

「addToWordCards」アクションが発生すると、状態はこ以下に変更します:

{
    state: 'added',
    left: -100,
    top: -100,
    selectedText: '',
}

「close」アクションが発生すると、状態はこ以下に変更します:

{
    state: 'closed',
    left: -100,
    top: -100,
    selectedText: '',
}

分析は以上です。これをChatGPTに入力し、型付きのuseReducer関連コードを要求すれば、一行も書かずに直接コピーできます。だから、Reduxが面倒だと文句を言う必要はありません。

ただし、これらは純粋関数の部分であり、副作用を含んでいません。どっちで副作用が発生しますか?

まずは状態は「selected」に変更した後、AIに翻訳結果を要求し、その結果をDOMと同期する必要があります。

あと、状態は「added」に変更した後、DBに同期する必要があります。それに、追加したため、選択した文字は選択している状態を解除することが必要です。

最後、状態は「closed」に変更した後も解除すべきです。

現在、なんで状態の名前が必要ことがわかりました。このようなコードで、簡潔でこのロジックを実装ことができます:

React.useEffect(() => {
    if (state === "selected") {
        effectGetMeaning();
    }
    if (state === "added") {
        effectInsertWordCard();
        effectCleanMeaningTextContent();
        effectCleanElementSelected();
    }
    if (state === "closed") {
        effectCleanMeaningTextContent();
        effectCleanElementSelected();
    }
}, [state]);

完全なコードはこちらにあります:

src/components/word-adder/index.tsx
"use client"
import React from "react";
import { readStreamableValue } from 'ai/rsc';
import { useSelector, TypedUseSelectorHook } from "react-redux";
import { RootState } from "@/store";
import { askAI } from "@/server-actions";
import { insertWordCard } from "./server-actions";

const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;

type StateType = {
    state: 'initial' | 'selected' | 'edited' | 'added' | 'closed';
    left: number;
    top: number;
    selectedText: string;
};

type Action =
    | { type: 'select'; payload: { left: number; top: number; selectedText: string } }
    | { type: 'addToWordCards' }
    | { type: 'close' };

const initialState: StateType = {
    state: 'initial',
    left: 0,
    top: 0,
    selectedText: '',
};

function reducer(state: StateType, action: Action): StateType {
    switch (action.type) {
        case 'select':
            return {
                ...state,
                state: 'selected',
                left: action.payload.left,
                top: action.payload.top,
                selectedText: action.payload.selectedText,
            };
        case 'addToWordCards':
            return {
                ...state,
                state: 'added',
                left: -100,
                top: -100,
                selectedText: '',
            };
        case 'close':
            return {
                ...state,
                state: 'closed',
                left: -100,
                top: -100,
                selectedText: '',
            };
        default:
            return state;
    }
}

// 1 知りたい単語を選択する
// 2 語句の翻訳結果を編集する(オプション)
// 3 追加ボタンを押して、単語帳に追加する
// 4 追加した後・WordCardAdder以外のDOM要素をクリックした後、WordCardAdderを非表示にする
export function WordCardAdder() {
    const containerRef = React.useRef<HTMLDivElement>(null);
    const { cardId } = useTypedSelector((state: RootState) => state.cardIdSlice);

    const meaningTextRef = React.useRef<HTMLDivElement>(null);
    const [{
        state,
        left,
        top,
        selectedText,
    }, dispatch] = React.useReducer(reducer, initialState);

    function effectCleanMeaningTextContent() {
        if (meaningTextRef.current) {
            meaningTextRef.current.textContent = "";
        }
    }

    function effectCleanElementSelected() {
        window.getSelection()?.removeAllRanges();
    }

    async function effectGetMeaning() {
        if (meaningTextRef.current) {
            const { output } = await askAI(`これは日本語の単語またはフレーズです:${selectedText}、それを中国語に翻訳してください、気をつけて原文とか余分な言葉を出さないで、翻訳結果だけを出してください。`);
            for await (const delta of readStreamableValue(output)) {
                if (meaningTextRef.current && delta) {
                    meaningTextRef.current.textContent += delta;
                }
            }
        }
    }

    async function effectInsertWordCard() {
        if (meaningTextRef.current?.textContent) {
            insertWordCard(selectedText, meaningTextRef.current.textContent, cardId);
        }
    }

    React.useEffect(() => {
        if (state === "selected") {
            effectGetMeaning();
        }
        if (state === "added") {
            effectInsertWordCard();
            effectCleanMeaningTextContent();
            effectCleanElementSelected();
        }
        if (state === "closed") {
            effectCleanMeaningTextContent();
            effectCleanElementSelected();
        }
    }, [state]);

    async function handleSelectEvent() {
        const selection = document.getSelection();
        if (selection && selection.rangeCount > 0) {
            const selected = selection.toString().trim();
            const range = selection.getRangeAt(0);
            const rect = range.getBoundingClientRect();
            if (selected.length) {
                dispatch({
                    type: "select",
                    payload: {
                        left: rect.right,
                        top: rect.bottom,
                        selectedText: selected
                    }
                })
            }
        }
    }

    React.useEffect(() => {
        function handleMouseUp(event: MouseEvent) {
            if (event.target instanceof Node) {
                const inContainer =
                    event.target === containerRef.current
                    || containerRef.current?.contains(event.target);
                // ポップアップ内でマウスがクリックした場合、何もしない
                if (!inContainer) {
                    if (selectedText) {
                        // もし現在ポップアップが表示されている場合、これを「close」イベントとして扱います
                        dispatch({
                            type: "close",
                        })
                    }
                    if (!selectedText && document.getSelection()) {
                        // 現在ポップアップが表示されておらず、かつ文字が選択されている場合は、これを「select」イベントとして扱います
                        handleSelectEvent();
                    }
                }
            }
        }
        document.addEventListener("mouseup", handleMouseUp);
        return () => {
            document.removeEventListener("mouseup", handleMouseUp);
        }
    }, [selectedText]);

    function handleAddWord() {
        dispatch({
            type: "addToWordCards"
        })
    }

    return (
        <div
            ref={containerRef}
            className="card max-w-[240px] z-[15] rounded-[6px] text-[15px] dark:bg-eleDark dark:text-white dark:shadow-dark-shadow p-3 mx-auto fixed"
            style={{ top, left, visibility: selectedText ? "visible" : "hidden" }}
        >
            <div>語句:{selectedText}</div>
            <div className="flex">
                意味:
                <div
                    contentEditable
                    ref={meaningTextRef}
                    className="whitespace-pre-wrap outline-none"
                ></div>
            </div>

            <div className="flex justify-center mt-2">
                <button
                    className="bg-white text-black rounded-[10px] text-sm font-semibold py-2 px-4 cursor-pointer border border-black shadow-none hover:-translate-y-1 hover:-translate-x-1 hover:shadow-[1px_3px_0_0_black] active:translate-y-1 active:translate-x-0.5 active:shadow-none"
                    onClick={handleAddWord}
                >
                    単語帳に追加
                </button>
            </div>
        </div>
    )
}

また、useEffectをしようしてる際に、意味論を厳しく従ってコードを書いてください。つまり、本当に副作用がある場合だけuseEffect内に記述してください。何を言っているのかわからない場合はReact公式ドキュメントを参照してください。考えずにuseEffectを書くことがReactコンポーネントの保守性を損なう主要な原因です。

カスタムフック

コードの簡潔感を保持するため、カスタムフックは不可欠です。

このプロジェクトで使用している2つのカスタムフックを紹介します。

useAudioRecorder

機能は録音ボタンをクリックすると録音が開始し、再度クリックすると録音が停止し、再生ボタンをクリックすると先ほどの内容が再生されます。

このHookの役割は、この機能をstartRecording、stopRecording、playRecordingという3つの関数に抽象化し、どこでも使用できるようにすることです。

src/hooks/event.ts
import { useRef, useEffect, useState } from 'react';

export function useAudioRecorder() {
  const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
  const audioChunks = useRef<Blob[]>([]);
  const audioURL = useRef<string | null>(null);
  const audio = useRef<HTMLAudioElement | null>(null);

  const startRecording = async () => {
    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
      console.error('ブラウザーは録音をサポートしていません。');
      return;
    }

    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const recorder = new MediaRecorder(stream);

      recorder.ondataavailable = (event: BlobEvent) => {
        audioChunks.current.push(event.data);
      };

      recorder.onstop = () => {
        if (audioURL.current) {
          window.URL.revokeObjectURL(audioURL.current);
        }
        const blob = new Blob(audioChunks.current, { type: 'audio/mp3' });
        audioURL.current = window.URL.createObjectURL(blob);
        audio.current = new Audio(audioURL.current!);
        audioChunks.current = [];
      };

      recorder.start();
      setMediaRecorder(recorder);
    } catch (err) {
      console.error('マイクにアクセスできません', err);
    }
  };

  const stopRecording = () => {
    if (mediaRecorder) {
      mediaRecorder.stop();
      setMediaRecorder(null);
    }
  };

  const playRecording = () => {
    if (audio.current) {
      audio.current.play();
    }
  };

  useEffect(() => {
    return () => {
      if (audioURL.current) {
        window.URL.revokeObjectURL(audioURL.current);
      }
    };
  }, []);

  return { startRecording, stopRecording, playRecording };
}

実は、核心は3つだけです。1つはrecorder、もう1つはデータchunks、最後の1つがaudioです。

プレーボタンを押して、まず権限の取得です。取得したら、録音開始します、audioChunksにデータを追加します。終わったら、dataChunksはフォーマット変換する必要があり、ローカルURLを変換して、それをaudioオブジェクトに割り当てることで再生できます。

こっち注意すべき点はメモリーリークの問題です。

まず、recorderは毎回新規ですが、それは大丈夫ですか?実は、recorderは一旦停止すれば、もう一度再開できないため、毎回新規ことは必要です。古いインスタンスはもう参照されず、自動的にガベージコレクションされるため、メモリーリークの問題を引き起こしません。

実際注意すべき点はwindow.URL.createObjectURL(blob)です、このコードはblobをローカルURL形式で持続的に存在します、window.URL.revokeObjectURL()を実行しないと、ずっと存在し続けます。

毎回停止する際に、前回のURLは確実に不要になるため、window.URL.revokeObjectURL()を実行することが必要です。

また、コンポーネントがアンマウントされた後も忘れずにクリーンアップする必要があります。

useEffect(() => {
    // アンマウント時,使用されないオーディオのblobがメモリを占有しないようにします。
    return () => {
      if (audioURL.current) {
        window.URL.revokeObjectURL(audioURL.current);
      }
    };
  }, []);

useRefState

このカスタムフックはReactのクラシッククロージャ問題に関わります。

簡単に言うと、クロージャーの問題は、スコープの問題に起因しており、状態の最新値を取得できないということです。

import React from "react";

export default function App() {
  const [count, setCount] = React.useState(0);
  console.log(count, "count outside");
  React.useEffect(() => {
    window.addEventListener("mouseup", () => {
      console.log(count, "count in closure");
      setCount(count + 1);
    });
  }, []);

  return (
    <h1>count: {count}</h1>
  );
}

画面で、何回クリックでも、count in closureのcountの値はゼロです。こちらで試してどうぞ:https://codesandbox.io/p/sandbox/sv8d8z

それは予想以外ですね、一般的に絶対最新値を取得したいです。この問題をちゃんと分析したいなら、以下のReactやJSコンパイルの基本原理が必要です:

  1. setCount実行すれば、再レンダリングためにClosureProblemはもう一回実行します
  2. 変数には対応するスコープがあります。JavaScriptのすべての変数は、一緒に保存されるわけではなく、それぞれが所属するスコープ内に保存されます
  3. 簡単に理解すると、各角かっこ内がスコープです。したがって、関数を実行するたびにスコープが作成されます

初めてリンダリングのとき、ClosureProblemは実行します、S0スコープを作成されます、countとsetCount変数を作成されます、こっちは重要なことです、名前はcountとsetCountだけど、実際はS0のcountとS0のsetCountです。その後、mouseupイベントをWindowにバインドします、イベントのcallbackの中のcountもS0のcountです

クリックして、setCountは実行し、再レンダリングを起こす、ClosureProblemはもう一回実行します、S1スコープを作成されます。S1のcount・setCountも作成されます、生成のとき前回のS0のcountを変更しない、その代わりに新しいcountを計算し、その結果をS1のcountに渡します。それに、今回は第一目のレンダリングではないから、effectは実行しない。

そのため、callbackは古いものであり、その中のcountはまだS0のcountです。あるスコープの中のcountはconstであり、永遠に変更しないから、このcallbackの中のcountは永遠にゼロです。

説明は明晰と思いますが、対策はなんですか?

Reactチームは、experimental_useEffectEventというHookを提供してこの問題を解決ようですが、現時点ではこのフックはその名前通りまだ実験段階で公開されていません。

これほど遅いのは、公式にリリースされたHookがさまざまな極端な状況を考慮し、将来的な進化において破壊的な変更が発生することを可能な限り回避する必要があるからです。でもうちのカスタムフックは考慮すべき要素がそれほど多くないです。useRefStateぐらいなカスタムフックは主要なケーズを解決できます。

export function useForceUpdate() {
    const [_, setState] = React.useState(false);
    return () => setState(prev => !prev);
}

export function useRefState<T>(intialState: T) {
    const forceUpdate = useForceUpdate();
    const stateRef = React.useRef(intialState);

    return [stateRef, (newState: T) => {
        stateRef.current = newState;
        forceUpdate();
    }] as [React.MutableRefObject<T>, (newState: T) => void]
}

useRefフックの処理ではuseStateのように新しいスコープで新しい変数を作成して計算した後に返すのではなく、オブジェクトを作成したらいつもそのオブジェクトに返します、再度作成されることはありません。そしてこのオブジェクトにはcurrentプロパティがあり、そこに望む値を格納することができます。

確かに毎回リンダリングすると、新しいstateRefを作成されるし、異なるスコープに属していますが、参照型であるため、実際のメモリ内では同じ領域を参照しているため、クロージャ問題は発生しません。

こうすると、setCountを使用して状態を更新すると再レンダリングが正常にトリガーされ、countRef.currentを読み取る際にも常に最新の値が取得できます。

import React from "react";

function useForceUpdate() {
  const [_, setState] = React.useState(false);
  return () => setState((prev) => !prev);
}

function useRefState<T>(intialState: T) {
  const forceUpdate = useForceUpdate();
  const stateRef = React.useRef(intialState);

  return [
    stateRef,
    (newState: T) => {
      stateRef.current = newState;
      forceUpdate();
    },
  ] as [React.MutableRefObject<T>, (newState: T) => void];
}

export default function App() {
  const [countRef, setCount] = useRefState(0);
  console.log(countRef.current, "count outside");
  React.useEffect(() => {
    window.addEventListener("mouseup", () => {
      console.log(countRef.current, "count in closure");
      setCount(countRef.current + 1);
    });
  }, []);

  return (
    <h1>count: {countRef.current}</h1>
  );
}

こちらで試しましょう:https://codesandbox.io/p/sandbox/4sz6jl

UI設計

ダークモード

Tailwind CSSを使用してるから、ダークモードの実現は簡単です。ただ2つステップ:

  1. ダークモードを希望する場合は、bodyタグにdarkクラスを追加し、ライトモードを希望する場合は除外します。
  2. ダークモードの場合、対応するスタイルが必要の要素に、「dark:」接頭辞のスタイルを追加すればよく、例えばdark:bg-eleDark dark:text-white。

「dark:」接頭辞の意味は、祖先要素にdarkというクラスがある場合、「dark:」後続の内容で指定されたスタイルが有効になります。

shadcn/ui

shadcnは現在、最も美しいUIライブラリと思いますし、実装コンセプトも非常にモダンです。

簡単に言えば、スタイルのないUIライブラリ@radix-uiにTailwindCSSで書かれたスタイルを追加します。

従来のUIライブラリと異なる点は、パッケージを直接インポートするのではなく生成されることです。

必要なコンポーネントがある場合は、対応するコマンドを実行し、その後、components/uiディレクトリに対応するコンポーネントが追加されます。

そうなると、一般的にはスタイルを変更せずに利用できますし、必要な場合はプロジェクト内で直接修正できます。これは以前のようにパラメータをコンポーネントに渡す形式よりもエレガントと思います。
インストールステップは2つだけ:
npx shadcn@latest init -d
どのコンポーネントが欲しいなら、追加する。button例:npx shadcn@latest add button

良いUIライブラリを選ぶことは非常に重要です。自分のサイドプロジェクト、無料のオープンソース製品、または商用製品であっても、見栄えが悪いと誰も注目しません。

自分でデザインすると、細部を磨くのに時間がかかりますし、スタイルが一貫していないし、標準設計ではないため、ユーザーが理解しにくい問題があります。そのため、このライブラリをお勧めします。

UIライブラリ以外の素材

もちろんUIライブラリはすべてのシーンをカバーするわけではありません。もし、あるものがUIライブラリに含まれているか分からない場合や、どのようにデザインすべきかわからない場合は、あなたの要件とおおよそのデザイン要件をv0に伝えて、v0にデザインしてもらうことができます。

また、uiverse.ioもたくさん美しい設計を含んでいます。いくつかのインスピレーションを得たり直接コピーしたりすることができます。現在tailwindCSSもサポートされているため、コピーして持ってくることが非常に簡単です。

デプロイとリリース

Vercelプラットフォームを利用してるから、特に何もいらないです。
もちろん、環境変数を設定するのを忘れないでください。
あと、Github開発者設定画面には、homepageURLやcallbackURLを本番のものに設定することは必要です。
上記の設定が完了したら、コードをプッシュするだけで、Vercelは自動的にビルドして公開し、無料のドメインと無料枠のDBを利用することもできます。

まとめ

RSC+Sever Actions+Prismaを使用すると、データ処理が従来よりもはるかに簡単で便利になります。バックエンド管理システムや小規模の一般ユーザー向けのアプリに適していると感じます。しかし、高い並行リクエストの場合、他のプロジェクトも利用したい場合は、従来のインターフェース形式を使用するが必要と思います。

Discussion