Постановка задачи
Представьте: у вас есть таблица с данными, которые можно редактировать. Как мы это оформим?
В начале создадим наш проект через бойлерплейт утилиту npx create-react-app
В компоненте app.js получим список космических кораблей и положим их в хранилище redux (позднее будет понятно, почему именно туда):
// app.jsx
const App = () => {
const dispatch = useDispatch();
useEffect(() => {
const getNews = async () => {
const {data} = await axios({
method: `get`,
url: `http://swapi.dev/api/vehicles`
})
dispatch(setStarships(data.results))
}
getNews();
}, [])
return (
<div className="App">
<Table />
</div>
);
}
Построим простую таблицу:
// table.jsx
const Table = () => {
const starships = useSelector(({ starships }) => starships.starships);
return (
starships
? <div className="table">
<TableHeader />
{starships.map((starship, idx) => <TableRow key={idx} starship={starship} />)}
</div>
: <div>loading...</div>
)
};
Строка таблицы:
// table-row.jsx
const TableRow = ({starship}) => {
const {
cargo_capacity,
cost_in_credits,
max_atmosphering_speed,
name
} = starship
return (
<div className="table__row">
<TableCell item={cargo_capacity} />
<TableCell item={cost_in_credits} />
<TableCell item={max_atmosphering_speed} />
<TableCell item={name} />
</div>
)
};
Ячейка:
// table-cell.jsx
const TableCell = ({ item }) => {
const [state, setState] = useState(item);
return (
<div className="table__cell">
<input
value={state}
onChange={({ target }) => setState(target.value)}
type="text" />
</div>
)
}
Чтобы при изменении стейта перерисовывалась только одна, ячейка мы кладём значение из пропсов в стейт компонента и меняем только его. При изменении значения в ячейке, происходит перерисовка одной ячейки. (Для хорошей видимости flash updates, я убрал outline
у input
в состоянии :focus
, не делайте так!)

Готово! Вы великолепны!
Массовое обновление
Но что делать, если надо обновить значения стейта нескольких ячеек одновременно? Примерно как в google sheets и чтобы ещё выделять ячейки можно было? Воспользуемся библиотекой https://mobius1.github.io/Selectable/index.html
npm i selectable.js
В компоненте Table создадим экземпляр selectable
// table.jsx
const Table = ({ starships }) => {
const escKeyDownHandler = useCallback((evt) => {
if (evt.key === `Escape` || evt.key === `Esc`) {
evt.preventDefault();
window.select.clear();
}
}, [])
useEffect(() => {
window.select = new Selectable({
appendTo: `.table`,
autoRefresh: false,
lasso: {
border: '1px solid blue',
backgroundColor: 'rgba(52, 152, 219, 0.1)',
},
ignore: [
`input`
],
})
}, []);
useEffect(() => {
document.addEventListener(`keydown`, (evt) => escKeyDownHandler(evt));
return document.removeEventListener(`keydown`, escKeyDownHandler);
}, [escKeyDownHandler]);
return (
...
)
};
Добавим в ignore input
, чтобы при фокусе в нём, библиотека не реагировала на него. Также сразу создадим обработчик события на esc
чтобы при клике на эту клавишу выделение сбрасывалось.

