От автора: обычно в React есть несколько способов написать код. И хотя можно создать одно и то же по-разному, могут быть один или два подхода, которые технически работают «лучше», чем другие. Я на самом деле сталкиваюсь со множеством примеров, когда код, используемый для создания компонента React, технически «правильный», но вызывает проблемы, которых можно было бы избежать
Итак, давайте рассмотрим некоторые из этих примеров. Я собираюсь предоставить три экземпляра «ошибочного» кода React, который технически выполняет свою работу в конкретной ситуации, и способы его улучшения, чтобы сделать его более удобным в обслуживании, отказоустойчивым и, в конечном итоге, функциональным.
Код ошибки # 1: Мутация состояния и свойств
Это большой анти-шаблон для изменения состояния или свойств в React. Не делайте этого!
Это не революционный совет — обычно это одна из первых вещей, которую вы усваиваете, когда начинаете работать с React. Но вы можете подумать, что можете справится с этим.
Я собираюсь показать вам, как ошибки могут закрасться в ваш код, если вы изменяете свойства. Иногда вам понадобится компонент, который будет показывать преобразованную версию некоторых данных. Давайте создадим родительский компонент, который содержит счетчик и кнопку, которая будет увеличивать его. Мы также создадим дочерний компонент, который получает счетчик через свойство и показывает, значение счетчика с добавленным к нему 5.
Вот пример, демонстрирующий наивный подход:
Этот пример работает. Он делает то, что мы хотим: мы нажимаем кнопку увеличения, и он добавляет единицу к счетчику. Затем дочерний компонент повторно визуализируется, чтобы показать значение счетчика с добавленным к нему 5. Мы поменяли свойство у потомка, и он отлично работает! Почему все говорят нам, что мутировать реквизит — это так плохо?
А что, если позже мы проведем рефакторинг кода и нам потребуется сохранить счетчик в объекте? Это может произойти, если нам нужно хранить больше свойств в одном и том же хуке useState по мере роста нашей кодовой базы.
Вместо увеличения числа, хранящегося в состоянии, мы увеличиваем свойство count. В нашем дочернем компоненте мы получаем объект через свойство и добавляем к count, чтобы показать, как будет выглядеть счетчик, если мы добавим 5.
Посмотрим, как это пойдет. Попробуйте увеличить состояние несколько раз:
О, нет! Теперь, когда мы увеличиваем счетчик, кажется, что на каждый клик добавляется 6! Почему это происходит? Единственное, что изменилось между этими двумя примерами, — это то, что мы использовали объект вместо числа!
Более опытные программисты на JavaScript знают, что большая разница здесь в том, что примитивные типы, такие как числа, логические значения и строки, неизменяемы и передаются по значению, тогда как объекты передаются по ссылке. Это значит, что:
Если вы поместите число в переменную, назначите ему другую переменную, а затем измените вторую переменную, первая переменная не изменится.
Если вы поместите объект в переменную, назначите ему другую переменную, а затем измените вторую переменную, первая переменная будет изменена.
Когда дочерний компонент изменяет свойство объекта, он добавляет 5 к тому же объекту, который React использует при обновлении состояния. Это означает, что когда наша функция увеличения срабатывает после клика, React использует тот же объект после того, как он был обработан нашим дочерним компонентом, что отображается как добавление 6 при каждом клике.
Решение
Есть несколько способов избежать этих проблем. В такой простой ситуации вы можете избежать любых мутаций и выразить изменение в функции рендеринга:
function Child({state}){ return <div><p>count + 5 = {state.count + 5} </p></div> }
Однако в более сложном случае вам может потребоваться многократное использование state.count + 5 или передача преобразованных данных нескольким дочерним элементам.
Один из способов сделать это — создать копию свойства в дочернем элементе, а затем преобразовать свойства клонированных данных. Есть несколько разных способов клонирования объектов в JavaScript с различными компромиссами. Вы можете использовать литерал объекта:
function Child({state}){ const copy = {...state}; return <div><p>count + 5 = {copy.count + 5} </p></div> }
Но если есть вложенные объекты, они все равно будут ссылаться на старую версию. Вместо этого вы можете преобразовать объект в JSON, а затем сразу же его распарсить:
JSON.parse(JSON.stringify(myobject))
Это будет работать для большинства простых типов объектов. Но если в ваших данных используются более сложные типы, вы можете использовать библиотеки. Популярным методом было бы использование deepClone lodash. Вот пример, который показывает версию с использованием литерала объекта и spread синтаксиса:
Еще один вариант — использовать такую библиотеку, как Immutable.js. Если у вас есть ограничение использовать только неизменяемые структуры данных, вы можете быть уверены, что ваши данные не будут неожиданно изменены. Вот еще один пример использования неизменяемого класса Map для представления состояния приложения счетчика:
Код ошибки # 2: производное состояние
Допустим, у нас есть родительский и дочерний компоненты. У них обоих есть хуки useState со счетчиком. И предположим, что родительский элемент передает свое состояние в качестве свойства дочернему элементу, который тот использует для инициализации своего счетчика.
function Parent(){ const [parentCount,setParentCount] = useState(0); return <div> <p>Parent count: {parentCount}</p> <button onClick={()=>setParentCount(c=>c+1)}>Increment Parent</button> <Child parentCount={parentCount}/> </div>; } function Child({parentCount}){ const [childCount,setChildCount] = useState(parentCount); return <div> <p>Child count: {childCount}</p> <button onClick={()=>setChildCount(c=>c+1)}>Increment Child</button> </div>; }
Что происходит с дочерним состоянием, когда состояние родительского элемента изменяется, и дочерний элемент повторно отображается с другими свойствами? Будет ли дочернее состояние оставаться прежним или изменится, чтобы отразить новый счетчик, который ему был передан?
Мы имеем дело с функцией, поэтому дочернее состояние должно быть удалено и заменено, верно? Неправильно! Состояние наследника проритетнее новое свойство от родителя. После того, как состояние дочернего компонента инициализировано при первом рендеринге, оно полностью не зависит от каких-либо свойств, которые он получает.
React сохраняет состояние компонента для каждого компонента в дереве, и состояние удаляется только при удалении компонента.
Использование свойств для инициализации состояния называется «производным состоянием» и является своего рода анти-шаблоном. Это устраняет преимущество компонента, имеющего единственный источник достоверных данных.
Использование свойств key
Но что, если у нас есть коллекция элементов, которые мы хотим редактировать, используя тот же тип дочернего компонента, и мы хотим, чтобы дочерний элемент содержал черновик элемента, который мы редактируем? Нам нужно будет сбрасывать состояние дочернего компонента каждый раз, когда мы перебираем элементы из коллекции.
Вот пример: давайте напишем приложение, в котором мы можем ежедневно составлять список из пяти вещей, за которые мы благодарны каждый день. Мы будем использовать родительский элемент с состоянием, инициализированным как пустой массив, который мы собираемся заполнить пятью строковыми операторами. Затем у нас будет дочерний компонент с текстовым вводом для ввода нашего оператора.
Мы собираемся использовать криминальный уровень чрезмерной инженерии в нашем крошечном приложении, но это чтобы проиллюстрировать шаблон, который может вам понадобиться в более сложном проекте: мы собираемся сохранить черновое состояние ввода текста в дочернем компоненте.
Понижение состояния дочернего компонента может быть оптимизацией производительности, чтобы предотвратить повторный рендеринг родительского компонента при изменении состояния ввода. В противном случае родительский компонент будет повторно отображаться каждый раз при изменении ввода текста.
Мы также передадим пример состояния в качестве значения по умолчанию для каждой из пяти заметок, которые мы напишем. Вот ошибочный способ сделать это:
// These are going to be our default values for each of the five notes // To give the user an idea of what they might write const ideaList = ["I'm thankful for my friends", "I'm thankful for my family", "I'm thankful for my health", "I'm thankful for my hobbies", "I'm thankful for CSS Tricks Articles"] const maxStatements = 5; function Parent(){ const [list,setList] = useState([]); // Handler function for when the statement is completed // Sets state providing a new array combining the current list and the new item function onStatementComplete(payload){ setList(list=>[...list,payload]); } // Function to reset the list back to an empty array function reset(){ setList([]); } return <div> <h1>Your thankful list</h1> <p>A five point list of things you're thankful for:</p> {/* First we list the statements that have been completed*/} {list.map((item,index)=>{return <p>Item {index+1}: {item}</p>})} {/* If the length of the list is under our max statements length, we render the statement form for the user to enter a new statement. We grab an example statement from the idealist and pass down the onStatementComplete function. Note: This implementation won't work as expected*/} {list.length<maxStatements ? <StatementForm initialStatement={ideaList[list.length]} onStatementComplete={onStatementComplete}/> :<button onClick={reset}>Reset</button> } </div>; } // Our child StatementForm component This accepts the example statement for it's initial state and the on complete function function StatementForm({initialStatement,onStatementComplete}){ // We hold the current state of the input, and set the default using initialStatement prop const [statement,setStatement] = useState(initialStatement); return <div> {/*On submit we prevent default and fire the onStatementComplete function received via props*/} <form onSubmit={(e)=>{e.preventDefault(); onStatementComplete(statement)}}> <label htmlFor="statement-input">What are you thankful for today?</label><br/> {/* Our controlled input below*/} <input id="statement-input" onChange={(e)=>setStatement(e.target.value)} value={statement} type="text"/> <input type="submit"/> </form> </div> }
Тут есть проблема: каждый раз, когда мы отправляем состояние, input ошибочно содержит отправленную запись в текстовом поле. Мы хотим заменить его примером из нашего списка.
Несмотря на то, что мы каждый раз передаем другую строку примера, наследник запоминает старое состояние, а наше новое свойство игнорируется. Вы потенциально можете проверить, изменились ли свойства при каждом рендеринге в useEffect, а затем сбросить состояние, если они изменились. Но это может вызвать ошибки, когда разные части ваших данных используют одни и те же значения, и вы хотите принудительно сбросить дочернее состояние, даже если свойство остается прежним.
Решение
Если вам нужен компонент наследника, где родительский компонент должен иметь возможность по требованию сделать сброс состояния наследника, то есть способ сделать это: это, изменяя свойство key наследника.
Возможно, вы видели особое свойство key, когда рендерили элементы на основе массива, и React выдает предупреждение с просьбой предоставить ключ для каждого элемента. Изменение ключа дочернего элемента гарантирует, что React создаст совершенно новую версию элемента. Это способ сообщить React, что вы визуализируете концептуально другой элемент, используя один и тот же компонент.
Давайте добавим ключевое свойство нашему дочернему компоненту. Значение — это индекс, который мы собираемся заполнить нашим состоянием:
<StatementForm key={list.length} initialStatement={ideaList[list.length]} onStatementComplte={onStatementComplete}/>
Вот как это выглядит в нашем приложении со списком:
Обратите внимание, что единственное, что здесь изменилось, это то, что у дочернего компонента теперь есть свойство key, основанное на индексе массива, который мы собираемся заполнить. Тем не менее, поведение компонента полностью изменилось.
Теперь каждый раз, когда мы отправляем и завершаем запись состояния, старое состояние в дочернем компоненте отбрасывается и заменяется состоянием из примера.
Код ошибки # 3: устаревшие ошибки замыкания
Это обычная проблема с хуками React. Давайте рассмотрим несколько ситуаций, в которых вы можете столкнуться с проблемами. Первый всплывает при использовании useEffect. Если мы делаем что-то внутри useEffect асинхронно, у нас могут возникнуть проблемы с использованием старого состояния или свойств.
Вот пример. Нам нужно увеличивать счет каждую секунду. Мы устанавливаем его при первом рендеринге с помощью useEffect, обеспечивая закрытие, которое увеличивает счетчик в качестве первого аргумента и пустой массив в качестве второго аргумента. Мы дадим ему пустой массив, так как мы не хотим, чтобы React перезапускал интервал при каждом рендеринге.
function Counter() { let [count, setCount] = useState(0); useEffect(() => { let id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); },[]); return <h1>{count}</h1>; }
О, нет! Счетчик увеличивается до 1, но после этого больше не изменяется! Почему это происходит? Это связано с двумя вещами:
поведение замыканий в JavaScript
второй аргумент useEffect
Взглянув на документацию MDN, мы можем видеть:
Замыкание — это комбинация функции и лексического окружения, в котором эта функция была объявлена. Это окружене состоит из любых локальных переменных, которые были в области видимости во время создания замыкания.
«Лексическая среда», в которой объявляется наше замыкание useEffect, находится внутри компонента Counter. Интересующая нас локальная переменная count равна нулю на момент объявления (первого рендеринга).
Проблема в том, что это замыкание больше никогда не объявляется. Если при объявлении времени счетчик равен нулю, он всегда будет равен нулю. Каждый раз, когда срабатывает интервал, он запускает функцию, которая начинается с нуля и увеличивает его до 1.
Итак, как мы можем снова объявить функцию? Здесь появляется второй аргумент вызова useEffect. Мы думали, что были очень умны, начав интервал только один раз, используя пустой массив, но при этом мы выстрелили себе в ногу. Если бы мы не использовали этот аргумент, замыкание внутри useEffect объявлялось бы снова с новым счетчиком каждый раз.
Массив зависимостей useEffect выполняет две функции:
Он запустит функцию useEffect при изменении зависимости.
Он также повторно объявит замыкание с обновленной зависимостью, сохраняя замыкание безопасным от устаревшего состояния или свойств.
Фактически, существует даже правило lint, чтобы защитить ваши экземпляры useEffect от устаревшего состояния и свойств, убедившись, что вы добавляете правильные зависимости ко второму аргументу.
Но на самом деле мы не хотим сбрасывать интервал каждый раз при рендеринге компонента. Как же тогда решить эту проблему?
Решение
Опять же, здесь есть несколько решений нашей проблемы. Начнем с самого простого: вообще не использовать состояние count, а вместо этого передать функцию в наш вызов setState:
function Counter() { let [count, setCount] = useState(0); useEffect(() => { let id = setInterval(() => { setCount(prevCount => prevCount+ 1); }, 1000); return () => clearInterval(id); },[]); return <h1>{count}</h1>; }
Это было просто. Другой вариант — использовать такой хук useRef, чтобы сохранить изменяемую ссылку счетчика:
function Counter() { let [count, setCount] = useState(0); const countRef = useRef(count) function updateCount(newCount){ setCount(newCount); countRef.current = newCount; } useEffect(() => { let id = setInterval(() => { updateCount(countRef.current + 1); }, 1000); return () => clearInterval(id); },[]); return <h1>{count}</h1>; } ReactDOM.render(<Counter/>,document.getElementById("root"))
Другие устаревшие ошибки замыкания
Но устаревшие замыкания на просто появятся в useEffect. Они также могут появляться в обработчиках событий и других замыканиях внутри ваших компонентов React. Давайте посмотрим на компонент React с устаревшим обработчиком событий; мы создадим полосу прокрутки, которая выполняет следующие действия:
увеличивает ширину экрана по мере того, как пользователь ее прокручивает
начинается с прозрачного и становится все более и более непрозрачной по мере того, как пользователь прокручивает
предоставляет пользователю кнопку, которая рандомизирует цвет полосы прокрутки
Мы собираемся оставить индикатор выполнения за пределами дерева React и обновить его в обработчике событий. Вот наша реализация с ошибками:
<body> <div id="root"></div> <div id="progress"></div> </body>
function Scroller(){ // We'll hold the scroll position in one state const [scrollPosition, setScrollPosition] = useState(window.scrollY); // And the current color in another const [color,setColor] = useState({r:200,g:100,b:100}); // We assign out scroll listener on the first render useEffect(()=>{ document.addEventListener("scroll",handleScroll); return ()=>{document.removeEventListener("scroll",handleScroll);} },[]); // A function to generate a random color. To make sure the contrast is strong enough // each value has a minimum value of 100 function onColorChange(){ setColor({r:100+Math.random()*155,g:100+Math.random()*155,b:100+Math.random()*155}); } // This function gets called on the scroll event function handleScroll(e){ // First we get the value of how far down we've scrolled const scrollDistance = document.body.scrollTop || document.documentElement.scrollTop; // Now we grab the height of the entire document const documentHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight; // And use these two values to figure out how far down the document we are const percentAlong = (scrollDistance / documentHeight); // And use these two values to figure out how far down the document we are const progress = document.getElementById("progress"); progress.style.width = `${percentAlong*100}%`; // Here's where our bug is. Resetting the color here will mean the color will always // be using the original state and never get updated progress.style.backgroundColor = `rgba(${color.r},${color.g},${color.b},${percentAlong})`; setScrollPosition(percentAlong); } return <div className="scroller" style={{backgroundColor:`rgb(${color.r},${color.g},${color.b})`}}> <button onClick={onColorChange}>Change color</button> <span class="percent">{Math.round(scrollPosition* 100)}%</span> </div> } ReactDOM.render(<Scroller/>,document.getElementById("root"))
Наша полоса становится шире и непрозрачнее по мере прокрутки страницы. Но если вы нажмете кнопку изменения цвета, наши рандомизированные цвета не повлияют на индикатор выполнения. Мы получаем эту ошибку, потому что на замыкание влияет состояние компонента, и это замыкание никогда не объявляется повторно, поэтому мы получаем только исходное значение состояния и никаких обновлений.
Вы можете увидеть, как настройка замыканий, которые вызывают внешние API-интерфейсы с использованием состояния React или свойства компонентов могут доставить вам неприятности, если вы не будете осторожны.
Решение
Опять же, есть несколько способов решить эту проблему. Мы могли бы сохранить состояние цвета в изменяемой ссылке, которую позже могли бы использовать в нашем обработчике событий:
const [color,setColor] = useState({r:200,g:100,b:100}); const colorRef = useRef(color); function onColorChange(){ const newColor = {r:100+Math.random()*155,g:100+Math.random()*155,b:100+Math.random()*155}; setColor(newColor); colorRef.current=newColor; progress.style.backgroundColor = `rgba(${newColor.r},${newColor.g},${newColor.b},${scrollPosition})`; }
Это работает достаточно хорошо, но не кажется идеальным. Вам может потребоваться написать подобный код, если вы имеете дело со сторонними библиотеками и не можете найти способ перенести их API в свое дерево React. Но, убирая один из наших элементов из дерева React и обновляя его внутри нашего обработчика событий, мы плывем против течения.
Это простое исправление, поскольку мы имеем дело только с DOM API. Простой способ рефакторинга — включить индикатор выполнения в наше дерево React и отобразить его в JSX, чтобы он мог ссылаться на состояние компонента. Теперь мы можем использовать функцию обработки событий исключительно для обновления состояния.
function Scroller(){ const [scrollPosition, setScrollPosition] = useState(window.scrollY); const [color,setColor] = useState({r:200,g:100,b:100}); useEffect(()=>{ document.addEventListener("scroll",handleScroll); return ()=>{document.removeEventListener("scroll",handleScroll);} },[]); function onColorChange(){ const newColor = {r:100+Math.random()*155,g:100+Math.random()*155,b:100+Math.random()*155}; setColor(newColor); } function handleScroll(e){ const scrollDistance = document.body.scrollTop || document.documentElement.scrollTop; const documentHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight; const percentAlong = (scrollDistance / documentHeight); setScrollPosition(percentAlong); } return <> <div class="progress" id="progress" style={{backgroundColor:`rgba(${color.r},${color.g},${color.b},${scrollPosition})`,width: `${scrollPosition*100}%`}}></div> <div className="scroller" style={{backgroundColor:`rgb(${color.r},${color.g},${color.b})`}}> <button onClick={onColorChange}>Change color</button> <span class="percent">{Math.round(scrollPosition * 100)}%</span> </div> </> }
Так лучше. Мы не только устранили возможность устаревания нашего обработчика событий, но и превратили индикатор выполнения в автономный компонент, который использует декларативную природу React.
Подведение итогов
Мы рассмотрели три различных способа создания ошибок в приложениях React и некоторые способы их исправления. Можно легко взглянуть на примеры счетчиков, которые идут по правильному пути и не показывают тонкостей API, которые могут вызвать проблемы.
Автор: Hugh Haworth
Источник: css-tricks.com
Редакция: Команда webformyself.
Читайте нас в Telegram, VK, Яндекс.Дзен