三个有 Bug 的 React 代码示例以及如何修复它们

Avatar of Hugh Haworth
Hugh Haworth

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费信用额度!

在 React 中,通常有多种方法可以编写代码。虽然可以通过不同的方式创建相同的内容,但可能有一两种方法在技术上比其他方法“更好”。我实际上遇到了很多例子,其中用于构建 React 组件的代码在技术上是“正确的”,但会引发完全可以避免的问题。

因此,让我们来看一些这样的例子。我将提供三个“有 Bug”的 React 代码实例,这些代码在技术上可以完成特定情况下的工作,以及可以改进这些代码以使其更易于维护、更具弹性和最终更具功能性的方法。

本文假设您已了解 React Hooks。这不是 Hooks 的入门教程——您可以在 CSS Tricks 上找到 Kingsley Silas 的一篇很好的入门文章,或者查看 React 文档 以熟悉它们。我们也不会讨论任何 React 18 中即将推出的令人兴奋的新功能。相反,我们将关注一些不会完全破坏您的应用程序但可能会潜入您的代码库并在您不小心时导致奇怪或意外行为的细微问题。

有 Bug 的代码 #1:修改状态和 Props

在 React 中修改状态或 Props 是一种反模式。不要这样做!

这不是什么革命性的建议——如果你刚开始学习 React,这通常是你学习的第一件事。但你可能会认为你可以侥幸逃脱(因为在某些情况下似乎确实可以)。

我将向您展示如果修改 Props,Bug 如何潜入您的代码。有时您需要一个组件来显示某些数据的转换版本。让我们创建一个父组件,它在状态中保存一个计数器,以及一个用于递增计数器的按钮。我们还将创建一个子组件,该组件通过 Props 接收计数器并显示添加 5 后的计数器值。

这是一个演示天真方法的 Pen

此示例有效。它实现了我们想要的功能:我们点击递增按钮,它会将计数器加 1。然后子组件重新渲染以显示添加 5 后的计数器值。我们在这里更改了子组件的 Props,它运行良好!为什么每个人都说修改 Props 这么糟糕呢?

那么,如果稍后我们重构代码并需要将计数器保存在对象中会怎样?如果我们的代码库越来越大,我们需要在与我们的代码库相同的 useState Hook 中存储更多属性,则可能会发生这种情况。

我们不是递增状态中保存的数字,而是递增状态中保存的对象的 count 属性。在我们的子组件中,我们通过 Props 接收对象,并向 count 属性添加值以显示如果我们添加 5,计数器将是什么样子。

让我们看看结果如何。尝试在这个 Pen 中递增几次状态

哦,不!现在,当我们递增计数器时,它似乎每次点击都加 6!为什么会这样?这两个示例之间唯一变化的是我们使用了对象而不是数字!

更有经验的 JavaScript 程序员会知道,这里的主要区别在于,诸如数字、布尔值和字符串之类的原始类型是不可变的,并且按值传递,而对象是按引用传递的。

这意味着

  • 如果你将一个数字放入一个变量中,将其分配给另一个变量,然后更改第二个变量,第一个变量将不会被更改。
  • 如果你将一个对象放入一个变量中,将其分配给另一个变量,然后更改第二个变量,第一个变量被更改。

当子组件更改状态对象的属性时,它会向 React 用于更新状态的相同对象中添加 5。这意味着当我们的递增函数在点击后触发时,React 会在我们的子组件对其进行操作之后使用相同的对象,这表现为每次点击都加 6。

解决方案

有多种方法可以避免这些问题。对于像这样简单的情况,您可以避免任何修改并在渲染函数中表达更改

function Child({state}){
  return <div><p>count + 5 = {state.count + 5} </p></div>
}

但是,在更复杂的情况下,您可能需要重复使用 state.count + 5 多次或将转换后的数据传递给多个子组件。

一种方法是在子组件中创建 Prop 的副本,然后转换克隆数据上的属性。在 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

另一种选择是使用像 Immutable.js 这样的库。如果您有一条规则,即只使用不可变的数据结构,那么您就可以相信您的数据不会被意外修改。这是一个使用不可变的 Map 类表示计数器应用程序状态的另一个示例

有 Bug 的代码 #2:派生状态

假设我们有一个父组件和一个子组件。它们都具有保存计数器的 useState Hook。并且假设父组件将其状态作为 Prop 传递给子组件,子组件使用该 Prop 初始化其计数器。

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>;
}

当父组件的状态发生变化并且子组件使用不同的 Props 重新渲染时,子组件的状态会发生什么变化?子组件的状态会保持不变,还是会更改以反映传递给它的新计数器?

我们正在处理一个函数,因此子组件的状态应该会被清除并替换,对吧?错了!子组件的状态优先于父组件的新 Prop。在子组件的状态在第一次渲染中初始化后,它与它接收的任何 Props 完全独立。

React 为树中的每个组件存储组件状态,并且只有在移除组件时才会清除状态。否则,状态不会受到新 Props 的影响。

使用 Props 初始化状态称为“派生状态”,它 有点反模式。它消除了组件拥有其数据单一来源的好处。

使用 key Prop

但是,如果我们有一组要使用相同类型的子组件编辑的项目,并且我们希望子组件保存我们正在编辑的项目的草稿会怎样?我们需要在每次从集合中切换项目时重置子组件的状态。

这是一个示例:让我们编写一个应用程序,我们可以每天编写五件我们感激的事情的清单。我们将使用一个父组件,其状态初始化为空数组,我们将用五个字符串语句填充它。

