Введение

Привет Хабр! Меня зовут Сергей и я фронтенд-разработчик. Уже несколько лет я использую React как основную библиотеку. Связка React + MUI + Styled Components (MUI-версия) + Storybook + Playwright. Стейт-менеджеры не использую, поскольку они избыточны в большинстве случаев, и достаточно грамотного использования контекста.

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

React был выпущен в далёком 2013 году. В своей реализации он использовал компонентный подход, Virtual DOM и синтаксис JSX. На тот момент виртуальный DOM казался отличной альтернативой работе с реальным, достаточно медленным DOM. Да и синтаксис JSX тоже многим зашел.

Шли годы.

React оброс целой инфраструктурой в виде библиотек, без которой он нормально не функционирует (тот же ReactDOM), а джуны даже не помышляют о том, что его можно использовать без state-менеджера.

Единственное, что прошло испытание временем и не подвержено критике — это компонентный подход. Это — уменьшение сложности. Это мастхэв. Но это совсем не уникальная черта React.

Изначальная идея виртуального DOM (которую разрабы обязаны были нахваливать на собеседованиях) оказалась малоэффективной. Позже её заменили на технологию волокон (React Fiber). Но и это не особо улучшило ситуацию.

Синтаксис JSX привёл к появлению такого извращения современной разработки, как CSS-in-JS. И теперь мы снова пишем всё в одном файле: разметку, стили и логику... Пишем и радуемся... Однако принципы разделения ответственности тихонько плачут в углу.

CSS-in-JS, конечно, зашёл не всем. Многие используют CSS-модули (и правильно делают). Другие — реинкарнацию подхода Аtomic CSS, применяя Tailwind и т.п. Третьи... что-то другое.

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

Что мы видим сейчас?

Самые простенькие сайты (пардон... приложения) тормозят на ровном месте. Разрабы пихают в простые проекты лишние библиотеки, словно цыганка, обвешивающая себя побрякушками. Потом они меряются друг с другом размерами своих стеков.

То, что на фронте царит безумие, когда фреймворк на фреймворке и фреймворком погоняет, уже мало кого удивляет.

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

Да, есть много достойных альтернатив React (те же Vue.js, Svelte и т.п.). Но давайте попробуем без них пока. Это будет неплохой эксперимент. И мы узнаем больше, чем знаем теперь. Кроме того, вы же читали название статьи?

Здесь мы соберём свой минималистичный starter-kit. В статье я покажу, как это получилось у меня.

Вам же я предлагаю так же поэкспериментировать и попробовать реализовать свою версию. Знания, которые вы приобретёте при этом, останутся с вами навсегда. Навыки по работе с хайповыми библиотеками через пару лет превратятся в тыкву. Увеличить свой стек, чтобы гордо показать его HR, вы и так успеете. Интернет пестрит статьями об этом. Да и любой фреймворк с сопутствующими библиотеками изучить и пользоваться на весьма достойном уровне можно за 1-2 недели.

Технологии для starter-kit

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

Что нам может понадобиться?

Ну.. сборщик, например. Проект мы будем писать на чистом JS, но, давайте будем реалистами. TS - слишком удобная и классная штука.. Рано или поздно нам все-таки захочется его подключить. Сразу же и предусмотрим изменение проекта в этом направлении. Я выбрал webpack.

Что еще?

Что-то необходимое для качества кода, но без чего сборка и так запустится.

Фреймворк тестирования. Если вы не пользуетесь тестами - значит будете потом, когда ваш уровень чуть увеличится или проект перерастет сложность "Hello world". Можете выбрать любой. Я выбрал Playwright.

Линтер. Возьмем самый популярный - ESLint. Применим минимальную конфигурацию, которую можно потом расширять по своему вкусу.

Ну.. В общем все.

Для реализации компонентов используем пользовательские элементы.

Все остальное - сами. Все будет свое, домашнее...

Proof of concept

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

Здесь 3 внутренних страницы. На первой можете ввести любое сообщение, которое отобразится в списке. При переходе на другую страницу оно будет так же отображено. Реализовано это с помощью общего для приложения реактивного хранилища (рассмотрим во второй части, если она вообще будет и не заминусуют эту), которое хранится в общей для всех страниц памяти. К данному хранилищу подключен пользовательский компонент messages-section. Shadow DOM я не использовал, причины будут описаны в третьей части (если она будет).

Реализован роутинг (рассмотрим в этой статье). По ссылкам мы можем перемещаться в рамках открытой вкладки без пересоздания всего окна. На третьей странице показан общий паттерн синхронизации с URL при изменении поля ввода.

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

Вот полный список тестов (правда, необходимо поднять проект локально).

