Comments 27
Если я правильно понял, Вы сделали в системе не более одного активного ассинхронного эффекта. Если это так, то в этом нет смысла — ассинхронные эффекты хочется всё же исполнять параллельно.
Также, совершенно непонятный кейс с await-ом эффектов. Мы же эффекты один раз настраиваем и они потом много раз перезапускаются — между эффектами нет явного порядка.
Короче, непонятно зачем это всё нужно (не в смысле ассинхронные эффекты в принципе, а конкретно Ваше виденье).
Вы очень невнимательно читали. Эффектов может быть сколько угодно. Я в статье привел пример такого теста, и еще дал ссылку на 30+ других тестов.
await эффектов это не какой-то отдельный кейс, и это никак не противоречит тому, что эффекты мы создаем один раз, а выполняются они сколько угодно раз.
Тогда не очень понятно. Если у Вас могут параллельно могут исполняться два ассинхронных эффекта, то как Вы отслеживание их зависимости?
Тест из примера в статье не показывает параллельно ли они исполняются, или последовательно (может у Вас внутри там семафор).
await эффектов это не какой-то отдельный кейс, и это никак не противоречит тому, что эффекты мы создаем один раз, а выполняются они сколько угодно раз.
Тогда совершенно непонятно, зачем он нужен. Какой юзкейс, безопасно ли пользователю забыть сделать await эффекту?
(может у Вас внутри там семафор)
Внутри LiFo стек, точно такой же какой используется в любом раниаме джаваскрипта. Об это сказано в статье.
безопасно ли пользователю забыть сделать await эффекту
Безопасно ли забыть сделать await любому промису в программе?
Не очень понятно. Давайте такой пример:
autorun(async () => {
state.shared;
await sleep(1000);
state.x1;
await sleep(500);
state.x2;
await sleep(1000);
write('x', state.x3);
})
autorun(async () => {
state.shared;
await sleep(500);
state.y1;
await sleep(1000);
state.y2;
await sleep(1000);
write('y', state.y3);
})
Сколько будет исполняться данный код:
При старте
После изменения shared
После изменения x2
После изменения y2
?
P.S. Я умышленно не сделал await autorun. Если это существенно влияет на ответы, либо корректность - скажите.
Вы можете самостоятельно проводить любые эксперименты, все необходимые ссылки в статье есть.
Еще немаловажно обозначить цель всего этого. В чем идея такого теста? Что именно он проверяет? Какое поведение вы ожидаете от программы если предположить, что реактивности вообще нет? То есть, опишите тоже самое обычным кодом на промисах без await, и скажите, что вы ожидаете и почему?
Ну, хорошая реализация исполняет этот код за 2.5 секунды. Если же вводится запрет на параллельное исполнение ассинхронных эффектов, то потребуется целых 5 секунд. Из Вашего описания в статье, я так понимаю, Вы именно что вводите этот запрет, чтобы понимать их какого эффекта происходит чтение.
Ваши упоминания LIFO стека я увидел и при прочтении статьи. Но что конкретно это значит — разительно непонятно. Я могу себе представить 2-3+ сильно разных способов использовать LIFO стек. Вот поэтому и спрашиваю.
Какой-то неинтересный у нас диалог выходит. Если хотите вести конструктивный диалог, приводите реальные примеры, с реальным кодом и расчетами, а не предположения.
Ну, хорошая реализация исполняет этот код за 2.5 секунды
А хорошая, эта какая? Приведите пример, ну или продемонстрируйте это на чистом js. Я утверждаю, что выполнятся этот код будет дольше. На чистом JS, без какой либо реактивности:
Код на промисах без реактивности
function sleep(num) {
return new Promise(resolve => {
setTimeout(() => {
resolve()
}, num)
})
}
async function first() {
console.time('start')
console.log('start first')
await sleep(1000);
await sleep(500);
// увеличил до 5 сек!
await sleep(5000);
console.log('end first')
}
async function second() {
console.time('second')
console.log('start second')
await sleep(500);
await sleep(1000);
await sleep(1000);
console.log('end second')
console.timeEnd('start')
}
async function test() {
first();
second();
}
test()
Запустив этот код, мы получим такие логи:
start first
start second
end second
exec time: 2506.698974609375 ms
// и через достаточное длительное время
end first
Вы утверждаете, что 2,5 сек, так приведите доказательство.
В JavaScript нет многопоточности (читай параллелизма), а только асинхронность.
Ну, так Ваши замеры и показывают, что 2.5 секунды. Не, понятно, что там ещё какие-то копейки будут от собственно синхронного исполнения — но это вообще не суть вопроса. Суть вопроса в том, чтобы понять как поведет Ваша методика, если там будут хоть какие-то нетривиальные промисы.
То, что Вы меряете только один промис, а не оба — просто придираетесь к формулировкам. Разумеется мерять нужно время от старта до момента появления обоих write-ов.
Ну и вопрос "сколько времени займет" здесь носит качественный, а не количественный характер. Вы делаете то, что в других библиотеках совершенно отсутствует — в этом случае вести разговор об абсолютном перфе нет особого смысла. Интересно понять качественное поведение Вашего решения.
Если хотите вести конструктивный диалог, приводите реальные примеры,
Я вроде задаю конкретные вопросы и привожу конкретные примеры. Основной вопрос сводится к тому, что не до конца понятен принцип работы Вашего подхода, а также его особенности (и в течении нашего разговора у меня вопросов становиться только больше).
Про расчеты написал выше, что в них нет смысла при разговоре о качественном поведении подхода.
P. S. Вы почему-то то и дело пытаетесь меня в чем-то обвинить. Не надо так. Вы кажется достаточно хорошо знакомы с деталями, чтобы можно было опустить какие-то формальности и терминологические споры.
Этот код:
autorun(async () => {
state.shared;
await sleep(1000);
state.x1;
await sleep(500);
state.x2;
await sleep(1000);
write('x', state.x3);
})
autorun(async () => {
state.shared;
await sleep(500);
state.y1;
await sleep(1000);
state.y2;
await sleep(1000);
write('y', state.y3);
})
выполнится за 2.5 сек, но тогда есть риск, что один autorun ошибочно подпишется на зависимости другого.
А этот:
await autorun(async () => {
state.shared;
await sleep(1000);
state.x1;
await sleep(500);
state.x2;
await sleep(1000);
write('x', state.x3);
})
await autorun(async () => {
state.shared;
await sleep(500);
state.y1;
await sleep(1000);
state.y2;
await sleep(1000);
write('y', state.y3);
})
выполнится за 5, но уже без риска.
Примерно как в таком псевдокоде:
async function getUserPosts() {
fetch('currentUser')
.then((result) => {
this.id = result.id
})
fetch(`/posts/${this.id}`) // какой тут id?
.then(/ ... /)
}
// надежно
async function getUserPosts() {
this.id = await fetch('currentUser')?.id
fetch(`/posts/${this.id}`) // тут точно текущий id
.then(/ ... /)
}
await autorun, как и await [somePromise] не полностью блокирует поток. Например выполнив этот код:
fetch('google.com').then(() => {
console.log('fetch')
})
const state = makeObservable({ value: 0 })
const otherState = makeObservable({ value: 0 })
async function foo() {
await autorun(async () => {
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('async autorun depends on state', state.value)
})
}
foo()
autorun(() => {
console.log('sync autorun depends on otherState', otherState.value)
})
console.log('just log');
setTimeout(() => {
console.log('change state')
state.value = 1
}, 3000)
setTimeout(() => {
console.log('change otherState')
otherState.value = 1
}, 5000)
мы получим такой лог:
sync autorun depends on otherState 0
just log
fetch
async autorun depends on state 0
change state
async autorun depends on state 1
change otherState
sync autorun depends on otherState 1
выполнится за 5, но уже без риска.
Никакого риска, это гарантированный выстрел в ногу: при перезапуске авторанов по изменению общей зависимости, они запустятся конкурентно и второй подпишется на зависимости первого.
Я согласен, что поведение может быть непредсказуемо в определенных сценариях. Это все же больше эксперимент, но конкретно этот кейс вроде отрабатывает как надо:
fetch('google.com').then(() => {
console.log('fetch')
})
const state = makeObservable({ value: 0 })
const otherState = makeObservable({ value: 0 })
async function foo() {
await autorun(async () => {
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('async autorun: state.value', state.value)
})
}
foo()
autorun(() => {
console.log('sync autorun: state & otherState', state.value, otherState.value)
})
console.log('just log');
setTimeout(() => {
console.log('changing state and otherState, both autorun should execute')
state.value = 1
otherState.value = 1
}, 5000)
setTimeout(() => {
console.log('change otherState, only sync autorun should execute')
otherState.value = 2
}, 10000)
setTimeout(() => {
console.log('change otherState again, only sync autorun should execute')
otherState.value = 3
}, 15000)
setTimeout(() => {
console.log('change state again, both autorun should execute')
otherState.value = 4
state.value = 2
}, 20000)
Логи такие:
sync autorun: state & otherState 0 0
just log
fetch
async autorun: state.value 0
changing state and otherState, both autorun should execute
sync autorun: state & otherState 1 1
async autorun: state.value 1
change otherState, only sync autorun should execute
sync autorun: state & otherState 1 2
change otherState again, only sync autorun should execute
sync autorun: state & otherState 1 3
change state again, both autorun should execute
sync autorun: state & otherState 2 4
async autorun: state.value 2
Ну, как поддержать синхронный авторан конкурентно с ассинхронным — понятно.
По сути, нужно внутри библиотеки вставлять семафор на ассинхронные автораны (чтобы одновременно было запущенно не более одного авторана). Иначе, как подтверждает @nin-jin, это выстрел в ногу, как только у нас запустится два ассинхронных авторана (например, если у них поменяется общая зависимость). Я об этом и написал в самом первом своем комментарии.
Поэтому для меня совершенно непонятна затея await-ить автораны (мы же делаем await только для первого запуска, а со всеми остальными ничего не понятно).
Ограничение на последовательное исполнение ассинхронных авторанов, в целом, понятно. Вопрос только в том, а что можно сделать полезного с учетом этого ограничения. Не будет ли это источником водопадов?
Лучше смотрите в сторону плагина для Vite/Webpack, который будет используя babel превращать это:
autorun(async () => {
state.shared;
await sleep(1000);
state.x1;
await sleep(500);
state.x2;
await sleep(1000);
write('x', state.x3);
})
autorun(async () => {
state.shared;
await sleep(500);
state.y1;
await sleep(1000);
state.y2;
await sleep(1000);
write('y', state.y3);
})
Вот в это:
autorun(async () => {
const currentEffectFn = myLib.getCurrentEffectFn();
state.shared;
await sleep(1000);
myLib.activeSubsribeTo(currentEffectFn);
state.x1;
await sleep(500);
myLib.activeSubsribeTo(currentEffectFn);
state.x2;
await sleep(1000);
myLib.activeSubsribeTo(currentEffectFn);
write('x', state.x3);
})
autorun(async () => {
const currentEffectFn = myLib.getCurrentEffectFn();
state.shared;
await sleep(500);
myLib.activeSubsribeTo(currentEffectFn);
state.y1;
await sleep(1000);
myLib.activeSubsribeTo(currentEffectFn);
state.y2;
await sleep(1000);
myLib.activeSubsribeTo(currentEffectFn);
write('y', state.y3);
})
Тогда это будет:
a) Надежно.
б) Пользователю не нужно будет об этом думать, оно просто работает.
в) А других вариантов и нет, чтобы при этом пользовательский код оставался чистым и красивым.
Я сам несколько плагинов написал себе, чтобы жизнь упростить и оставлять код чистым, поэтому сразу подсказка - https://astexplorer.net/
Там выбираете JavaScript, @babel/parser
Вставляете код и наслаждаетесь этой прекрасной утилитой, которая архи круто помогает при написании code transformers
У такого способа есть свои недостатки. Например, нужно не забыть подключить плагин. Еще добавляется магия и неочевидность. И самое главное, не будет работать там где нет этапа сборки.
Возможно эту же идею можно реализовать как-то так:
class TrackableFunction extends Function {
constructor(effect) {
const trackableEffect = makeTrackable(effect.toString())
super(trackableEffect)
}
}
const effect = new TrackableFunction(function() {})
но тут нужно хорошенько подумать, насколько это безопасно.
Такой подход ломается на банальном `await process(state)` — поскольку внутри process могут быть другие await-ы, или того хуже коллбеки. Препроцессить абсолютно весь код — сложно (например, потому что браузерные апи не выйдет препроцессить).
Вообще, правильным решением было бы профорсить AsyncLocalStorage (он же cls). Но кажется проползал завис и есть только реализация в ноде :(
Можно было бы проэмулировать cls через препроцессор, и я думаю уже даже есть такие решения. Но хрупко это все, в прод бы я такое не потянул.
Если код и декорировать, то делать это нужно как-то так, выключая трекинг перед потерей управления, и включая обратно, при возвращении:
sub.warm( await sub.freeze( sleep( 1000 ) ) )
Интересная библиотека, спасибо.
Скажите, происходит ли переподписывание при срабатывании эффекта?
Если у нас есть autorun(() => state.isLoading ? null : state.data)
подпишется ли он на state.data
?
Автор, вам сюда Объектное Реактивное Программирование
Зачем?
там можно посмотреть на уже готовую реализацию в которой решены описанные в вашей статье проблемы
Заблуждаетесь. Описанные мной проблемы никем еще не решены.
Файберы и их эмуляция худо-бедно решают вопрос поддержки трекинга асинхронных задач. С асинхронными функциями этот вопрос в принципе не решаем, пока нет асинхронного контекста, если не считать вариант запрета их конкурентного исполнения.
Реактивные системы: возможно ли отслеживать зависимости в асинхронном коде?