первый взгляд на create-t3-app

create-t3-app — это fullstack React framework и CLI, который появился как развитие стека T3, рекомендованного на сайте Тео Брауна init.tips. Создатели описывают его как «своего рода шаблон», что должно подчеркнуть, что это «НЕ шаблон».

Конспект

  • Введение
    • История стека t3
    • Создание приложения Nex
  • Создание приложения t3
    • Структура проекта
  • Предоставление базы данных PostgreSQL
    • Добавьте модель Posts в схему Prisma
    • Установите CLI и инициализируйте проект Railway
    • Запустите миграцию базы данных
    • Создайте запись в блоге
  • Запрос постов с помощью tRPC
    • Создайте маршрутизатор постов
    • Запрос постов с помощью useQuery
  • Добавление ячеек для получения декларативных данных
    • Создание ячейки запроса по умолчанию
    • Создать страницу сообщений
    • Создать ячейку для постов
  • Развертывание в Vercel
  • Ресурсы
    • Статьи
    • Видео

Весь код для этой статьи можно найти на моем GitHub.

Введение

Цель ct3a — предоставить самый быстрый способ начать новое полнофункциональное, безопасное для типов веб-приложение. Для достижения этой цели стек построен вокруг трех основных составляющих:

  • типизированный фронтенд React (TypeScript и Next.js)
  • типизированный клиент базы данных (Prisma)
  • типизированные удаленные вызовы процедур (tRPC).

Источник: Сабин Адамс — Конечная безопасность типов

Как человека, который до сих пор сопротивлялся TypeScript, меня это пугает. Но я сделаю исключение и впервые в жизни приму TypeScript, если этот стек действительно сможет обеспечить плавную и оптимизированную работу с TypeScript.

Но те, кто уже влюблен в TypeScript и полнофункциональные фреймворки React, вероятно, сейчас испытывают странное чувство дежа-вю. Этот стек почти идентичен Blitz.js и разделяет многие из тех же архитектурных принципов. Заметное отличие заключается в том, что CTA включает в себя tRPC (который сам по себе часто сравнивается с Blitz.js).

История стека t3

Первая итерация сайта init.tips предполагала, что для инициализации в основном оптимального boilerplate для большинства веб-приложений в 2021 году требуется всего одна команда. Это предложение (в своей бесконечной мудрости) было следующим: Создайте приложение Next.js, но с TypeScript.

Когда люди начали обдумывать этот совет, многие разработчики неизбежно спросили:

«Мммм, а как насчет всего остального, не включенного в этот стек, что мне нужно для создания хотя бы погранично функционального приложения?».

Это привело к другим рекомендациям по дополнениям к стеку. Эти дополнения были нацелены на конкретные случаи использования, такие как:

  • Prisma для управления миграциями баз данных и SQL-запросами через ORM
  • Next-auth для аутентификации на стороне клиента
  • Tailwind для стилизации CSS и пользовательского интерфейса
  • tRPC для сквозного типобезопасного API.

Если эти команды часто рекомендуются, то логично было бы создать новую, более полнофункциональную команду. Она будет генерировать не только типизированный проект Next.js, но и проект с ORM, аутентификацией, стилизацией и протоколом API.

Они будут включаться автоматически, но при этом будет возможность отказаться от них, если вы все еще хотите получить «голую» версию. Я рад, что эта идея набирает обороты и что некоторые считают ее оригинальной.

Последние два года я неустанно продвигал фреймворки, собирающие различные версии подобных стеков. RedwoodJS, Blitz.js и Bison имеют чрезвычайно похожие, но в то же время немного отличающиеся стеки. Чтобы понять, как они соотносятся друг с другом, я бы разбил их следующим образом:

Этот список не должен быть исчерпывающим, и я намеренно опустил такие вещи, как тесты, моки, Storybook, развертывание и другие неархитектурные части.

По мере развития проекта от init.tips до create-t3-app, он обрел свою собственную жизнь. Тео неоднократно заявлял, что он не был инициатором создания create-t3-app, он просто много раз публично говорил об этой идее.

Создать приложение Nex

На самом деле, у него никогда бы не хватило времени на создание или управление таким проектом. Помимо того, что он постоянно занимается созданием контента, он является генеральным директором стартапа, создающего ping.gg, инструмент для совместного потокового вещания. Его влияние на проект в основном обусловлено его различными публичными обсуждениями стека.

Эти обсуждения вдохновили группу людей, которые были членами его недавно созданного сервера Discord. Это онлайн-пространство было создано для объединения поклонников его каналов на Twitch и YouTube. Группа независимо друг от друга начала создавать полноценный проект. Эта деятельность была сосредоточена вокруг работы Шубхита Даша.

Известный в сети как nexxel или nexxeln, Шубхит взял на себя инициативу по формализации стека, разработав интерактивный CLI-инструмент, который мог бы построить проект, используя произвольные комбинации различных технологий, используемых в стеке. nexxel, 17-летний разработчик-самоучка, является настоящим камнем в этом проекте.

Nexxel вел блог о tRPC в мае, прямо перед запуском фреймворка. Создание конечных типобезопасных API с помощью tRPC стало сигналом к рождению фреймворка 21 мая 2022 года вместе с начальным коммитом 20 мая 2022 года. Первоначально проект назывался Create Nex App, в README он описывался следующим образом:

