以下哪种策略是在 props 更改时重置组件状态的最佳方法

IT技术 javascript reactjs typescript
2021-05-23 11:21:19

我有一个非常简单的组件,带有一个文本字段和一个按钮:

<图片>

它接受一个列表作为输入并允许用户在列表中循环。

该组件具有以下代码:

import * as React from "react";
import {Button} from "@material-ui/core";

interface Props {
    names: string[]
}
interface State {
    currentNameIndex: number
}

export class NameCarousel extends React.Component<Props, State> {

    constructor(props: Props) {
        super(props);
        this.state = { currentNameIndex: 0}
    }

    render() {
        const name = this.props.names[this.state.currentNameIndex].toUpperCase()
        return (
            <div>
                {name}
                <Button onClick={this.nextName.bind(this)}>Next</Button>
            </div>
        )
    }

    private nextName(): void {
        this.setState( (state, props) => {
            return {
                currentNameIndex: (state.currentNameIndex + 1) % props.names.length
            }
        })
    }
}

这个组件很好用,除了我没有处理状态改变时的情况。 当状态改变时,我想将 重置currentNameIndex为零。

做这个的最好方式是什么?


我已经考虑的选项:

使用 componentDidUpdate

这个解决方案是ackward,因为componentDidUpdate在render之后运行,所以我需要在render方法中添加一个子句,当组件处于无效状态时“什么都不做”,如果我不小心,我可能会导致空指针异常.

我在下面包含了一个实现。

使用 getDerivedStateFromProps

getDerivedStateFromProps方法是static和签名只给你访问到当前的状态和未来的props。这是一个问题,因为您无法判断props是否已更改。结果,这迫使您将props复制到状态中,以便您可以检查它们是否相同。

使组件“完全受控”

我不想这样做。这个组件应该私有拥有当前选择的索引是什么。

使组件“完全不受钥匙控制”

我正在考虑这种方法,但不喜欢它如何导致父级需要了解子级的实现细节。

关联


杂项

我花了很多时间阅读你可能不需要派生状态, 但对那里提出的解决方案很不满意。

我知道这个问题的变体已经被问过多次,但我觉得任何答案都没有权衡可能的解决方案。一些重复的例子:


附录

解决方案使用componetDidUpdate(见上面的描述)

import * as React from "react";
import {Button} from "@material-ui/core";

interface Props {
    names: string[]
}
interface State {
    currentNameIndex: number
}

export class NameCarousel extends React.Component<Props, State> {

    constructor(props: Props) {
        super(props);
        this.state = { currentNameIndex: 0}
    }

    render() {

        if(this.state.currentNameIndex >= this.props.names.length){
            return "Cannot render the component - after compoonentDidUpdate runs, everything will be fixed"
        }

        const name = this.props.names[this.state.currentNameIndex].toUpperCase()
        return (
            <div>
                {name}
                <Button onClick={this.nextName.bind(this)}>Next</Button>
            </div>
        )
    }

    private nextName(): void {
        this.setState( (state, props) => {
            return {
                currentNameIndex: (state.currentNameIndex + 1) % props.names.length
            }
        })
    }

    componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>): void {
        if(prevProps.names !== this.props.names){
            this.setState({
                currentNameIndex: 0
            })
        }
    }

}

解决方案使用getDerivedStateFromProps

import * as React from "react";
import {Button} from "@material-ui/core";

interface Props {
    names: string[]
}
interface State {
    currentNameIndex: number
    copyOfProps?: Props
}

export class NameCarousel extends React.Component<Props, State> {

    constructor(props: Props) {
        super(props);
        this.state = { currentNameIndex: 0}
    }

    render() {

        const name = this.props.names[this.state.currentNameIndex].toUpperCase()
        return (
            <div>
                {name}
                <Button onClick={this.nextName.bind(this)}>Next</Button>
            </div>
        )
    }


    static getDerivedStateFromProps(props: Props, state: State): Partial<State> {

        if( state.copyOfProps && props.names !== state.copyOfProps.names){
            return {
                currentNameIndex: 0,
                copyOfProps: props
            }
        }

        return {
            copyOfProps: props
        }
    }

    private nextName(): void {
        this.setState( (state, props) => {
            return {
                currentNameIndex: (state.currentNameIndex + 1) % props.names.length
            }
        })
    }


}
3个回答

正如我在评论中所说,我不喜欢这些解决方案。

组件不应该关心父级在做什么或父级的当前state什么,它们应该简单地接收props和输出 some JSX,这样它们才真正可重用、可组合和隔离,这也使测试更容易。

我们可以让NamesCarousel组件将轮播的名称与轮播的功能和当前可见的名称放在一起,并制作一个Name只做一件事组件,显示通过传入的名称props

要重置selectedIndex项目更改时添加一个useEffect项目作为依赖项,但如果您只是将项目添加到数组的末尾,则可以忽略此部分

const Name = ({ name }) => <span>{name.toUpperCase()}</span>;

