在React中,通常有不止一种方法来编码一件事。虽然有可能用不同的方法来创建同一个东西,但可能有一两种方法在技术上比其他方法 "更好"。实际上,我遇到过很多例子,用来构建React组件的代码在技术上是 "正确的",但却带来了完全可以避免的问题。
所以,让我们看看其中的一些例子。我将提供三个 "有问题 "的React代码的例子,这些代码在技术上可以完成特定情况下的工作,以及可以改进的方法,使其更具可维护性、弹性和最终功能。
本文假定对React钩子有一些了解。这不是对钩子的介绍--你可以从Kingsley Silas的CSS Tricks上找到一个很好的介绍,或者看一下React文档来熟悉它们。我们也不会去看那些即将在React 18中出现的令人兴奋的新东西。相反,我们要看一些细微的问题,这些问题不会完全破坏你的应用程序,但可能会悄悄地进入你的代码库,如果你不小心的话,会导致奇怪或意外的行为。
有问题的代码#1:突变的状态和道具
在React中突变状态或道具是一个很大的反模式。不要这样做!
这不是一个革命性的建议--如果你开始使用React,这通常是你最先学会的事情之一。但你可能认为你可以逃脱它(因为在某些情况下你似乎_可以_)。
我将向你展示,如果你在突变道具时,错误是如何悄悄进入你的代码的。有时你会想要一个组件来显示一些数据的转换版本。让我们创建一个父组件,它持有一个状态下的计数,并有一个按钮将其递增。我们还将创建一个子组件,通过props接收计数,并显示计数添加到5后的样子。
这里有一个演示天真的笔。
CodePen 嵌入回退
这个例子是有效的。它做了我们想做的事:我们点击增量按钮,它为计数增加一个。然后,子组件被重新渲染,以显示添加了5的计数的样子。我们在这里改变了子组件中的道具,它运行得很好为什么每个人都在告诉我们改变道具是很糟糕的?
好吧,如果以后我们重构代码,需要在一个对象中保存计数,怎么办?如果我们需要在同一个useState 钩子中存储更多的属性,这可能会发生,因为我们的代码库越来越大。
我们不增加状态中持有的数字,而是增加状态中持有的对象的count 属性。在我们的子组件中,我们通过props接收对象,并添加到count 属性中,以显示如果我们增加5,计数会是什么样子。
让我们来看看这是怎么一回事。试着在这支笔中把状态递增几次。
CodePen 嵌入 Fallback
哦,不!现在当我们递增计数时,似乎每次点击都会增加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))
这对大多数简单的对象类型是有效的。但如果你的数据使用了更多奇特的类型,你可能想使用一个库。一个流行的方法是使用lodash的deepClone。这里有一个Pen,它显示了一个使用对象字面和传播语法来克隆对象的固定版本。
CodePen 嵌入回溯
还有一个选择是使用Immutable.js这样的库。如果你有一个规则,只使用不可变的数据结构,你就能相信你的数据不会被意外地变异。这里还有一个例子,使用不可变的Map 类来表示计数器应用程序的状态。
CodePen嵌入回退
有问题的代码#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>;
}
当父方的状态发生变化,子方用不同的道具重新渲染时,子方的状态会发生什么?子代的状态是保持不变,还是会改变以反映传递给它的新计数?
我们处理的是一个函数,所以子状态应该被吹走和替换,对吗?错了!子组件的状态胜过父组件的新道具。当子组件的状态在第一次渲染时被初始化后,它就完全独立于它收到的任何道具。
CodePen嵌入回退
React为树上的每个组件存储了组件状态,只有当组件被移除时,状态才会被吹走。否则,状态不会受到新道具的影响。
使用道具来初始化状态被称为 "派生状态",它是一种反模式的做法。它消除了一个组件对其数据有单一真理来源的好处。
使用钥匙道具
但是,如果我们有一个项目的集合,我们想用相同类型的子组件来编辑,而且我们想让这个子组件持有我们正在编辑的项目的草稿,怎么办?我们需要在每次从集合中切换项目时重置子组件的状态。
这里有一个例子。让我们写一个应用程序,我们可以写一个每日清单,列出我们每天感谢的五件事。我们将使用一个父类,其状态初始化为一个空数组,我们将用五个字符串语句来填充。
然后,我们将有一个带有文本输入的子组件来输入我们的声明。
我们将在我们的小程序中使用一个犯罪级别的过度工程,但这是为了说明你在一个更复杂的项目中可能需要的模式。我们将在子组件中保持文本输入的草稿状态。
将状态降低到子组件中可以是一种性能优化,以防止输入状态改变时父组件重新渲染。否则,每次文本输入发生变化时,父组件都会重新渲染。
我们还将传下一个示例语句,作为我们要写的五个笔记中每一个的默认值。
这里有一个错误的方法来做到这一点。
// 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>
}
CodePen嵌入回退
这样做有一个问题:每次我们提交一个完成的语句时,输入法都会错误地保留文本框中提交的笔记。我们想用我们列表中的一个例子声明来替换它。
尽管我们每次都传下不同的例子字符串,但孩子还是会记住旧的状态,而我们较新的道具则被忽略。你可以在useEffect ,在每次渲染时检查道具是否有变化,如果有,就重置状态。但是,当你的数据的不同部分使用相同的值,而你想强制重置孩子的状态,即使道具保持不变,这也会引起错误。
解决方案
如果你需要一个子组件,而父组件需要按需重置子组件的能力,_有_一个方法可以做到:就是通过改变子组件上的key 道具。
你可能已经看到了这个特殊的key ,当你基于一个数组渲染元素时,React会抛出一个警告,要求你为每个元素提供一个键。改变一个子元素的键可以确保React为该元素创建一个全新的版本。这是一种告诉React你正在使用同一个组件渲染一个概念上不同的项目的方式。
让我们给我们的子组件添加一个键道具。这个值是我们要用我们的声明来填充的索引。
<StatementForm key={list.length} initialStatement={ideaList[list.length]} onStatementComplte={onStatementComplete}/>
下面是在我们的列表应用程序中的样子。
CodePen嵌入回退
请注意,这里唯一改变的是,子组件现在有一个基于我们即将填充的数组索引的key 道具。然而,该组件的行为已经完全改变。
现在,每次我们提交并完成写出的语句,子组件中的旧状态就会被扔掉,并被示例语句所取代。
错误的代码#3:陈旧的闭合错误
这是React钩子的一个常见问题。之前有一篇CSS-Tricks的文章介绍了在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>;
}
CodePen嵌入回退
哦,不!计数被递增到1,但此后再也没有变化!为什么会这样?为什么会发生这种情况?
这与两件事有关。
- JavaScript中闭包的行为
- 那个
useEffect调用的第二个参数
看了一下MDN关于闭包的文档,我们可以看到。
闭包是一个函数和声明该函数的词法环境的组合。这个环境由创建闭包时在范围内的任何局部变量组成。
我们的useEffect 封闭所处的 "词法环境 "是在我们的Counter React组件中。我们感兴趣的局部变量是count ,它在声明的时候(第一次渲染)是零。
问题是,这个闭包从未被再次声明。如果在声明时计数为零,它将永远是零。每次间隔启动时,它都在运行一个从0开始并递增到1的函数。
那么,我们怎样才能让这个函数再次声明呢?这就是useEffect 调用的第二个参数的作用。我们认为自己非常聪明,只用空数组启动了一次间隔,但这样做我们就把自己打入了冷宫。如果我们忽略了这个参数,useEffect 里面的闭合就会被再次声明,每次都有新的计数。
我喜欢的思考方式是,useEffect 依赖数组做两件事。
- 当依赖关系发生变化时,它将启动
useEffect函数。 - 它还会用更新后的依赖关系重新声明闭包,使闭包不受陈旧状态或道具的影响。
事实上,甚至有一个lint规则,通过确保你在第二个参数中添加正确的依赖关系,使你的useEffect 实例不受陈旧状态和道具的影响。
但实际上我们也不想在每次组件被渲染时重置我们的时间间隔。那么我们如何解决这个问题呢?
解决办法
同样,我们的问题有多种解决方案。让我们从最简单的开始:完全不使用计数状态,而是将一个函数传入我们的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"))
CodePen嵌入回退
要更深入地了解使用间隔和钩子,你可以看看Dan Abramov的这篇文章,他是React核心团队成员之一,关于在React中创建一个useInterval 。他采取了一种不同的方式,不是把计数放在ref ,而是把整个闭合放在ref 。
如果想更深入地了解useEffect ,你可以看看他在useEffect的帖子。
更多陈旧闭包的错误
但是陈旧的闭包不仅仅出现在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"))
CodePen嵌入回退
随着页面的滚动,我们的进度条变得更宽,而且越来越不透明。但如果你点击改变颜色按钮,我们的随机颜色并不影响进度条。我们得到这个bug是因为闭包受到组件状态的影响,而这个闭包从未被重新声明,所以我们只得到状态的原始值,没有更新。
你可以看到,如果你不小心,设置使用React状态或组件道具调用外部API的闭包可能会给你带来麻烦。
解决办法
同样,有多种方法来解决这个问题。我们可以把颜色状态保存在一个可变的 ref 中,然后在我们的事件处理程序中使用。
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})`;
}
CodePen Embed Fallback
这样做足够好,但感觉并不理想。如果你和第三方库打交道,而你又找不到办法把他们的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>
</>
}
CodePen嵌入回退
这感觉更好。我们不仅消除了事件处理函数过时的可能性,我们还将我们的进度条转换为一个自包含的组件,从而利用了React的声明性特性。
另外,像这样的滚动指示器,你可能甚至都不需要JavaScript--看看新兴的@scroll-timeline CSS函数,或者看看Chris书中关于最伟大的CSS技巧中使用梯度的方法吧
总结
我们已经看了三种在React应用程序中产生bug的不同方式,以及一些修复它们的方法。看一些反面的例子是很容易的,这些例子遵循的是一条快乐的道路,没有显示出API中可能导致问题的细微之处。
如果你仍然发现自己需要对你的React代码所做的事情建立一个更强大的心理模型,这里有一个资源列表,可以帮助你。
- The React Docs
- MDN关于闭包的文档
- 关于CSS技巧的React文章
- React repo上的问题可以显示常见的问题和它们的解决方案
- Stack Overflow上的React标签
- Eve Porcello的博客
- Dan Abramov的博客
- Kent C. Dodds的博客
The postThree Buggy React Code Examples and How to Fix The themappeared first onCSS-Tricks.你可以通过成为MVP支持者来支持CSS-Tricks。