React Router v4 - 切换组件时保持滚动位置

30

我有两个使用react-router创建的 <Route>

  • /cards -> 卡牌游戏列表
  • /cards/1 -> 卡牌游戏#1详情

当用户点击"返回列表"时,我想滚动到他在列表中的位置。

我该怎么做?

7个回答

22

codesandbox中有一个工作示例。

React Router v4没有为滚动恢复提供开箱即用的支持,而且目前他们也不会提供。您可以在他们文档的React Router V4 - 滚动恢复部分了解更多信息。

所以,每个开发人员都需要编写逻辑来支持这一点,尽管我们有一些工具来使之生效。

element.scrollIntoView()

.scrollIntoView()可在元素上调用,如您所料,它将其滚动到视图中。支持相当好,目前,97%的浏览器都支持它。来源:icanuse

Link /组件可以传递state状态

React Router的Link组件有一个to属性,您可以提供一个对象而不是一个字符串。下面是这个属性的样式。

<Link to={{ pathname: '/card', state: 9 }}>Card nine</Link>

我们可以使用状态将信息传递给将要呈现的组件。在这个例子中,状态被赋予一个数字,它将足以回答你的问题,稍后你会看到,但它可以是任何内容。路由/card呈现<Card />现在可以访问变量状态在props.location.state,我们可以随意使用它。

为每个列表项打上标签

在呈现各种卡片时,我们为每个卡片添加一个唯一的类。这样我们就有了一个标识符,我们可以传递并知道这个项目需要在导航回卡片列表概述时滚动到视图中。

解决方案

  1. <Cards />呈现一个列表,每个条目都有一个唯一的类;
  2. 当点击一个项目时,Link />将唯一标识符传递给<Card />
  3. <Card />呈现卡片详细信息和一个带有唯一标识符的返回按钮;
  4. 当点击按钮,并且<Cards />被加载时,.scrollIntoView()使用props.location.state中的数据滚动到先前单击的项目。

以下是各个部分的代码片段。

// Cards component displaying the list of available cards.
// Link's to prop is passed an object where state is set to the unique id.
class Cards extends React.Component {
  componentDidMount() {
    const item = document.querySelector(
      ".restore-" + this.props.location.state
    );
    if (item) {
      item.scrollIntoView();
    }
  }

  render() {
    const cardKeys = Object.keys(cardData);
    return (
      <ul className="scroll-list">
        {cardKeys.map(id => {
          return (
            <Link
              to={{ pathname: `/cards/${id}`, state: id }}
              className={`card-wrapper restore-${id}`}
            >
              {cardData[id].name}
            </Link>
          );
        })}
      </ul>
    );
  }
}

// Card compoment. Link compoment passes state back to cards compoment
const Card = props => {
  const { id } = props.match.params;
  return (
    <div className="card-details">
      <h2>{cardData[id].name}</h2>
      <img alt={cardData[id].name} src={cardData[id].image} />
      <p>
        {cardData[id].description}&nbsp;<a href={cardData[id].url}>More...</a>
      </p>
      <Link
        to={{
          pathname: "/cards",
          state: props.location.state
        }}
      >
        <button>Return to list</button>
      </Link>
    </div>
  );
};