const NamesCarousel = ({ names }) => {
  const [selectedIndex, setSelectedIndex] = useState(0);

  useEffect(() => {
    setSelectedIndex(0)
  }, [names])// when names changes reset selectedIndex

  const next = () => {
    setSelectedIndex(prevIndex => prevIndex + 1);
  };

  const prev = () => {
    setSelectedIndex(prevIndex => prevIndex - 1);
  };

  return (
    <div>
      <button onClick={prev} disabled={selectedIndex === 0}>
        Prev
      </button>
      <Name name={names[selectedIndex]} />
      <button onClick={next} disabled={selectedIndex === names.length - 1}>
        Next
      </button>
    </div>
  );
};

现在这很好,但是NamesCarousel可重复使用吗?不,Name组件只是CarouselName组件耦合

那么我们可以做些什么来使其真正可重用并看到孤立设计组件的好处呢?

我们可以利用渲染props模式。

让我们创建一个通用Carousel组件,它将获取一个通用列表items并调用children传入所选函数item

const Carousel = ({ items, children }) => {
  const [selectedIndex, setSelectedIndex] = useState(0);

  useEffect(() => {
    setSelectedIndex(0)
  }, [items])// when items changes reset selectedIndex

  const next = () => {
    setSelectedIndex(prevIndex => prevIndex + 1);
  };

  const prev = () => {
    setSelectedIndex(prevIndex => prevIndex - 1);
  };

  return (
    <div>
      <button onClick={prev} disabled={selectedIndex === 0}>
        Prev
      </button>
      {children(items[selectedIndex])}
      <button onClick={next} disabled={selectedIndex === items.length - 1}>
        Next
      </button>
    </div>
  );
};

现在这个模式实际上给了我们什么?

它使我们能够Carousel像这样渲染组件

// items can be an array of any shape you like
// and the children of the component will be a function 
// that will return the select item
<Carousel items={["Hi", "There", "Buddy"]}>
  {name => <Name name={name} />} // You can render any component here
</Carousel>

现在它们既是孤立的,又是真正可重用的,您可以将它们items作为图像、视频甚至用户的数组传递

您可以更进一步,为轮播提供要作为props显示的项目数量,并使用项目数组调用子函数

return (
  <div>
    {children(items.slice(selectedIndex, selectedIndex + props.numOfItems))}
  </div>
)

// And now you will get an array of 2 names when you render the component
<Carousel items={["Hi", "There", "Buddy"]} numOfItems={2}>
  {names => names.map(name => <Name key={name} name={name} />)}
</Carousel>

您可以使用功能组件吗?可能会简化一些事情。

import React, { useState, useEffect } from "react";
import { Button } from "@material-ui/core";

interface Props {
    names: string[];
}

export const NameCarousel: React.FC<Props> = ({ names }) => {
  const [currentNameIndex, setCurrentNameIndex] = useState(0);

  const name = names[currentNameIndex].toUpperCase();

  useEffect(() => {
    setCurrentNameIndex(0);
  }, names);

  const handleButtonClick = () => {
    setCurrentIndex((currentNameIndex + 1) % names.length);
  }

  return (
    <div>
      {name}
      <Button onClick={handleButtonClick}>Next</Button>
    </div>
  )
};

useEffect类似于componentDidUpdate它将依赖数组(状态和props变量)作为第二个参数的地方。当这些变量发生变化时,将执行第一个参数中的函数。就那么简单。您可以在函数体内进行额外的逻辑检查以设置变量(例如,setCurrentNameIndex)。

如果您在函数内部更改的第二个参数中有依赖项,请小心,然后您将有无限的重新渲染。

查看useEffect 文档,但在习惯了钩子之后,您可能再也不想使用类组件了。

你问什么是最好的选择,最好的选择是让它成为受控组件。

组件在层次结构中太低,不知道如何处理它的属性更改 - 如果列表更改但只是轻微更改(可能添加新名称) - 调用组件可能希望保持原始位置。

在所有情况下,我都认为如果父组件可以决定组件在提供新列表时的行为方式,我们会更好。

也有可能这样的组件是更大整体的一部分,需要将当前选择传递给它的父级 - 也许作为表单的一部分。

如果您真的坚持不让它成为受控组件,还有其他选择:

  • 您可以将整个名称(或 id 组件)保留在状态中而不是索引 - 如果该名称不再存在于名称列表中,则返回列表中的第一个。这与您的原始要求略有不同,对于一个非常非常长的列表可能是一个性能问题,但它非常干净。
  • 如果你对钩子没问题,useEffect那么 Asaf Aviv 建议是一种非常干净的方法。
  • 用类来做的“规范”方法似乎是getDerivedStateFromProps- 是的,这意味着在状态中保留对名称列表的引用并进行比较。如果你这样写,它看起来会好一点:
   static getDerivedStateFromProps(props: Props, state: State = {}): Partial<State> {

       if( state.names !== props.names){
           return {
               currentNameIndex: 0,
               names: props.names
           }
       }

       return null; // you can return null to signify no change.
   }

state.names如果您选择这条路线,您可能也应该在渲染方法中使用

但实际上 - 受控组件是要走的路,当需求发生变化并且父级需要知道所选项目时,您可能迟早会这样做。