Обоснование необходимости роутинга

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

Сложности начинаются, когда страниц уже больше трёх. Да, пусть шаблон делается с помощью встроенного в большинство редакторов Emmet по нажатию "!", но и там нужно прописать дополнительную информацию в head. Вроде не особо большая проблема, но как-то утомительно...

Кроме того, при переходе по ссылкам каждая страница воссоздаётся заново. Допустим, мы предварительно получили какую-либо информацию о пользователе (например, права, роли и т.д.) и на основании этой информации мы отображаем пользовательский интерфейс. Что нам делать при переходе на другую страницу? Перезапрашивать данные? Не годится, это может быть дорогой операцией... Хранить их в каком-нибудь LocalStorage или IndexedDB? Можно и так. Но тогда потребуется реализовывать дополнительно логику валидации/обновления/удаления информации при перемещениях пользователя... Что, в общем-то, не так уж тривиально.

Да и забывать о FOUC (Flash Of Unstyled Content) не стоит. Когда страница грузится в первый раз - это не так раздражает, когда это постоянно происходит при перемещениях по страницам одного сайта. Реализовать роутинг в этом случае кажется более простым решением.

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

Общие принципы

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

Всё, что уникально для каждой страницы, мы поместим в тег main. Также в общем файле main.js мы поместим общую логику для всех страниц. К примеру, роутинг. При внутренних переходах мы будем попросту запрещать истинный переход и будем загружать соответствующие стили и JS-код.

Страницы мы поместим в папку pages. Удивлены? И я нет. Логично же. Каждая страница будет иметь такую структуру (естественно, вложенные страницы совсем необязательны):

src/pages └── page-1 ├── index.html ├── index.js ├── page-1-deeper-page │ ├── index.html │ ├── index.js │ └── style.css └── style.css

Доступные для всех страниц файлы мы поместим в директорию stores. Почему не shared? Ну.. На самом деле я ещё не определился. Просто мне кажется, что там ничего, кроме общих для проекта реактивных хранилищ, не должно быть.

Содержимое компонентов будет содержаться в папке src/components. Компонентами могут быть не только простые библиотечные элементы, но и даже целые части страниц... В общем, работать можно так же, как и с компонентами React.

Каждый компонент у нас должен содержать файлы template.htmlindex.jsstyle.css. Вот пример структуры:

src/components/chat-message/ ├── index.js ├── style.css └── template.html

Содержимое файла template.html импортируется сборщиком и будет храниться в виде строки непосредственно в файле index.js. Другие способы вставки шаблонов на страницу мне показались неудобными и сложно реализуемыми.

JS-код простейшего компонента будет выглядеть так.

import template from "./template.html";
import "./style.css";
import { initCustomElement } from "@/utils/customElementHelpers";

class SomeSimplestComponent extends HTMLElement {
  connectedCallback() {
    this.innerHTML = template;
  }
}

initCustomElement("some-simplest-component", SomeSimplestComponent);

В итоге мы получим компонентный подход + разделение ответственности (логики и отображения) из коробки. Плюсы очевидны. К примеру, мы можем предварительно поставить какого-нибудь сильного верстальщика (который часто сделает лучше, чем адепт CSS-in-JS) для первоначального создания разметки. И лишь потом подключить программиста для реализации логики. Да и вообще, как можно променять удобство редактирования CSS, когда при клике на стиле тебе автоматически открывается страница, где этот стиль определён, на подход CSS-IN-JS? Из минусов - при работе с веб-компонентами придётся использовать более императивную логику. И изучить DOM API (ужас, ужас...).

В данной статье мы рассмотрим один из вариантов настройки webpack + роутинга. Реализацию хранилищ и вебкомпонентов - в других. Все зависит от реакции на эту часть.

Грабли реализации

Webpack

Как это ни удивительно, но самым сложным в проекте оказалась настройка webpack. Изначально я вообще хотел сделать это с помощью Vite. Подумал, что заграница AI мне поможет... Ну, существуют же миллионы конфигов. Теоретически схема сборки довольно простая.. Нет. Мои попытки с Vite не увенчались успехом. Решил перейти на webpack. Но и тут AI слажал... Шаг в сторону, другой - инструмент становится бесполезным. Ну хоть примитивные функции генерирует. Пришлось самому настраивать.

