【译】三个有问题的React代码例子以及如何修复它们

261 阅读17分钟

在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,但此后再也没有变化!为什么会这样?为什么会发生这种情况?

这与两件事有关。

看了一下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 postThree Buggy React Code Examples and How to Fix The themappeared first onCSS-Tricks.你可以通过成为MVP支持者来支持CSS-Tricks。