Таблицы в 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 [[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),       ]      } }   ...

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

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

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 месяцев, долго искал решение в интернете, но не нашёл, решил поделиться с сообществом результатом своей работы, может быть кому-то пригодится. Жду конструктивной критики и предложений. Если будет интересно — сообщите об этом в комментариях, и я опубликую вторую часть статьи, где я расскажу, как собираю данные из этой таблицы для отправки на сервер только тех строк, в которых были сделаны изменения.


ссылка на оригинал статьи https://habr.com/ru/articles/586526/