All streams
Search
Write a publication
Pull to refresh

Comments 19

Как говорит один мой знакомый: "с ростом сложности архитектуры бэка, разработчики стали уделять больше времени фронту.". %)

Попробую дать небольшую обратную связь.

Сразу видны нарушения принципов, которым мы пытаемся следовать при проектировании. Например:

Здесь модуль Router зависит от низкоуровневых модулей (конкретных компонентов)

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

Чтобы добавить новый роут, например /users/:id/comments, нужно изменить код роутера.

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

/router.tsx отвечает и за создание роутера, и за рендеринг.

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

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

Ещё один существенный недостаток такой реализации — проблема тестируемости. Мы не можем просто протестировать UserSettings

Почему? Вы даже сами создали отдельный элемент <UserSettings />

Так же можете его создать в любом другом месте и тестировать совершенно независимо от всего остального.

Для меня лично они существенны — поэтому я хочу предложить другой способ реализации маршрутизации.

Вообще, сейчас Реакт Роутер можно использовать по разному. И вы показали только один способ.

Нет жестких зависимостей

function UserSettings() {
  if (!router.match('/users/:id/settings')) return;
  
  return <div>Settings</div>;
}

Получается следующее, у вас некоторая сущность UserSettings во первых, занимается не только собственной поставленной задачей вывода <div>Settings</div>, но еще и обслуживает задачи маршрутизации. Что очень похоже на наружение принципа единственной ответственности. Вот для тестирования такого UserSettings действительно нужно поднимать обертку обеспечивающую маршрутизацию. Что вы в тестах и делаете.

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

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

// index.ts
import { router } from './Router'
import { HomePage } from '@/pages/home'; 
import { UsersPage } from '@/pages/users';
import { App } from './App'

const pages = [HomePage, UsersPage];

render(<App router={router} pages={pages} />)

Данный index.ts как раз и является аналогом изначального файла роутера. Он так же зависим от всех "ниже лежащих" модулей и конфигурирует их зависимости.

И это тоже любопытно:

Скрытый текст
// Users.tsx
import { UsersList } from './UsersList';
import { UserProfile } from './UserProfile';
import { UserSettings } from './UserSettings';

const nestedPages = [UsersList, UserProfile, UserSettings]

const RealUsersPage: Page = function({ router, pages }) {
  return (
    <Layout>
      {pages.map(Page => <Page router={router} />)}
    </Layout>
  )
}

export const Users: Page = function({ router }) {
  if (!router.match('/users')) return null;
  return <RealUsersPage pages={pages} router={router}  />
}

Если честно, я не очень понимаю что тут происходит. Например, зачем вам отдельный RealUsersPage? Ваш User в котором захардкожен маршрут, имеет так же захардкоженный RealUsersPage. Иными словами, вы не можете к данном маршруту привязать другой RealUsersPage. Поэтому существование отдельного RealUsersPage просто бессмысленно.

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

Реализацию чего? Что вы решили подменять? У вас все захардкожено на один маршрут /users. Если подменить реализацию, то это будет другой маршрут, а не другая реализация данного маршрута.

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

Классический бэкенд:

HTTP Request -> Controller -> Service -> HTTP Response

Во фреймворках обычно контроллеры не имеют связи с маршрутом. Например, если вы будете использовать React Router как фрэймворк. Маршрут у вас будет отдельно от контроллера:

route("/users", "./users.tsx")

Проблема прямых импортов

И избавляемся от прямых импортов:

import { router } from './Router'
import { App } from './App'

import * as Pages from '@/pages';

const pages = Object.values(Pages); // 

render(<App router={router} pages={pages} />)

Вот вы пишете:

По хорошему, наши App или Users должны зависеть от интерфейса, а сейчас это не так, у нас прямые импорты страниц.

Не понял. Что не так? Вам не понравилось что index делает импорт страниц и вы сделали их реимпорт из другого места? Но прямо имопорт страниц из модуря pages никуда не делся. У вас index.ts как зависел от этих модулей так и зависит, только теперь добавилась транзитивная зависимость.

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

Новые страницы автоматически подхватываются при их добавлении в директорию, при условии, что они экспортируются из index.ts. Достаточно создать страницу и экспортировать её — и она "попадает в систему".

