You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React应用状态变更未重渲染:分页1与2页切换异常求助

React分页组件渲染异常修复

问题描述

在实现书籍列表React应用的分页功能时,出现以下异常:控制台显示每次切换页面时状态均已更新,但第1页与第2页之间切换无法触发内容重渲染,而第2页到第3页、第1页到第3页(或反向)的切换可正常渲染。

完整代码

const propTypes = {
    books: React.PropTypes.array.isRequired,
    onChangePage: React.PropTypes.func.isRequired,
    initialPage: React.PropTypes.number    
}

const defaultProps = {
    initialPage: 1
}

class Pagination extends React.Component {
    constructor(props) {
        super(props);
        this.state = { pager: {} };
    }

    componentWillMount() {
        // set page if books array isn't empty
        if (this.props.books && this.props.books.length) {
            this.setPage(this.props.initialPage);
        }
    }

    componentDidUpdate(prevProps, prevState) {
        // reset page if books array has changed
        if (this.props.books !== prevProps.books) {
            this.setPage(this.props.initialPage);
        }
    }

    setPage(page) {
        var books = this.props.books;
        var pager = this.state.pager;
        
        if (page < 1 || page > pager.totalPages) {
            return;
        }

        // get new pager object for specified page
        pager = this.getPager(books.length, page);

        // get new page of books from books array
      
        var pageofBooks = books.slice(pager.startIndex, pager.endIndex + 1);
      
        // update state
        this.setState({ pager: pager });

        // call change page function in parent component
        this.props.onChangePage(pageofBooks);
    }

    getPager(totalbooks, currentPage, pageSize) {
        // default to first page
        currentPage = currentPage || 1;

        // default page size is 8
        pageSize = pageSize || 8;

        // calculate total pages
        var totalPages = Math.ceil(totalbooks / pageSize);

        var startPage, endPage;
        if (totalPages <= 10) {
            // less than 10 total pages so show all
            startPage = 1;
            endPage = totalPages;
        } else {
            // more than 10 total pages so calculate start and end pages
            if (currentPage <= 6) {
                startPage = 1;
                endPage = 10;
            } else if (currentPage + 4 >= totalPages) {
                startPage = totalPages - 9;
                endPage = totalPages;
            } else {
                startPage = currentPage - 5;
                endPage = currentPage + 4;
            }
        }

        // calculate start and end item indexes
        var startIndex = (currentPage - 1) * pageSize;
        var endIndex = Math.min(startIndex + pageSize - 1, totalbooks - 1);

        // create an array of pages to ng-repeat in the pager control
        var pages = [...Array((endPage + 1) - startPage).keys()].map(i => startPage + i);
        
        // return object with all pager properties required by the view
        return {
            totalbooks: totalbooks,
            currentPage: currentPage,
            pageSize: pageSize,
            totalPages: totalPages,
            startPage: startPage,
            endPage: endPage,
            startIndex: startIndex,
            endIndex: endIndex,
            pages: pages
        };
    }

    render() {
        var pager = this.state.pager;

        if (!pager.pages || pager.pages.length <= 1) {
            // don't display pager if there is only 1 page
            return null;
        }

        return (
            <ul className="pagination">
                <li className={pager.currentPage === 1 ? 'disabled' : ''}>
                    <a onClick={() => this.setPage(1)}>First</a>
                </li>
                <li className={pager.currentPage === 1 ? 'disabled' : ''}>
                    <a onClick={() => this.setPage(pager.currentPage - 1)}>Previous</a>
                </li>
                {pager.pages.map((page, index) =>
                    <li key={index} className={pager.currentPage === page ? 'active' : ''}>
                        <a onClick={() => this.setPage(page)}>{page}</a>
                    </li>
                )}
                <li className={pager.currentPage === pager.totalPages ? 'disabled' : ''}>
                    <a onClick={() => this.setPage(pager.currentPage + 1)}>Next</a>
                </li>
                <li className={pager.currentPage === pager.totalPages ? 'disabled' : ''}>
                    <a onClick={() => this.setPage(pager.totalPages)}>Last</a>
                </li>
            </ul>
        );
    }
}

Pagination.propTypes = propTypes;
Pagination.defaultProps = defaultProps;


/* App Component 
-------------------------------------------------*/

class App extends React.Component {
    constructor() {
        super();

        // an example array of books to be paged
        axios.get(`https://goodreads-server-express--dotdash.repl.co/search/name`)
      .then(response => {
        this.setState({bookList: response.data.list}) ;
      })
      .catch(error => {
        // edge case
        // alert("Yikes! Looks like we don't have anything for that search. Please edit your search and try again.");
        console.log(error);
      });
      
        this.state = {
            bookList: [],
            pageofBooks: []
        };

        // bind function in constructor instead of render (https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-no-bind.md)
      //console.log(this.state.pageofBooks)
      this.onChangePage = this.onChangePage.bind(this);
      this.handleChange = this.handleChange.bind(this);
      this.handleSubmit = this.handleSubmit.bind(this);
    }

