Closed69

Remixを触ってみる

てりーてりー

npm run devでエラー

Could not locate @remix-run/serve. Please verify you have it installed to use the dev command.
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! remix-app-template@ dev: `remix dev`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the remix-app-template@ dev script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

nodeのバージョンが原因らしい

てりーてりー

useLoaderDataを使う

useLoaderData

バックエンドとフロントエンドを繋ぐapiとして活用。

import {useLoaderData} from "remix";

// json格納(バックエンドの代わり)
export let loader = ()=>{
    return[
        {
            slug:"my-first-post",
            title:'My First Post'
        },
        {
            slug:"90s-mixtape",
            title:'A Mixtape I Made Just For You'
        }
    ]
}

export default function Posts(){
 // フロント側でjson受け取り
    let posts = useLoaderData()
    console.log(useLoaderData())
    return(
        <div>
            <h1>Posts</h1>
        </div>
    );
}

console側
Image from Gyazo

てりーてりー

チュートリアル完走。
クラスメソッドの記事読みながら振り返り

てりーてりー

サーバー/クライアントモデル

RemixはSSGなし→動的にサイトを作る。
Next.jsはSSG,SSRをどっちも使える。

SSGって??

Static Site Generationの略。
Pre-renderingの一種。
Pre-renderingは空のhtmlを読み込んでから、Jsをロードするのではなく、
html、Jsを同時に読み込む。

https://zenn.dev/luvmini511/articles/1523113e0dec58

SSGでは外部からのデータ取得はgetStaticProps関数を使う。
ビルド時1回だけデータフェッチして、ビルドされた後にデータを変更することができないので、外部データが動的に変わるサイトには向かない。

SSRって??

Server-side Rendering の略。
もう一つのPre-rendering。

ユーザーからのリクエストの度にhtmlを書き換える。
なので、SSGよりは当然遅い。

ビルド後にもユーザーのリクエストに応じてページを更新できる。

まとめ

Remixはデータをフェッチする前にフィルターをかけて、ネットワークで通信するデータ量を減すなど、SSRに絞っている分、動的なパフォーマンスはNext.jsより高そう。
→これはNestedRoutingによる恩恵も絡んでくる。

てりーてりー

joke-appやってみる

次はjoke-appやってみる
https://remix.run/docs/en/v1/tutorials/jokes#jokes-app-tutorial

完成のディレクトリ構成
Image from Gyazo

てりーてりー

ルーティング

Outletの使い方

親コンポーネント側

route/jokes.tsx
import {Outlet} from "remix";

export default function JokesRoute() {
    return (
        <div>
            <h1>J🤪KES</h1>
            <main>
                <Outlet/> // 子コンポーネントを描画
            </main>
        </div>
    )
}

子コンポーネント

route/jokes/index.tsx
export default function JokesIndexRoute(){
    return(
        <p>random joke:</p>
    );
}

画面で/jokes

Image from Gyazo

子コンポーネントとしてjokes/index.tsxが描画される。

ルートをjokes/newにしたら、子コンポーネントはjokes/new.tsxが描画される。

てりーてりー

流れ

  1. prismaを使ってdbにデータを入れる
  2. remix側でLoaderFunctionを使ってデータを受け取る

dbにデータを入れる際に、今回はseed.tsを使ってdev.dbに格納。

prisma/seed.ts

import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();

async function seed() {
    await Promise.all(
        getJokes().map(joke => {
            return db.joke.create({ data: joke });
        })
    );
}

seed();

function getJokes() {
    // shout-out to https://icanhazdadjoke.com/

    return [
        {
            name: "Road worker",
            content: `I never wanted to believe that my Dad was stealing from his job as a road worker. But when I got home, all the signs were there.`
        },
        {
            name: "Frisbee",
            content: `I was wondering why the frisbee was getting bigger, then it hit me.`
        },
        {
            name: "Trees",
            content: `Why do trees seem suspicious on sunny days? Dunno, they're just a bit shady.`
        },
        {
            name: "Skeletons",
            content: `Why don't skeletons ride roller coasters? They don't have the stomach for it.`
        },
        {
            name: "Hippos",
            content: `Why don't you find hippopotamuses hiding in trees? They're really good at it.`
        },
        {
            name: "Dinner",
            content: `What did one plate say to the other plate? Dinner is on me!`
        },
        {
            name: "Elevator",
            content: `My first time using an elevator was an uplifting experience. The second time let me down.`
        }
    ];
}

remix側で受け取り

jokes/$jokeId.tsx
import type {LoaderFunction} from "remix";
import {Link, useLoaderData} from "remix";
import type {Joke} from "@prisma/client";
import {db} from "~/utils/db.server";