Scaffold a starting project using the t3 stack using this interactive CLI.

Ранние прототипы проекта включали Next.js, Tailwind и TypeScript вместе с tRPC. В течение июня проект начал привлекать около десятка участников. Юлиус Марминге (juliusmarminge) был одним из самых ранних участников и остается активным до сих пор.

Примерно через месяц, 26 июня 2022 года, компания nexxel опубликовала стек T3 и мой самый популярный проект с открытым исходным кодом. Этот пост в блоге был опубликован после совместной работы с другими участниками над полной интеграцией Prisma и Next Auth, что ознаменовало завершение начального этапа интеграции стека.

В течение июня репозиторий GitHub набрал почти 2 000 звезд GitHub. Несмотря на то, что проект был создан только в конце мая, он достиг почти беспрецедентного уровня динамики. 17 июля 2022 года nexxel перенес свой личный блог на create-t3-app, а к середине августа проект набрал более 5 000 звезд.

Создать t3-приложение

Чтобы начать работу с ct3a, вы можете выполнить любую из следующих трех команд и ответить на вопросы командной строки:

npx create-t3-app@latest
Войти в полноэкранный режим Выйти из полноэкранного режима
yarn create t3-app
Войти в полноэкранный режим Выйти из полноэкранного режима
pnpm dlx create-t3-app@latest
Войти в полноэкранный режим Выход из полноэкранного режима

В настоящее время доступны следующие опции CLI:

Опция Описание
--noGit Явно указать CLI не инициализировать новое git-репо в проекте
Обходить CLI и использовать все опции по умолчанию для загрузки нового t3-app
[dir] Включить аргумент каталога с именем проекта
--noInstall Генерировать проект без установки зависимостей

Мы дадим нашему проекту имя и выберем опцию по умолчанию, которая включает все четыре внешние зависимости.

pnpm dlx create-t3-app@latest ajcwebdev-t3 -y
Вход в полноэкранный режим Выйти из полноэкранного режима

Если оставить опцию -y, вы сможете выбрать пользовательскую конфигурацию с определенными пакетами, которые вы хотите включить в свой проект. Он также спросит, хотите ли вы использовать JavaScript или TypeScript. Если вы попытаетесь выбрать JavaScript, то обнаружите, что эта опция — всего лишь иллюзия.

На самом деле вы должны использовать TypeScript, а Бога нет.

   ___ ___   _ _____ ___   _____ ____    _  ___  ___
  / __| _  __| /__   _| __| |_   _|__ /   /_ | _  _ 
 | (__|   / _| / _ | | | _|    | |  |_   / _ |  _/  _/
  ___|_|____/_/ __| |___|   |_| |___/ /_/ __| |_|


Using: pnpm

✔ ajcwebdev-t3 scaffolded successfully!

Installing packages...
✔ Successfully installed nextAuth
✔ Successfully installed prisma
✔ Successfully installed tailwind
✔ Successfully installed trpc

Initializing Git...
✔ Successfully initialized git
Вход в полноэкранный режим Выход из полноэкранного режима

Войдите в каталог вашего проекта и установите vercel CLI, чтобы мы могли развернуть наш проект позже.

cd ajcwebdev-t3
pnpm add -D vercel
Войти в полноэкранный режим Выход из полноэкранного режима

Запустите сервер разработки:

pnpm dev
Войти в полноэкранный режим Выйти из полноэкранного режима

Откройте localhost:3000, чтобы увидеть сгенерированный проект.

Структура проекта

Если мы проигнорируем конфигурационные файлы в корне нашего проекта, то структура папок и файлов будет выглядеть следующим образом:

.
├── prisma
│   └── schema.prisma
├── public
│   └── favicon.ico
└── src
    ├── env
    │   ├── client.mjs
    │   ├── schema.mjs
    │   └── server.mjs
    ├── pages
    │   ├── _app.tsx
    │   ├── api
    │   │   ├── auth
    │   │   │   └── [...nextauth].ts
    │   │   ├── examples.ts
    │   │   ├── restricted.ts
    │   │   └── trpc
    │   │       └── [trpc].ts
    │   └── index.tsx
    ├── server
    │   ├── db
    │   │   └── client.ts
    │   └── router
    │       ├── context.ts
    │       ├── example.ts
    │       ├── index.ts
    │       ├── protected-example-router.ts
    │       └── protected-router.ts
    ├── styles
    │   └── globals.css
    ├── types
    │   └── next-auth.d.ts
    └── utils
        └── trpc.ts
Вход в полноэкранный режим Выход из полноэкранного режима

Откройте src/pages/index.tsx и внесите некоторые изменения для настройки главной страницы. Не стесняйтесь следовать за мной или вносить свои собственные изменения, существует много различных способов организации этого проекта. Сначала я создам файл styles.tsx для хранения всех стилей, содержащихся в проекте.

echo > src/styles/styles.tsx
Вход в полноэкранный режим Выход из полноэкранного режима

Я создам объект styles и абстрагирую стили Tailwind в переменные, которые можно использовать повторно во всем проекте.

// src/styles/styles.tsx

export const styles = {
  appContainer: "container mx-auto flex flex-col items-center justify-center min-h-screen p-4",
  title: "text-5xl md:text-[5rem] leading-normal font-extrabold text-gray-700",
  purple: "text-purple-300",
  body: "text-2xl text-gray-700",
  grid: "grid gap-3 pt-3 mt-3 text-center md:grid-cols-2 lg:w-2/3",
  queryResponse: "pt-6 text-2xl text-blue-500 flex justify-center items-center w-full",
  cardSection: "flex flex-col justify-center p-6 duration-500 border-2 border-gray-500 rounded shadow-xl motion-safe:hover:scale-105",
  cardTitle: "text-lg text-gray-700",
  cardDescription: "text-sm text-gray-600",
  link: "mt-3 text-sm underline text-violet-500 decoration-dotted underline-offset-2",
  blogContainer: "container mx-auto min-h-screen p-4",
  blogTitle: "text-5xl leading-normal font-extrabold text-gray-700",
  blogBody: "mb-2 text-lg text-gray-700",
  blogHeader: "text-5xl leading-normal font-extrabold text-gray-700"
}
Вход в полноэкранный режим Выход из полноэкранного режима

Добавьте переменные стилей в компонент Home.

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"

type TechnologyCardProps = {...}

const TechnologyCard = ({
  name, description, documentation
}: TechnologyCardProps) => {...}

export default function Home() {
  const hello = trpc.useQuery([
    "example.hello", { text: "from tRPC" }
  ])

  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta
          name="description"
          content="Example t3 project from A First Look at create-t3-app"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.appContainer}>
        <h1 className={styles.title}>
          Create <span className={styles.purple}>T3</span> App
        </h1>

        <p className={styles.body}>This stack uses:</p>

        <div className={styles.grid}>...</div>
        <div className={styles.queryResponse}>...</div>
      </main>
    </>
  )
}
Войти в полноэкранный режим Выход из полноэкранного режима

