Comments 57
Когда приходит ответ от сервера, то нужно заполнить соответствующие сущности, найдя их по идентификаторам. Поэтому стор лучше организовывать в нормализованном виде c перекрёстными ссылками: store.folders['id123'].tasks[0] === store.tasks['id321']
Для того чтобы обновить сущности когда приходит ответ от сервера (также как и при получении обновлений по вебсокетам) когда данные приходят в нормализированном виде — хеше объектов где айдишнику соответствует объект с данными то нужно по айдишнику обновить нужный объект в нашем вложенном дереве объектов связанных ссылками. В статье этот способ не описан в деталях я лишь упомянул что нужно добавить учет каждого созданного объекта в глобальной хеш-мапе. То есть нужно просто в конструкторе базового класса сгенерировать айдишник для нового созданного объекта и закешировать его в глобальном словаре.
И теперь при получении данных от сервера всегда можно вытащить нужный объект по его айдишнику и обновить нужные в нем данные а сама структура всех данных в состоянии остается в древовидном виде или точнее в виде графа (если учитывать обратные ссылки на родительские объекты).
То есть нормализация остается только для нужд общения с сервером а для компонент и всего остального данные у нас удобно вложены и ссылаются друг на друга по ссылкам. Это упрощает использование данных как и в шаблонах компонентах так и в обработчиках в отличии от организации данных изначально в нормализованном виде в плоском хеше таблиц как это принято делать используя redux в котором мы теряем возможность обращаться к другим частям состояния просто обращаясь по ссылке.
Поскольку с нормализованным подходом ссылок на объекты больше нет то связи мы теперь вынуждены моделировать через айдишники, и как следствие каждый раз когда нам нужно обратиться к родительской сущности или вложенным сущностям нам нужно каждый раз вытаскивать объект по его айдишнику из глобального стора. А это неудобно.
Например, когда нужно узнать рейтинг родительского комментария мы не можем просто написать как comment.parent.rating — нам нужно сначала вытащить объект по айдишнику — AppState.comments[comment.parentId].raiting
. А как мы знаем ui может сколь угодно быть разнообразным и компонентам может потребоваться информация о различных частях состояния и такой код вытаскивания по айдишнику на каждый чих легко превращается в некрасивую лапшу и будет пронизывать все приложение. Например, нам нужно узнать самый большой рейтинг у вложенных комментариев, то через ссылки можно просто записать как
comment.children.sort((a,b)=>b.rating - a.rating))[0]
а в варианте с айдишниками нужно еще дополнительно замапить айдишники на объеты —
comment.children.map(сhildId=>AppState.comments[childId]).sort((a,b)=>b.rating - a.rating))[0]
Или когда требуется достать глубоко вложенные данные (например у объекта комментария нужно узнать имя папки в котором он находится где схема сущностей выглядит как user->folder->project->task->comment) то используя ссылки все просто и лаконично
comment.task.project.folder.name
а вот через айдишники это превращается в
AppState.folders[AppState.projects[AppState.tasks[comment.taskId].projectId].folderId].name
Ну наконец есть момент производительности — операция получения объекта по ссылке это O(1), а операция вытаскивания объекта по айдишнику это уже O(log(n)) что может сказаться на обработке большого количества данных
"а операция вытаскивания объекта по айдишнику это уже O(log(n))" — с каких пор?
Поскольку с нормализованным подходом ссылок на объекты больше нет то связи мы теперь вынуждены моделировать через айдишники, и как следствие каждый раз когда нам нужно обратиться к родительской сущности или вложенным сущностям нам нужно каждый раз вытаскивать объект по его айдишнику из глобального стора. А это неудобно.
Для этого просто пишется набор денормализующих ф-й.
Если же вы храните денормализованное состояние в сторе, то у вас две проблемы:
- состояние прибито к виду — стор должен знать о том, какую вложенность объектов требуют виды, чтобы именно с этой вложенностью объекты и хранились
- избыточность и дублирование данных — при хоть сколько-нибудь сложных связях между объектами у вас один и тот же объект будет вложен в много разных других объектов. своевременно все это обновлять и следить за корректностью очень быстро становится сильно сложнее, чем денормализовывать данные на выходе стора.
- состояние прибито к виду — стор должен знать о том, какую вложенность объектов требуют виды, чтобы именно с этой вложенностью объекты и хранились
В статье описан как раз противоположеный подход — мы храним данные в сторе моделируя как сущности и связи между ними (например юзер может создавать (изменять, удалять) папки, у них проекты, у проектах задачи и у задачах подзадачи. И соотвественно структура стора будет прямо соответствовать этим сущностям и связям — у объекта User будет храниться массив папок, у каждой папки массив проектов, у каждого проекта массив задач и у каждой задачи массив подзадач. В итоге нигде в структуре стора нет завязки на вид. А структура видов может быть совершенно разная — можно отображение проектов сделать на одной странице с папками (где список папок будет в сайдпанели) можно разнести по разным страницам. Можно как угодно структурировать и моделировать отображение — на структуру состояния это никак не повлияет.
2. избыточность и дублирование данных — при хоть сколько-нибудь сложных связях между объектами у вас один и тот же объект будет вложен в много разных других объектов. своевременно все это обновлять и следить за корректностью очень быстро становится сильно сложнее, чем денормализовывать данные на выходе стора.
Вот тут я вас не понимаю — объекты в сторе связываются по ссылке — соотвественно если проект отрендерил список задач (<div>{project.tasks.map(task=><Task task={task}/>}</div>
) а компонент задачи отрендерил список подзадач точно так же передав подзадачу (<div>{this.props.task.subtasks.map(subtask=><Subtask subtask={subtask}/>}</div>
) то изменение задачи в компоненте <Task/> (this.props.task.text = newText) и изменение задачи в компоненте <Subtask/> через ссылку на родительский объект (this.props.subtask.task.text = newText) это изменение одного и того же объекта и соотвественно никакого дублирования и неконсистентности не будет
мы храним данные в сторе моделируя как сущности и связи между ними (например юзер может создавать (изменять, удалять) папки, у них проекты, у проектах задачи и у задачах подзадачи.
Ну то есть как раз то, о чем я и сказал — стор прибит к виду. Если бы вам надо было менять юзеров у папок, а не папки у юзеров, вы бы хранили папки со списком юзеров у каждой, а не наоборот.
это изменение одного и того же объекта и соотвественно никакого дублирования и неконсистентности не будет
Ну так у вас примитивное дерево. Представьте теперь, что на какой-то Task из списка для конкретного проекта есть ссылки из десятка разных кусков графа. Вы загрузили новый (обновленный) список задач для проекта, ваши действия?
Если бы вам надо было менять юзеров у папок, а не папки у юзеров, вы бы хранили папки со списком юзеров у каждой, а не наоборот.
Если появится фича коллаборации — например расшарить папку другим пользователем то появляется просто many-to-many связь и у юзера будет массив ссылок на папки а у папки будет массив ссылок на других объектов-юзеров. Не вижу тут как стор будет прибит к виду. В состоянии приложения объекты хранятся почти также как они хранятся в базе данных (где ссылки это просто внешний ключ который превращает плоский список хешей в граф объектов) и данные точно также как и реляционных базах данных принято нормализировать чтобы при изменении одной сущности достаточно обновить одну единственную запись а не менять во всех местах где этот конкретная сущность может находиться. Как при таком подходе стор может быть прибит к виду?
Представьте теперь, что на какой-то Task из списка для конкретного проекта есть ссылки из десятка разных кусков графа. Вы загрузили новый (обновленный) список задач для проекта, ваши действия?
Вы хотите сказать что когда загружаем из сервера список задач то получаем новые объекты а вот ссылки из разных мест графа останутся старыми? Я в статье и в комментарии выше упомянул что при получении обновленных объектов от сервера нужно вытащить из хешмапы по его айдишнику тот объект который находится в графе и обновить его свойства не создавая новый объект
Если появится фича коллаборации — например расшарить папку другим пользователем то появляется просто many-to-many связь и у юзера будет массив ссылок на папки а у папки будет массив ссылок на других объектов-юзеров.
Ну вот, о чем и речь. У вас изменился вид — и пришлось менять стор.
Как при таком подходе стор может быть прибит к виду?
Именно так, как вы описываете. Поменялась немного логика вида = переписываем стор.
Я в статье и в комментарии выше упомянул что при получении обновленных объектов от сервера нужно вытащить из хешмапы по его айдишнику тот объект который находится в графе и обновить его свойства не создавая новый объект
Ну вот мне и хотелось от вас услышать, как именно вы собираетесь искать и обновлять нужные таски и ссылки на них. С нормализованным состоянием у вас просто есть таблица тасок по которой вы можете пройтись. В вашем случае у вас таски кусками разбросаны по всему графу то там, то сям.
Ну вот, о чем и речь. У вас изменился вид — и пришлось менять стор.
Тут меняется не сколько вид сколько бизнес-логика (схема сущностей и связей). Как тут можно обойтись без изменения стора? На бекенде потребуется добавить новые таблицы и логику, и на фронте соотвественно тоже придется изменить стор неважно будут ли там объекты связываться через айдишники или через ссылки.
Ну вот мне и хотелось от вас услышать, как именно вы собираетесь искать и обновлять нужные таски и ссылки на них. С нормализованным состоянием у вас просто есть таблица тасок по которой вы можете пройтись. В вашем случае у вас таски кусками разбросаны по всему графу то там, то сям
Я выше упомянул что того чтобы обновить объект который находится глубоко в дереве-графе нужно будет вытащить этот объект по его айдишнику из отдельного хеша и обновить его свойства. То есть помимо того что объект находится глубоко в дерево на него еще будет ссылка по айдишнику из хеша. И при создании объекта нужно еще и записывать его айдишник и ссылку на него в этом хеше и удобней будет это сделать в конструкторе базового класса. Получается что с одной стороны у нас сущности хранятся в виде графа объектов связанный ссылками и эти объекты удобно передавать по ссылке компонентам (без маппинга айдишников на объекты) и удобно обращаться к связанным сущностям из компонентов по ссылке а не вытаскивать каждый раз объект по его айдишнику из хеша. А с другой стороны для нужд общения с сервером, обновления данных и прочего эти же самые объекты (точнее ссылки на них) заодно хранятся в отдельном хеше где айдишнику будет соответствовать нужный объект и теперь удобно обратиться по объекту зная его айдишник когда это потребуется.
Это Вы прямо один-в-один mobx-state-tree описываете :-)
Только там еще снапшоты состояния, и лог изменений в формате JSON Patch с возможностью rollback.
Я выше упомянул что того чтобы обновить объект который находится глубоко в дереве-графе нужно будет вытащить этот объект по его айдишнику из отдельного хеша и обновить его свойства. То есть помимо того что объект находится глубоко в дерево на него еще будет ссылка по айдишнику из хеша.
То есть вы обновляете объект сперва в хеше, а потом проходите по графу и обновляете все ссылки. Вам действительно кажется это проще и удобнее, чем сделать только первое действие (обновить объект в хеше и все)?
Тут меняется не сколько вид сколько бизнес-логика (схема сущностей и связей). Как тут можно обойтись без изменения стора?
Так бизнес-логика не менялась и бд не менялась. Как были две таблицы со связью многие-ко-многим, так они же и есть.
и на фронте соотвественно тоже придется изменить стор неважно будут ли там объекты связываться через айдишники или через ссылки.
В вашем случае — придется, если же состояние было нормализовано, то нет. Просто добавите новую ф-ю для того, чтобы доставать список юзеров по папке.
То есть вы обновляете объект сперва в хеше, а потом проходите по графу и обновляете все ссылки. Вам действительно кажется это проще и удобнее, чем сделать только первое действие (обновить объект в хеше и все)?
Да. Намного удобнее один раз обновить ссылку чем каждый раз делать поиск...
Так бизнес-логика не менялась и бд не менялась. Как были две таблицы со связью многие-ко-многим, так они же и есть.
Нет, была связь один-ко-многим, а стала многие-ко-многим. Если бы сразу была связь многие-ко-многим — то и менять бы ничего не пришлось.
Да. Намного удобнее один раз обновить ссылку чем каждый раз делать поиск...
Просто поиск делается тривиальной ф-ей (использование которой по факту не отличается от доступа через поле объекта), и не зависит от сложности связей никак. А обновление графа — вобщем-то задача в общем случае достаточно нетривиальная, и ее сложность растет со сложностью графа.
В случае описанной структуры (тривиальное дерево) все, конечно, ок, и если есть гарантии, что сложнее связи в бд не станут — вариант годен. но если нет — тут уж, мне кажется, нет.
Если бы сразу была связь многие-ко-многим — то и менять бы ничего не пришлось.
Почему бы не пришлось? Была связь многие-ко-многим, при этом в сторе у юзеров есть список папок, но у папки нету списка юзеров (т.к. не нужно это в данном сервисе). Потом оказалось, что нужна фича, предполагающая получение списка юзеров для данной папки. Или вы предлагаете уже стор прибить гвоздями к структуре бд? Тоже ведь ничего хорошего.
Причём вообще схема БД, если мы говорим о фронте?
Это вопрос не ко мне, я как раз за то, чтобы схема БД была ни при чем.
Да и односторонняя связь многие-ко-многим и двусторонняя — это значительные изменения бизнес-логики.
Если представлять стор в виде графа — да, если в нормализованном виде — то нет.
Можно представлять как угодно, но одни представления — более гибкие и требует меньше телодвижений для изменений, а другие — менее гибкие и требуют больше.
То есть вы обновляете объект сперва в хеше, а потом проходите по графу и обновляете все ссылки. Вам действительно кажется это проще и удобнее, чем сделать только первое действие (обновить объект в хеше и все)?
Не нужно делать никакого поиска по графу.Когда вытаскиваем объект по айдишнику из хеша то не заменяем его новым объектом а обновляем его свойства. И поскольку все ссылки в графе ссылаются на этот объект который мы достали из хеша они как бы автоматически получат (потому что это один и тот же объект) обновленные значения свойств
Я неточно выразился, подразумевалась загрузка нового объекта (например, новой папки для юзера), а не обновление свойств старой. С-но, вам надо пробежаться по графу и везде расставить на этот объект ссылки (и обратные с объекта, если надо). Аналогично в случае удаления.
Хранить лучше всего в нормализованном виде. А вот работать — через удобное апи, которое инкапсулирует в себе способ получения связанных сущностей.
class Task extends Entity {
title : string
folder_id : string
get folder() {
return this.store.folders[ this.folder_id]
}
}
Преимущества:
- Все объекты можно создавать лениво по мере необходимости.
- Так как доступ к данным всегда идёт через реестр, то у нас всегда есть информация нужен ли эти данные хоть кому-нибудь или занятую ими память можно освободить.
- Собственно и освободить память легко, ибо нет прямых ссылок кроме как через реестр.
- Легко (де)сериализуемое состояние стандартным методами
JSON
. - Сборщику мусора гораздо проще собрать группу объектов без перекрёстных ссылок.
Недостатки:
- Чуть больше бойлерплейта, если не использовать магических десериализаторов, которые сами создадут такие геттеры.
- Время перехода по связям дольше, чем по прямым ссылкам.
В вашем примере
get folder() {
return this.store.folders[ this.folder_id]
}
если вы предлагаете просто вынести болерплейт аналогично такому
class Comment {
get folderName {
return AppState.folders[AppState.projects[AppState.tasks[this.taskId].projectId].folderId].name
}
в стор то суть проблемы не изменится — каждый раз при изменении компонентов нужно добавлять всякие хелперы в стор, более того теперь стор через эти хелперы будет знать о том какие данные нужны для вьюх и у нас model превращается в view-model.
А если же вы предлагаете создавать сторы и ссылаться друг на друга через геттеры внутри которых будет спрятано получение получения объектов по айдишнику, например
class Comment {
get task(){
return AppState.tasks[this.task_id]
}
}
class Task {
get project(){
return AppState.projects[this.project_id]
}
}
class Project {
get folder(){
return AppState.folders[this.folder_id]
}
}
тогда да, благодаря геттерам у нас мы избавимся от болерплейта во вьюхах и сверху будет выглядеть как будто это обращение через ссылки
<div>{comment.task.project.folder.name}</div>
Но надо еще не забыть про прямые связи чтобы мы могли удобно рендерить списки (<div>{project.tasks.map(task=><Task task={task}/>}</div>
) а не заниматься маппингом айдишников на объекты вручную
class Task {
get comments(){
return this.comments.map(commentId => AppState.comments[commentId])
}
}
class Project {
get tasks(){
return this.tasks.map(taskId => AppState.tasks[taskId])
}
}
class Folder {
get projects {
return this.projects.map(projectId => AppState.comments[projectId])
}
}
И таким образом писать хелпер для каждой связи между сущностями чтобы скрыть слой получения объекта по айдишнику.
А если вдруг кроме нормализации еще требуется и иммутабельность то придется добавлять геттеры не только на связи но и на каждое поле вместе с сеттерами
class Task {
...
get text(){
return AppStore.tasks[this.id].text;
}
set text(newVal){
return AppStore.tasks[this.id].text = newVal;
}
}
В итоге, на мой взгляд, эта нормализация а потом попытка скрыть этот факт за сеттерами и геттерами это добавление ненужной абстракции на пустом месте. У нас уже есть возможность сохранить объект прямо на свойстве и обращаться к нему по ссылке. И это нативная возможность js без всяких слоев сверху. А мы добавили еще один слой абстракции только для того чтобы избавиться от ссылок и обращаться к объектам по айдишниками но при этом навешиваем сверху кучу геттеров все равно создав видимость обращение по ссылке.
Да, можно возразить, что для больших приложений чтобы не было тормозов все равно придется подключить mobx и там тоже будут геттеры и сеттеры но в этом случае они необходимы и не создают отдельный логически слой абстракции так как решают чисто техническую проблему уменьшения времени обновления компонентов. Более того с mobx это нормализация будет неэффективной потому что mobx трекает только факт обращения к хешу а не смотрит на айдишник и это значит что если комментарий в рендере обращается к стору <div>{comment.task.text}</div>
где свойство ".task" это геттер (AppState.tasks[this.comment_id]) то любое изменение хеша (например добавление нового комментария) вызовет перерендер всех компонентов у которых происходит обращение к хешу AppState.tasks)
А если же вы предлагаете создавать сторы и ссылаться друг на друга через геттеры внутри которых будет спрятано получение получения объектов по айдишнику
Я вроде бы именно так и написал. Собственно, преимущества и недостатки я расписал. Бойлерплейт легко прячется за обобщённым кодом.
mobx трекает только факт обращения к хешу а не смотрит на айдишник
Я сделал прокси-реестр, который трекает каждый ключ отдельно.
Чем хуже вариант:
class Task {
_folder: Folder
get folder() {
return this._folder;
}
}
Создавать по необходимости можно, нужность отслеживается стандартным сборщиком мусора и мы вообще о ней не думаем, пока он справляется. Проще ему или нет нас особо не интересует, пока не возникнет нужда в оптимизации, которая далеко не факт, что вообще возникнет.
нужность отслеживается стандартным сборщиком мусора и мы вообще о ней не думаем
Сборщик может отследить только доступность, а не нужность. При наличии перекрёстных ссылок между всеми сущностями или общего реестра — все они всегда доступны. Но нужны обычно только когда где-либо рендерятся. ОРП позволяет отслеживать что реально где-либо используется, а что нет.
Проще ему или нет нас особо не интересует, пока не возникнет нужда в оптимизации, которая далеко не факт, что вообще возникнет.
А когда возникает — начинаем рвать на себе волосы. В чём проблема сразу использовать паттерны допускающие масштабирование?
Но можно ли хранить обрабатывать и рендерить состояние в реакт приложении не используя ни setState, ни какие-то дополнительные библиотеки и обеспечить общий доступ к этим данным из любых компонентов?
Можете пояснить, зачем эти ограничения?
Дети, воспитанные редуксом, боятся setState, потому что Дэн Абрамов заругается.
А ограничение на дополнительные библиотеки — вероятно, просто потому что возникает ощущение, будто их и так уже слишком много?
Прочитав статью возникает ощущение, что получается «как не использовать стороннюю библиотеку, а написать свою»
Ваша критика использования редакс выглядит сугубо эмоциональной. Кто-то в свою очередь может сказать что «Дети воспитанные хабром боятся использовать сторонние библиотеки потому что k12th заругается»
Такой подход не раскрывает плюсов и минусов ни вашего подхода, ни redux
Теперь в любом компоненте можно заимпортить и использовать данные нашего стора.
И сделать компонент view слоя завязанным на полную схему данных model слоя? Звучит как сомнительное удовольствие
«Где-то» будет, но хорошо бы не в коде компонентов уровня представления.
Преимущества распределения ответственности и принципов low coupling / high cohesion далеко не ограничивается "возможностью легкого переименования", как наиболее значимые на мой субъективный взгляд я бы привел
- улучшение реиспользуемости компонента
- тестируемости (плюс, для реакт компонента, — возможность размещения в storybook не создавая в нем полный дубликат состояния приловения)
- упрощение изменения приложения, от изменения схемы хранения состояния до полного перехода на другой принцип управления состоянием без переписывания компонентов представления
Достигается например передачей во вью контроллера в классическом MVC или созданием «контейнер-компонентов» с помощью например react-redux connect
или react 16.3 context
Возможно я неправильно понял вашу концепцию, но меня удивило именно предложение "в любом компоненте… заимпортить и использовать данные нашего стора"
Можно заюзать контекст (или например ненужные на мой взгляд инжекты mobx-а)
@inject
из mobx-react это и есть HOC для контекста. Просто удобнее писать:
@inject('projectStore')
@inject('todoStore')
class MyComponent
Чем:
<ProjectContext.Consumer>
{projectStore => (
<TodoContext.Consumer>
{todoStore => (
<MyComponent projectStore={projectStore} todoStore={todoStore} />
)}
</TodoContext.Consumer>
)}
</ProjectContext.Consumer>
При описанном подходе никто не мешает вам создать свой аналог редаксовского connect(), возвращающий HOC, который маппит данные импортированного стора на свойства целевого компонента.
Костыль
React.createContext
Всего-то 5 лет понадобилось, и вот, костыли из первого ангуляра теперь называют "некостылями" в реакте.
А как вы предлагаете с помощью React.createContext
управлять достаточно сложным состоянием (хотя бы массивом)?
Например, чтобы Context.Consumer
перерендерил свои внутренние компоненты при добавлении элемента в массив, этот массив должен быть либо immutable (что подводит нас к Redux), либо реактивным (в терминах MobX).
этот массив должен быть либо immutable
Да, react context про immutable значения. ===
React.createContext
дает нам аналоги <Provider>
и connect
из react-redux.
Но не дает аналоги dispatch
, action
и reducer
из самого Redux.
Так же, как не дает аналоги @observable
и @observer
из MobX.
Таким образом, он отвечает на вопрос, как передавать состояние вниз по дереву. Но оставляет открытым вопрос, как управлять этим общим состоянием извне компонента, в котором находится <Context.Consumer>
.
А еще за бортом остаются такие вещи, как Dev Tools, Time Travel, Single Source of Truth, Middleware, etc.
Вместе с тем, стандартизация React Context API — огромный шаг вперед. Теперь мы можем не пихать Redux в небольшие приложения только чтобы "расшарить" тривиальное состояние.
class BaseStore {
constructor(data){
Object.update(this, data);
}
}
А что за метод update у Object?
Как организовать общее состояние в react-приложениях без использования библиотек (и зачем нужен mobx)