Обложка: Создаём своё первое приложение на Sapper.js

Создаём своё первое приложение на Sapper.js

Михаил Шпаков
Михаил Шпаков

ведущий фронтенд-разработчик в Timeweb

В этой статье будет показан подход к созданию современного приложения с помощью SSR-фреймфорка Sapper.js.

Чтобы сделать материал понятным для разработчиков с разным уровнем подготовки, в нашем приложении мы не будем использовать TypeScript, HTML-шаблонизатор, кастомные шрифты, не будем писать тесты и даже не станем настраивать линтер.

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

В этой статье мы сосредоточимся на создании простого приложения, которое поможет продемонстрировать базовые принципы работы Sapper.

В качестве REST API мы будем использовать ресурс JSON Placeholder, который отлично подходит для быстрого тестирования и прототипирования.

Для тех, кто хочет сразу увидеть финальный результат:

Немного о Sapper.js

Sapper.js — это высокоуровневый SSR фреймворк, построенный на основе Svelte.js, который позволяет разрабатывать веб-приложения, абстрагируя детали распределения серверного и клиентского кода, поэтому вы можете сосредоточиться на разработке приложений.

Основные преимущества при использовании Sapper:

  • SSR и пререндер уже настроены, всё что требуется сделать — выбрать нужный режим при запуске.
  • Отличный SEO для всех поисковых систем, как следствие использования SSR или пререндера.
  • Быстрое взаимодействие с сайтом, в сравнении со статическими сайтами, за счет подгрузки только необходимых js chunks, css styles и API запросов (большую часть этого процесса автоматизирует webpack или rollup) и за счёт оптимизации приложения на этапе компиляции.
  • Отличные показатели Google Lighthouse/Page Speed при правильной настройке, возможность получить 100/100 даже на слабом сервере.
  • Возможность использования любых пакетов из экосистемы Svelte.js.

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

Несмотря на огромный потенциал Sapper.js на текущий момент он находится в стадии беты, и некоторые вещи могут поменяться, когда он дойдёт до первого стабильного релиза. Для этой статьи используется версия Sapper 0.28.10.

Дизайн

При наличии готового дизайна процесс разработки приложения сильно ускоряется, ведь уже не надо думать над тем, что и как должно быть. Ещё лучше, когда существует не просто дизайн, а UI-кит.

Для этой статьи был подготовлен дизайн приложения с минимальной функциональностью, достаточной для демонстрации возможностей Sapper.js.

Для разработки была использована Figma, дизайн и UI-кит доступны по ссылке. Вы можете скопировать этот шаблон и использовать его в своём проекте.

Создание проекта

К сожалению, в отличие от более взрослых коллег, таких как Nuxt.js или Next.js, Sapper не имеет CLI-утилиты для конфигурации необходимого шаблона.

Самый простой способ начать создавать приложение Sapper — скопировать к себе на компьютер репозиторий шаблона при помощи утилиты degit. Давайте так и сделаем:

npx degit "sveltejs/sapper-template#rollup" sapper-placeholder
# или: npx degit "sveltejs/sapper-template#webpack" my-app

Примечание Создатели Sapper предлагают нам два варианта стартового шаблона с разным сборщиком: rollup или webpack. В целом webpack является более зрелым решением, но в нашем случае мы будем использовать rollup, так как он проще в настройке и для данного проекта его возможностей будет достаточно.

После скачивания шаблона давайте перейдём в созданную директорию и установим зависимости:

cd sapper-placeholder
npm i

Теперь мы можем запустить наше приложение, используя команду npm run dev. После этого оно будет доступно на localhost:3000.

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

По умолчанию в стартовом шаблоне Sapper реализована структура директорий и файлов, подходящая для быстрого старта разработки.

├ src
│ ├ components
│ │ ├ # Здесь компоненты
│ ├ routes
│ │ ├ # Здесь маршруты
│ │ ├ _layout.svelte
│ │ ├ _error.svelte
│ │ └ index.svelte
│ ├ client.js
│ ├ server.js
│ ├ service-worker.js
│ └ template.html
├ static
│ ├ # Здесь картинки и прочая статика
├ rollup.config.js
└ package.json

Для нашего проекта такая структура подходит как нельзя лучше, поэтому мы не будем её менять.

Подробнее о предназначении разных директорий вы можете прочитать в репозитории шаблона.

Настройка SCSS

В этом проекте мы хотим переиспользовать стили, заданные в нашем UI-ките, поэтому нам потребуется препроцессор, который упростит наше взаимодействие с CSS.

