Fresh позиционируется как современный фреймворк на TS/JS, спроектированный для легкого создания высококачественных и производительных веб-приложений.
Кратко об особенностях:
Отсутствие сборки!
Preact для описания UI.
Островная архитектура.
0 кб JS поставляется на клиент по умолчанию.
TS из коробки.
JIT-рендеринг на сервере.
File-system роутинг.
Zero-конфиг.
По заявлениям разработчиков, при проектировании Фреша делался акцент на следующих принципах:
Максимально быстрая загрузка страницы.
Количество работы на клиентской части сведено к минимум.
Ошибки должны затрагивать как можно меньшую часть системы. Graceful degradation.
Ну и давайте к примерам. Инициализируем проект:
deno run -A -r https://fresh.deno.dev my-project
Deno загрузит пакеты, спросит хотите ли использовать twind и затем сгенерирует проект с 3 роутами и одним островом (island). У меня это выглядело так:
deno.json: частично покрывает функциональность package.json из node.js. В данном случае сгенерировался с командой для запуска приложения (
deno task start)
и указанием где искать файл с картами импортов.import_map.json: собственно, сама карта импортов. Про это как-то уже писал статью.
dev.ts: точка входа для режима разработки. Именно его запускает команда
deno task start
.main.ts: точка входа для production.
fresh.gen.ts: содержит описание роутов и "островов" проекта. Генерируется автоматически на основе директорий routes и islands. Наряду с обычными файлами проекта так же должен быть в git.
/islands: содержит все интерактивные элементы проекта. Код содержащий в этой директории может быть запущен как на клиенте, так и на сервере.
/routes: думаю, из названия понятно, что содержимое директории отвечает за роутинг в проекте. Компоненты из этой директории не будут напрямую передаваться на клиентскую часть.
/static: просто статичный контент.
Routing
Система роутов основана на файлах и их расположении относительно директории routes (file based routing). Подход не новый, в том же Next.js подобное уже давно есть.
URL-паттерн строится по следующей логике:
Расширения файлов игнорируются.
Обычные названия файлов/директорий обрабатывают статичную часть пути.
Динамический сегмент пути указывается через
[
и]
.Динамический суффикс пути описывается через завершающую
[...path]
.
Путь к файлу | Обрабатываемый паттерн | Обрабатываемый URL |
index.ts | / | / |
about.tsx | /about | /about |
blog/index.tsx | /blog | /blog |
blog/[slug].ts | /blog/:slug | /blog/foo |
blog/[slug]/comments.ts | /blog/:slug/comments | /blog/bar/comments |
blog/[...path].ts | /blog/:path* | /blog/test |
Роуты описывают как должен быть обработан запрос и что должно быть отправлено в ответ. Поэтому роуты состоят из двух частей: обработчиков (handlers) и компонентов (components). Роут может содержать одну из частей или обе.
Пример простого роута в файле /routes/post/[id].tsx
описывающего вывод страницы по адресу /post/:id
/** @jsx h */
import { h } from "preact";
import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { client } from "../../db/index.ts"; // опустим детали подключения к СУБД. В данном примере используется https://deno.land/x/postgres
interface Post {
id: number;
author: string;
title: string;
text: string;
}
export const handler: Handlers = {
async GET(_req: Request, ctx: HandlerContext) {
const { id } = ctx.params; // id – строка. И похоже пока нет встроенных средств приведения к нужным типам (наподобие id:i)
const { rows } = await client.queryObject<Post>({
text: "select * from posts where id = $1",
args: [id],
});
const resp = await ctx.render(rows[0]);
resp.headers.set("X-Custom-Header", "custom value"); // пример кастомного хедера
return resp;
},
};
export default function PostPage(props: PageProps<Post>) {
const { data } = props;
if (!data) {
return <div>Post not found</div>;
}
return (
<article>
<h1>{data.title}</h1>
<div>{data.text}</div>
<footer>Author: {data.author}</footer>
</article>
);
}
И тут начинаются первые странности. Я так до конца и не понял, почему нужно именно так экспортировать Preact. Но смешно то, что если убрать эти строки, на странице будет отображаться ошибка о подключении к React:
Обработчик определяется через экспорт объекта handlers
. Обработчик может представлять из себя функцию или объект. Если он определён как объект, как в нашем случае, то каждый ключ в объекте должен соответствовать обрабатываемому HTTP-методу. Если для HTTP-запроса не окажется соответствующего ключа-обработчика, то вернётся ошибка 405.
Далее видно, что информацию о текущем id статьи мы может получить из параметров контекста запроса (ctx.params). Из контекста запроса мы и вызываем темплейт через ctx.render
описанный через компонент на Preact, передавая ему найденную (или нет) статью и возвращая результат из обработчика GET.
Если вы знакомы с [p]react, то вам уже понятно, как с этим работать.
Фреш передаёт в props
компонента поле data
содержащее нашу статью, ну и дальше обычная работа с JSX.
Вообще, чтобы отправить ответ, нам не обязательно писать для этого компонент. ctx.render
возвращает стандартный тип Response, объект которого мы можем получить напрямую через конструктор. Например:
export const handler: Handlers = {
async GET(_req: Request, ctx: HandlerContext) {
const { id } = ctx.params;
const { rows } = await client.queryObject<Post>({
text: "select * from posts where id = $1",
args: [id],
});
if (rows.length === 0) {
return new Response(undefined, { status: 404 });
}
return new Response(JSON.stringify(rows[0]));
},
};
Этот вариант прекрасно подходит для создания HTTP API.
Middleware
Промежуточные обработчики объявляются в файле _middleware.ts
.
Как и другие миддлвари, тут так же можно модифицировать ответы. Каждый обработчик через контекст-аргумент получает функцию next
для вызова последующих обработчиков. В контексте запроса также есть поле state,
для пробрасывания промежуточной информации дальше.
Пример миддлвари:
import { MiddlewareHandlerContext } from "$fresh/server.ts";
interface State {
data: string;
}
export async function handler(
_req: Request,
ctx: MiddlewareHandlerContext<State>
) {
ctx.state.data = "some data"; // запись в state для последующей обработки в роутах
const resp = await ctx.next();
resp.headers.set("x-custom-header", "value"); // модифицируем ответ
return resp;
}
Middleware может быть разбито по слоям и для разных роутов могут быть разные middleware-обработчики. Например, возьмём проект со следующим роутингом:
routes/_middleware.ts
routes/index.ts
routes/admin/_middleware.ts
routes/admin/index.ts
Для запроса по адресу /
логика обработки будет такой:
Вызывается
routes/_middleware.ts
.После выхова
ctx.next()
будет вызван роутroutes/index.ts
.
А для /admin
:
Как и раньше, вызов
routes/_middleware.ts
.Дальше, вызов
ctx.next()
вызовет ужеroutes/admin/_middleware.ts
middleware.И последующий
ctx.next()
уже дойдёт до обработчикаroutes/admin/index.ts
.
Islands
Острова отвечают за интерактивность на странице и представляют из себя изолированные Preact-компоненты. Они располагаются в директории /islands
проекта. Имя файла должно быть формате PascalCase и должно содержать export default
.
Остров может без проблем использоваться в компоненте роута, а Фреш сам позаботится об его регидратации на клиенте.
При инициализации проекта был создан один остров в islands/Counter.tsx
.
/** @jsx h */
import { h } from "preact";
import { useState } from "preact/hooks";
import { IS_BROWSER } from "$fresh/runtime.ts";
import { tw } from "@twind";
interface CounterProps {
start: number;
}
export default function Counter(props: CounterProps) {
const [count, setCount] = useState(props.start);
const btn = tw`px-2 py-1 border(gray-100 1) hover:bg-gray-200`;
return (
<div class={tw`flex gap-2 w-full`}>
<p class={tw`flex-grow-1 font-bold text-xl`}>{count}</p>
<button
class={btn}
onClick={() => setCount(count - 1)}
disabled={!IS_BROWSER}
>
-1
</button>
<button
class={btn}
onClick={() => setCount(count + 1)}
disabled={!IS_BROWSER}
>
+1
</button>
</div>
);
}
Он ни чем не отличается от обычного preact-компонента, но есть один нюанс. В качестве props
могут передаваться только типы, которые поддерживаются при сериализации JSON. Сложные объекты типа Date
, функций, классов или тех же children
Фреш пока не умеет передавать, а это означает, что вложенные острова тоже не поддерживаются.
Обработка ошибок
Для HTTP-ошибок 404 и 500 есть специальные роуты _404.tsx
и _500.tsx
соответственно.
Пример ошибки 500:
/** @jsx h */
import { h } from "preact";
import { ErrorPageProps } from "$fresh/server.ts";
export default function Error500Page({ error }: ErrorPageProps) {
return <p>500 internal error: {(error as Error).message}</p>;
}
Резюме
Как бы я ни радовался развитию конкуренции с Node.js и его фреймворками, Fresh пока не вызвал однозначно положительных эмоций.
С одной стороны, отсутствие сборки, есть SSR из коробки (SEO-шники будут довольны), приятная система роутинга, отлично прописаны типы. И это всё прекрасно интегрируется со стандартными веб-технологиями, использование того же Response, реализованного в Deno, это просто отлично.
Но с другой – отсутствие альтернатив Preact, не ясно как управлять сборкой JS и CSS, нет Source Maps, нет интеграций с СУБД.
Пока слишком выпирает коммерческое ориентирование проекта, будто его делали под себя, для своих сервисов, того же разрекламленного Deno Deploy (хоть и не могу ничего плохо про него сказать) и попытались скорее релизнуть.
В общем, ждём 1.х/2.0, ибо пока с тем же Next.js тягаться сложно.