Введение

Привет Хабр! Меня зовут Сергей и я фронтенд-разработчик. Уже несколько лет я использую 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), а также логику нахождения неиспользуемых стилей и скриптов на странице. В общем, реализовать ещё несколько хелперов.

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

Комментарии (8)


  1. isumix
    05.10.2025 09:04

    У меня тоже получилось сделать функциональные компоненты как в Реакте. А затем роутинг и реактивность, уместить на 1 экран кода.


  1. cpud47
    05.10.2025 09:04

    Напоминает вайб turbolinks.

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

    Кстати, интересно что конкретно не получилось сделать с vite. Потому что вообще говоря он для такого должен хорошо подойти.

    В целом, желание сделать без фреймворков похвально. Но, ИМХО, гораздо интереснее научиться делать кроссфреймворкные решения. Чтобы был компонент Button и его можно было использовать в react, vue, svelte, или в даже vanilla. И при этом, чтобы было не важно, а на каком фреймворке он написан(или вообще без фреймворка). Кажется это действительно дистилирует подход к вебу и фреймворки станут просто библиотеками.


    1. EvilEngeneer Автор
      05.10.2025 09:04

      Кстати, интересно что конкретно не получилось сделать с vite. 

      Честно говоря, уже не помню сути... Помню, что там внутренний сервер, который поднимается при разработке, работает совсем не так, как должен работать реальный сервер после деплоя (или как я изначально ожидал).
      Вроде еще были проблемы при предобработке html - файлов... Ну а может дело во врожденной кривизне рук )
      Насчет необходимости роутера.. Здесь вопрос, конечно, спорный. Я его сделал для возможности разработки именно приложений, в которых есть какое-либо сохраняемое состояние. Динамические роуты - здесь вопрос добавления нескольких регулярок и небольшой доработки. Я не стал с этим заморачиваться по причине отсутствия необходимости.
      Ну и js выполняется не мгновенно. До того момента как JS отработает и отрисуются наши веб-компоненты пройдет некоторое время. Поэтому могут быть мигания. Когда же в окне компоненты уже зарегистрированы - такой проблемы нет.



      1. cpud47
        05.10.2025 09:04

        Про проблему с vite, если вспомните, будет интересно почитать поподробнее.

        Ну и js выполняется не мгновенно. До того момента как JS отработает и отрисуются наши веб-компоненты пройдет некоторое время. Поэтому могут быть мигания.

        А Вы эти мигания на практике наблюдали? Потому что вообще говоря, если делать правильно, их быть не должно.

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

        Хм. Не могу придумать подходящий кейс. Обычно на сайтах всё кросстраничное состояние персистентно (либо на сервере, либо в условном localStorage). Можете привести пример состояния, на которое Вы делали расчёт.

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

        Тут аккуратнее. Там ещё вопросы приоритетов есть. Вообще, хороший роут матчер — это штука весьма нетривиальная.

        Плюс, не очень понятно, как это всё интегрировать с деплоем. То есть сейчас, у Вас initial page load делается в режиме html, няп. И можно просто закинуть весь фронт на cdn и оно просто будет работать. А если появляются динамические роуты, то нужно либо делать какой-то стаб для initial page load (что вредит времени загрузки), либо дублировать роутер в виде конфига для cdn...

        Ну и динамические роуты сейчас нужны плюс-минус постоянно. Даже для сайтов. Поэтому важный момент.

        P.S. на всякий случай: рассматривали для Ваших задач какой-нибудь Astro, или solid-start?


        1. EvilEngeneer Автор
          05.10.2025 09:04

          Ну и динамические роуты сейчас нужны плюс-минус постоянно. Даже для сайтов. Поэтому важный момент.

          Логично. Добавил пример. https://grigorenkosergey.github.io/native-SPA/pages/dynamic/12 Правда, реализовал на коленках, может что-то еще упустил.
          Может потом внесу UPD в статью.
          По приоритетам.. Если высчитывать совпадение с от наиболее специфичного к наименее специфичному, то особых проблем быть не должно.

          А Вы эти мигания на практике наблюдали? Потому что вообще говоря, если делать правильно, их быть не должно.

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

          Хм. Не могу придумать подходящий кейс. 

          Ну.. Мне тоже сходу в голову не приходит ) Но я уже говорил об этом, что
          "React со своим роутером нанес непоправимый урон моей психике". Потом проведу чуть более полное исследование этого вопроса.

          P.S. на всякий случай: рассматривали для Ваших задач какой-нибудь Astro, или solid-start?

          Нет. Посмотрю как-нибудь.


          1. zababurin
            05.10.2025 09:04

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

            Как избежать мелькания нестилизованного контента

            До появления Declarative Shadow DOM одним из распространенных методов предотвращения FOUC было применение правила стиля display:none к пользовательским элементам, которые еще не были загружены, поскольку у них не был прикреплен и заполнен их теневой корень. Таким образом, контент не отображается до тех пор, пока он не будет «готов»:

            <style>
              x-foo:not(:defined) > * {
                display: none;
              }
            </style>

            C декларативным теневой DOM

            <style>
              x-foo:not(:defined) > template[shadowrootmode] ~ *  {
                display: none;
              }
            </style>

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


  1. zababurin
    05.10.2025 09:04

    template.html лучше js файлом сделать.

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


    1. EvilEngeneer Автор
      05.10.2025 09:04

      Ну да, как вариант. Только потом TS прикрутить сложно будет без сборщика. Ну и я именно исходные htm/css файлы использовал для того, чтобы автодополнение, которое дает emmet из коробки работало безупречно. Например, такие штуки .parent>.child*5.