然后我们将有一个具有文本输入的子组件来输入我们的语句。

我们将在我们的微型应用程序中使用犯罪级别的过度工程,但这是为了说明您可能在更复杂的项目中需要的模式:我们将子组件中文本输入的草稿状态保存在子组件中。

将状态降低到子组件可以作为性能优化,以防止输入状态更改时父组件重新渲染。否则,父组件将在文本输入发生更改时每次重新渲染。

我们还将传递一个示例语句作为我们将编写的五个笔记中的每个笔记的默认值。

这是一个有 Bug 的方法

// 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>
}

这里有一个问题:每次我们提交完成的语句时,输入都会错误地保留文本框中的提交的笔记。我们希望用我们列表中的示例语句替换它。

即使我们每次都传递不同的示例字符串,子组件也会记住旧状态,并且忽略了我们较新的 Prop。您可以在 useEffect 中的每次渲染中检查 Prop 是否已更改,然后如果它们已更改则重置状态。但这会在您的数据不同部分使用相同的值并且您希望强制重置子组件状态(即使 Prop 保持不变)时导致 Bug。

解决方案

如果您需要一个子组件,其中父组件需要能够按需重置子组件,则有一种方法可以做到:通过更改子组件的 key Prop。

你可能在根据数组渲染元素时见过这个特殊的 key 属性,并且 React 会抛出一个警告,要求你为每个元素提供一个 key。更改子元素的 key 可以确保 React 创建一个全新的元素版本。这是一种告诉 React 你正在使用相同的组件渲染一个概念上不同的项目的途径。

让我们为子组件添加一个 key 属性。其值为我们即将用语句填充的索引。

<StatementForm key={list.length} initialStatement={ideaList[list.length]} onStatementComplte={onStatementComplete}/>

这是它在我们列表应用中的样子。

请注意,这里唯一变化的是子组件现在有一个基于我们即将填充的数组索引的 key 属性。然而,组件的行为已经完全改变了。

现在,每次我们提交并完成语句的编写后,子组件中的旧状态都会被丢弃并替换为示例语句。

错误代码 #3:陈旧闭包错误

这是 React hooks 中的一个常见问题。之前有一篇 CSS-Tricks 文章讨论了如何处理 React 函数组件中的陈旧 props 和 state

让我们看看一些你可能遇到麻烦的情况。第一个出现的问题是在使用 useEffect 时。如果我们在 useEffect 内部进行任何异步操作,我们可能会在使用旧的 state 或 props 时遇到麻烦。

这是一个例子。我们需要每秒递增一个计数器。我们在第一次渲染时使用 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 但之后再也没有改变!为什么会发生这种情况?

这与两件事有关。

查看MDN 关于闭包的文档,我们可以看到。

闭包是函数与其声明所在词法环境的组合。这个环境包含了在创建闭包时处于作用域内的任何局部变量。

我们 useEffect 闭包声明的“词法环境”位于我们的 Counter React 组件内部。我们感兴趣的局部变量是 count,在声明时(第一次渲染)它为零。

问题是,这个闭包永远不会再次被声明。如果声明时的计数器为零,它将始终为零。每次间隔触发时,它都会运行一个从计数器为零开始并将其递增到 1 的函数。

那么我们如何才能让函数再次声明呢?这就是 useEffect 调用的第二个参数发挥作用的地方。我们认为我们非常聪明,只使用空数组启动一次间隔,但这样做却适得其反。如果我们省略了这个参数,useEffect 内部的闭包将在每次渲染时都会使用新的 count 重新声明。

我喜欢这样理解 useEffect 依赖项数组。

  • 当依赖项发生变化时,它将触发 useEffect 函数。
  • 它还会使用更新的依赖项重新声明闭包,使闭包免受陈旧的 state 或 props 的影响。

事实上,甚至有一个lint 规则,通过确保你向第二个参数添加正确的依赖项,来使你的 useEffect 实例免受陈旧的 state 和 props 的影响。

但我们实际上也不希望在每次组件渲染时都重置我们的间隔。那么我们如何解决这个问题呢?

解决方案

同样,这里有多种解决我们问题的方案。让我们从最简单的开始:根本不使用 count state,而是将一个函数传递到我们的 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 hook,像这样来保存 count 的可变引用。

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"))

要更深入地了解如何使用间隔和 hooks,你可以查看 Dan Abramov(React 核心团队成员之一)关于在 React 中创建 useInterval这篇文章。他采用了一种不同的方法,而不是将 count 保存在 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"))

随着页面滚动,我们的条形变得更宽并且越来越不透明。但是,如果你点击更改颜色按钮,我们的随机颜色不会影响进度条。我们遇到这个错误是因为闭包受组件状态的影响,并且这个闭包从未被重新声明,因此我们只获得了状态的原始值,而没有更新。

你可以看到,如果不小心,使用 React 状态或组件 props 设置调用外部 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})`;
}

这效果不错,但感觉不太理想。如果你正在处理第三方库并且找不到将它们的 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 的声明式特性。

此外,对于这样的滚动指示器,你可能甚至不需要 JavaScript——看看即将推出的@scroll-timeline CSS功能Chris 关于最佳 CSS 技巧的书中使用渐变的方法

总结

我们已经了解了三种在 React 应用程序中创建错误的不同方法以及一些修复方法。很容易查看遵循快乐路径的反例,而这些反例并没有显示出可能导致问题的 API 中的细微差别。

如果你仍然需要构建一个更强大的 React 代码工作原理的心智模型,这里有一份可以帮助你的资源列表。