create-t3-app
— это fullstack React framework и CLI, который появился как развитие стека T3, рекомендованного на сайте Тео Брауна init.tips
. Создатели описывают его как «своего рода шаблон», что должно подчеркнуть, что это «НЕ шаблон».
- Конспект
- Введение
- История стека t3
- Создать приложение Nex
- Создать t3-приложение
- Структура проекта
- Обеспечение базы данных PostgreSQL
- Добавление модели Posts в схему Prisma
- Установка CLI и инициализация проекта Railway
- Запуск миграции базы данных
- Создание записи в блоге
- Запрос постов с помощью tRPC
- Создание маршрутизатора Post Router
- Запрос постов с помощью UseQuery
- Добавление ячеек для декларативной выборки данных
- Создание ячейки запроса по умолчанию
- Создание страницы поста
- Создать ячейку для постов
- Развертывание в Vercel
- Ресурсы
- Статьи
- Видео
Конспект
- Введение
- История стека 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 |