Добавьте переменные стиля к компоненту TechnologyCard:

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"

type TechnologyCardProps = {...}

const TechnologyCard = ({
  name, description, documentation
}: TechnologyCardProps) => {
  return (
    <section className={styles.cardSection}>
      <h2 className={styles.cardTitle}>
        {name}
      </h2>

      <p className={styles.cardDescription}>
        {description}
      </p>

      <a
        className={styles.link}
        href={documentation}
        target="_blank"
        rel="noreferrer"
      >
        Documentation
      </a>
    </section>
  )
}

export default function Home() {...}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь я изменю четыре карточки, чтобы включить в них ссылки на мой блог и профили социальных сетей. С учетом этого изменения я буду использовать url вместо documentation для более подходящего имени реквизита. Я также изменю ссылки, чтобы они включали всю карту в теги якоря, так что щелчок в любом месте карты будет открывать гиперссылку.

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"

type TechnologyCardProps = {
  name: string
  url: string
}

const TechnologyCard = ({
  name, url
}: TechnologyCardProps) => {
  return (
    <a href={`https://${url}`} target="_blank" rel="noreferrer">
      <section className={styles.cardSection}>
        <h2 className={styles.cardTitle}>
          {name}
        </h2>

        <span className={styles.link}>
          {url}
        </span>
      </section>
    </a>
  )
}

export default function Home() {
  const hello = trpc.useQuery([
    "example.hello", { text: "from tRPC" }
  ])

  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta
          name="description"
          content="Example t3 project from A First Look at create-t3-app"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.appContainer}>
        <h1 className={styles.title}>
          Hello from <span className={styles.purple}>ajc</span>webdev
        </h1>

        <div className={styles.grid}>
          <TechnologyCard name="Blog" url="ajcwebdev.com/" />
          <TechnologyCard name="Twitter" url="twitter.com/ajcwebdev/" />
          <TechnologyCard name="GitHub" url="github.com/ajcwebdev/" />
          <TechnologyCard name="Polywork" url="poly.work/ajcwebdev/" />
        </div>

        <div className={styles.queryResponse}>
          {hello.data ? <p>{hello.data.greeting}</p> : <p>Loading..</p>}
        </div>
      </main>
    </>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вернитесь на localhost:3000, чтобы увидеть изменения.

Наконец, я вынесу компонент TechnologyCard в отдельный файл и переименую его в Card.

echo > src/components/Card.tsx
Вход в полноэкранный режим Выход из полноэкранного режима

Переименуйте TechnologyCardProps в CardProps и создайте компонент Card.

// src/components/Card.tsx

import { styles } from "../styles/styles"

type CardProps = {
  name: string
  url: string
}

