И вот совсем недавно React принес нам очередной подарок. Очередную возможность косячить меньше — getDeviredStateFromProps.
Технически — имея статический мапинг из пропсов в стейт логика приложения должна стать более проста, более понятна, тестируема и так далее. По факту многие люди начали топать ногами, и требовать prevProps обратно, не в силах (или без особого желания) переделать логику своего приложения.
В общем разверлись пучины ада. Ранее простая задача стала сложней.
Изначальная дискуссия развернулась на страницах github/reactjs.org, и была вызвана необходимостью знать как именно поменялись props, в целях логирования
We have found a scenario where the removal of componentWillReceiveProps will encourage us to write worse code than we currently do.
// OLD WAY
componentWillReceiveProps(newProps){
if (this.props.visible === true && newProps.visible === false) {
registerLog('dialog is hidden');
}
}
// NEW WAY
static getDerivedStateFromProps(nextProps, prevState){
if (this.state.visible === true && nextProps.visible === false) {
registerLog('dialog is hidden');
}
return {
visible : nextProps.visible
};
}
PS: Но вы то знаете, что такие операции надо выполнять в `componentDidUpdate`?
Но это было только начало. В тот же день был (пере)создан issue о модификации getDerivedStateFromProps, потому что без prevProps жизни нет никакой. Точно такой же issue уже был единожды закрыт с «Wont fix», и на этот раз, после долгих словестных баталей, он опять же был закрыт с «Wont fix». Так ему и надо.
Но, перед тем как обсудить выход из положения, и почему issue был закрыт — лучше придумать какой-либо удобный пример для наглядности рассуждений.
Таблица. С сортировкой и постраничной навигацией
Обратимся к TDD, и в начале определим задачу, и пути ее решения
- Что нужно сделать чтобы нарисовать таблицу?
- Взять данные для отображения
- Отсортировать их
- Взять slice, с данными только для текущей страницы
- Не перепутать порядок пунктов
- Что делать если данные изменились?
- Начать все с начала
- А если изменилась только страница?
- Выполнить пункт 1.3 и далее.
- Как изменить страницу
- this.setState({page})
- Как отреагировать на изменение state.page?
- Никак
В том и проблема — можно отреагировать на изменение props, но для изменение стейта такой функции нет (даже если вы прочитали ее в названии этой статьи).
Правильное решение номер 1
Точнее «правильное» решение. Я думаю это должен быть конечный автомат. Изначально он находится в состоянии idle. При поступлении сигнала setState({page}) он перейдет в другое состояние — changing page. При входе в это состояние он посчитает что там ему надо и пошлет сигнал setState({temporalResult}). По хорошему далее автомат должен перейти в состояние «next step», который посчитает все что угодно из шага после текущего, и в итоге попадает в commit, и где передаст данные из temporalResult в data, после чего перейти в idle.
Технически — это правильное решение, и возможно все так и работает, где-то глубоко в железе, или листочке бумаги. Пускай там и остается.
Правильное решение номер 2
А что если создать еще один элемент, в который передать в виде пропсов state и props из текущего элемента, и использовать getDerivedStateFromProps?
Тоесть «первый» компонент — это «smart» controller, в котором происходит setState({page}), а его dumb будет не такая уж и dump, вычисляя нужные данные при изменении внешних параметров.
Все хорошо, но пункт «пересчитать только то что нужно» не реализуем, так как мы ЗНАЕМ что что-то изменилось (потому что кто-то вызвал getDerivedStateFromProps), но не знаем ЧТО.
В этом плане не изменилось ни-че-го.
Правильное решение номер 3 («официальное»)
Основой «решения», которое и послужило аргументаций закрытия issue, было одно простое утверждение.
You might not needreduxgetDerivedStateFromProps. You need memoization.
// base - https://github.com/reactjs/rfcs/pull/40#discussion_r180818891
import memoize from "lodash.memoize";
class Example {
getSortedData = memoize((list, sortFn) => list.slice().sort(sortFn))
getPagedData = memoize((list, page) => list.slice(page*10, (page+1)*10))
render() {
const sorted = this.getSortedData(this.props.data, this.props.sort);
const pages = this.getPagedData(sorted, this.props.page);
// Render with this.props, this.state, and derived values ...
}
}
Мемоизация и будет следить за «изменениями», потому что она просто знает «старые» значения, и вызывает мемоизированную функцию только когда значение изменяется.
Но тут есть две проблемы. И обе я взял из второго комментария к оригинальному issue
Проблема номер 1
I'm having to resort to a weird multi-depth WeakMap, and making decisions about when to drop different levels of the cache.Тот самый «значимый» порядок изменения значений, помноженный на кривые руки. Появляются какие-то уровни кеширования, WeakMaps. Охо, что ты делаешь, остановись!
Проблема номер 2
One solution suggested memoizing that computation and calling it each time, which is a good idea but in practice it means managing caches which, when you're dealing with a function that takes more than one argument, greatly increases your surface area for potential bugs and mistakes.А это одна из главных проблем всех библиотек мемоизации — требования использования «конечных» значений как аргументов функции. В общем просто неудобно, а заодно можно переменну перепутать.
Первая проблема имеет решение в reselect. В каскадах reselect, когда имея два мемоизированных значения на вход, можно сформировать третье мемоизированное значение на выход.
Еще лучше — композиция мемоизированных функций, когда вы просто определяете порядок исполнения, а некий (конечный) автомат исполняет их одно за другим… Вообще каскады reselect это тоже «composing», но у них там дерево, а тут нужен линейный процесс — waterfall.
Хм, я видел водопад в анонсе этот статьи. К чему бы это?
const input = {...this.state, ...this.props };
const resultOfStep1 = {...input, sorted:this.getSortedData(input.data, input.sort);
const resultOfStep1 = {... resultOfStep1, sorted:this.getPagedData(resultOfStep1.sorted, resultOfStep1.page);
Если «весь мусор» вынести в хеплер, то получим достаточно чистый код
const Flow = (input, fns) => fns.reduce( (acc,fn) => ({...acc, ...fn(acc)}), input);
const result = Flow({...this.state, ...this.props },[
({ data, sort }) => ({data: this.getSortedData(data, sort) });
({ data, page }) => ({data: this.getPagedData(data, page)
]);
Чистое, простое и очень красивое решение для проблемы номер 1, четко определяющее порядок формирования конечного значение, которое совершенно не возможно мемоизировать.
Которое совершенно не возможно мемоизировать потому что у «шага» исполнения только один аргумент, и при любом изменении input надо начинать с самого первого этапа — нельзя понять что изменился только page и надо перезапустить только последний шаг.
Или можно?
import {MemoizedFlow} from "react-memoize";
class Example {
getSortedData = (list, sortFn) => list.slice().sort(sortFn)
getPagedData = (list, page) => list.slice(page*10, (page+1)*10))
render() {
return (
<MemoizedFlow
input={this.props}
flow = [
({data, sort}) => ({ data: this.getSortedData(data, sort)}),
({data, page}) => ({ data: this.getPagedData(sorted, page)});
]
>{ ({data}) => <table>this is data you are looking for {data}</table> }
</MemoizedFlow>
)
}
}
Как не странно — на этот раз все будет работать как часики. И даже функция Flow, которая будет использована для расчета конечного значения будет точно такая же, как и раньше.
Весь секрет — в другой функции мемоизации, memoize-state, про которую я расказывал месяц назад — она то и знает какие части state были использованны на конкретном этапе, давая возможность реальзовать мемоизированный waterfall.
Более сложный пример на поиграться — codesandbox.io/s/23ykx5z5jpВ итоге — статическая функция getDerivedStateFromProps заменяется на (в неком смысле) статически определенный компонент, настройка которого позволяет четко определить «способ и метод» получения результата, точнее формирование конечного результата из набора исходных данных.
Это может быть getDerivedStateFromProps, getDerivedStateFromState, getDerivedPropsFromProps — все что угодно. Можно даже сайдэффекты запускать (работает, но лучше не надо).
И самое главное — такой подход позволяет определить именно реацию на изменение параметра. И позволяет определить именно в том виде который «правильный»
Данные надо обновить если изменились даные, или страница. А не только если «страница».Однаждый определенный Flow невозможно сломать. Главное перестать хотеть знать старые значения.
Заключение
В общем React последнее время учит нас «не хотеть» различные подходы, которые могут привести к говнокоду, или проблемам с асинхронным рендером. Но люди остаются людьми, и не хотят отказываться от старых, проверенных временем подходов. Именно в этом и проблема.
На самом деле иногда очень сложно понять как сегодня «правильно» готовить реакт, ведь буквально две недели назад вы его готовили, а тут БАЦ и рецепт изменился.
Но не отчаивайтесь — memoize-state и react-memoize построенный на его основе немного притупят болевые ощущения. Все проблемы можно решить, главное просто попытаться взглянуть на проблему под другим углом.
PS: Тот самый оригинальный issue с заключением.
PS: Немного про то как и почему memoize-state работает.
Комментарии (14)
stopwaiting
16.04.2018 10:54Эх, я так и не понял зачем в данных ситуациях пытались юзать componentWillReceiveProps. ((
Если делать пейджер то все что он должен «знать» это сырые данные (props.allRows), и номер отображаемой страницы (state). Вроде все. Какие еще state.visible могут быть у пейджера? это же задача вьюхи самой страницы.kashey Автор
16.04.2018 10:55state.visible — это копипаст конкретной боли конкретного человека из конкретного issue. Разговор переходит на задачу с таблицей парой абзацев ниже.
faiwer
16.04.2018 14:28+2Как ни пытался — не получилось уловить всю нить повествования. Что, где, как, почему? ;) Я так и не понял в чём там вообще была проблема. Но как я понял, поплевавшись на вложенные WeakMap-ы мы пришли к Proxy с трекингом зависимостей. Ух.
Итак. Нам нужно взять таблицу и отсортировать. У нас есть
data
и естьsort
. Мемоизируем это тем жеcreateSelector
-ом (можно и не им, не принципиально):
const getSortedTable = createSelector( obj => obj.data, obj => obj.sort, (data, sort) => magic(data, sort)); const sortedData = getSortedTable({ data, sort });
Каждый холостой вызов
getSortedTable
будут вызваны две крохотные функции и будет произведено три===
. Затем что нам надо? Взятьslice
? Ну ок:
const getPageData = createSelector( obj => obj.data, obj => obj.page, (data, page) => anotherMagic(data, page)); const sortedData = getPageData({ data: sortedData, page });
Картинка та же: два
() => select
и три===
. Зачем тутweakMap
-ы? Зачем тутproxy
?
kashey где я потерял нить и свернул не туда?
P.S. вложенные weakmap-ы крутая штука, но явно не в этой задаче.
kashey Автор
16.04.2018 14:45Нить нигде не потеряна, и поворот не пройден. Только кода получилось примерно в 6 раз больше чем в первом примере на мемоизацию.
getSortedData = (lodash)memoize(magic) getPagedData = memoize(anotherMagic) render() { const sorted = this.getSortedData(this.props.data, this.props.sort); const pages = this.getPagedData(sorted, this.props.page); }
Вся проблема в том как это написать так чтобы было просто, удобно и всем понятно. В случае с reselect или обычной мемозаиций — каждый шаг понятен, но не понятно как они сочетаются.
Тут — сильно понятнее и короче. И декларативнее.
memoizedFlow([ ({data, sort}) => ({ data: magic(data, sort)}), ({data, page}) => ({ data: anotherMagic(sorted, page)}); ])
WeakMapов тут нет, это какой-то ботаник начал их городить непонятно зачем и почему. И proxy тут нет, так как работать должно под IE11/React Native. (на самом деле есть и то и другое, но не всегда)
А скорость? memoize-state просто знает какие ключи были использованы для формирования результата и проверяет их. При этом достаточно умно, понимая вложеность обьектов и как вообще «современная иммутабельность» работает. Я к тому, что при холостой работе там будет те же самые два ===, под одному на каждую функцию. В общем perf тесты часть репозитория, согластно измерениям там сотни тысяч, а то и миллионы мемоизированных операций в секунду. Чуть чуть медленее reselect или memoize-one.faiwer
16.04.2018 14:54но не понятно как они сочетаются
А почему непонятно? У тебя же тут по сути всего 2 строки (пример с
lodash/memoize
), как в них можно запутаться? :) Ну или спрошу по-другому: неужели весь этот код, что в разделе "Или можно?" с<MemoizedFlow/>
, клеевым-редьюсером иfunction-as-children
проще и понятнее, этих двух строк? :) Что мы выиграли непосредственно в этом примере с таблицей.kashey Автор
16.04.2018 15:10Это тут все понятно. А если вы приходите на готовый проект (или уходите с такого) — было бы хорошо иметь совсем-совсем понятную логику.
В принципе все так называемые «code smells» и антипатерны примерно об этом — оно вообще работает, но рано или поздно все сломается. Когда новый джун выйдет, например.
У меня за джуна выступает жена, для которой до сих пор составляет проблему написать js, c правильным reselect она 100% не справится, в то время как такая вот развесистая конструкция у нее проблем не вызывает.8bitjoey
17.04.2018 12:13А почему не использовать memoizedFlow из memoize-state прямо в getDeviredStateFromProps? Суть остается та же, зато render() не захламляется.
kashey Автор
18.04.2018 01:37Основых идей две:
— все думаю перенести все дела из getDeviredStateFromState в componentDidUpdate, потому что второй представляет и данных побольше, и сайдэффекты «разрешает».
— зачем писать свой getDeviredStateFromProps если он вам не нужен, и вообще у вас Stateless компонент?
Но на самом деле — почему бы и не использовать прямо в render. Проблем нет.8bitjoey
18.04.2018 01:45Ну это как-то странно. Во-первых, переносить в componentDidUpdate и обновлять там state — значит рендерить два раза. Во-вторых, MemoizedFlow хоть и позволяет в некоторых случаях писать компонент без стейта, все же представляет собой стейт, хоть и неявный. Так что stateless'ом тут не очень пахнет.
kashey Автор
18.04.2018 01:49Ну вот потому и не переношу :)
А про stateless — MemoizedFlow сам то stateful, но никак не регламентирует чем должны быть «вы».
baldrs
«Более сложный пример» подозрительно напоминает reducer засунутый прямо во view и занимающий там явно много места. Можно было вынести весь этот list/sort и прочее в redux и сопокойно пользоваться getDerivedStateFromProps + actions.
kashey Автор
Это, как не странно, может быть сложная задача. Проблема в именно в «Flow».
Чтобы правильно (и оптимально, что важно) все посчитать redux должен по некому события сделать «что должен», после чего как-то тригернуть следуйщий этап. В редьсерах такого не сделать.
Быть может сага? В принципе она может ожидать прихода событий и вызывать функции обработчики, а они уже будут вызывать известные им этапы и будет все работать просто и удобно. (надо будет написать хелпер для такого, спасибо за идею)
А что делать если послали два события? Тогда все посчитается два раза, чтобы этого избежать, то прийдется «для расчета следуйщего этапа» тоже кидать событые, и надется что takeLatest сделает все правильно.
В общем я постарался решить задачу без redux, потому что не redux-ом единым. В нем хорошо хранить данные, но переключение направления сортировки или страницы — личное дело компонента отображения.
Miraage
+1. Палкой по рукам бить надо, когда логику во view layer пихают.
Сами люди из facebook пишут, что getDerivedStateFromProps в крайне редких случаях должен использоваться.
kashey Автор
Мне казалось, что вопрос о том что React не является view layer давно закрыт. Как и вопрос об уместности постоянного использования redux.
Он вроде как должен помогать решать сложные задачи работы со стейтом, но тут нет стейта — только результат который надо показать на основе текущего стейта.
Всегда и везде это было в mapStateToProps, на стороне view(в react-redux), но никак не в redux-core.
Reselect composition в mapStateToProps сделает тоже самое, что и Reselect composition описанная в этой статье, только в возможно более «правильном» месте и будет болеть теми же самыми проблемами. Заменить reselect на memoize-state(он для того и был придуман) — и дело в шляпе.