Pull to refresh

Fresh – новый full stack фреймворк для Deno

Reading time6 min
Views7.1K

Fresh позиционируется как современный фреймворк на TS/JS, спроектированный для легкого создания высококачественных и производительных веб-приложений.

Кратко об особенностях:

  1. Отсутствие сборки!

  2. Preact для описания UI.

  3. Островная архитектура.

  4. 0 кб JS поставляется на клиент по умолчанию.

  5. TS из коробки.

  6. JIT-рендеринг на сервере.

  7. File-system роутинг.

  8. 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/bar

blog/[slug]/comments.ts

/blog/:slug/comments

/blog/bar/comments
/blog/test/comments

blog/[...path].ts

/blog/:path*

/blog/test
/blog/test/bar
/blog/test/bar/foo

Роуты описывают как должен быть обработан запрос и что должно быть отправлено в ответ. Поэтому роуты состоят из двух частей: обработчиков (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

Для запроса по адресу / логика обработки будет такой:

  1. Вызывается routes/_middleware.ts .

  2. После выхова ctx.next() будет вызван роут routes/index.ts.

А для /admin:

  1. Как и раньше, вызов routes/_middleware.ts.

  2. Дальше, вызов ctx.next() вызовет уже routes/admin/_middleware.ts middleware.

  3. И последующий 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 тягаться сложно.

Tags:
Hubs:
Total votes 8: ↑7 and ↓1+9
Comments7

Articles