export default function Card({
  name, url
}: CardProps) {
  return (
    <a href={`https://${url}`} target="_blank" rel="noreferrer">
      <section className={styles.cardSection}>
        <h2 className={styles.cardTitle}>
          {name}
        </h2>

        <span className={styles.link}>
          {url}
        </span>
      </section>
    </a>
  )
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Импортируйте Card в src/pages/index.tsx и удалите CardProps.

// src/pages/index.tsx

import Head from "next/head"
import Card from "../components/Card"
import { styles } from "../styles/styles"

export default function Home() {
  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta
          name="description"
          content="Example t3 project from A First Look at create-t3-app"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.appContainer}>
        <h1 className={styles.title}>
          Hello from <span className={styles.purple}>ajc</span>webdev
        </h1>

        <div className={styles.grid}>
          <Card name="Blog" url="ajcwebdev.com/" />
          <Card name="Twitter" url="twitter.com/ajcwebdev/" />
          <Card name="GitHub" url="github.com/ajcwebdev/" />
          <Card name="Polywork" url="poly.work/ajcwebdev/" />
        </div>
      </main>
    </>
  )
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Обеспечение базы данных PostgreSQL

Поскольку это fullstack framework, он уже включает инструмент под названием Prisma для работы с нашей базой данных. Наши модели будут определены в файле prisma/schema.prisma вместе с нашим конкретным поставщиком базы данных.

Добавление модели Posts в схему Prisma

В первоначально сгенерированном проекте база данных datasource установлена на SQLite. Поскольку мы хотим использовать реальную базу данных, откройте schema.prisma и обновите datasource до провайдера PostgreSQL.

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
Вход в полноэкранный режим Выход из полноэкранного режима

В дополнение к текущим моделям в схеме добавьте модель Post с id, title, description, body и меткой времени createdAt.

// prisma/schema.prisma

model Post {
  id                String    @id
  title             String
  description       String
  body              String
  createdAt         DateTime  @default(now())
}
Вход в полноэкранный режим Выход из полноэкранного режима

Также откомментируйте все появления @db.Text в модели Account.

// prisma/schema.prisma

model Account {
  id                String    @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?   @db.Text
  access_token      String?   @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?   @db.Text
  session_state     String?
  user              User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}
Вход в полноэкранный режим Выход из полноэкранного режима

Установка CLI и инициализация проекта Railway

Мы будем использовать Railway для инициализации базы данных PostgreSQL. Сначала вам нужно создать учетную запись Railway и установить Railway CLI. Если вы не можете войти в систему через браузер, выполните команду railway login --browserless.

railway login
Войдите в полноэкранный режим Выход из полноэкранного режима

Выполните следующую команду, выберите «Empty Project» и дайте проекту имя.

railway init
Войти в полноэкранный режим Выход из полноэкранного режима

Чтобы создать базу данных, добавьте плагин в проект Railway и выберите PostgreSQL.

railway add
Войти в полноэкранный режим Выход из полноэкранного режима

Установите переменную окружения DATABASE_URL для вашей базы данных и создайте файл .env для ее хранения.

echo DATABASE_URL=`railway variables get DATABASE_URL` > .env
Вход в полноэкранный режим Выйдите из полноэкранного режима

Запуск миграции базы данных

Запустите миграцию с помощью prisma migrate dev, чтобы создать папки и файлы, необходимые для создания новой миграции. Мы назовем нашу миграцию init с помощью аргумента --name.

pnpm prisma migrate dev --name init
Вход в полноэкранный режим Выход из полноэкранного режима

После завершения миграции создайте клиент Prisma с помощью команды prisma generate.

pnpm prisma generate
Войти в полноэкранный режим Выход из полноэкранного режима

Создание записи в блоге

Сейчас мы не будем реализовывать конечную точку через наше приложение с функциями записи, обновления или удаления, поскольку в этом разделе мы не будем включать аутентификацию. Однако существует как минимум пять различных способов записи данных в базу данных. Для Prisma вы можете либо:

  • Запустить Prisma Studio на localhost 5555 с помощью pnpm prisma studio.
  • Войти в платформу данных Prisma по адресу cloud.prisma.io

Приборная панель Railway предоставляет следующие методы доступа к вашей базе данных:

  • Подключиться к базе данных с помощью команды psql на вкладке Connect
  • Ввод данных с помощью пользовательского интерфейса Railway на вкладке Данные
  • Выполнение необработанных SQL-запросов на вкладке Query.

Графические интерфейсы более интуитивно понятны для разработчиков, не имеющих большого опыта работы с SQL. Но, к сожалению, они также могут быть глючными или громоздкими. Особенно нежелательно вводить каждую строку вручную, когда нужно сразу ввести большое количество данных.

Команды SQL обеспечивают более последовательную и масштабируемую технику для заполнения базы данных или ввода новых данных на постоянной основе. Последний вариант в списке (вкладка «Запрос» на приборной панели Railway) дает нам лучшее из двух миров.

Он не требует ввода данных в какой-либо графический интерфейс, но также не требует установки клиента Postgres, такого как psql, на локальную машину. Мы можем создать запись в блоге с помощью следующей команды:

INSERT INTO "Post" (id, title, description, body) VALUES (
  '1',
  'A Blog Post Title',
  'This is the description of a blog post',
  'The body of the blog post is here. It is a very good blog post.'
);
Войти в полноэкранный режим Выйти из полноэкранного режима

Эту команду SQL можно ввести непосредственно в текстовую область на вкладке «Запрос».

Нажмите «Выполнить запрос», а затем добавьте еще две записи в блог:

