Размышления об архитектуре SolidJS приложений

Vladislav Lipatov
5 min readJul 12, 2023

--

Предисловие

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

SolidJS даёт нам много возможностей писать код, поскольку компоненты в SolidJS — это всего лишь способ группировки кода, всего лишь функции. Состояние не привязано к “компонентам”, поэтому этот термин в SolidJS носит скорее формальный характер. Такая свобода иногда может сбивать с толку, потому что можно написать одно и то же по-разному, и всё будет работать. Кроме того, было бы здорово сохранять быстрый темп разработки новой функциональности и при этом избежать усложнения архитектуры со временем, чтобы код было легко поддерживать. При разработке приложения на SolidJS у нас могут возникнуть вопросы:

  • Стоит ли оставлять логику внутри “компонентов” или выносить её наружу?
  • Как разделить функциональность таким образом, чтобы код было легко поддерживать?
  • Как не запутаться в зависимостях derived сигналов?
  • Когда инициализировать определённую функциональность?
  • Как легко масштабироваться в будущем, добавляя новую функциональность?
  • Как поддерживать единую структуру на проекте, чтобы разработчикам было сложнее делать ошибки?
  • Как избежать циклов в графе зависимостей сигналов?

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

Выстраиваем архитектуру

Конечная цель

Давайте рассмотрим пример приложения, в котором реализованы n различных функциональностей.

Скорее всего, мы хотим, чтобы наше приложение выглядело так:

Structured architecture

А не вот так:

Spaghetti code

В случае с ацикличным графом мы можем легко проследить зависимости конкретной функциональности.

Особенности SolidJS

В SolidJS мы можем отделить логику от представления (view) с помощью, например, создания глобального стейта (createRoot) или с помощью переноса этой логики в отдельный компонент, который не занимается вёрсткой. Что же нам выбрать?

Прежде чем посмотреть на оба варианта, следует учесть некоторые особенности разработки на SolidJS. В приложении мы используем состояние, которое может быть реактивным примитивом (сигналом), либо же derived состоянием, которое имеет некоторые зависимости. Очень важно в данном подходе избегать циклов (когда state A зависит от state B, а state B в свою чередь зависит от state A), потому что есть вероятность уйти в бесконечный цикл или создать баги.

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

Global stores approach (createRoot)

Я заметил, что если использовать подход с createRoot, то очень быстро можно запутаться, импортируя stores в разных компонентах. Конечно, это действительно может оказаться полезным, когда мы хотим сделать какую-то логику приложения доступной для всего и сразу. Однако, такой подход не всегда применим. Импортируя глобальный store в разных местах, мы быстро начинаем терять контроль над зависимостями, поскольку импорты множатся, и становится труднее проследить иерархию зависимостей. К тому же возникает резонный вопрос: когда инициализировать или уничтожать какую-то функциональность, освобождая память?

На помощь приходят контексты!

Преимущества Context-based approach

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

Изолированная логика

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

Чёткая иерархия зависимостей

Если одна функциональность (feature A) зависит от другой функциональности (feature B), то она должна находиться ниже по дереву компонентов, чтобы использовать API через контекст. Например:

const FeatureBService = (props) => (
<FeatureBContext.Provider value={featureBAPI}>
{props.children}
</FeatureBContext.Provider>
)

const FeatureAService = (props) => {
// Turns out that useContext is some kind of DI here
const featureBAPI = useContext(FeatureBContext);

// do stuff here with featureBAPI

return (
<FeatureAContext.Provider value={featureAAPI}>
{props.children}
</FeatureAContext.Provider>
);
}

const App = () => (
<FeatureBService>
<FeatureAService>
...
</FeatureAService>
</FeatureBService>
)

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

  • Либо отрефакторить существую логику, избавившись от цикла зависимостей
  • Либо объединить функциональность в один сервис

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

Предсказуемая инициализация логики и её очищение

Поскольку каждая функциональность является компонентом, то при рендере этого компонента происходит её инициализация, а на onCleanup мы можем очистить память. Таким образом можно правильно и по запросу инициализировать логику, которая нужна только при отображении какой-то специфичность области UI. Пример:

import {Show, onCleanup} from 'solid-js';

const CardService1 = (props) => {
// kind of DI
const api1 = useContext(SomeServiceContext1);
const api2 = useContext(SomeServiceContext2)

// initialize here or on mount

onCleanup(() => {
// release some memory here
});

return (
<CardService1Context.Provider value={cardService1API}>
{props.children}
</CardService1Context.Provider>
);
}

const SomeComponent = () => {

...

return (
<section>
{/* Render some card conditionally */}
<Show when={isCardVisible()}>
<CardService1>
<CardService2>
...
</CardService2>
</CardService1>
</Show>
</section>
);
}

Не забывайте использовать при этом про code splitting, чтобы не импортировать лишнее, когда компонент не рендерится :)

Консистентность разработки

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

  • Все зависимости конкретного сервиса указываются в начале компонента (через useContext)
  • API сервиса описывается с помощью типа контекста: createContext<ServiceAPI>()

Недостатки Context-based подхода

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

Контекст доступен всем ниже по дереву

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

Визуальное неудобство

Скорее всего, вы столкнётесь со следующей ситуацией:

const App = () => (
<Service1>
<Service2>
<Service3>
<Service4>
<Service5>
{...}
</Service5>
</Service4>
</Service3>
</Service2>
</Service1>
);

В этом случае можно:

  • Завернуть группу сервисов в один компонент
  • Отделить группы сервисов комментариями

Лично для меня это не является большой проблемой, хотя многие могут со мной не согласиться :)

Выводы

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

Делитесь своими мыслями и подходами в комментариях, буду рад узнать ваше мнение!

--

--