В своих проектах я отдаю предпочтение препроцессору SCSS, поэтому давайте настроим rollup для его использования.

Первое, что нам требуется сделать, это установить необходимые для работы пакеты:

npm i -D svelte-preprocess autoprefixer node-sass postcss

После этого давайте внесём изменения в rollup.config.js.

Первым делом добавим в начало файла импорт препроцессора:

import sveltePreprocess from 'svelte-preprocess';

Затем создадим сразу после блока с импортами переменную с конфигурацией SCSS, чтобы у нас появилась возможность ссылаться на неё из серверного и клиентского блоков опций rollup:

const preprocess = sveltePreprocess({
  scss: {
    includePaths: ['src'],
  },
  postcss: {
    plugins: [require('autoprefixer')],
  },
});

Теперь всё, что нам остаётся сделать, это добавить вызов этой переменной в оба блока:

export default {
  client: {
    plugins: [
      svelte({
        // ...
        preprocess, // <-- Добавим сюда
      }),
  },
  server: {
    plugins: [
      svelte({
       // ...
        preprocess, // <-- Сюда тоже
      }),
    ],
  },
};

Отлично, теперь нам стало доступно использование атрибута lang для тега style:

<style lang="scss"></style>

Переиспользуемые стили

Так как в нашем проекте все используемые стили описаны единым набором правил, что существенно облегчает разработку, то давайте сразу перенесём их из Figma в файлы проекта.

В директории static создадим поддиректорию styles, в которой будем хранить все переиспользуемые стили в проекте. В нашем случае у нас небольшой проект и мы можем ограничиться всего тремя файлами:

  1. variables.scss — будет содержать все SCSS-переменные, используемые в проекте.
  2. typography.scss — будет содержать все классы помощники для текста и ссылок. Обратите внимание, что эти классы хелперы меняют стили в зависимости от разрешения используемого пользователем устройства: смартфона или ПК.
  3. index.scss — общий файл, который будет использован как единая точка экспорта всех глобальных классов и переменных.

Директория на GitHub.

Теперь мы должны подключить эти переменные и классы в проект таким образом, чтобы они были доступны глобально в любом нашем компоненте. Для этого требуется немного изменить переменную preprocess в rollup.config.js:

const preprocess = sveltePreprocess({
	scss: {
		includePaths: ['src', 'static/styles'], // <-- Добавим в массив 'static/styles'
		prependData: `@import 'index.scss';`, // <-- Добавим эту строку
	},
	postcss: {
		plugins: [require('autoprefixer')],
	},
});

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

Последнее, что нам остаётся сделать, это указать ключевое слово global в секции style файла _layout.svelte:

<style lang="scss" global></style>

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

Решить эту проблему можно немного изменив конфигурацию переменной onwarn в rollup.config.js:

const onwarn = (warning, onwarn) =>
	(warning.code !== "css-unused-selector") || <-- Добавим эту строку
	(warning.code === 'MISSING_EXPORT' && /'preload'/.test(warning.message)) ||
	(warning.code === 'CIRCULAR_DEPENDENCY' && /[/\\]@sapper[/\\]/.test(warning.message)) ||
	onwarn(warning);

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

Перед тем, как переходить к следующему этапу, давайте также удалим файл global.css, который был скачан вместе с шаблоном, из директории static и удалим ссылку для его загрузки из файла src/template.html.

Отлично, мы готовы двигаться дальше.

Компоненты

Компоненты — это те кирпичики, из которых будет состоять наше приложение.

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

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

Обратите внимание, что при создании компонентов и страниц будут использованы служебные CSS-классы, такие как h1, body1, body2, medium, которые мы создали ранее в файле typography.scss.

PageHeader

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

Компонент будет иметь следующий вид:

<script>
  export let title;
  export let subtitle;
</script>

<div class="ph">
  <h1 class="h1 ph__title">{@html title}</h1>
  <p class="body2 ph__subtitle">{@html subtitle}</p>
</div>

Как мы видим, этот компонент является простой обёрткой для отображаемых данных и не содержит никакой логики.

Обратите внимание, что для интерполяции данных мы используем специальный тег @html, так как в дальнейшем нам понадобится передавать через входные параметры не только текст, но и ссылки на другие страницы и ресурсы, то есть фрагменты HTML.

PageFooter

Компонент футера очень простой. Он содержит ссылку на репозиторий и условный copyright.

Давайте посмотрим на шаблон компонента:

<div class="pf body2">
  <a
      href="https://github.com/mikhail-shpakov/sapper-placeholder"
      target="_blank"
      rel="noopener noreferrer">
    Проект на Github
  </a>
  <p class="body2 pf__subtitle">
    {new Date().getUTCFullYear()}
    · Команда Timeweb для Tproger.ru
  </p>
</div>

Так как в этом компоненте не содержится никакой логики, то секция script будет отсутствовать.

UserCard

Информация о каждом пользователе будет отрисовываться в своём собственном компоненте. Давайте посмотрим, как будет выглядеть этот компонент:

<script>
  import UserCardInfo from "./UserCardInfo.svelte";

  export let user;
</script>

<div class="uc">
  <p class="uc__name body1 medium">{user.name}</p>

  <UserCardInfo icon="city" info={user.address.city}/>
  <UserCardInfo icon="site" info={user.website}/>
  <UserCardInfo icon="email" info={user.email}/>

  <a
      class="uc__link"
      href="/user/{user.id}">
    Смотреть посты пользователя
  </a>
</div>

Этот компонент принимает входной параметр user, который содержит всю информацию о пользователе, полученную с JSON Placeholder, и дальше просто отображает её в шаблоне.

В карточке пользователя мы должны будем показать три иконки: city, site и email. Давайте создадим поддиректорию icons в директории static и добавим туда эти иконки, скачав их из нашего макета в Figma.

Вы, должно быть, обратили внимание, что для отображения части информации мы используем компонент UserCardInfo, который ещё не создали. Давайте исправим это.

UserCardInfo

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

<script>
  export let icon;
  export let info;

  let iconPath = `icons/${icon}.svg`
</script>

<div class="uci">
  <img
      src={iconPath}
      alt={icon}
      loading="lazy">
  <p class="uci__info">{info}</p>
</div>

Обратите внимание, что для тега img указан атрибут loading, который реализует ленивую загрузку изображений, но на данный момент имеет плохую поддержку в браузерах.

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

Такую функциональность можно реализовать как самому, так и воспользоваться уже готовым решением.

UserPost

Последний компонент, который нам осталось реализовать, это компонент для отображения конкретного поста пользователя.

Он будет выглядеть следующим образом:

<script>
  export let post;
</script>

<div class="up">
  <p class="up__title body1 medium">{post.title}</p>
  <p class="up__body body2">{post.body}</p>
</div>

Теперь, когда все наши компоненты готовы, мы можем начать собирать из них страницы.

Страницы

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

Давайте удалим те страницы, которые были получены нами из стартового шаблона, и приведём структуру директории routes к следующему виду:

├ routes
│ ├ user
│ │ └ [id].svelte
│ ├ _layout.svelte
│ ├ _error.svelte
│ └ index.svelte

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

Увидеть полный код страниц можно на GitHub.

Главная страница со списком пользователей

Давайте начнём с секции script главной страницы:

<script>
  import {onMount} from 'svelte'
  import PageHeader from "../components/PageHeader.svelte";
  import UserCard from "../components/UserCard.svelte";

  let users = []

  onMount(async () => {
    try {
      const res = await fetch(`https://jsonplaceholder.typicode.com/users`);
      users = await res.json();
    } catch (e) {
      alert(`Произошла ошибка при загрузке пользователей: ${e}`)
    }
  })
</script>

Разберёмся, что здесь происходит.

Сперва мы инициализируем переменную users, в которой будем хранить список наших пользователей. Дальше в хуке onMount мы делаем AJAX-запрос для получения наших пользователей и записываем полученные данные в users. Чтобы не устанавливать дополнительные библиотеки, мы используем нативную функцию fetch.

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

Теперь давайте взглянем на шаблон нашей страницы:

<svelte:head>
  <title>Первое приложение на Sapper.js</title>
</svelte:head>

<PageHeader
    title="Sapper.js"
    subtitle="Создаём своё первое приложение с помощью
      <a href='https://jsonplaceholder.typicode.com/' target='_blank' rel='noopener noreferrer'>
        JSON Placeholder
      </a>"
/>

<div class="users">
  {#each users as user}
    <UserCard user={user}/>
  {/each}
</div>

Здесь мы сперва используем служебный тег <svelte:head> для установки заголовка страницы, а затем вызываем ранее созданные компоненты PageHeader и UserCard, передавая в них данные через входные параметры.

При этом при вызове компонента UserCard мы используем директиву #each, для итерирования элементов массива users.

Страница со списком постов пользователя

Вторая страница, которую нам требуется реализовать, это страница со списком постов пользователя. Эта страница будет иметь динамический параметр, которым будет являться id пользователя.

В Sapper динамические параметры задаются при помощи квадратных скобок […]. В нашем случае мы хотим, чтобы посты пользователя были доступны по URL /user/[id], поэтому нам требуется создать в директории routes поддиректорию user и добавить в неё файл [id].svelte.

Давайте посмотрим на секцию script этого файла:

<script context="module">
  export async function preload({params}) {
    try {
      const res = await this.fetch(`https://jsonplaceholder.typicode.com/posts?userId=${params.id}`);
      const posts = await res.json();
      return {posts}
    } catch (e) {
      this.error(e);
    }
  }
</script>

<script>
  import UserPost from "../../components/UserPost.svelte";
  import PageHeader from "../../components/PageHeader.svelte";

  export let posts;
</script>

Как можно заметить, в данном случае у нас две секции script. Со второй должно быть всё понятно, это привычная нам секция. Здесь мы просто указываем входной параметр posts, из которого будем получать список постов.

Для первой секции script мы добавили атрибут context="module", а внутри вызвали функцию preload (подробнее здесь). Эта конструкция позволяет нам выполнить вызов функции на сервере до создания экземпляра компонента, а полученные данные передать в компонент в виде входного параметра.

В браузере мы можем использовать fetch для выполнения AJAX-запросов, но на сервере есть некоторые ограничения для этого (подробнее здесь), поэтому разработчики Sapper позаботились об этом и предоставили нам функцию this.fetch, которая работает аналогично fetch в браузере.

Ещё одна полезная функция для работы на стороне сервера от разработчиков Sapper — это this.error. Подробнее о ней можно прочитать здесь.

Теперь давайте посмотрим на шаблон нашей страницы:

<svelte:head>
  <title>Первое приложение на Sapper.js</title>
</svelte:head>

<PageHeader
    title="Все посты"
    subtitle="<a href='/'>Вернуться на главную</a>"/>

<div class="posts">
  {#each posts as post}
    <UserPost post={post}/>
  {/each}
</div>

Здесь, подобно тому, как это реализовано на главной странице, мы сперва указываем заголовок страницы, а затем отрисовываем компоненты PageHeader и UserPost, передавая в них данные через входные параметры.

_layout.svelte

В Sapper есть возможность создания компонента-обёртки над страницами, которая позволяет переиспользовать между ними общие компоненты и реализовывать необходимую общую логику.

Давайте немного изменим полученный из стартового шаблона файл и приведём его к следующему виду:

<script>
  import PageFooter from "../components/PageFooter.svelte";
</script>

<main>
  <slot/>
  <PageFooter/>
</main>

Как вы видите, на всех страницах мы будем автоматически добавлять футер.

_error.svelte

По умолчанию при любой ошибке, возвращённой с сервера в статусе HTTP Sapper делает редирект на routes/_error.vue и передаёт входные параметры status и error с описанием полученной ошибки.

В данном приложении нас устроит логика и внешний вид этой страницы, поэтому мы не будем её менять.

Деплой на Hostman

Теперь, когда наше приложение готово, самое время заняться его деплоем.

Для этой задачи я буду использовать облачную платформу Hostman, которая позволяет автоматизировать процесс деплоя и максимально его упростить.

Наше приложение будет статичным сайтом, так как в Hostman для статичных сайтов доступен бесплатный тариф без ограничений по производительности, объёму трафика или времени использования.

Всё, что требуется сделать для публикации, это нажать в интерфейсе платформы кнопку Create, выбрать бесплатный тариф и подключить наш GitHub-репозиторий, указав необходимые опции для деплоя.

Сразу после этого автоматически будет запущен процесс публикации и создан бесплатный домен в зоне *.hostman.site с установленным SSL-сертификатом от Let’s Encrypt.

И теперь при каждом новом пуше в выбранную ветку (master по-умолчанию) также будет выполняться деплой новой версии приложения. Очень просто и удобно.

Заключение

В этой статье мы разобрали, как можно создать простое SSR-приложение с помощью Sapper.js, и использовали при этом лишь часть всех его возможностей.

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

Ссылки на финальный результат:

Хинт для программистов: если зарегистрируетесь на соревнования Huawei Cup, то бесплатно получите доступ к онлайн-школе для участников. Можно прокачаться по разным навыкам и выиграть призы в самом соревновании.

Перейти к регистрации