// App router compoment.
function App() {
  return (
    <div className="App">
      <Router>
        <div>
          <Route exact path="/cards" component={Cards} />
          <Route path="/cards/:id" component={Card} />
        </div>
      </Router>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);


7
这回答了问题,但当用户点击浏览器返回按钮时并没有解决问题。 - Damien

8

由于在函数组件中没有解决此问题的答案,下面是我为一个项目实现的钩子解决方案:

import React from 'react';
import { useHistory } from 'react-router-dom';

function useScrollMemory(): void {
  const history = useHistory<{ scroll: number } | undefined>();

  React.useEffect(() => {
    const { push, replace } = history;

    // Override the history PUSH method to automatically set scroll state.
    history.push = (path: string) => {
      push(path, { scroll: window.scrollY });
    };
    // Override the history REPLACE method to automatically set scroll state.
    history.replace = (path: string) => {
      replace(path, { scroll: window.scrollY });
    };

    // Listen for location changes and set the scroll position accordingly.
    const unregister = history.listen((location, action) => {
      window.scrollTo(0, action !== 'POP' ? 0 : location.state?.scroll ?? 0);
    });

    // Unregister listener when component unmounts.
    return () => {
      unregister();
    };
  }, [history]);
}

function App(): JSX.Element {
  useScrollMemory();

  return <div>My app</div>;
}

使用此覆盖解决方案,您无需担心在所有元素中传递状态。改进的方法是使其通用,以便与 history 的 push 和 replace 方法向后兼容,但在我的特定情况下这不是必需的,因此我省略了它。

我正在使用react-router-dom,但您也可以轻松地覆盖原始historyAPI的方法。


3

这个问题的另一个可能解决方法是将您的/cards/:id路由呈现为全屏模式,同时保持/cards路由在其后面挂载。


最佳答案。这也是Instagram浏览器的方法。 - Gabriel Arghire
这绝对是目前为止最简单的解决方案。 - Jon Wyatt

2
完整实现Redux的方法可以在CodeSandbox上查看。
我是通过使用历史API来实现的。
以下是具体步骤:
1. 路由更改后保存滚动位置。 2. 当用户点击返回按钮时,恢复滚动位置。 3. 在`getSnapshotBeforeUpdate`中保存滚动位置,在`componentDidUpdate`中恢复滚动位置。
  // Saving scroll position.
  getSnapshotBeforeUpdate(prevProps) {
    const {
      history: { action },
      location: { pathname }
    } = prevProps;

    if (action !== "POP") {
      scrollData = { ...scrollData, [pathname]: window.pageYOffset };
    }

    return null;
  }

  // Restore scroll position.
  componentDidUpdate() {
    const {
      history: { action },
      location: { pathname }
    } = this.props;

    if (action === "POP") {
      if (scrollData[pathname]) {
        setTimeout(() =>
          window.scrollTo({
            left: 0,
            top: scrollData[pathname],
            behavior: "smooth"
          })
        );
      } else {
        setTimeout(window.scrollTo({ left: 0, top: 0 }));
      }
    } else {
      setTimeout(window.scrollTo({ left: 0, top: 0 }));
    }
  }

这个解决方案对我有用,只需要进行一些小修改。在getSnapshotBeforeUpdate中,我绑定了一个防抖事件处理程序到“scroll”事件上,而不是更新scrollData对象,这样我就不会频繁地更新scrollData。我还修改了componentDidUpdate函数,这样当pathname !== prevProp.location.pathname时,我才会滚动窗口,这样我就不会因为其他非路径相关属性的更改而滚动窗口。 - tlfu
为什么在上述代码中需要使用 setTimeout? - vijayst

1

在我的一个使用函数组件的React项目中,我遇到了类似的问题。由于某些原因,提供的确切解决方案在我的情况下无法正常工作,因此我根据@Shortchange@Agus Syahputra提供的答案创建了一个解决方案。

我按照@Shortchange的答案创建了一个名为useScrollMemory的自定义钩子,但进行了一些小修改。这里的useScrollMemory函数接受scrollData对象作为参数,该全局对象根据@Agus Syahputra的答案存储已访问路径名的滚动位置。 scrollData对象在App组件中初始化。

useScrollMemory.js

import { useEffect } from 'react';
import { useHistory } from 'react-router-dom';

/**
 * @description Overrides the PUSH and REPLACE methods in history to
 * save the window scroll position for the route.
 *
 * @param { Object } scrollData - Contains pathname and its scroll position.
 */
const useScrollMemory = (scrollData) => {
  const history = useHistory();

  useEffect(() => {
    const { push, replace } = history;

    // Override the history PUSH method to automatically set scroll state.
    history.push = (path, state = {}) => {
      scrollData[history.location.pathname] = window.scrollY;
      push(path, state);
    };

    // Override the history REPLACE method to automatically set scroll state.
    history.replace = (path, state = {}) => {
      scrollData[history.location.pathname] = window.scrollY;
      replace(path, state);
    };

    // Listen for location changes and set the scroll position accordingly.
    const unregister = history.listen((location, action) => {
      window.scrollTo(
        0,
        action !== 'POP' ? 0 : scrollData[location.pathname] ?? 0,
      );
    });

    // Unregister listener when component unmounts.
    return () => {
      unregister();
    };
  }, [history]);
};

export default useScrollMemory;

App 组件中,在开头调用 useScrollMemory:

App.js

import useScrollMemory from '../../hooks/useScrollMemory';

const scrollData = {};

const App = () => {
  useScrollMemory(scrollData);

  return <div>My app</div>;
}

export default App;

1

我刚遇到了这个问题。我找到了一个解决方案,似乎可以很好地解决它:

export const useKeepScrollPositionOnRouteChange = () => {
    const route = useLocation();

    useLayoutEffect(() => {
        const { scrollY, requestAnimationFrame, scrollTo } = window;
        requestAnimationFrame(() => {
            scrollTo(0, scrollY);
        });
    }, [route]);
};

在stackoverflow上的第一篇帖子!


0
自React Router 6.4版本开始,可以使用ScrollRestoration组件来实现此功能。

该组件将模拟浏览器的滚动恢复功能,以确保在加载程序完成后对位置进行更改时可以将滚动位置恢复到正确的位置,即使跨域。

要保存每个不同路径的滚动位置,请将getKey函数传递给ScrollRestoration,该函数返回location.pathname作为用于区分路由滚动位置的键。

在根组件中渲染ScrollRestoration一次,以将其应用于所有路径:

import { ScrollRestoration } from 'react-router-dom';
function App() {
    return <>
        <div>Content</div>
        <ScrollRestoration getKey={(location, matches) => location.pathname}/>
    </>;
}

如果未设置getKey,则默认行为是在导航到新页面(例如通过单击链接)时将滚动位置重置为顶部,并在返回到先前页面时(例如通过单击浏览器的后退按钮)保持原始滚动位置。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接