    onChangePage(pageofBooks) {
        // update state with new page of books
      
        this.setState({ pageofBooks: pageofBooks }, () => {
          console.log(this.state.pageofBooks)
        });
    }
  
    handleChange (e) {
      e.preventDefault();
      this.setState({searchString: e.target.value})
    }

    handleSubmit (e) {
      e.preventDefault();
      this.setState({ bookList : [] });

      // edge case
      if (!this.state.searchString) {
        alert('Oops! Please enter your search in the box below.')
      } else {
        axios.get(`https://goodreads-server-express--dotdash.repl.co/search/${this.state.searchString}`)
        .then(response => {
          this.setState({ bookList: response.data.list });
        })
        .catch(error => {
          // edge case
          alert("Yikes! Looks like we don't have anything for that search. Please edit your search and try again.");
          console.log(error);
        });
      } 
    }

    render() {
        return (
            <div>
                <div className="container">
                    <div className="text-center">
                      <form className="search-bar">
                        <label htmlFor="search">Find me a book</label>
                        <input id="search" onChange={this.handleChange} />
                        <button onClick={this.handleSubmit}>Search</button>
                      </form>
                      <div className="search-results">
                        {this.state.pageofBooks.map( (item, i) =>
                            <BookCard book={item} key={i} />
                        )}
                      </div>
                        <Pagination books={this.state.bookList} onChangePage={this.onChangePage} />
                    </div>
                </div>
                <hr />
            </div>
        );
    }
}

class BookCard extends React.Component {
  constructor(props) {
    super(props);
    
    this.state = {
      author: props.book.authorName,
      title: props.book.title,
      image: props.book.imageUrl
    }
  }

  render() {
    return (
      <div className="book-card">
        <div className="image__container">
          <img src={this.state.image} />
        </div>
        <div className="book-card__header">
          <h3>{this.state.author}</h3>
          <h2>{this.state.title.length > 40 ? this.state.title.slice(0, 40) + '...' : this.state.title}</h2>
        </div>
      </div>
    );
  }
}

ReactDOM.render(<App />, document.getElementById('app'));

问题根源

问题出在BookCard组件的实现上:该组件在构造函数中将props的值存入自身state,但构造函数仅在组件首次挂载时执行。当从第1页切换到第2页时,pageofBooks数组长度不变(均为8条),React会复用已有的BookCard组件实例,而这些实例的state并未同步更新新的props,导致渲染内容仍为旧数据。

而切换到第3页时,数组长度通常小于8,React会卸载多余的组件并创建新的组件实例,新实例会用新props初始化state,因此显示正常。

修复方案

方案1:移除组件state,直接使用props渲染(推荐)

将BookCard改为纯展示组件,无需维护自身state,直接使用传入的props进行渲染:

class BookCard extends React.Component {
  render() {
    const { authorName, title, imageUrl } = this.props.book;
    return (
      <div className="book-card">
        <div className="image__container">
          <img src={imageUrl} />
        </div>
        <div className="book-card__header">
          <h3>{authorName}</h3>
          <h2>{title.length > 40 ? title.slice(0, 40) + '...' : title}</h2>
        </div>
      </div>
    );
  }
}

方案2:同步props到state(若需保留state)

如果需要保留组件state,可通过componentDidUpdate方法在props变化时更新state:

class BookCard extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      author: props.book.authorName,
      title: props.book.title,
      image: props.book.imageUrl
    };
  }

  componentDidUpdate(prevProps) {
    if (this.props.book !== prevProps.book) {
      this.setState({
        author: this.props.book.authorName,
        title: this.props.book.title,
        image: this.props.book.imageUrl
      });
    }
  }

  render() {
    return (
      <div className="book-card">
        <div className="image__container">
          <img src={this.state.image} />
        </div>
        <div className="book-card__header">
          <h3>{this.state.author}</h3>
          <h2>{this.state.title.length > 40 ? this.state.title.slice(0, 40) + '...' : this.state.title}</h2>
        </div>
      </div>
    );
  }
}

补充优化建议

渲染列表时建议使用书籍的唯一标识(如bookId)作为key,而非数组索引,这样React能更准确地识别组件变化,避免不必要的复用问题:

// 在App组件的render方法中修改列表渲染逻辑
{this.state.pageofBooks.map(item =>
  <BookCard book={item} key={item.bookId} />
)}

内容的提问来源于stack exchange,提问作者Emily Grace Acres

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.16 00:45:55