type LoaderData = { joke: Joke };


export const loader: LoaderFunction = async ({
                                                 params
                                             }) => {
    const joke = await db.joke.findUnique({
        where: {id: params.jokeId}
    })
    if (!joke) throw new Error("Joke not found");
    const data: LoaderData = {joke};
    return data
}


export default function JokeRoute() {
    const data = useLoaderData<LoaderData>()
    return (
        <div>
            <p>Here's your hilarious joke:</p>
            <p>{data.joke.content}</p>
            <Link to=".">{data.joke.name} Permalink</Link>
        </div>
    )
}
てりーてりー

LoaderFunctionでそのページで使うデータを受け取る

export const loader: LoaderFunction = async ({
                                                 params
                                             }) => {
    const joke = await db.joke.findUnique({
        where: {id: params.jokeId}
    })
    if (!joke) throw new Error("Joke not found");
    const data: LoaderData = {joke};
    return data
}

dom部分でデータを描画する

export default function JokeRoute() {
    const data = useLoaderData<LoaderData>()
    return (
        <div>
            <p>Here's your hilarious joke:</p>
            <p>{data.joke.content}</p>
            <Link to=".">{data.joke.name} Permalink</Link>
        </div>
    )
}
てりーてりー

action

リファレンス
https://remix.run/docs/en/v1/api/conventions#action

データの変更やその他のアクションを処理するためのサーバーのみの機能です。ルートに対して非GET要求(POST、PUT、PATCH、DELETE)が行われると、ローダーの前にアクションが呼び出されます。

get以外のrequestのタイミングで発火する関数

てりーてりー

新規登録でエラー

Image from Gyazo

post時にrouteでエラーしているっぽい。

てりーてりー

バリデーション

useActionDataを用いる。
action発火時のデータを返す。主にバリデーションに使う。

https://remix.run/docs/en/v1.0.6/api/remix#useactiondata

バリデーションの基本形

