Таблицы в react

Постановка задачи

Представьте: у вас есть таблица с данными, которые можно редактировать. Как мы это оформим?

В начале создадим наш проект через бойлерплейт утилиту 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, не делайте так!)

Готово! Вы великолепны!

Github исходник

Массовое обновление

Но что делать, если надо обновить значения стейта нескольких ячеек одновременно? Примерно как в 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 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), ] } } ...

Теперь апдейт выглядит так:

Готово. Вы великолепны!

Github исходник

Но почти… Теперь при обновлении одной ячейки у вас будет перерисовываться вся таблица. И, если в таблице такого размера это не критично — в большой таблице вас ждут большие проблемы. Мне по работе пришлось делать массовый апдейт в таблице 23×200, т.е. в 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, но какое-то время он был единственным, который я смог придумать.

Github исходник

Вариант третий: каноничный

Мозг — удивительная штука. Можно очень долго биться над решением какой-то задачи и на ум ничего не приходит, ты словно в стену упираешься, решение не приходит. Но стоит отвлечься, пойти на прогулку, заняться другими вещами — мозг в фоновом режиме продолжает биться над решением задачи. И когда происходит «Эврика» — он выплёвывает практически готовое решение в сознание. Такое со мной происходило множество раз, то же самое случилось и в этот. Сейчас я продемонстрирую своё финальное решение.

Для начала переработаем 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 ( ... )
}

Теперь это выглядит так:

Готово! Теперь вы действительно великолепны!

Github исходник

Заключение

Это моя первая статья на Хабре и вообще первая по теме фронтенда. Я работаю в индустрии 7 месяцев, долго искал решение в интернете, но не нашёл, решил поделиться с сообществом результатом своей работы, может быть кому-то пригодится. Жду конструктивной критики и предложений. Если будет интересно — сообщите об этом в комментариях, и я опубликую вторую часть статьи, где я расскажу, как собираю данные из этой таблицы для отправки на сервер только тех строк, в которых были сделаны изменения.

Читайте так же:

  • Установка и обслуживание ПО как одна из услуг IT аутсорсингаУстановка и обслуживание ПО как одна из услуг IT аутсорсинга Огромный выбор программного обеспечения способен поставить в тупик и дезориентировать. Даже если вы точно знаете. Что вам нужно. Помочь выбрать. Купить и установить необходимые продукты помогут специалисты IT аутсорсинга.К сожалению. В процессе работы за персональным компьютером могут […]
  • Pornhub сообщил о росте трафика платформы в период падения Facebook, Instagram и WhatsApp 4 октябряPornhub сообщил о росте трафика платформы в период падения Facebook, Instagram и WhatsApp 4 октября Pornhub сообщил о росте на 10,5% трафика платформы в период падения Facebook, Instagram и WhatsApp 4 октября. В это время каждый час недоступности соцсетей принес Pornhub около 500 тыс. новых пользователей. Это произошло из-за инцидента с недоступностью Facebook, Instagram и WhatsApp по […]
  • Microsoft отдаёт флагманский смартфон Surface Duo с двумя дисплеями со скидкой более 50% в ЕвропеMicrosoft отдаёт флагманский смартфон Surface Duo с двумя дисплеями со скидкой более 50% в Европе Microsoft UK предлагает смартфон Surface Duo с двумя дисплеями со скидкой более 50% от оригинальной розничной цены в 1400 фунтов стерлингов. Телефон Surface Duo Android можно приобрести в официальном магазине Microsoft за 679 фунтов стерлингов за версию со 128 ГБ флеш-памяти и за 729 […]
  • Golang пощупаем дженерикиGolang пощупаем дженерики Скоро выйдет релиз 1.18 в котором появятся долгожданные дженерики. Они позволят сделать универсальные методы. Я написал пару примеров для себя. Может быть они будут интересны кому-нибудь ещё.Интерфейсы any, comparable, constraints. и ~Появились новые ключевые слова any - аналог […]