INSERT INTO "Post" (id, title, description, body) VALUES (
  '2',
  'Second Blog Post',
  'This is the description of ANOTHER blog post',
  'Even better than the last!'
);
INSERT INTO "Post" (id, title, description, body) VALUES (
  '3',
  'The Final Blog Post',
  'This is the description for my final blog post',
  'My blogging career is over. This is the end, thank you.'
);
Войти в полноэкранный режим Выйти из полноэкранного режима

Запрос постов с помощью tRPC

tRPC — это библиотека, предназначенная для написания безопасных API. Вместо импорта серверного кода клиент импортирует только один тип TypeScript. tRPC преобразует этот тип в полностью безопасный для типов клиент, который можно вызывать из фронтенда.

Создание маршрутизатора Post Router

Создайте файл, в котором мы инициализируем экземпляр маршрутизатора postRouter для запроса всех наших постов.

echo > src/server/router/post.ts
Вход в полноэкранный режим Выход из полноэкранного режима

Добавьте конечную точку запроса в маршрутизатор с помощью метода .query(). Он принимает два аргумента: name для имени конечной точки и params для параметров запроса.

  • Используйте params.resolve для реализации конечной точки (функция с одним аргументом req).
  • Используйте params.input для проверки ввода (подробнее об этом позже).
// src/server/router/post.ts

import { prisma } from "../db/client"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    },
  })
Вход в полноэкранный режим Выход из полноэкранного режима

В src/server/router/index.ts есть базовый appRouter для нашей точки входа в сервер. Постепенно она может быть расширена за счет дополнительных типов и преобразована в единый объект. Импортируйте postRouter и используйте метод .merge() для объединения следующих объектов в один экземпляр appRouter:

  • exampleRouter
  • postRouter
// src/server/router/index.ts

import superjson from "superjson"
import { createRouter } from "./context"
import { exampleRouter } from "./example"
import { postRouter } from "./post"
import { protectedExampleRouter } from "./protected-example-router"

export const appRouter = createRouter()
  .transformer(superjson)
  .merge("example.", exampleRouter)
  .merge("post.", postRouter)
  .merge("question.", protectedExampleRouter)

export type AppRouter = typeof appRouter
Вход в полноэкранный режим Выход из полноэкранного режима

Запросы, связанные с постами блога, будут иметь префикс post (post.all, post.byId). Пример запроса hello будет иметь префикс example, как и example.hello.

Запрос постов с помощью UseQuery

Откройте src/pages/index.tsx, чтобы запросить все посты и отобразить их на главной странице. Создайте компонент Posts и инициализируйте переменную postsQuery над оператором возврата. Установите переменную postsQuery на вывод post.all с помощью хука useQuery().

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"
import Card from "../components/Card"

const Posts = () => {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  return (...)
}

export default function Home() {...}
Вход в полноэкранный режим Выход из полноэкранного режима

Как упоминалось в предыдущем разделе, объект appRouter может быть выведен на клиенте. Стрингируйте вывод JSON из postsQuery.data и отобразите данные под заголовком страницы.

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"
import Card from "../components/Card"

const Posts = () => {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  const { data } = postsQuery

  return (
    <div className={styles.queryResponse}>
      {data
        ? <p>{JSON.stringify(data)}</p>
        : <p>Loading..</p>
      }
    </div>
  )
}

export default function Home() {...}
Вход в полноэкранный режим Выйти из полноэкранного режима

Верните Posts в компоненте Home.

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"
import Card from "../components/Card"

const Posts = () => {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  const { data } = postsQuery

  return (
    <div className={styles.queryResponse}>
      {data
        ? <p>{JSON.stringify(data)}</p>
        : <p>Loading..</p>
      }
    </div>
  )
}

export default function Home() {
  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta
          name="description"
          content="Example t3 project from A First Look at create-t3-app"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.appContainer}>
        <h1 className={styles.title}>
          Hello from <span className={styles.purple}>ajc</span>webdev
        </h1>

        <div className={styles.grid}>
          <Card name="Blog" url="ajcwebdev.com/" />
          <Card name="Twitter" url="twitter.com/ajcwebdev/" />
          <Card name="GitHub" url="github.com/ajcwebdev/" />
          <Card name="Polywork" url="poly.work/ajcwebdev/" />
        </div>

        <Posts />
      </main>
    </>
  )
}
Войти в полноэкранный режим Выход из полноэкранного режима

У нас есть некоторая условная логика для обеспечения отображения сообщения о загрузке, если данные еще не вернулись с сервера. Но что, если в базе данных нет записей блога или сервер возвращает ошибку? В этом случае идеально подойдет ячейка.

Добавление ячеек для декларативной выборки данных

Один из моих любимых паттернов из Redwood, который я надеялся увидеть в других фреймворках, — это концепция ячеек. Ячейки обеспечивают встроенную конвенцию для декларативной выборки данных, которая не совсем похожа на машину состояний, но имеет общие преимущества и характеристики.

В отличие от конечных автоматов общего назначения, Cells специально ориентированы на общие результаты выборки данных. Они дают разработчикам возможность избежать написания условной логики, поскольку ячейка будет управлять тем, что происходит во время следующих четырех потенциальных состояний выборки данных:

  • Успех — отображение данных ответа
  • Неудача — Обработка сообщения об ошибке и предоставление инструкций пользователю
  • Пустой — Показать сообщение или графику, сообщающую о пустом списке.
  • Загрузка — Показать сообщение или графику о том, что данные все еще загружаются.

