React - 如何检测父组件的所有子组件何时对用户可见?

IT技术 reactjs
2021-05-16 03:26:06

TL; 博士

父组件如何知道其下每个子组件的渲染何时完成以及用户可见的 DOM 是最新版本?

假设我有一个孙组件组成的component A组件。这些孙组件中的每一个都从一个 RESTful API 端点获取数据,并在数据可用时呈现自身。Grid3x3

我想Component Aloader占位符覆盖整个区域,只有当网格中的最后一个组件成功获取数据并呈现数据时才会显示,这样它就已经在 DOM 上并且可以查看了。

用户体验应该是从“加载器”到完全填充的网格的超级平滑过渡,而不会闪烁。

我的问题是确切知道何时在装载机下揭开组件。

有什么我可以依靠的机制来绝对准确地做到这一点吗?我不会硬编码加载器的时间限制。据我所知,依赖ComponentDidMount每个孩子也不可靠,因为它实际上并不能保证组件在调用时对用户完全可见。

进一步提炼这个问题:

我有一个呈现某种数据的组件。在它初始化之后它没有它,所以componentDidMount在它里面点击了一个 API 端点。一旦它接收到数据,它就会改变它的状态来反映它。可以理解,这会导致重新渲染该组件的最终状态。我的问题是:我怎么知道重新渲染何时发生反映在面向用户的 DOM 中。该时间点 != 组件状态已更改为包含数据的时间点。

4个回答

React 中有两个生命周期钩子,它们在组件的 DOM 呈现后被调用:

对于您的用例,N个子组件都满足某个条件X时,您的父组件P感兴趣X 可以定义为一个序列:

  • 异步操作完成
  • 组件已呈现

通过结合组件的状态并使用componentDidUpdate钩子,您可以知道序列何时完成以及您的组件何时满足条件 X。

您可以通过设置状态变量来跟踪异步操作何时完成。例如:

this.setState({isFetched: true})

设置状态后,React 将调用您的组件componentDidUpdate函数。通过比较此函数中的当前和先前状态对象,您可以向父组件发出异步操作已完成且新组件的状态已呈现的信号:

componentDidUpdate(_prevProps, prevState) {
  if (this.state.isFetched === true && this.state.isFetched !== prevState.isFetched) {
    this.props.componentHasMeaningfullyUpdated()
  }
}

在您的 P 组件中,您可以使用一个计数器来跟踪有多少孩子进行了有意义的更新:

function onComponentHasMeaningfullyUpdated() {
  this.setState({counter: this.state.counter + 1})
}

最后,通过知道N的长度,您可以知道所有有意义的更新何时发生,并在P的渲染方法中采取相应的行动

const childRenderingFinished = this.state.counter >= N

我会设置它,以便您依靠全局状态变量来告诉您的组件何时呈现。Redux 更适合这种许多组件相互通信的场景,您在评论中提到您有时会使用它。所以我将使用 Redux 草拟一个答案。

您必须将 API 调用移动到父容器Component A. 如果您只想在 API 调用完成后才让孙子渲染,则不能将这些 API 调用保留在孙子本身中。如何从尚不存在的组件进行 API 调用?

完成所有 API 调用后,您可以使用操作来更新包含一堆数据对象的全局状态变量。每次收到数据(或捕获错误)时,您都可以调度一个动作来检查您的数据对象是否已完全填写。完全填写后,您可以将loading变量更新false,并有条件地呈现您的Grid组件。

例如:

// Component A

import { acceptData, catchError } from '../actions'

class ComponentA extends React.Component{

  componentDidMount () {

    fetch('yoururl.com/data')
      .then( response => response.json() )
      // send your data to the global state data array
      .then( data => this.props.acceptData(data, grandChildNumber) )
      .catch( error => this.props.catchError(error, grandChildNumber) )

    // make all your fetch calls here

  }

  // Conditionally render your Loading or Grid based on the global state variable 'loading'
  render() {
    return (
      { this.props.loading && <Loading /> }
      { !this.props.loading && <Grid /> }
    )
  }

}


const mapStateToProps = state => ({ loading: state.loading })

const mapDispatchToProps = dispatch => ({ 
  acceptData: data => dispatch( acceptData( data, number ) )
  catchError: error=> dispatch( catchError( error, number) )
})
// Grid - not much going on here...

render () {
  return (
    <div className="Grid">
      <GrandChild1 number={1} />
      <GrandChild2 number={2} />
      <GrandChild3 number={3} />
      ...
      // Or render the granchildren from an array with a .map, or something similar
    </div>
  )
}
// Grandchild

// Conditionally render either an error or your data, depending on what came back from fetch
render () {
  return (
    { !this.props.data[this.props.number].error && <Your Content Here /> }
    { this.props.data[this.props.number].error && <Your Error Here /> }
  )
}

const mapStateToProps = state => ({ data: state.data })

您的减速器将持有全局状态对象,该对象将说明一切是否准备就绪:

// reducers.js

const initialState = {
  data: [{},{},{},{}...], // 9 empty objects
  loading: true
}

const reducers = (state = initialState, action) {
  switch(action.type){

    case RECIEVE_SOME_DATA:
      return {
        ...state,
        data: action.data
      }

     case RECIEVE_ERROR:
       return {
         ...state,
         data: action.data
       }

     case STOP_LOADING:
       return {
         ...state,
         loading: false
       }

  }
}

在你的行动中:


