All streams
Search
Write a publication
Pull to refresh
4
-0.9
Константин Роман @nihil-pro

User

Send message

Кажется вы просто предрались к словам что сигналы просты и эффективны.

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

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

Не могу согласится. Лично мне вызывать у какого-то числа метод get или set как-то неудобно, и непривычно. Но это дело вкуса.

но как сказано в начале статьи - реализация больше наивная - для демонстрации механики работы

В статье она наивна, потому что в оригинале все еще хуже. А упомянутый в статье Solid.js одумался, и отказывается от сигналов в пользу Proxy.

на бенчмарках можно увидеть разницу

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

https://krausest.github.io/js-framework-benchmark/2025/table_chrome_141.0.7390.55.html

Как видим, Solid.js с Proxy – быстрее. Vue reactivity с Proxy – быстрее. Может показаться странным что я включил в выборку Preact + hooks, но, вот в чем дело: на третьем месте – Observable с Proxy, и он эффективнее ангуляровских сигналов! Но это не все. Для связи Observable с Preact-ом используется HOC observer, который внутри себя использует Preact-овские хуки (useState + useEffect + useLayoutEffect + useRef), и при всем этом – он быстрее ангуляровских сигналов.

И если вас не затруднит, то расскажите про встроенные в язык сигналы

Я взял слово сигналы в кавычки:

...уже встроенный в язык «Сигнал» ...

Разумеется Сигнал отсутствует как явный класс в джаваскрипте, но как я написал выше, Signal – это просто извращенная форма accessor-а. Дескриптор accessor-а выглядит так:

const descriptor: PropertyDescriptor = {
  get() {},
  set(value) {},
  // ...
}

В приведенном мной примере:

class Signal {
  #value;

  get value() {
    // ...
    return this.#value;
  }

  set value(newValue) {
    // ...
    this.#value = newValue;
  }
}

все созданные сигналы будут использовать один и ту же функцию get и set из дескриптора свойства value прототипа Signal, что гораздо эффективнее создания двух новых объектов Function для каждого сигнала.

потому что классы это синтаксический сахар свежих стандартов ES

Это не совсем так.

class IAmNotJustAFunction {
  #privateDataProperty = 1;

  static #staticPrivateDataProperty = 1;
  
  get #privateAccessor() { 
    return 1 
  }

  static #staticPrivateAccessor() {
    return 2
  }

  #privateMethod() {}

  static #staticPrivateMethod() {}

  static {
    try {
      
    } catch {
      
    }
  }
}

Сигналы (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;
  }
}

Если вы получили какое-то письмо, то похвастайтесь более прямолинейно, пожалуйста!

Нет, не получал. Просто был похожий опыт, вот и строю догадки

А вроде и не нужно))

Результаты должны были озвучить в сентябре, вроде с 20 по 25. Если не пришло письмо, значит вы не победили.

Ого. Я про это не знал, а оно полезно. Спасибо

1) в чем по-вашему выражается сильная связь?

Вы пишите:

...передавать 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

Nav3Host(
	    backStack = backStack,
	    router = router,
	) { backStack, onBack, _ ->
        NavDisplay(
            backStack = backStack,
            onBack = onBack,
            entryProvider = entryProvider {
                entry<Screen.Home> { HomeScreen() } // !
                entry<Screen.Details> { DetailsScreen() } // !
            }
        )
    }

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

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

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

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

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

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

Вы имете в виду, что тут маршрут /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)
	})
}

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

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

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

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

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

По 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 в которою передаем вложенные страницы.
Это также позволяет делать что-то такое:

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 при добавлении новой страницы.

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

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

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

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

async function getUsers() {
  try {
    const res = await fetch('/api/users');
    if (!res.ok) throw new Error(`${res.status}`);
    return await res.json();
  } catch (e) {
    logger.error('Users fetch failed', e);
    throw e; // пробрасываем дальше
  }
}

async function createUser(data) {
  try {
    const res = await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(data)
    });
    if (!res.ok) throw new Error(`${res.status}`);
    return await res.json();
  } catch (e) {
    logger.error('User creation failed', e);
    throw e;
  }
}

А вы действительно так код писали?

не менее, а более безопасно) в случае чего ошибки вывалятся нормально, а не uncaught (in promise)

class ModuleB {

  constructor() {
    this.init()
  }

  async init() {
    this.data = await fetch('someApi');
    throw new Error('surprise')
  }
}

// moduleA.js
import { ModuleB } from './moduleB'

class ModuleA {
  moduleB = new ModuleB();

  // вы, видимо, пропустили
  constructor() {
    this.init()
  }

  async init() {
    this.data = await fetch('someApi')
  }
}

new ModuleA()
Uncaught (in promise) Error: surprise

Вы просто создаете floating promises.

Ну и в чем смысл этих await-ов, если в итогде вы пришли к моему первому примеру, только менее безопасно?

Чтобы эмулировать реальное поведение программы с использованием 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 выполняются паралельно
async function doWork1() {
  return new Promise(resolve => {
    setTimeout(resolve, 3000)
  })
}

async function doWork2() {
  return new Promise(resolve => {
    setTimeout(resolve, 3000)
  })
}

async function main() {
  await doWork1()
  await doWork2()
}

const t1 = performance.now()
await main()
const t2 = performance.now();
console.log('script took', t2-t1, 'ms')
script took 6002.199999988079 ms

Против:

function doWork1() {
  return new Promise(resolve => {
      setTimeout(resolve, 3000)
  })
}

function doWork2() {
  return new Promise(resolve => {
      setTimeout(resolve, 3000)
  })
}

function main() {
  return Promise.all([doWork1(), doWork2()])
}

const t1 = performance.now()
main().then(() => {
  const t2 = performance.now();
  console.log('script took', t2-t1, 'ms')
})
script took 3002 ms

Чем только люди не занимаются, лишь бы не использовать async/await

Думают, наверное.

async/await превращает асинхронный код – в сихронный.

1
23 ...

Information

Rating
Does not participate
Registered
Activity