К счастью, мои надежды оправдались, когда ведущий сопровождающий tRPC, Алекс Йоханссон, открыл PR с примером tRPC Cell, который, по его признанию, был создан под влиянием RedwoodJS.

Создание ячейки запроса по умолчанию

createQueryCell используется для создания ячейки DefaultQueryCell, которая может быть использована в любом месте вашего приложения.

echo > src/utils/DefaultQueryCell.tsx
Вход в полноэкранный режим Выход из полноэкранного режима

В идеале, когда-нибудь это будет встроено в tRPC или create-t3-app, и вы сможете просто писать ячейки, не задумываясь об этом. Но пока нам нужно создать это самим.

// src/utils/DefaultQueryCell.tsx

import { TRPCClientErrorLike } from "@trpc/client"
import NextError from "next/error"
import type { AppRouter } from "../server/router/index"
import {
  QueryObserverIdleResult,
  QueryObserverLoadingErrorResult,
  QueryObserverLoadingResult,
  QueryObserverRefetchErrorResult,
  QueryObserverSuccessResult,
  UseQueryResult,
} from "react-query"

type JSXElementOrNull = JSX.Element | null

type ErrorResult<TData, TError> =
  | QueryObserverLoadingErrorResult<TData, TError>
  | QueryObserverRefetchErrorResult<TData, TError>

interface CreateQueryCellOptions<TError> {
  error: (query: ErrorResult<unknown, TError>) => JSXElementOrNull
  loading: (query: QueryObserverLoadingResult<unknown, TError>) => JSXElementOrNull
  idle: (query: QueryObserverIdleResult<unknown, TError>) => JSXElementOrNull
}

interface QueryCellOptions<TData, TError> {
  query: UseQueryResult<TData, TError>
  error?: (query: ErrorResult<TData, TError>) => JSXElementOrNull
  loading?: (query: QueryObserverLoadingResult<TData, TError>) => JSXElementOrNull
  idle?: (query: QueryObserverIdleResult<TData, TError>) => JSXElementOrNull
}

interface QueryCellOptionsWithEmpty<TData, TError>
  extends QueryCellOptions<TData, TError> {
  success: (query: QueryObserverSuccessResult<NonNullable<TData>, TError>) => JSXElementOrNull
  empty: (query: QueryObserverSuccessResult<TData, TError>) => JSXElementOrNull
}
interface QueryCellOptionsNoEmpty<TData, TError>
  extends QueryCellOptions<TData, TError> {
  success: (query: QueryObserverSuccessResult<TData, TError>) => JSXElementOrNull
}

function createQueryCell<TError>(
  queryCellOpts: CreateQueryCellOptions<TError>,
) {
  function QueryCell<TData>(opts: QueryCellOptionsWithEmpty<TData, TError>): JSXElementOrNull
  function QueryCell<TData>(opts: QueryCellOptionsNoEmpty<TData, TError>): JSXElementOrNull
  function QueryCell<TData>(opts:
    | QueryCellOptionsNoEmpty<TData, TError>
    | QueryCellOptionsWithEmpty<TData, TError>,
  ) {
    const { query } = opts

    if (query.status === 'success') {
      if ('empty' in opts &&
        (query.data == null ||
          (Array.isArray(query.data) && query.data.length === 0))
      ) {
        return opts.empty(query)
      }
      return opts.success(query as QueryObserverSuccessResult<NonNullable<TData>, TError>)
    }

    if (query.status === 'error') {
      return opts.error?.(query) ?? queryCellOpts.error(query)
    }
    if (query.status === 'loading') {
      return opts.loading?.(query) ?? queryCellOpts.loading(query)
    }
    if (query.status === 'idle') {
      return opts.idle?.(query) ?? queryCellOpts.idle(query)
    }
    return null
  }
  return QueryCell
}

type TError = TRPCClientErrorLike<AppRouter>

export const DefaultQueryCell = createQueryCell<TError>({
  error: (result) => (
    <NextError
      title={result.error.message}
      statusCode={result.error.data?.httpStatus ?? 500}
    />
  ),
  idle: () => <div>Loading...</div>,
  loading: () => <div>Loading...</div>,
})
Вход в полноэкранный режим Выход из полноэкранного режима

Мы хотим иметь возможность запрашивать отдельные записи блога на основе их id. Создайте страницу post с динамическим маршрутом на основе id.

mkdir src/pages/post
echo > src/pages/post/[id].tsx
Вход в полноэкранный режим Выход из полноэкранного режима

Поскольку мы будем отправлять данные в базу данных, нам необходимо проверить input. zod — это валидатор схем TypeScript со статическим определением типов. Мы также импортируем TRPCError для обработки ошибок.

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })
Вход в полноэкранный режим Выход из полноэкранного режима

Добавьте запрос byId в Post router в src/server/router/post.ts и деструктурируйте id из input.

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })

  .query('byId', {
    input: z.object({ id: z.string() }),
    async resolve({ input }) {
      const { id } = input
    },
  })
Вход в полноэкранный режим Выход из полноэкранного режима

