Комментарии 12
Да выбросьте вы из головы Redux, крайне неэффективный стейт-менеджер. Понимаю, что взяли его скорее для опыта и понимания работы легаси-проектов, но на том же MobX сделать намного проще. Вот пример реализации на нем + TS.
Импорты и типы
import _ from 'lodash';
import axios from 'axios';
import { observer } from 'mobx-react';
import { autorun, observable, runInAction, toJS } from 'mobx';
import { ChangeEvent, Component, ContextType, createContext } from 'react';
import Selectable from 'selectable.js';
import styles from './Table.scss';
type TypeStarship = {
url: string;
name: string;
crew: string;
model: string;
length: string;
edited: string;
created: string;
passengers: string;
consumables: string;
manufacturer: string;
vehicle_class: string;
cargo_capacity: string;
cost_in_credits: string;
max_atmosphering_speed: string;
};
Контекст для прямого доступа из дочерних компонентов
// eslint-disable-next-line @typescript-eslint/naming-convention
const StarshipsStore = createContext<{ starships: Array<TypeStarship> }>({ starships: [] });
type TypeStarshipsStoreContext = ContextType<typeof StarshipsStore>;
class ConnectedComponentStarship<TProps = any> extends Component<TProps> {
static context: TypeStarshipsStoreContext;
static contextType = StarshipsStore;
declare context: TypeStarshipsStoreContext;
}
Верхняя обертка с пробросом контекста
export class App extends Component {
render() {
return (
<StarshipsStore.Provider value={observable({ starships: [] })}>
<Table />
</StarshipsStore.Provider>
);
}
}
Верхний компонент таблицы. В нем загружается контент, набрасывается Selectable и трекаются измененные значения в ячейках
@observer
class Table extends ConnectedComponentStarship {
localState: { prevStarships: Array<TypeStarship> } = observable({
prevStarships: [],
});
select: any;
trackDisposer: IReactionDisposer | null = null;
componentDidMount() {
void axios({
method: `get`,
url: `http://swapi.dev/api/vehicles`,
}).then((result: any) => {
runInAction(() => {
this.context.starships = result.data.results;
this.localState.prevStarships = result.data.results;
});
this.select = new Selectable({
appendTo: `.${styles.table}`,
filter: `.${styles.tableCell}`,
autoRefresh: false,
lasso: {
border: '1px solid blue',
backgroundColor: 'rgba(52, 152, 219, 0.1)',
},
ignore: [`input`],
});
document.addEventListener(`keydown`, this.escKeyDownHandler);
this.trackChangedStarships();
});
}
escKeyDownHandler = (evt: any) => {
if (evt.key === `Escape` || evt.key === `Esc`) {
evt.preventDefault();
this.select.clear();
}
};
trackChangedStarships = () => {
this.trackDisposer = autorun(() => {
if (_.isEqual(this.localState.prevStarships, this.context.starships)) return;
const changedStarships = _.differenceWith(
this.localState.prevStarships,
this.context.starships,
_.isEqual
);
changedStarships.forEach((starship) => {
console.log('starship data changed', toJS(starship));
});
runInAction(() => {
this.localState.prevStarships = toJS(this.context.starships);
});
});
};
componentWillUnmount() {
document.removeEventListener(`keydown`, this.escKeyDownHandler);
this.trackDisposer?.();
}
render() {
const { starships } = this.context;
const renderedKeys: Array<keyof TypeStarship> = [
'cargo_capacity',
'cost_in_credits',
'max_atmosphering_speed',
'name',
];
return (
<div className={styles.table}>
<TableHeader renderedKeys={renderedKeys} />
{starships.map((starship, index) => (
<TableRow key={index} starship={starship} renderedKeys={renderedKeys} />
))}
</div>
);
}
}
Заголовки и строки таблицы. Добавил динамический вывод столбцов, по сравнению с оригинальным кодом
@observer
class TableHeader extends ConnectedComponentStarship<{ renderedKeys: Array<keyof TypeStarship> }> {
render() {
const { renderedKeys } = this.props;
return (
<div className={styles.tableRowHeader}>
{renderedKeys.map((param) => (
<div key={param} className={styles.tableCell}>
{param}
</div>
))}
</div>
);
}
}
@observer
class TableRow extends ConnectedComponentStarship<{
starship: TypeStarship;
renderedKeys: Array<keyof TypeStarship>;
}> {
render() {
const { starship, renderedKeys } = this.props;
return (
<div className={styles.tableRow}>
{renderedKeys.map((param) => (
<TableCell key={param} starship={starship} param={param} />
))}
</div>
);
}
}
Ячейка. Если выбрано несколько ячеек, то в хранилище целевой корабль ищется по url, значения мутируются одним батчем благодаря runInAction, в отличие от оригинального решения, где много последовательных dispatch. Если выбрана одна ячейка, значение просто мутируется.
@observer
class TableCell extends ConnectedComponentStarship<{
starship: TypeStarship;
param: keyof TypeStarship;
}> {
handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const { starships } = this.context;
const { starship, param } = this.props;
const selectedFields = document.querySelectorAll(`.ui-selected`);
if (!selectedFields.length) {
runInAction(() => (starship[param] = event.target.value));
return;
}
runInAction(() => {
selectedFields.forEach(({ dataset }) => {
const targetStarship = starships.find((s) => s.url === dataset.url);
if (targetStarship) targetStarship[dataset.param] = event.target.value;
});
});
};
render() {
const { starship, param } = this.props;
return (
<div data-url={starship.url} data-param={param} className={styles.tableCell}>
<input onChange={this.handleChange} value={starship[param]} type={'text'} />
</div>
);
}
}
Также исправил ряд ошибок оригинального кода, но все равно это все - чисто для примера, в реальном проекте нужен будет ряд доработок (обработка ошибок, валидации ответа запроса, слой запросов, вместо нереактового Selectable я бы предпочел кастомное решение, без необходимости делать querySelectorAll) и т.п.
В итоге обслуживающий код - минимальный, ответственность разделена (ячейки изменяют данные в хранилище, а высокоуровневый компонент Table независимо отслеживает измененные данные и может вызывать сохранение в базу). Благодаря этому можно изменять значения из-вне (по клику на кнопки вне Table, из модалок и т.п.).
Благодаря использованию классов логика асинхронных эффектов, состояния, вотчеров, обработки пользовательских событий не смешивается в одной функции, а выносится в соответствующие методы (которые равны по ссылкам при перерендерах) и отдельные слои. В целом рекомендую присмотреться именно к классам, так учиться грамотной композиции сущностей будет проще.
Также присмотритесь к CSS Components вместо строкового указания глобальных классов. К теме статьи не относится, но видеть БЭМ в 2021 как-то очень удивительно.
Чтобы сделать изменение только одной колонки, достаточно добавить фильтр
selectedFields.filter(({ dataset }) => dataset.param === param)
Благодарю за ответ, интересно было посмотреть на решение через MobX, с ним не работал раньше, попробую ваш вариант.
Вопросы:
Почему классовые компоненты? Сами создатели библиотеки говорят о переходе на функциональные компоненты, классовые уходят в прошлое, они дороже и более громоздки.
Почему БЭМ в 2021 удивительно? Он прекрасно ложится на компонентный подход Реакта.
Почему классовые компоненты?
Потому что функциональный компонент на 100 строк — это кошмар, в то время как класс на 10 методов по 10 строк — совершенно нормально организованный код.
Какие-нибудь TableHeader и TableRow и правда лучше как функцию оформить, а вот за оформление чего-то вроде Table через хуки надо по рукам бить.
Попробуйте, должно понравиться — бойлерплейта в разы меньше (по факту только observer и проброс контекста, в остальном работа со стором — как с обычным объектом, только реактивным).
Писал несколько проектов на хуках, но вернулся к классам из-за удобства организации кода. Как писал выше, в хуках получается смешивание разнородной логики (сайд-эффекты, локальное хранилище, обработка пользовательских событий, асинхронные вызовы, управление жизненным циклом), они несемантичны, больше забот о равенстве по ссылкам и необходимости оптимизации, нет метода для componentWillMount — это прямо серьезный недостаток, так как я в проектах вызываю в нем асинхронные действия и дожидаюсь их выполнения для SSR (есть, конечно, и схожие библиотеки для хуков — но это лишняя зависимость и определенное усложнение). И в целом подход, когда внутри функции есть некое состояние (useState), которое хранится где-то внутри фреймворка и не изменяется при повторных вызовах функций — это как-то не по джавоскриптовому. При больших компонентах функция рендера раздувается из-за комбинации десятка(-ов) хуков, при этом они не имеют доступа к результатам выполнения друг друга, пропсам и контексту без явной передачи — в классах же методы легко комбинировать и в каждом можно получить к ним доступ и к локальному стейту.
Это не значит, что не умею "готовить" функциональные компоненты и грамотно выделять кастомные хуки — просто с классами работать намного удобнее и организовывать код в них проще.
БЭМ — это один из вариантов решения проблемы глобальной области видимости, когда создаются околоуникальные именования (хотя нередко они все же пересекаются и возникают баги). Минусы — длинные названия, нет автодополнения, нет быстрого перехода в стилевые файлы на конкретный класс, сложнее отслеживать наличие неиспользованных или отсутствующих классов, сложнее придумывать названия. Так как в реакт-проектах практически везде используется сборщик, то намного эффективнее использовать CSS Modules и получать все преимущества — автоматические суффиксы и префиксы по названию файла в наименованиях (исключают возможность пересечения), быстрые переходы сразу на нужный класс в стилях, автодополнение, возможность проверки неиспользуемых или отсутствующих классов. Это намного удобнее.
Не нужно советовать то, что вы не понимаете
Да выбросьте вы из головы React, крайне неэффективный рендерер. Понимаю, что взяли его скорее для опыта и понимания работы легаси-проектов, но на том же $mol сделать намного проще. Вот пример реализации на нем + произвольные формулы в ячейках: https://habhub.hyoo.ru/#!author=nin-jin/repo=HabHub/article=10
с таким количеством кода - проще на ванильном js писать.
А как насчет варианта на контексте без внешнего стейт-менеджера?
Таблицы в react