流れとしては

  1. formをpostした時に、actionフックが発火
  2. action内でバリデーションのロジックを書いて、errorsとしてreturn
  3. action`の戻り値をuseActionData``で取得、バリデーションメッセージに反映
  4. errorでない場合は、postの内容を実行
import { redirect, json, Form, useActionData } from "remix";

// ②
export function action({ request }) {
  let form = await request.formData();
  let email = form.get("email");
  let password = form.get("password");
  let errors = {};
  
  if (typeof email !== "string" || !email.includes("@")) {
    errors.email =
            "That doesn't look like an email address";
  }

  if (typeof password !== "string" || password.length < 6) {
    errors.password = "Password must be > 6 characters";
  }
  
  if (Object.keys(errors).length) {
    return json(errors, { status: 422 });
  }
  
  // ④
  await createUser(body);
  return redirect("/dashboard");
}

export default function Signup() {
    // ③
  let data = useActionData();
  return (
          <>
            <h1>Signup</h1>
            // ①
            <Form method="post">
              <p>
                <input type="text" name="email" />
                {errors?.email && <span>{errors.email}</span>}
              </p>
              <p>
                <input type="text" name="password" />
                {errors?.password && (
                        <span>{errors.password}</span>
                )}
              </p>
              <p>
                <button type="submit">Sign up</button>
              </p>
            </Form>
          </>
  );
}

useActionDataの使い方としてリファレンスでは

一般に、フォームの検証が失敗した場合は、アクションからデータを返し、コンポーネントでレンダリングしますが、実際に(データベースなどで)データを変更したら、リダイレクトする必要があります。

と書いてあります。
データを変更した際にリダイレクトしないと、変更後にブラウザバックした際に再度データを変更する事になるので注意が必要です。

例外として以下のパターンは例外のようです。

詳しくは今後。公式にも現時点であまり情報がない…

  • <form>
  • <Form reloadDocument>
  • <Scripts/>をレンダリングしていない
  • ユーザーがJavaScriptを無効にしている
てりーてりー

画面更新の際にはnpm run buildが必要。
buildしていないと変化は現れんよね。(凡ミス…)。

npm run devで常時解決。

てりーてりー

loaderでMVCのcontrollerの様にデータをいじって受け取れる。
これでいらないデータ通信を抑える。

export const loader: LoaderFunction = async () => {
  const data: LoaderData = {
    jokeListItems: await db.joke.findMany({
      take: 5,
      select: { id: true, name: true },
      orderBy: { createdAt: "desc" },
    }),
  };
  return data;
};
てりーてりー

LoaderFunctionはパラメータを引数のparamsとして受け取るらしい。

//urlとidが一致するjokesを取得
export const loader: LoaderFunction = async ({ params }) => {
  const joke = await db.joke.findUnique({ where: { id: params.jokeid } });
  if (!joke) throw new Error("joke not found");
  const data: LoaderData = { joke };
  return data;
};
てりーてりー

mutatioon

新しいformをdbに追加する。(create)

ActionFunctionを使う。

export const action: ActionFunction = async ({ request }) => {
  const form = await request.formData();
  const content = form.get("content");
  const name = form.get("name");

  // バリデーション
  if (typeof name !== "string" || typeof content !== "string") {
    throw new Error("Form not submit correctly");
  }

  const fields = { content, name };
  const joke = await db.joke.create({ data: fields });
  return redirect(`/jokes/${joke.id}`);
};
てりーてりー

redirectした時には追加分のjokeも記載されていた。
→Remixは自動的にキャッシュを無効化している。

てりーてりー

認証

prismaのモデル更新

Userテーブル追加。既にあるJokeとralation貼る。

User:1対 Joke:多 の関係


model User {
  id           String   @id @default(uuid())
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
  userName     String   @unique
  passwardHash String
  jokes        Joke[]
}

model Joke {
  id         String   @id @default(uuid())
  jokesterId String
  jokester   User     @relation(fields: [jokesterId], references: [id], onDelete: Cascade)
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
  name       String
  content    String
}

https://www.prisma.io/docs/concepts/components/prisma-schema/relations

てりーてりー

認証の画面側を作成

パスワードをハッシュさせる為に「bcryptjs」をinstall
https://github.com/dcodeIO/bcrypt.js

てりーてりー

やる事

  • ログインフォーム作成
  • フォームデータの検証
  • 「新規登録」ならユーザー名が使えるか検証
  • 「ログイン」ならユーザーがいるか検証
  • sessionを作成、/jokesにリアダイレクト
てりーてりー

logout機能

作る関数

getUser

requestからuserIdを取得し、そこからdb経由でuserを取得する関数。
エラー時はlogout実行

export async function getUser(request: Request) {
  //userId取得
  const userId = await getUserId(request);
  if (typeof userId !== "string") return null;
  try {
    // userIdからuserデータを取得
    const user = await db.user.findUnique({
      where: { id: userId },
    });
    return user;
  } catch {
    //userが取得出来なければlogout
    throw logout(request);
  }
}

logout

sessionを破棄してlogoutする

export async function logout(request: Request) {
  // requestからcookie経由でsessionを取得
  const session = await storage.getSession(request.headers.get("cookie"));
  // sessionを破棄してlogin画面に遷移
  return redirect("/login", {
    headers: {
      "Set-Cookie": await storage.destroySession(session),
    },
  });
}

https://developer.mozilla.org/ja/docs/Web/API/Window/sessionStorage

てりーてりー

通常のredirectだけでなく、headerを設定する為にも使える。

redirect(redirecTo, {
    headers: {
      "Set-Cookie": await storage.commitSession(session),
    },
てりーてりー

ActionFuctionとLoaderFunctionの発火タイミング違いが気になる。

logout.tsx
import type { ActionFunction, LoaderFunction } from "remix";
import { redirect } from "remix";
import { logout } from "~/utils/session.server";

// logoutする
export const action: ActionFunction = async ({ request }) => {
  return logout(request);
};

//home画面にredirect
export const loader: LoaderFunction = async () => {
  return redirect("/");
};
てりーてりー

(ローダーではなく)アクションを使用している理由は、GETリクエストではなくPOSTリクエストを使用してCSRFの問題を回避したいためです。

らしい。あんまり詳しくはまだ分からん

てりーてりー

ユーザー登録機能を作る

  • ユーザー登録のresigter関数を作る
  • login.tsxで使える様にする

register関数

prismaのapiのcreate使う
https://www.prisma.io/docs/concepts/components/prisma-client/crud#create

passwordはbcryptを使ってhash化する。

export async function register({ username, password }: LoginForm) {
  // passwordをhash化する
  const passwordHash = await bcrypt.hash(password, 10);
  return db.user.create({
    data: {
      username,
      passwordHash,
    },
  });
}

login.tsx

ActionFunctionの中で実装。


switch (loginType) {
    case "login": {
      const user = await login({ username, password });
      if (!user) {
        return {
          fields,
          formError: `Username/Password combination is incorrect`,
        };
      }
      return createUserSession(user.id, redirectTo);
    }

    case "register": {
      const userExists = await db.user.findFirst({
        where: { username },
      });
      if (userExists) {
        return badRequest({
          fields,
          formError: `User with username ${username} already exists`,
        });
      }
      const user = await register({ username, password });
      if (!user) {
        return badRequest({
          fields,
          formError: `Something went wrong trying to create a new user`,
        });
      }
    }

    default: {
      return badRequest({
        fields,
        formError: `Login type invalid`,
      });
    }
  }
};
てりーてりー

エラー対応

ReactのError Boundaries

https://reactjs.org/docs/error-boundaries.html#gatsby-focus-wrapper

あまりこれ自体の内容も分かっていないので、この際にキャチアップする。

てりーてりー

React 16からの機能。

  • UIの一部のJavaScriptによりアプリ全体が壊れない様にする機能
  • エラーが起きたコンポーネントのログを取り、クラッシュしたコンポーネントの代わりにUIをフォールバックするコンポーネント
てりーてりー

実際にjoke-appに加えて行く

root.tsx
export function ErrorBoundary({ error }: { error: Error }) {
  return (
    <Document title="UH-oh!">
      <div className="error-container">
        <h1>App Error</h1>
      </div>
    </Document>
  );
}
てりーてりー
てりーてりー

クライアントエラー(400系)に対してはCatchBOundariesを使う

CatchBoundaryは、アクションまたはローダーがをスローするたびにレンダリングするReactコンポーネント。

てりーてりー
root.tsx
export function CatchBoundary() {
  // response dataを取得
  const caught = useCatch();

  return (
    <Document title={`${caught.status} ${caught.statusText}`}>
      <div className="error-container">
        <h1>
          {caught.status} {caught.statusText}
        </h1>
      </div>
    </Document>
  );
}
てりーてりー

Metaタグの追加

SEO対策用のMetaタグを設定できる。

https://remix.run/docs/en/v1.1.1/api/conventions#meta

root.tsx
export const meta: MetaFunction = () => {
  const description = `Learn Remix and laugh at the same time!`;
  return {
    description,
    keywords: "Remix,jokes",
    "twitter:image": "https://remix-jokes.lol/social.png",
    "twitter:card": "summary_large_image",
    "twitter:creator": "@remix_run",
    "twitter:site": "@remix_run",
    "twitter:title": "Remix Jokes",
    "twitter:description": description,
  };
};
てりーてりー

リソースルート

ルートをUIコンポーネントとしてではなく、汎用的なエンドポイントとして使う方法。

例)

  • RemixUIでサーバー側のコードを再利用するモバイルアプリ用のJSONAPI
  • PDFを動的に生成する
  • ブログ投稿や他のページのソーシャル画像を動的に生成する
  • StripeやGitHubなどの他のサービス用のWebhook
  • ユーザーの好みのテーマのカスタムプロパティを動的にレンダリングするCSSファイル

https://remix.run/docs/en/v1.1.1/guides/resource-routes

今回はRSSフィードを作る。

RSSフィードとは?

  • RSS(RDF Site Summary/Rich Site Summary)はXMLを応用したデータ形式の一種
  • RSSフィードは特定のURLに置かれたRSS形式のデータ
  • リーダーが一定時間ごとに自動的に更新の有無をチェックし、自動的に最新情報を取得して表示する

参考
https://e-words.jp/w/RSSフィード.html

作成後の画面

Image from Gyazo

てりーてりー

Optimistic UI

凄い機能!!
サーバーとの通信が重い時に、ユーザーに見せるUI。
サーバー側のバリデーションで失敗する場合は、そのままエラーが返ってくるなどエラー対応も使ってない場合と同様になる。

ドキュメント
https://remix.run/docs/en/v1.1.1/guides/optimistic-ui

useTransitionを使う
https://remix.run/docs/en/v1.1.1/api/remix#usetransition

てりーてりー

デプロイ

fly.ioでデプロイする

サインインする

flyctl auth signup

エラーが出る

You must be authenticated to view this.

fly.ioのサイトからサインアップすれば解決。
https://fly.io/

てりーてりー

dababseのurlを変えてみる。

schema.prisma
datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

上手くいった。

てりーてりー

色々と相談に乗ってもらったので、コミュニティに報告。
Image from Gyazo

デプロイ完了。

てりーてりー

振り返り

ちゃんとチュートリアルをやってログを残すと色々と学びがある。

  • エラーログが残っていて、試行錯誤の過程があるとコミュニティで質問しやすい
  • GithubのissueやPRでチュートリアルについて言及している内容が結構多い
    • バグレポートは上げるチャンスが多い
    • OSSに貢献するチャンスがある
このスクラップは2022/01/26にクローズされました