export const acceptData = (data, number) => {
  // First revise your data array to have the new data in the right place
  const updatedData = data
  updatedData[number] = data
  // Now check to see if all your data objects are populated
  // and update your loading state:
  dispatch( checkAllData() )
  return {
    type: RECIEVE_SOME_DATA,
    data: updatedData,
  }
}

// error checking - because you want your stuff to render even if one of your api calls 
// catches an error
export const catchError(error, number) {
  // First revise your data array to have the error in the right place
  const updatedData = data
  updatedData[number].error = error
  // Now check to see if all your data objects are populated
  // and update your loading state:
  dispatch( checkAllData() )
  return {
    type: RECIEVE_ERROR,
    data: updatedData,
  }
}

export const checkAllData() {
  // Check that every data object has something in it
  if ( // fancy footwork to check each object in the data array and see if its empty or not
    store.getState().data.every( dataSet => 
      Object.entries(dataSet).length === 0 && dataSet.constructor === Object ) ) {
        return {
          type: STOP_LOADING
        }
      }
  }

在旁边

如果您真的接受 API 调用存在于每个孙子中的想法,但是在所有 API 调用完成之前不会呈现整个孙子网格,那么您将不得不使用完全不同的解决方案。在这种情况下,您的孙子必须从一开始就渲染以进行调用,但是有一个带有 的 css 类display: none该类仅在全局状态变量loading标记为 false后才会更改这也是可行的,但有点不符合 React 的观点。

您可以使用 React Suspense潜在地解决这个问题。

需要注意的是,暂停经过进行渲染的组件树不是一个好主意(也就是说:如果您的组件开始渲染过程,根据我的经验,让该组件暂停不是一个好主意),所以它是呈现单元格的组件中启动请求可能是一个更好的主意像这样的东西:

export default function App() {
  const cells = React.useMemo(
    () =>
      ingredients.map((_, index) => {
        // This starts the fetch but *does not wait for it to finish*.
        return <Cell resource={fetchIngredient(index)} />;
      }),
    []
  );

  return (
    <div className="App">
      <Grid>{cells}</Grid>
    </div>
  );
}

现在,我不确定 Suspense 如何与 Redux 配对。这个(实验性的!)Suspense 版本背后的整个想法是,您在父组件的渲染周期期间立即开始提取,并将表示提取的对象传递给子组件。这可以防止您必须拥有某种 Barrier 对象(您在其他方法中需要)。

我要说的是,我不认为等到一切都已获取才显示任何内容是正确的方法,因为这样 UI 将与最慢的连接一样慢,或者可能根本无法工作!

这是缺少的其余代码:

const ingredients = [
  "Potato",
  "Cabbage",
  "Beef",
  "Bok Choi",
  "Prawns",
  "Red Onion",
  "Apple",
  "Raisin",
  "Spinach"
];

function randomTimeout(ms) {
  return Math.ceil(Math.random(1) * ms);
}

function fetchIngredient(id) {
  const task = new Promise(resolve => {
    setTimeout(() => resolve(ingredients[id]), randomTimeout(5000));
  });

  return new Resource(task);
}

// This is a stripped down version of the Resource class displayed in the React Suspense docs. It doesn't handle errors (and probably should).
// Calling read() will throw a Promise and, after the first event loop tick at the earliest, will return the value. This is a synchronous-ish API,
// Making it easy to use in React's render loop (which will not let you return anything other than a React element).
class Resource {
  constructor(promise) {
    this.task = promise.then(value => {
      this.value = value;
      this.status = "success";
    });
  }

  read() {
    switch (this.status) {
      case "success":
        return this.value;

      default:
        throw this.task;
    }
  }
}

function Cell({ resource }) {
  const data = resource.read();
  return <td>{data}</td>;
}

function Grid({ children }) {
  return (
    // This suspense boundary will cause a Loading sign to be displayed if any of the children suspend (throw a Promise).
    // Because we only have the one suspense boundary covering all children (and thus Cells), the fallback will be rendered
    // as long as at least one request is in progress.
    // Thanks to this approach, the Grid component need not be aware of how many Cells there are.
    <React.Suspense fallback={<h1>Loading..</h1>}>
      <table>{children}</table>
    </React.Suspense>
  );
}

还有一个沙箱:https : //codesandbox.io/s/falling-dust-b8e7s

首先,生命周期可以有 async/await 方法。

我们需要知道的componentDidMountcomponentWillMount

componentWillMount 首先被称为父级,然后是子级。
componentDidMount 做相反的事情。

在我看来,简单地使用正常的生命周期来实现它们就足够了。

  • 子组件
async componentDidMount() {
  await this.props.yourRequest();
  // Check if satisfied, if true, add flag to redux store or something,
  // or simply check the res stored in redux in parent leading no more method needed here
  await this.checkIfResGood();
}
  • 父组件
// Initial state `loading: true`
componentDidUpdate() {
  this.checkIfResHaveBad(); // If all good, `loading: false`
}
...
{this.state.loading ? <CircularProgress /> : <YourComponent/>}

Material-UI CircularProgress

由于孩子重新渲染导致父母做同样的事情,抓住它就didUpdate可以了。
而且,由于它是在渲染后调用的,如果您将检查功能设置为您的需求,则在此之后不应更改页面。

我们在我们的 prod 中使用了这个实现,它有一个 api 导致 30s 得到响应,据我所知,一切都很好。

在此处输入图片说明