Запрос findUnique позволяет получить одну запись базы данных на основе id, переданного в опцию Prisma where.

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })

  .query('byId', {
    input: z.object({ id: z.string() }),
    async resolve({ input }) {
      const { id } = input
      const post = await prisma.post.findUnique({
        where: { id }
      })
    },
  })
Вход в полноэкранный режим Выйти из полноэкранного режима

И последнее, но не менее важное: выбросить ошибку TRPCError, если пост не возвращен.

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })

  .query('byId', {
    input: z.object({ id: z.string() }),
    async resolve({ input }) {
      const { id } = input
      const post = await prisma.post.findUnique({
        where: { id }
      })
      if (!post) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: `No post with id '${id}'`
        })
      }
      return post
    }
  })
Войти в полноэкранный режим Выход из полноэкранного режима

Создание страницы поста

Импортируйте DefaultQueryCell в src/pages/post/[id].tsx и создайте компонент под названием PostPage.

// src/pages/post/[id].tsx

import { useRouter } from "next/router"
import Head from "next/head"
import { DefaultQueryCell } from "../../utils/DefaultQueryCell"
import { trpc } from "../../utils/trpc"

export default function PostPage() {
  return (...)
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Верните DefaultQueryCell и передайте postQuery в query и data в success.

// src/pages/post/[id].tsx

import { useRouter } from "next/router"
import Head from "next/head"
import { DefaultQueryCell } from "../../utils/DefaultQueryCell"
import { trpc } from "../../utils/trpc"

export default function PostPage() {
  const id = useRouter().query.id as string
  const postQuery = trpc.useQuery([
    'post.byId', { id }
  ])

  return (
    <DefaultQueryCell
      query={postQuery}
      success={({ data }) => (
        <>
          <Head>
            <title>{data.title}</title>
            <meta
              name="description"
              content={data.description}
            />
          </Head>

          <main>
            <h1>{data.title}</h1>
            <p>{data.body}</p>
            <em>
              Created {data.createdAt.toLocaleDateString()}
            </em>
          </main>
        </>
      )}
    />
  )
}
Вход в полноэкранный режим Выйдите из полноэкранного режима

Наконец, добавьте стили.

// src/pages/post/[id].tsx

import { useRouter } from "next/router"
import Head from "next/head"
import { DefaultQueryCell } from "../../utils/DefaultQueryCell"
import { trpc } from "../../utils/trpc"
import { styles } from "../../styles/styles"

export default function PostPage() {
  const id = useRouter().query.id as string
  const postQuery = trpc.useQuery([
    'post.byId', { id }
  ])

  return (
    <DefaultQueryCell
      query={postQuery}
      success={({ data }) => (
        <>
          <Head>
            <title>{data.title}</title>
            <meta
              name="description"
              content={data.description}
            />
          </Head>

          <main className={styles.blogContainer}>
            <h1 className={styles.blogTitle}>
              {data.title}
            </h1>
            <p className={styles.blogBody}>
              {data.body}
            </p>
            <em>
              Created {data.createdAt.toLocaleDateString()}
            </em>
          </main>
        </>
      )}
    />
  )
}
Войти в полноэкранный режим Выход из полноэкранного режима

Откройте localhost:3000/post/1, чтобы увидеть вашу первую запись в блоге.

Создать ячейку для постов

echo > src/components/PostsCell.tsx
Вход в полноэкранный режим Выйти из полноэкранного режима

Создайте функцию PostsCell и импортируйте над ней следующее:

// src/components/PostsCell.tsx

import Link from "next/link"
import { styles } from "../styles/styles"
import { DefaultQueryCell } from "../utils/DefaultQueryCell"
import { trpc } from "../utils/trpc"

export default function PostsCell() {
  return (...)
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Создайте тип BlogPostProps с id и title типа string. Удалите компонент Posts в src/pages/index.tsx и переместите хук useQuery в компонент PostsCell.

// src/components/PostsCell.tsx

import Link from "next/link"
import { styles } from "../styles/styles"
import { DefaultQueryCell } from "../utils/DefaultQueryCell"
import { trpc } from "../utils/trpc"

type BlogPostProps = {
  id: string
  title: string
}

export default function PostsCell() {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  return (...)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Верните DefaultQueryCell с query, установленным на postsQuery. success отобразит объект data и покажет ссылку для каждой записи блога.

// src/components/PostsCell.tsx

import Link from "next/link"
import { styles } from "../styles/styles"
import { DefaultQueryCell } from "../utils/DefaultQueryCell"
import { trpc } from "../utils/trpc"

type BlogPostProps = {
  id: string
  title: string
}

export default function PostsCell() {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  return (
    <>
      <h2 className={styles.blogHeader}>
        Posts
      </h2>

      {postsQuery.status === 'loading'}

      <DefaultQueryCell
        query={postsQuery}
        success={({ data }: any) => (
          data.map(({id, title}: BlogPostProps) => (
            <Link key={id} href={`/post/${id}`}>
              <p className={styles.link}>
                {title}
              </p>
            </Link>
          ))
        )}
        empty={() => <p>WE NEED POSTS!!!</p>}
      />
    </>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Импортируйте PostsCell в src/pages/index.tsx и верните компонент в функцию Home.

// src/pages/index.tsx

import Head from "next/head"
import { styles } from "../styles/styles"
import Card from "../components/Card"
import PostsCell from "../components/PostsCell"

export default function Home() {
  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta name="description" content="Example t3 project from A First Look at create-t3-app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.appContainer}>
        <h1 className={styles.title}>
          Hello from <span className={styles.purple}>ajc</span>webdev
        </h1>

        <div className={styles.grid}>
          <Card name="Blog" url="ajcwebdev.com/" />
          <Card name="Twitter" url="twitter.com/ajcwebdev/" />
          <Card name="GitHub" url="github.com/ajcwebdev/" />
          <Card name="Polywork" url="poly.work/ajcwebdev/" />
        </div>

        <PostsCell />
      </main>
    </>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Развертывание в Vercel

Зафиксируйте текущие изменения и создайте новый репозиторий на GitHub с помощью GitHub CLI.

git add .
git commit -m "ct3a"
gh repo create ajcwebdev-t3 --public --push 
  --source=. 
  --description="An example T3 application with Next.js, Prisma, tRPC, and Tailwind deployed on Vercel." 
  --remote=upstream
Войти в полноэкранный режим Выйти из полноэкранного режима

Поскольку create-t3-app в основном состоит из Next.js и Prisma, его можно легко развернуть на таких платформах, как Vercel. Но в обмен на эту простоту использования вы получите снижение производительности при каждом запросе к базе данных.

Когда Prisma работает в функции Lambda, она имеет заметный холодный старт. В будущих руководствах в документации ct3a будет показано, как использовать такие платформы, как Fly, Railway и Render для развертывания проекта на долго работающем сервере.

Используйте следующую команду для передачи переменной окружения вашей базы данных и развертывания на Vercel. Используйте --confirm, чтобы дать ответ по умолчанию на каждый вопрос.

pnpm vercel --env DATABASE_URL=YOUR_DATABASE_URL_HERE
Войти в полноэкранный режим Выйти из полноэкранного режима

После первого развертывания эта команда развернет на ветку предварительного просмотра. Вам нужно будет включить --prod, чтобы перенести изменения непосредственно на живой сайт для последующих развертываний.

Откройте ajcwebdev-t3.vercel.app, чтобы увидеть свой блог.

Конечные точки API находятся в api/trpc/, поэтому ajcwebdev-t3.vercel.app/api/trpc/post.all отобразит все записи блога.

Или вы можете обратиться к конечной точке с помощью curl:

curl "https://ajcwebdev-t3.vercel.app/api/trpc/post.all" | npx json
Войти в полноэкранный режим Выход из полноэкранного режима
{
  "id": null,
  "result": {
    "type": "data",
    "data": {
      "json": [
        {
          "id": "1",
          "title": "A Blog Post Title",
          "description": "This is the description of a blog post",
          "body": "The body of the blog post is here. It is a very good blog post.",
          "createdAt": "2022-08-13T08:30:59.344Z"
        },
        {
          "id": "2",
          "title": "Second Blog Post",
          "description": "This is the description of ANOTHER blog post",
          "body": "Even better than the last!",
          "createdAt": "2022-08-13T08:36:59.790Z"
        },
        {
          "id": "3",
          "title": "The Final Blog Post",
          "description": "This is the description for my final blog post",
          "body": "My blogging career is over. This is the end, thank you.",
          "createdAt": "2022-08-13T08:40:32.133Z"
        }
      ],
      "meta": {
        "values": {...}
      }
    }
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Для отдельных постов блога попробуйте любой из следующих способов:

  • %7B%220%22%3A%7B%22json%22%3A%7B%22id%22%3A%221%22%7D%7D%7D
  • %7B%220%22%3A%7B%22json%22%3A%7B%22id%22%3A%222%22%7D%7D%7D
  • %7B%220%22%3A%7B%22json%22%3A%7B%22id%22%3A%223%22%7D%7D%7D

И скопируйте их в конец:

https://ajcwebdev-t3.vercel.app/api/trpc/post.byId?batch=1&input=

Проверьте PageSpeed Insights для Desktop здесь.

Если то, что я знаю об этих метриках, верно, то я полагаю, что 100 считается предпочтительным показателем по сравнению с другими показателями, которые не равны 100.

Проверьте PageSpeed Insights для мобильных устройств здесь.

Снова 100! Столь же предпочтительно!

Ресурсы

  • create.t3.gg
    • Repo
    • Документация
  • init.tips
    • Repo
    • Документация

Статьи

Дата Название
2022-08-10 Создание полностекового приложения с помощью create-t3-app
2022-07-10 Предложение по сквозному учебнику ct3a
2022-06-26 Стек T3 и мой самый популярный проект с открытым исходным кодом за всю историю
2022-05-21 Построение сквозных типобезопасных API с помощью tRPC

Видео

Дата Название
2022-07-17 Создание приложения для чата с помощью стека T3
2022-07-12 Стек T3 — как мы его создали
2022-07-10 Обзор создания приложения T3
2022-07-03 ЛУЧШИЙ стек для вашего следующего проекта
2022-06-28 Создание блога с помощью стека T3

Оцените статью
devanswers.ru
Добавить комментарий