Теперь, печатая в одной ячейке, нам нужно изменить значения во всех выделенных ячейках. Но как это сделать? В React однонаправленный поток данных и мы не можем из одного ребёнка поменять значение другого. Функцию изменения стейта придётся класть в родителя или воспользоваться апдейтом через redux, что мы и сделаем.
Вариант первый. Самый быстрый и неоптимальный
Для начала изменим передачу пропсов в компонент TableCell
Было:
<TableCell item={cargo_capacity} />
Стало:
<TableCell url={url} item={{cargo_capacity}} />
Это нам понадобилось, чтобы получить в компоненте имя ячейки и url как универсальный идентификатор строчки
Меняем компонент TableCell:
// table-cell.jsx
const TableCell = ({ item, url }) => {
const [[name, value]] = Object.entries(item)
...
const updateHandler = ({ target }) => {
const { value } = target;
const selectedFields = document.querySelectorAll(`.ui-selected`);
if (selectedFields.length) {
selectedFields.forEach(({ dataset }) => {
const { name, id } = dataset
dispatch(updateStarship({ value, fieldName: name, url: id }))
})
} else {
dispatch(updateStarship({ value, fieldName: name, url }))
}
}
return (
...
<input
value={value}
onChange={updateHandler}
type="text" />
...
</div>
)
}
Библиотка Selectable вешает на выбранную ячейку класс ui-selected
. До этого мы присвоили класс ui-selectable
каждому DOM элементу, который может быть выбран. Добавим data-
атрибуты id
и name
для идентификации ячейки.
Теперь напишем функцию обновления стейта:
// table-cell.jsx
const TableCell = ({ item, url }) => {
...
const updateHandler = ({ target }) => {
const { value } = target;
const selectedFields = document.querySelectorAll(`.ui-selected`);
if (selectedFields.length) {
selectedFields.forEach(({ dataset }) => {
const { name, id } = dataset
dispatch(updateStarship({ value, fieldName: name, url: id }))
})
} else {
dispatch(updateStarship({ value, fieldName: name, url }))
}
}
return (
...
<input
value={value}
onChange={updateHandler}
type="text" />
...
</div>
)
}
Теперь в файле starships-reducer.js напишем функцию updateStarship
:
// starships-reducer.js
import { createSlice } from '@reduxjs/toolkit';
export const starshipsSlice = createSlice({
...
reducers: {
...
updateStarship: (state, action) => {
const { value, fieldName, url } = action.payload
const updatingItem = state.starships.find(starship => starship.url === url);
const updatingIndex = state.starships.findIndex(starship => starship.url === url);
if (updatingIndex < 0) {
throw new Error(`no such index`);
}
updatingItem[fieldName] = value;
state.starships = [
...state.starships.slice(0, updatingIndex),
updatingItem,
...state.starships.slice(updatingIndex + 1),
]
}
}
...
Теперь апдейт выглядит так:

Готово. Вы великолепны!
Но почти... Теперь при обновлении одной ячейки у вас будет перерисовываться вся таблица. И, если в таблице такого размера это не критично - в большой таблице вас ждут большие проблемы. Мне по работе пришлось делать массовый апдейт в таблице 23x200, т.е. в DOM присутствовало одномоментно 4600 ячеек. Представьте, какие фризы это вызывало. И нельзя было воспользоваться библиотекой вроде react-window
, так как она хранит в DOM одновременно только несколько строк. А нам нужно обновлять одновременно вплоть до двухсот строк, не говоря уже о количестве ячеек.
Вариант второй: хитрый
Я очень долго думал как решить эту задачу без потери перформанса и на ум мне пришёл вариант, который выводит нас за рамки реакта. Это не просто императивный подход, это обходит само понятие апдейта стейта.
Для этого нам надо переписать функцию апдейта в TableCell
// table-cell.jsx
const TableCell = ({ url, item }) => {
...
const updateHandler = ({ target }) => {
const { value } = target;
const selectedFields = document.querySelectorAll(`.ui-selected`);
if (selectedFields.length) {
selectedFields.forEach((item) => {
const input = item.children[0];
input.value = value;
})
}
setState(value)
}
return (
...
)
}
Как видите - здесь мы напрямую меняем значение остальных ячеек через input.value
значения в ячейках меняются, но стейт внутри не обновляется. Происходит перерисовка только одной ячейки:

Вот что происходит со стейтом ячеек:

Этот способ нарушает философию библиотеки React, но какое-то время он был единственным, который я смог придумать.
Вариант третий: каноничный
Мозг - удивительная штука. Можно очень долго биться над решением какой-то задачи и на ум ничего не приходит, ты словно в стену упираешься, решение не приходит. Но стоит отвлечься, пойти на прогулку, заняться другими вещами - мозг в фоновом режиме продолжает биться над решением задачи. И когда происходит "Эврика" - он выплёвывает практически готовое решение в сознание. Такое со мной происходило множество раз, то же самое случилось и в этот. Сейчас я продемонстрирую своё финальное решение.
Для начала переработаем redux-store:
// starships-reducer.js
export const starshipsSlice = createSlice({
name: 'starships',
initialState: {
...
// создаём новые списки
cargo_capacity: [],
cost_in_credits: [],
max_atmosphering_speed: [],
name: [],
},
reducers: {
...
// кладём значения в эти списки
setItemsList: (state, action) => {
const {listName, list} = action.payload
state[listName] = list
},
},
})
Затем в компоненте App при получении данных с сервера записываем значения в эти списки:
// app.jsx
const App = () => {
...
useEffect(() => {
if (!starships) {
const getNews = async () => {
const { data } = await axios({
method: `get`,
url: `http://swapi.dev/api/vehicles`
})
const { results } = data
dispatch(setStarships(results));
// пустые массивы
let cargo_capacity = [];
let cost_in_credits = [];
let max_atmosphering_speed = [];
let name = [];
// пробегаемся по массиву кораблей и записываем значения
// важно: к каждому элементу массива добавляем url как идентификатор
for (const starship of results) {
cargo_capacity.push({
url: starship.url,
value: starship.cargo_capacity
})
cost_in_credits.push({
url: starship.url,
value: starship.cost_in_credits
})
max_atmosphering_speed.push({
url: starship.url,
value: starship.max_atmosphering_speed
})
name.push({
url: starship.url,
value: starship.name
})
};
// записываем значения в стор
dispatch(setItemsList({ listName: `cargo_capacity`, list: cargo_capacity }))
dispatch(setItemsList({ listName: `cost_in_credits`, list: cost_in_credits }))
dispatch(setItemsList({ listName: `max_atmosphering_speed`, list: max_atmosphering_speed }))
dispatch(setItemsList({ listName: `name`, list: name }))
}
getNews();
}
}, [starships])
return (
...
);
}
export default App;
Теперь передаём эти значения в компонент TableCell. В пропсы компонента передаём index
для получения нужного элемента в селекторе.
// table-cell.jsx
const TableCell = ({ url, item, index }) => {
const dispatch = useDispatch();
// теперь нам нужно только название списка
const [name] = Object.keys(item)
// используем хитрый селектор. извлекаем только тот элемент,
// который нам нужен в этом компоненте
const itemValue = useSelector(({ starships }) => starships[name][index]);
const updateHandler = ({ target }) => {
const { value } = target;
const selectedFields = document.querySelectorAll(`.ui-selected`);
if (selectedFields.length) {
selectedFields.forEach(({ dataset }) => {
const { id, name } = dataset;
// во всех выделенных ячейках меняем стейт, передаём туда
// значение, название списка и url как индентификатор
dispatch(updateItem({ value, listName: name, url: id }));
})
} else {
dispatch(updateItem({ value, listName: name, url }));
}
}
return (
<div
data-id={url}
data-name={name}
className="table__cell ui-selectable">
<input
// больше не используем функцию локального стейта,
// берём значение из стора
value={itemValue ? itemValue.value : `loading...`}
onChange={updateHandler}
type="text" />
</div>
)
}
В редьюсере напишем функцию обновления нужного элемента в списке:
// starships-reducer.js
export const starshipsSlice = createSlice({
name: 'starships',
initialState: {
...
},
reducers: {
...
updateItem: (state, action) => {
// достаём значение, название списка и url
const { value, listName, url } = action.payload
const updatingItem = state[listName]
.find(listItem => listItem.url === url);
const updatingIndex = state[listName]
.findIndex(listItem => listItem.url === url);
if (updatingIndex < 0) {
throw new Error(`no such index`);
}
updatingItem.value = value;
state[listName] = [
...state[listName].slice(0, updatingIndex),
updatingItem,
...state[listName].slice(updatingIndex + 1),
]
},
},
})
Как теперь выглядит обновление значений в ячейках таблицы:

Последний штрих: если нам нужно чтобы при любом выделении менялись значения только в конкретном столбце, даже если выделены значения в других столбцах - пишем простую проверку в компоненте TableCell:
// table-cell.jsx
const TableCell = ({ url, item, index }) => {
...
const updateHandler = ({ target }) => {
...
if (selectedFields.length) {
selectedFields.forEach(({ dataset }) => {
const { id, name: listName } = dataset;
// пишем проверку на соответствие названия списка выделенной
// ячейки списку ячейки, в которой мы пишем значение
if (listName === name) {
dispatch(updateItem({ value, listName, url: id }));
}
})
} else {
dispatch(updateItem({ value, listName: name, url }));
}
}
return (
...
)
}
Теперь это выглядит так:

Готово! Теперь вы действительно великолепны!
Заключение
Это моя первая статья на Хабре и вообще первая по теме фронтенда. Я работаю в индустрии 7 месяцев, долго искал решение в интернете, но не нашёл, решил поделиться с сообществом результатом своей работы, может быть кому-то пригодится. Жду конструктивной критики и предложений. Если будет интересно - сообщите об этом в комментариях, и я опубликую вторую часть статьи, где я расскажу, как собираю данные из этой таблицы для отправки на сервер только тех строк, в которых были сделаны изменения.
DmitryKazakov8
Да выбросьте вы из головы Redux, крайне неэффективный стейт-менеджер. Понимаю, что взяли его скорее для опыта и понимания работы легаси-проектов, но на том же MobX сделать намного проще. Вот пример реализации на нем + TS.
Импорты и типы
Контекст для прямого доступа из дочерних компонентов
Верхняя обертка с пробросом контекста
Верхний компонент таблицы. В нем загружается контент, набрасывается Selectable и трекаются измененные значения в ячейках
Заголовки и строки таблицы. Добавил динамический вывод столбцов, по сравнению с оригинальным кодом
Ячейка. Если выбрано несколько ячеек, то в хранилище целевой корабль ищется по url, значения мутируются одним батчем благодаря runInAction, в отличие от оригинального решения, где много последовательных dispatch. Если выбрана одна ячейка, значение просто мутируется.
Также исправил ряд ошибок оригинального кода, но все равно это все - чисто для примера, в реальном проекте нужен будет ряд доработок (обработка ошибок, валидации ответа запроса, слой запросов, вместо нереактового Selectable я бы предпочел кастомное решение, без необходимости делать querySelectorAll) и т.п.
В итоге обслуживающий код - минимальный, ответственность разделена (ячейки изменяют данные в хранилище, а высокоуровневый компонент Table независимо отслеживает измененные данные и может вызывать сохранение в базу). Благодаря этому можно изменять значения из-вне (по клику на кнопки вне Table, из модалок и т.п.).
Благодаря использованию классов логика асинхронных эффектов, состояния, вотчеров, обработки пользовательских событий не смешивается в одной функции, а выносится в соответствующие методы (которые равны по ссылкам при перерендерах) и отдельные слои. В целом рекомендую присмотреться именно к классам, так учиться грамотной композиции сущностей будет проще.
Также присмотритесь к CSS Components вместо строкового указания глобальных классов. К теме статьи не относится, но видеть БЭМ в 2021 как-то очень удивительно.
Чтобы сделать изменение только одной колонки, достаточно добавить фильтр
Makemanback Автор
Благодарю за ответ, интересно было посмотреть на решение через MobX, с ним не работал раньше, попробую ваш вариант.
Вопросы:
Почему классовые компоненты? Сами создатели библиотеки говорят о переходе на функциональные компоненты, классовые уходят в прошлое, они дороже и более громоздки.
Почему БЭМ в 2021 удивительно? Он прекрасно ложится на компонентный подход Реакта.
mayorovp
Потому что функциональный компонент на 100 строк — это кошмар, в то время как класс на 10 методов по 10 строк — совершенно нормально организованный код.
Какие-нибудь TableHeader и TableRow и правда лучше как функцию оформить, а вот за оформление чего-то вроде Table через хуки надо по рукам бить.
sovaz1997
Проблема не в хуках, а в прослойке между монитором и креслом. Если делать все правильно, у вас не будет 100 строк в функциональном компоненте.
mayorovp
Разумеется, и "правильно" тут вовсе не писать функциональный компонент.
sovaz1997
Правильно для того, кто не способен написать функциональный компонент грамотно
DmitryKazakov8
Попробуйте, должно понравиться — бойлерплейта в разы меньше (по факту только observer и проброс контекста, в остальном работа со стором — как с обычным объектом, только реактивным).
Писал несколько проектов на хуках, но вернулся к классам из-за удобства организации кода. Как писал выше, в хуках получается смешивание разнородной логики (сайд-эффекты, локальное хранилище, обработка пользовательских событий, асинхронные вызовы, управление жизненным циклом), они несемантичны, больше забот о равенстве по ссылкам и необходимости оптимизации, нет метода для componentWillMount — это прямо серьезный недостаток, так как я в проектах вызываю в нем асинхронные действия и дожидаюсь их выполнения для SSR (есть, конечно, и схожие библиотеки для хуков — но это лишняя зависимость и определенное усложнение). И в целом подход, когда внутри функции есть некое состояние (useState), которое хранится где-то внутри фреймворка и не изменяется при повторных вызовах функций — это как-то не по джавоскриптовому. При больших компонентах функция рендера раздувается из-за комбинации десятка(-ов) хуков, при этом они не имеют доступа к результатам выполнения друг друга, пропсам и контексту без явной передачи — в классах же методы легко комбинировать и в каждом можно получить к ним доступ и к локальному стейту.
Это не значит, что не умею "готовить" функциональные компоненты и грамотно выделять кастомные хуки — просто с классами работать намного удобнее и организовывать код в них проще.
БЭМ — это один из вариантов решения проблемы глобальной области видимости, когда создаются околоуникальные именования (хотя нередко они все же пересекаются и возникают баги). Минусы — длинные названия, нет автодополнения, нет быстрого перехода в стилевые файлы на конкретный класс, сложнее отслеживать наличие неиспользованных или отсутствующих классов, сложнее придумывать названия. Так как в реакт-проектах практически везде используется сборщик, то намного эффективнее использовать CSS Modules и получать все преимущества — автоматические суффиксы и префиксы по названию файла в наименованиях (исключают возможность пересечения), быстрые переходы сразу на нужный класс в стилях, автодополнение, возможность проверки неиспользуемых или отсутствующих классов. Это намного удобнее.
DanUnited
Не нужно советовать то, что вы не понимаете
Makemanback Автор
Вы о чём конкретно?
nin-jin
Да выбросьте вы из головы React, крайне неэффективный рендерер. Понимаю, что взяли его скорее для опыта и понимания работы легаси-проектов, но на том же $mol сделать намного проще. Вот пример реализации на нем + произвольные формулы в ячейках: https://habhub.hyoo.ru/#!author=nin-jin/repo=HabHub/article=10