В чём проблема применить описанный выше подход в webpack? Он генерирует общий бандл. Т.е. всё, что мы импортируем, он подставляет в виде непосредственно кода. Элементарный импорт модуля там не работает. Причём разделение на чанки особо не решает проблему... Забавно, но чтобы нормально подключить логику модулей и реализовать описанные выше принципы работы, мне потребовалось:

  • при прогоне страниц через HTMLWebpackPlugin включить scriptLoading: "module"(логично);

  • включить свойство externals (ну ОК);

  • добавить externalsType: "module" (ну хватит тебе, перестань...);

  • для файлов хранилищ, которые должны быть разделены между всеми страницами мы добавляем зависимость от чанка state-management (хмм...).

  • в output включить свойство library (да ты издеваешься...)

  • добавить experiments, в которых указать ouptuModule: true...

Мой примерный спектр эмоций на тот момент
Мой примерный спектр эмоций на тот момент

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

Отдельно пришлось разбираться с механизмом работы MiniCssExtractPlugin, чтобы стили автоматически обновлялись при изменениях во время разработки. Стандартный подход с инлайновой вставкой не работал (style-loader), т.к. сложно поменять стили при переходах по страницам. Даже элементарной вставки атрибутов в теги на основании положения в файловой структуре там не предусмотрено.

Роутинг

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

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

  • Как нам отключить переходы по внутренним ссылкам в приложении, но чтобы URL при этом менялся?

  • Как нам определить, для каких именно ссылок мы будем блокировать действия браузера по умолчанию?

Для ответа на первый вопрос мы могли бы попросту блокировать действия браузера по умолчанию и использовать History API. Можно, к примеру, переписать функцию window.history.pushState. То есть сделать простенький декоратор. Можно применить чуть-чуть магии и добавить проксирование этой функции. Здесь я решил надеть робу белого мага и воззвать к силам Proxy и Reflect.

window.history.pushState = new Proxy(window.history.pushState, {
  async apply(...args) {
    const url = args[2][2];

    const newPathname = new URL(url).pathname;
    const oldPathName = new URL(window.location.href).pathname;
    if (newPathname !== oldPathName) buildPage(url);

    return Reflect.apply(...args);
  },
});

На самом деле, нет особой разницы.

Для решения второго вопроса мы можем воспользоваться приемом "поведение". Давайте добавим таким ссылкам специальный атрибут data-inner-link. Получится примерно следующее:

document.addEventListener("click", async event => {
  const isInnerLink = "innerLink" in event.target.dataset;
  if (!isInnerLink) return;

  event.preventDefault();

  const link = event.target;

  const currentHref = window.location.href;
  const newHref = new URL(link.href, window.location.href).href;

  if (newHref !== currentHref) {
    window.history.pushState(null, "", newHref);
  }
});

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

window.addEventListener("popstate", event => {
  const { href } = event.target.location;
  buildPage(href);
});

Теперь нам потребуется реализовать функцию buildPage. Собственно, здесь особо ничего сложного и нет. Помним, что у нас каждая HTML-страница самодостаточна, но там есть общий код, который менять не стоит. Поэтому мы загрузим её HTML, заменим только body, применим недостающие скрипты и стили.

async function buildPage(url) {
  const pageTemplateUrl = url + "index.html";

  const response = await fetch(pageTemplateUrl);
  const template = await response.text();

  const newDocument = new DOMParser().parseFromString(template, "text/html");

  addHeadStylesheets(newDocument);
  addHeadScripts(newDocument);
  document.body.replaceWith(newDocument.body);

  applyPageLogic(url);
}

Остается вопрос, что здесь делает функция applyPageLogic?

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

Я решил этот вопрос так. Мы экспортируем ту часть скрипта, которая должна быть выполнена, с помощью export default. Что-то вроде этого:

const logic = () => {
  const h1 = document.querySelector("h1");
  h1.textContent = "Винни Пух";

  // ... more logic

  return () => {
    // ...some cleanup logic
  };
};

export default logic;

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

При переходах будем вызывать функцию очистки (которую будем хранить в замыкании). Функции для логики каждой страницы мы будем хранить (кешировать) в объекте, где ключ - адрес страницы, значение - функция с логикой страницы.

const pageLogics = {};
let cleanup;

async function applyPageLogic(url) {
  cleanup?.();

  const pageScriptElement = getPageScript(url);
  const scriptKey = pageScriptElement.src;
  if (scriptKey in pageLogics) return pageLogics[scriptKey]?.();

  const logic = (await import(/* webpackIgnore: true */ scriptKey)).default;
  pageLogics[scriptKey] = logic;
  cleanup = pageLogics[scriptKey]?.();
}

Далее останется только добавить дополнительную логику для первоначального захода на страницу (я использовал событие DOMContentLoaded), а также логику нахождения неиспользуемых стилей и скриптов на странице. В общем, реализовать ещё несколько хелперов.

Вот полный код роутинга. Вроде бы простой, как палка. При желании любой может поправить под свой проект.