Как видим, Solid.js с Proxy – быстрее. Vue reactivity с Proxy – быстрее. Может показаться странным что я включил в выборку Preact + hooks, но, вот в чем дело: на третьем месте – Observable с Proxy, и он эффективнее ангуляровских сигналов! Но это не все. Для связи Observable с Preact-ом используется HOC observer, который внутри себя использует Preact-овские хуки (useState + useEffect + useLayoutEffect + useRef), и при всем этом – он быстрее ангуляровских сигналов.
И если вас не затруднит, то расскажите про встроенные в язык сигналы
Я взял слово сигналы в кавычки:
...уже встроенный в язык «Сигнал» ...
Разумеется Сигнал отсутствует как явный класс в джаваскрипте, но как я написал выше, Signal – это просто извращенная форма accessor-а. Дескриптор accessor-а выглядит так:
class Signal {
#value;
get value() {
// ...
return this.#value;
}
set value(newValue) {
// ...
this.#value = newValue;
}
}
все созданные сигналы будут использовать один и ту же функцию get и set из дескриптора свойства value прототипа Signal, что гораздо эффективнее создания двух новых объектов Function для каждого сигнала.
потому что классы это синтаксический сахар свежих стандартов ES
Сигналы (signals) как примитив управления состоянием стали популярны во фронтенд-фреймворках благодаря простоте и эффективности
Если бы сигналы были просты, то не было бы столько попыток объяснить их концепцию. Или, возможно, это какая-то компания по популяризации сигналов в Angular, потому что за последний месяц это уже вторая статья. Но что бы это ни было, мне больше интересно, как вы определили, что сигналы эффективны?
Это не может быть правдой. Смотрите:
const a = 1;
const b = 2;
то же самое, но с «абстрактной» реализацией сигналов:
class Signal {
value;
constructor(initialValue) {
this.value = initialValue;
}
get() {}
set(value) {}
subscribers = []
}
const a = new Signal(1);
const b = new Signal(2);
В самом простом случае, на одно значение приходится один вызов функции + создание двух объектов.
Предложенная вами реализация еще менее эффективна:
export const customSignal = (initValue) => {
// замыкание
let value = initValue;
// аллоцируем память под Мапку
const watchers = new Map();
// аллоцируем память под объект Function
function get(): T {
if (watcher) { // тут ошибка, watcher-а не существует
// Тут не очень ненадежно
const key = Math.random().toString(16);
watchers.set(key, watcher);
// еще аллоцируем память под масив
watcher.deps.push([key, watchers]);
}
return value;
}
// аллоцируем память под объект Function
function set(newValue: T) {
if (value !== newValue) {
value = newValue;
// на каждое изменение создается новый итератор
for (let watcherItem of watchers.values()) {
try {
watcherItem();
} catch (e) {
console.log(e);
}
}
}
}
// аллоцируем память под масив
return [get, set];
};
Тут нет никакой эффективности.
Сама концепция сигналов не нова и до появления в Angular уже активно использовалась в других frontend-фреймворках
Скорее сигнал, это извращенная форма acceessor-ов. Если использовать уже встроенный в язык «Сигнал», получится чуть более эффективно:
// Мономорфный класс, великолепно оптимизируется JIT-ом
class Signal {
#value;
#publishers = new Map;
#subscribers = new Map;
// переиспользуется для всех сигналов в приложении
get value() {
// реактивная машинерия
// ...
return this.#value;
}
// переиспользуется для всех сигналов в приложении
set value(newValue) {
// реактивная машинерия
// ...
this.#value = newValue;
}
}
...передавать NavBackStack в ViewModel (что в моем понимании нарушает принципы архитектуры, так как я считаю...
То есть, ссылаетесь на какие-то принципы, но прямо не озвучиваете их. Напишите о каких принципах речь, и первый ваш вопрос отпадет.
2) чем может помочь path навигация? ... ...типизированная в контексте android подходит гораздо больше..
Типизированная в любом контексте лучше, я с этом полностью согласен. Но path-based тоже может быть типизированным.
3) чем плох config based router в контексте android разработки?
А как контекст андроид приложения меняет суть? Плох тем, что получается good-object + service-locator в одном.
Я честно не вижу, каким образом ваш роутер решил вами же описанные проблемы.
Было:
// Добавить экран
backStack.add(Screen.Details("123"))
// Вернуться назад
backStack.removeLastOrNull()
// Заменить текущий экран
backStack.set(backStack.lastIndex, Screen.Success)
стало
// Переход на новый экран
router.push(Screen.Details("123"))
// Возврат назад
router.pop()
// Переход с заменой текущего экрана (назад вернуться нельзя)
router.replaceCurrent(Screen.Success)
На мой взгляд, у вас все еще сильная связь между разными экранами, условно Product знает о существовании Home. Я бы смотрел в сторону path-navigation. Благодаря нормальному DI и AOP в котлине, вы могли бы сделать что-то такое, только гораздо чище с точки зрения архитектуры. Сейчас у вас больше config-based-router
router.match как раз может быть одним из факторов для принятия решения о том, нужно ли компоненту рендерится, а эта реализация еще допускает наличие нескольких компонентов зависящих от одного и того же паттерна. location.match больше бы соответствовал назначению, но он уже занят
Однако, специально для таких случаев, в react-router есть createRoutesStub, а в более ранних версиях MemoryRouter
Вы назвали мой аргумент странным, но приводите в качестве контр-аргумента тот факт, что проблема с тестированием из-за неправильной архитектуры настолько существенна, что сама библиотека вынуждена выпустить костыль чтобы хоть как-то ее решить.
У Вас в руках появился молоток, и теперь все вокруг кажется гвоздями.
Кажется что в статье моя позиция достаточно обоснована. Я не слепо пытаюсь следовать каким-то принципам, а описываю реальные проблемы. Если для тестирования отдельного компонента мне нужно запускать приложение с корня, настраивать контексты и т.д., то это мне создает реальные проблемы.
По Dependency Inversion верхнеуровневый модуль здесь это react-router.
Модуль не становится верхнеуровневым, только из-за того, что вы физически расположили выше в иерархии ваших компонентов. В классической чистой архитектуре, верхнеуровневый модуль это бизнес-логика.
Вы пытаетесь написать свой роутер. У которого своим "правильным" образом реализован DI, OCP и тд. И уже по результатам видно, что прямо кардинально лучше и чище конфигурация роутинга не стала.
Реализация своего роутера это следствие того, чего я пытаюсь достичь. Из-за компонентной модели реакта, это действительно кажется необычным. Суть же в другом. С помощью этого достигается то, что реальный компонент отвечающий за отображение, например, пользователя, вообще не знает о существовании роутера и о том, что мы приняли решение отобразить его в браузере по ссылке /users/1, мы это контролируем в слое выше, в данном случая на основе pathname. Можно себе представить другой интерфейс, например чат-бот, и тогда функцию pathname будет выполнять запрос пользователя. Компоненты типа Page это способ зарегистрировать контроллеры которые декларируют какие пути они обрабатывают, а обработав пути вызывают соответствующие сервисы.
Спасибо за комментарий, попробую ответить также развернуто.
Само по себе это врядли нарушение. Все зависит от принятых на проекте паттернов. Например, находясь в слайсе 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 в которою передаем вложенные страницы. Это также позволяет делать что-то такое:
Во фреймворках обычно контроллеры не имеют связи с маршрутом.
// классический контроллер в 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 при добавлении новой страницы.
Я посмотрел финальный результат.
Там так все наворочено, что разобраться просто читая сверху вниз довольно трудно.
В этом и смысл. Это «децентрализованный» роутер, у него нет файла конфигурации с описанием роутов, а пример демонстрирует, что даже в запутанном графе компонентов все роуты корректно определяются и регистрируются, в том числе относительные, с учетом скоупа.
Чтобы эмулировать реальное поведение программы с использованием async/await как предлагает @Sirion. Promise.all никак это проблему не решает, если только мы не найдем способ собрать в одном месте все функции из разных модулей которые стартуют при старте программы, и все их положить в Promise.all.
В реальности это скорее невозможно, а без async/await все достаточно просто:
class ModuleB {
constructor() {
this.init()
}
init() {
fetch('someApi')
.then(data => this.data = data);
}
}
// moduleA.js
import { ModuleB } from './moduleB'
class ModuleA {
moduleB = new ModuleB();
init() {
fetch('someApi')
.then(data => this.data = data);
}
}
new ModuleA()
// ModuleB.init и ModuleA.init выполняются паралельно
Кажется Хабр не развлекательный портал, а полу-научное сообщество, и заявленное в статье нужно подтверждать пруфами.
Не могу согласится. Лично мне вызывать у какого-то числа метод
get
илиset
как-то неудобно, и непривычно. Но это дело вкуса.В статье она наивна, потому что в оригинале все еще хуже. А упомянутый в статье Solid.js одумался, и отказывается от сигналов в пользу Proxy.
Это не очень полезный бенчмарк для оценки эффективности реактивных примитивов, но ок, давайте сравним:
Как видим, Solid.js с Proxy – быстрее. Vue reactivity с Proxy – быстрее. Может показаться странным что я включил в выборку
Preact + hooks
, но, вот в чем дело: на третьем месте – Observable с Proxy, и он эффективнее ангуляровских сигналов! Но это не все. Для связи Observable с Preact-ом используется HOC observer, который внутри себя использует Preact-овские хуки (useState + useEffect + useLayoutEffect + useRef), и при всем этом – он быстрее ангуляровских сигналов.Я взял слово сигналы в кавычки:
Разумеется Сигнал отсутствует как явный класс в джаваскрипте, но как я написал выше, Signal – это просто извращенная форма accessor-а. Дескриптор accessor-а выглядит так:
В приведенном мной примере:
все созданные сигналы будут использовать один и ту же функцию
get
иset
из дескриптора свойстваvalue
прототипаSignal
, что гораздо эффективнее создания двух новых объектовFunction
для каждого сигнала.Это не совсем так.
Если бы сигналы были просты, то не было бы столько попыток объяснить их концепцию. Или, возможно, это какая-то компания по популяризации сигналов в Angular, потому что за последний месяц это уже вторая статья. Но что бы это ни было, мне больше интересно, как вы определили, что сигналы эффективны?
Это не может быть правдой. Смотрите:
то же самое, но с «абстрактной» реализацией сигналов:
В самом простом случае, на одно значение приходится один вызов функции + создание двух объектов.
Предложенная вами реализация еще менее эффективна:
Тут нет никакой эффективности.
Скорее сигнал, это извращенная форма acceessor-ов. Если использовать уже встроенный в язык «Сигнал», получится чуть более эффективно:
Нет, не получал. Просто был похожий опыт, вот и строю догадки
А вроде и не нужно))
Результаты должны были озвучить в сентябре, вроде с 20 по 25. Если не пришло письмо, значит вы не победили.
Чуть чаем не подавился))
Ого. Я про это не знал, а оно полезно. Спасибо
Вы пишите:
То есть, ссылаетесь на какие-то принципы, но прямо не озвучиваете их. Напишите о каких принципах речь, и первый ваш вопрос отпадет.
Типизированная в любом контексте лучше, я с этом полностью согласен. Но path-based тоже может быть типизированным.
А как контекст андроид приложения меняет суть? Плох тем, что получается good-object + service-locator в одном.
Я честно не вижу, каким образом ваш роутер решил вами же описанные проблемы.
Было:
стало
На мой взгляд, у вас все еще сильная связь между разными экранами, условно Product знает о существовании Home.
Я бы смотрел в сторону path-navigation. Благодаря нормальному DI и AOP в котлине, вы могли бы сделать что-то такое, только гораздо чище с точки зрения архитектуры.
Сейчас у вас больше config-based-router
Революшн же!
router.match как раз может быть одним из факторов для принятия решения о том, нужно ли компоненту рендерится, а эта реализация еще допускает наличие нескольких компонентов зависящих от одного и того же паттерна. location.match больше бы соответствовал назначению, но он уже занят
Вы назвали мой аргумент странным, но приводите в качестве контр-аргумента тот факт, что проблема с тестированием из-за неправильной архитектуры настолько существенна, что сама библиотека вынуждена выпустить костыль чтобы хоть как-то ее решить.
Интересно, что вы сначала уверено утверждаете:
А в ответ на предоставленный пример, спрашиваете:
Это из любого.
Java
Nest.js
.NET
GO
Вы уверено аргументируете, но я не вижу наличия экспертизы.
Это ваша собственная интерпретация чистой архитектуры, которую вы выдаете за правду в последней инстанции.
Кажется что в статье моя позиция достаточно обоснована. Я не слепо пытаюсь следовать каким-то принципам, а описываю реальные проблемы. Если для тестирования отдельного компонента мне нужно запускать приложение с корня, настраивать контексты и т.д., то это мне создает реальные проблемы.
Модуль не становится верхнеуровневым, только из-за того, что вы физически расположили выше в иерархии ваших компонентов. В классической чистой архитектуре, верхнеуровневый модуль это бизнес-логика.
Реализация своего роутера это следствие того, чего я пытаюсь достичь. Из-за компонентной модели реакта, это действительно кажется необычным. Суть же в другом. С помощью этого достигается то, что реальный компонент отвечающий за отображение, например, пользователя, вообще не знает о существовании роутера и о том, что мы приняли решение отобразить его в браузере по ссылке /users/1, мы это контролируем в слое выше, в данном случая на основе pathname. Можно себе представить другой интерфейс, например чат-бот, и тогда функцию pathname будет выполнять запрос пользователя.
Компоненты типа
Page
это способ зарегистрировать контроллеры которые декларируют какие пути они обрабатывают, а обработав пути вызывают соответствующие сервисы.Спасибо за комментарий, попробую ответить также развернуто.
Если вы в проекте придумали свои паттерны, следуете им, для вас они понятны и приемлемы, то вы, безусловно, правы. В статье речь про принципы SOLID и Clean Architecture, а они как раз нарушаются.
Это не посредник, а сервис-локатор. Кроме того, это нарушает OCP.
В моей реализации новая страница сама включается в систему через автодискавери, как в бэкенде (например, FastAPI с автозагрузкой роутов), а не через внесение изменений в роутер.
Эти объекты слабо связаны друг с другом, и очень жестко с роутером.
Это не так. Чтобы протестировать этот компонент с react-router, нужно смоделировать весь стек роутинга:
RouterProvider
,createBrowserRouter
и т.д. Если ваш компонент использует useMatch, например, то без провайдера он просто упадет с ошибкой.В некотором смысле да, но любой способ требует обернуть приложение в RouterProvider.
Тут могу ответить цитатой из статьи:
Роль Page-компонента – быть контроллером. Он определяет с помощью
router.match()
, должен ли «обработать этот запрос» (текущий URL), и если да, то делегирует выполнение бизнес-логики и рендеринг нижележащим компонентам (PageImpl). Эти нижележащие компоненты и выступают в роли сервисов, непосредственно занимаясь получением данных и их преобразованием в UI (рендером).Так получилось, что в реакте у нас все компонент, но если абстрагироваться и вспомнить что это в первую очередь функция, то все встает на свои места.
Конечно, это же просто пример. Хардкод пути в
match
, ничем не хуже хардкода пути в<Route path="..." />
, и ничто не мешает вынести его в константу.В некотором смысле да, и поэтому ниже предлагается решение. Но даже без этого решения есть разница. Здесь App импортирует только модули первого уровня (Home, Users), но не UsersSettings, UserProfile.
Page интерфейс. App рендерит верхний модуль Users, но у Users есть вложенные страницы, которые он принимает пропсами. Можно передать их из App, но тогда App начинает зависеть от внутренней реализации Users. В этом примере мы из Users возвращаем другую реализацию Users в которою передаем вложенные страницы.
Это также позволяет делать что-то такое:
Это не совсем так. Было так:
Стало так:
То есть, теперь мы зависим от интерфейса ESM модуля, доступного по алиасу pages. Это можно читать как:
Меняет. В моем случае добавление новой страницы выглядит так: Создать в директории pages новую страницу, включить ее в index.ts. В случае с react-router тоже самое + импортировать ее в router.
Изменилось. Нам больше не нужно редактировать файл App.tsx при добавлении новой страницы.
В этом и смысл. Это «децентрализованный» роутер, у него нет файла конфигурации с описанием роутов, а пример демонстрирует, что даже в запутанном графе компонентов все роуты корректно определяются и регистрируются, в том числе относительные, с учетом скоупа.
Еще раз спасибо за развернутый комментарий.
А вы действительно так код писали?
Вы просто создаете floating promises.
Ну и в чем смысл этих
await
-ов, если в итогде вы пришли к моему первому примеру, только менее безопасно?Чтобы эмулировать реальное поведение программы с использованием async/await как предлагает @Sirion.
Promise.all
никак это проблему не решает, если только мы не найдем способ собрать в одном месте все функции из разных модулей которые стартуют при старте программы, и все их положить вPromise.all
.В реальности это скорее невозможно, а без async/await все достаточно просто:
Против:
Думают, наверное.
async/await
превращает асинхронный код – в сихронный.