То, что вы написали - это называется вручную, а не автоматически. То, что вы вручную добавляете в один файл, а не в другой дело не меняет. Вот если бы у вас работала утилита, которая бы автоматически генерировала index.ts, тогда это было бы автоматически.

Типобезопасность на уровне композиции

Какой композиции? Вы реэкспорт из index файла называете композицией?

Упрощение рефакторинга

Если автор Users решит изменить название компонента на MyCoolUsersPage, это никого вокруг не затронет.

Например, в FSD так же есть реэкспорты из index. Поэтому пока публичный апи не изменится все будет локализовано в пределах слайса.

Частично соблюдаем Open/Closed Principle

Вот в этом плане вообще ничего не изменилось, по моему.

-----

Я посмотрел финальный результат.

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

Например:

Скрытый текст
const BarNestedStaticRouteStaticRoute = observer(
  function barNestedStaticRouteStaticRoute() {
    const result = router.match('./nested', barNestedStaticRouteStaticRoute);
    if (!result) return null;
    return (
      <div className="route">
        <div>
          It's a match for <code>./nested/</code> relative to{' '}
          <code>./static/*</code> relative to <code>/bar/*</code>
        </div>
      </div>
    );
  }
);

const BarNestedStaticRoute = observer(function barNestedStaticRoute() {
  if (!router.match('./static/*', barNestedStaticRoute)) return null;
  return (
    <div className="route">
      <div>
        It's a match for <code>./static/*</code> relative to <code>/bar/*</code>
      </div>
      <BarNestedStaticRouteStaticRoute />
    </div>
  );
});

У меня даже слов нету...

Спасибо за комментарий, попробую ответить также развернуто.

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

Если вы в проекте придумали свои паттерны, следуете им, для вас они понятны и приемлемы, то вы, безусловно, правы. В статье речь про принципы SOLID и Clean Architecture, а они как раз нарушаются.

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

Это не посредник, а сервис-локатор. Кроме того, это нарушает OCP.
В моей реализации новая страница сама включается в систему через автодискавери, как в бэкенде (например, FastAPI с автозагрузкой роутов), а не через внесение изменений в роутер.

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

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

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

Почему? Вы даже сами создали отдельный элемент <UserSettings />

Так же можете его создать в любом другом месте и тестировать совершенно независимо от всего остального.

Это не так. Чтобы протестировать этот компонент с react-router, нужно смоделировать весь стек роутинга: RouterProvider, createBrowserRouter и т.д. Если ваш компонент использует useMatch, например, то без провайдера он просто упадет с ошибкой.

Вообще, сейчас Реакт Роутер можно использовать по разному. И вы показали только один способ.

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

Получается следующее, у вас некоторая сущность UserSettings во первых, занимается не только собственной поставленной задачей вывода <div>Settings</div>, но еще и обслуживает задачи маршрутизации. Что очень похоже на наружение принципа единственной ответственности. Вот для тестирования такого UserSettings действительно нужно поднимать обертку обеспечивающую маршрутизацию. Что вы в тестах и делаете.

Тут могу ответить цитатой из статьи:
Роль Page-компонента – быть контроллером. Он определяет с помощью router.match(), должен ли «обработать этот запрос» (текущий URL), и если да, то делегирует выполнение бизнес-логики и рендеринг нижележащим компонентам (PageImpl). Эти нижележащие компоненты и выступают в роли сервисов, непосредственно занимаясь получением данных и их преобразованием в UI (рендером).

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

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

Конечно, это же просто пример. Хардкод пути в match, ничем не хуже хардкода пути в <Route path="..." />, и ничто не мешает вынести его в константу.

Данный index.ts как раз и является аналогом изначального файла роутера. Он так же зависим от всех "ниже лежащих" модулей и конфигурирует их зависимости.

В некотором смысле да, и поэтому ниже предлагается решение. Но даже без этого решения есть разница. Здесь App импортирует только модули первого уровня (Home, Users), но не UsersSettings, UserProfile.

Если честно, я не очень понимаю что тут происходит. Например, зачем вам отдельный RealUsersPage? Ваш User в котором захардкожен маршрут, имеет так же захардкоженный RealUsersPage. Иными словами, вы не можете к данном маршруту привязать другой RealUsersPage. Поэтому существование отдельного RealUsersPage просто бессмысленно.

Page интерфейс. App рендерит верхний модуль Users, но у Users есть вложенные страницы, которые он принимает пропсами. Можно передать их из App, но тогда App начинает зависеть от внутренней реализации Users. В этом примере мы из Users возвращаем другую реализацию Users в которою передаем вложенные страницы.
Это также позволяет делать что-то такое:

const isExperimental = localStorage.getItem('layout') === 'new';
return isExperimental 
  ? <NewUsersPage pages={nestedPages} router={router} />
  : <OldUsersPage pages={nestedPages} router={router} />;

Во фреймворках обычно контроллеры не имеют связи с маршрутом.

// классический контроллер в Java
@RequestMapping("instances")
public class InstanceController {
  
    @GetMapping("{id}")
    public InstanceDto getById(@PathVariable UUID id) {
        return service.get(id);
    }
}

Не понял. Что не так? Вам не понравилось что index делает импорт страниц и вы сделали их реимпорт из другого места? Но прямо имопорт страниц из модуря pages никуда не делся. У вас index.ts как зависел от этих модулей так и зависит, только теперь добавилась транзитивная зависимость.

Это не совсем так. Было так:

import { HomePage } from '@/pages/home'; 
import { UsersPage } from '@/pages/users';

Стало так:

import * as Pages from '@/pages';

То есть, теперь мы зависим от интерфейса ESM модуля, доступного по алиасу pages. Это можно читать как:

type Pages = Record<string, Page>

То, что вы написали - это называется вручную, а не автоматически. То, что вы вручную добавляете в один файл, а не в другой дело не меняет.

Меняет. В моем случае добавление новой страницы выглядит так: Создать в директории pages новую страницу, включить ее в index.ts. В случае с react-router тоже самое + импортировать ее в router.

Какой композиции? Вы реэкспорт из index файла называете композицией?

render(
  <App 
    router={router} 

    // тут будет ошибка если в экспорт попало не то что нужно
    pages={pages} 
/>)

Вот в этом плане вообще ничего не изменилось, по моему.

Изменилось. Нам больше не нужно редактировать файл App.tsx при добавлении новой страницы.

Я посмотрел финальный результат.

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

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

Еще раз спасибо за развернутый комментарий.

Честно говоря, яснее не стало.

В статье речь про принципы SOLID и Clean Architecture, а они как раз нарушаются.

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

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

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

Это не посредник, а сервис-локатор. Кроме того, это нарушает OCP.

Что такое "сервис-локатор"? Какой у него шаблон? Почему нарушается OCP? Иными словами, вот у вас есть задача создать не переиспользуемый модуль. Шаблона у вас нет. Почему тогда нарушается OCP? Дальше вы так же будете создавать не переиспользуемые модули.

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

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

Это не так. Чтобы протестировать этот компонент с react-router, нужно смоделировать весь стек роутинга: RouterProvider, createBrowserRouter и т.д. Если ваш компонент использует useMatch, например, то без провайдера он просто упадет с ошибкой.

А это зачем? Вы что собрались тестировать? Судя по названию UserSettings - это компонент отвечающий за какую-то бизнес логику. Если он содержит в себе какие-то вложенные маршруты, то нужна обертка, а если нет, то обертка не нужна.

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

И что из этого следует?

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

Подождите. Вы рассматриваете приложение как систему из конфигурации компонентов. У компонентов есть назначение. Это назначение определяет место компонента в архитектуре приложения. Поэтому причем тут функция? Компонент нужно рассматривать по назначению в архитектуре приложения.

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

Поэтому я и отослался на то, что контроллеры обычно не такие.

Конечно, это же просто пример. Хардкод пути в match, ничем не хуже хардкода пути в <Route path="..." />, и ничто не мешает вынести его в константу.

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

В некотором смысле да, и поэтому ниже предлагается решение. Но даже без этого решения есть разница. Здесь App импортирует только модули первого уровня (Home, Users), но не UsersSettings, UserProfile.

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

Page интерфейс. App рендерит верхний модуль Users, но у Users есть вложенные страницы, которые он принимает пропсами. Можно передать их из App, но тогда App начинает зависеть от внутренней реализации Users. В этом примере мы из Users возвращаем другую реализацию Users в которою передаем вложенные страницы.

То, что вы написали означает, что App транзитивно связан со вложенными страницами Users. Иными словами, это ровно тоже самое что было в начале, если бы вы просто вынесли какие-то маршруты в отдельный файл. Вы обсолютно так же в этом отдельном файле без избыточного интерфейса Page можете условно выбирать любые вложенные страницы.

Вы нигде не разрушили связь. У вас всегда все связано на уровне модулей.

@RequestMapping("instances")

public class InstanceController {

    @GetMapping("{id}")

    public InstanceDto getById(@PathVariable UUID id) {

        return service.get(id);
    }
}

Вы имете в виду, что тут маршрут /instances? Это из какого-то фреймворка?

То есть, теперь мы зависим от интерфейса ESM модуля, доступного по алиасу pages. Это можно читать как:

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

Какой композиции? Вы реэкспорт из index файла называете композицией?

render(
  <App 
    router={router} 

    // тут будет ошибка если в экспорт попало не то что нужно
    pages={pages} 
/>)

Где тут композиция? По факту, вы создаете элемент App и передаете ему на вход pages. Само по себе, это не композиция. Просто у вас один модуль жестко связан с другими модулями.

Вот если бы вы в App импортировали компоненты Pages и тут же декларативно указывали как должно и где создаваться элементы - это была бы композиция.

Изменилось. Нам больше не нужно редактировать файл App.tsx при добавлении новой страницы.

Так и раньше можно было легко взять и раскидать все по разным файлам. Никто этому не мешал.

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

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

Интересно, что вы сначала уверено утверждаете:

Во фреймворках обычно контроллеры не имеют связи с маршрутом.

А в ответ на предоставленный пример, спрашиваете:

Вы имете в виду, что тут маршрут /instances? Это из какого-то фреймворка?

Это из любого.

Java
@RequestMapping("instances")
public class InstanceController {

    @GetMapping("{id}")
    public InstanceDto getById(@PathVariable UUID id) {
        return service.get(id);
    }
}
Nest.js
@Controller('instances')
export class InstanceController {
  constructor(private readonly service: InstanceService) {}

  @Get(':id')
  getById(@Param('id') id: string): InstanceDto {
    const parsedId = Number(id); // or use UUID lib: uuid.validate(id)
    const instance = this.service.get(parsedId);
    if (!instance) {
      throw new NotFoundException('Instance not found');
    }
    return instance;
  }
}
.NET
[Route("instances")]
[ApiController]
public class InstanceController : ControllerBase
{
    private readonly IInstanceService _service;

    public InstanceController(IInstanceService service)
    {
        _service = service;
    }

    [HttpGet("{id}")]
    public ActionResult<InstanceDto> GetById(Guid id)
    {
        var instance = _service.Get(id);
        return Ok(instance);
    }
}
GO
func controller() {
	service := &InstanceService{}

	instanceGroup := app.Group("/instances")

	instanceGroup.Get("/:id", func(c *fiber.Ctx) error {
		idParam := c.Params("id")
		id, err := uuid.Parse(idParam)
		if err != nil {
			return c.Status(http.StatusBadRequest).JSON(fiber.Map{
				"error": "Invalid UUID",
			})
		}

		instance := service.Get(id)
		return c.JSON(instance)
	})
}

Вы уверено аргументируете, но я не вижу наличия экспертизы.

Рискну утвердить, что Вы слишком глубоко погрузились в идеи SOLID, Clean Architecture и в целом в Энтерпрайз паттерны. У Вас в руках появился молоток, и теперь все вокруг кажется гвоздями.

Это не посредник, а сервис-локатор. Кроме того, это нарушает OCP.

По Dependency Inversion верхнеуровневый модуль здесь это react-router. Код в файле router это низкоуровневый код, по сути конфигурация роутера. Именно react-router обеспечивает OCP за счёт возможности модифицировать его поведение, внедрять хуки и ТД. Это необходимо, потому что роутер используется в миллионах приложений и надо сделать его максимально генерик. Ваше предложение не генерик - это вполне конкретное приложение с вполне конкретной конфигурацией. А конфигурация должна быть простой и достаточно часто она централизована (примеров этому масса в разных фреймворках).

Вы пытаетесь написать свой роутер. У которого своим "правильным" образом реализован DI, OCP и тд. И уже по результатам видно, что прямо кардинально лучше и чище конфигурация роутинга не стала. Можете просто сравнить число строк в вашей имплементации против react-router на вашем реальном боевом приложении. Да, теперь конфигурация децентрализованная. Ну так и у next js она децентрализованная. И dev experience получше у next js.

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

По Dependency Inversion верхнеуровневый модуль здесь это react-router

По идее, верхнеуровневый модуль - это тот который использует react-router и находясь на самом верху имеет связи с нижестоящими модулями. Поэтому внутри router наоборот самый высокоуровневый код. В данном конкретном примере из статьи.

Ноу, верхнеуровневый модуль это тот, который управляет низкоуровневым. Для простоты понимания - react-router определяет интерфейс: как задавать роуты, какие есть параметры, какой жизненный цикл и тд.

В контексте использования роутера из кода приложения - это отношение use, т.е. прямая зависимость. Не инверсированная.

У Вас в руках появился молоток, и теперь все вокруг кажется гвоздями.

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

По Dependency Inversion верхнеуровневый модуль здесь это react-router.

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

Вы пытаетесь написать свой роутер. У которого своим "правильным" образом реализован DI, OCP и тд. И уже по результатам видно, что прямо кардинально лучше и чище конфигурация роутинга не стала.

Реализация своего роутера это следствие того, чего я пытаюсь достичь. Из-за компонентной модели реакта, это действительно кажется необычным. Суть же в другом. С помощью этого достигается то, что реальный компонент отвечающий за отображение, например, пользователя, вообще не знает о существовании роутера и о том, что мы приняли решение отобразить его в браузере по ссылке /users/1, мы это контролируем в слое выше, в данном случая на основе pathname. Можно себе представить другой интерфейс, например чат-бот, и тогда функцию pathname будет выполнять запрос пользователя.
Компоненты типа Page это способ зарегистрировать контроллеры которые декларируют какие пути они обрабатывают, а обработав пути вызывают соответствующие сервисы.

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

Ответил выше.

С помощью этого достигается то, что реальный компонент отвечающий за отображение, например, пользователя, вообще не знает о существовании роутера и о том, что мы приняли решение отобразить его в браузере по ссылке /users/1, мы это контролируем в слое выше, в данном случая на основе pathname.

Весь этот код расположен в роутере. Поэтому страница в общем случае не знает, по какому пути она расположена. Об этом знает роутер.

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

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

Во-первых, в статье я не видел, чтобы были озвучены такие залачи, чтобы роутер работал и для чат бота и для web UI и для голосового управления.

Во-вторых, в таком случае просто необходимы отдельные роутеры для чат бота и для web UI и для мобилки и для десктопа. Тут встаёт вопрос внутренней навигации между страницами. В таком (и только в таком) случае, имеет смысл уже делать какую-то абстракцию, помня, что любая абстракция более ограниченна, чем любая конкретная реализация. И тогда будет верхнеуровневый интерфейс роутера, в который будет передаваться ключ роута и его параметры (желательно это типизировать как-то). Далее к этой новой абстракции будет написано несколько адаптеров - один на react-routerдля web UI, другой для чат-бота и тд. Соответственно будет 2 точки входа (в самом приложении может быть одна, но конфигурации будет 2, соответственно 2 ручки для запуска с разными конфигурациями и корнями композиции). И тогда уже можно говорить, что такой роутер имеет смысл для решения вашей конкретной задачи с вашими контрактными ограничениями. Но абстракция этого роутера будет ограничена - будет просто ключ роута и параметры. Но не будет возможности определить, параметры пришли из пути запроса или из квери стринги или из deep link и тд (пока не произойдет приведения более общего роутера к более частному, что сломает абстракцию, и все равно привяжет имплементацию к частному).

Если смотреть с такой точки зрения - тогда всё ещё Ваше решение слишком сложное. Достаточно сделать `IAppRouter.navigate(key, params)` и forward/back/refresh/currentRoute и сделать разные адаптеры под разные контексты. И потом в страницу делать инъекцию роутера (по интерфейсу) и давать смотреть параметры текущего роута. Выбор адаптера происходит либо в build time в зависимости от конфигурации билда или в runtime в зависимости от переданной конфигурации. И ещё до кучи допилить свои интерфейсы аналоги react-router вроде Link routeKey, routeParams и тд.

Далее делаете один мок для IAppRouter и проверяете, что страница себя ведёт так, как ожидается, в зависимости от currentRoute, а так же, что вызываются соответствующие методы, когда Вы того ожидаете, вроде navigate,back,forward при нажатии на ту или иную кнопку. Все тестируется и не требует долго разбираться в Вашем уникальном подходе.

И OCP будет заключаться не в том, как легко добавлять новые страницы страницы, а в том, как легко добавлять новые типы роутеров.

Ответил выше.

Это ваша собственная интерпретация чистой архитектуры, которую вы выдаете за правду в последней инстанции.

Это интерпретация принципа Dependency Inversion. А точнее констатация.

Чистая архитектура это про отделение инфраструктуры от аппликейшена, нужна для того, чтобы можно было менять инфраструктуру не меняя код бизнес логики. Или разрабатывать и тестировать бизнес логику в отрыве от инфраструктуры (не важно какой сервер, сеть, диск, база данных и ТД), а потом подключать адаптерами ту инфраструктуру, которая лучше подходит для обеспечения бизнес логики.

UI (весь) это только один из адаптеров к входящим портам бизнес логики: web UI, консоль, HTTP API, RPC, чат бот. Может ли быть полезным проектировать адаптеры по принципам чистой архитектуры? Может, если Вы проектируете фреймворк, на котором потом будет реализовываться различные адаптеры.

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

Даже синтаксически, я бы вместо того, чтобы добавлять код проверки роута внутрь самого компонента и если не матч вернуть null - сделал бы что-то вроде

const Page = () => some code
Page.matchRoute = (context) => return true/false. 

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

Довольно странный аргумент насчет тестирования. Я так понимаю, имеються ввиду ситуации, когда необходимо протестировать компонент который использует хуки из react-router. Однако, специально для таких случаев, в react-router есть createRoutesStub, а в более ранних версиях MemoryRouter

https://reactrouter.com/start/framework/testing

https://v5.reactrouter.com/web/api/MemoryRouter

Однако, специально для таких случаев, в react-router есть createRoutesStub, а в более ранних версиях MemoryRouter

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

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

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

Работа с глобальным состоянием по абсолютному пути ломает композицию.

Столько телодвижений, чтоб размазать роутер и выдать это за инновацию 😁 Нет уж, оставьте это в стенах университетов или там, где у вас есть время такими вещами заниматься, я хочу прийти на работу и видеть привычную архитектуру из документации, когда все роуты в одном файле. Не несите эту муть в реальную разработку, пожалуйста. А глобальный клик хэндлер перещеголял концептуально упомянутый God Object, до последнего я хотя бы докликаю в IDE.

Разработчик явно указал, что роутер не должен перехватывать переход по этой ссылке (атрибут data-no-spa)

Для кого явно? Для другого разработчика, который скажет "А это ж NihilRouter, смотри data-no-spa в глобальном хэндлере кликов, ты что не видишь там кроссфреймворковый npm пакет роутера на 100М скачиваний?"

Пожалуйста, не надо, спасибо. Нам стока не платят, чтоб ещё и это вот всё :)

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

Хотя в своем примере тестирования тоже мокает зависимости, просто в новом подходе это делается чуть проще.

Ну а что мешает один раз написать моки для этих зависимостей и переиспользовать?

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

/router.tsx отвечает и за создание роутера, и за рендеринг

Нет, router.tsx отвечает за роутинг (единственная ответственность). А роутинг в себя включает и определение роутов, и их отображение отталкиваясь от window.location, и их композицию и тд.

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

А роутинг в одном файле - очень удобно, нежели искать по проекту кто как и где рендерится

Sign up to leave a comment.

Articles