路由页面跳转后状态保存实践

10,007 阅读8分钟

“路由页面跳转后状态保存”指的是在开发业务需求过程中经常会碰到的一种情况——在一个页面(通常是表格页)进行一顿猛如虎的操作之后,点击某一项详情进入另外一个页面之后再返回(点击后退按钮),上一页还可以保持原先的状态不变(页面、搜索词等)。

为什么要保持页面状态

主要是为了用户体验。试想一下当你辛辛苦苦翻了好几十页才找到想要的数据时,却一个不小心手滑点进去了另外一条数据,再返回来时早就忘记刚才的页码是多少了,这样的体验会有很大概率让用户心里不爽。

代码示例

为了演示方便,在这里称呼原来的组件为“ A 组件”,准备要跳转的组件为“ B 组件”。

在这里准备一个小项目,用 react-router 来模拟路由组件跳转情况。

演示

test

代码
function App() {
  return (
    <Router>
      <Switch>
        <Route path="/B" component={B} />
        <Route path="/" component={A} />
      </Switch>
    </Router>
  );
}

export default App;

const A = ({ history }) => {
  const [count, setCount] = useState(0);
  const handleChangeStatus = () => setCount(count + 1);

  return (
    <div className="App">
      <header className="App-header">
        <h1>这里是 A 组件</h1>
        <p>当前计数:{count}</p>
        <br />
        <button onClick={handleChangeStatus}>点我 +1</button>
        <br />
        <button onClick={() => history.push("B")}>跳转到 B 组件</button>
      </header>
    </div>
  );
};

const B = ({ history }) => {
  return (
    <div className="App">
      <header className="App-header">
        <p>这里是 B 组件</p>
        <br />
        <button onClick={() => history.goBack()}>点我返回</button>
      </header>
    </div>
  );
};

在此例中 A 组件中的状态在 B 组件中跳转回来后会失去,接下来让我们来挽回它~

image-20200322224403581

保持页面状态的方法

既然是要保持页面的状态(其实也就是组件的状态),那么会出现以下两种情况:

  • 前组件会被卸载
  • 前组件不会被卸载

那么我们可以按照这两种情况分别得到以下几个方法:

A 组件会被卸载:

1. 将状态储存在 LocalStorage / SessionStorage

用法

这个方法很简单,只需要在组件即将被销毁的声明周期 componentWillUnmount 中在 LocalStorage / SessionStorage 中把当前组件的 state 通过 JSON.stringify() 储存下来就可以了。在这里面需要注意的是组件更新状态的时机

比如我们从 B 组件跳转到 A 组件的时候,A 组件需要更新自身的状态。但是如果我们从别的组件跳转到 B 组件的时候,实际上我们是希望 B 组件重新渲染的,也就是不要从 Storage 中读取信息。

所以我们需要在 Storage 中的状态加入一个 flag 属性,用来控制 A 组件是否读取 Storage 中的状态。

代码
const A = ({ history }) => {
  const [count, setCount] = useState(0);
  const handleChangeStatus = () => setCount(count + 1);

  useEffect(() => {
    // 组件挂载时读取 Storage 状态
    const status = JSON.parse(sessionStorage.getItem("_STATUS_A") || "{}");
    const { count = 0, flag = false } = status;
    flag && setCount(count);
    return () => {
      // 组件卸载时设置 flag
      changeStorageStatus("_STATUS_A", "flag", false);
    };
  }, []);

  return (
    <div className="App">
      <header className="App-header">
        <h1>这里是 A 组件</h1>
        <p>当前计数:{count}</p>
        <br />
        <button onClick={handleChangeStatus}>点我 +1</button>
        <br />
        <button
          onClick={() => {
            // 前往 B 组件时保持状态
            sessionStorage.setItem("_STATUS_A", JSON.stringify({ count }));
            history.push("/B");
          }}
        >
          跳转到 B 组件
        </button>
      </header>
    </div>
  );
};

const changeStorageStatus = (key = "", statusKey = "", status = "") => {
  const preStatus = JSON.parse(sessionStorage.getItem(key) || "{}");
  preStatus[statusKey] = status;
  sessionStorage.setItem(key, JSON.stringify(preStatus));
};

const B = ({ history }) => {
  return (
    <div className="App">
      <header className="App-header">
        <h1>这里是 B 组件</h1>
        <br />
        <button
          onClick={() => {
            // 返回 A 组件时设置 flag
            changeStorageStatus("_STATUS_A", "flag", true);
            history.goBack();
          }}
        >
          点我返回
        </button>
      </header>
    </div>
  );
};
演示

Mar-22-2020 16-39-43

优点
  • 兼容性好,不需要额外库或工具。
  • 简单快捷,基本可以满足大部分需求。
缺点
  • 状态通过 JSON 方法储存(相当于深拷贝),如果状态中有特殊情况(比如 Date 对象、Regexp 对象等)的时候会得到字符串而不是原来的值。(具体参考用 JSON 深拷贝的缺点)
  • 如果 B 组件后退或者下一页跳转并不是前组件,那么 flag 判断会失效,导致从其他页面进入 A 组件页面时 A 组件会重新读取 Storage,会造成很奇怪的现象。

2. 路由传值

用法

通过 react-router 的 Link 组件的 prop —— to 可以实现路由间传递参数的效果。

to 接受一个对象,参数可以包括:

  • pathname: A string representing the path to link to.
  • search: A string representation of query parameters.
  • hash: A hash to put in the URL, e.g. #a-hash.
  • state: State to persist to the location

在这里我们需要用到 state 参数,在 B 组件中通过 history.location.state 就可以拿到 state 值,保存之。返回 A 组件时再次携带 state 达到路由状态保持的效果。

代码
const A = ({ history }) => {
  const { count: preCount = 0 } = history.location.state || {};
  const [count, setCount] = useState(preCount); // 读取回传状态
  const handleChangeStatus = () => setCount(count + 1);

  return (
    <div className="App">
      <header className="App-header">
        <h1>这里是 A 组件</h1>
        <p>当前计数:{count}</p>
        <br />
        <button onClick={handleChangeStatus}>点我 +1</button>
        <br />
        <button>
          <Link
            to={{
              pathname: "/B",
              state: {
                count,
              },
            }}
          >
            跳转到 B 组件
          </Link>
        </button>
      </header>
    </div>
  );
};

const B = ({ history }) => {
  const [prePageStatus, setPrePageStatus] = useState();

  // 保存 A 组件状态
  useEffect(() => {
    const { state } = history.location;
    state && setPrePageStatus(state);
  }, []);

  return (
    <div className="App">
      <header className="App-header">
        <h1>这里是 B 组件</h1>
        <br />
        <button>
          <Link
            to={{
              pathname: "/",
              state: prePageStatus,
            }}
            replace
          >
            点我返回
          </Link>
        </button>
      </header>
    </div>
  );
};
优点
  • 简单快捷,不会污染 LocalStorage / SessionStorage。
  • 可以传递 Date、RegExp 等特殊对象(不用担心 JSON.stringify / parse 的不足)
缺点
  • 如果 A 组件可以跳转至多个组件,那么在每一个跳转组件内都要写相同的逻辑。

A 组件不会被卸载:

1. 单页面渲染

用法

要切换的组件作为子组件全屏渲染,父组件中正常储存页面状态。

代码
const PAGE = {
  A: Symbol(),
  B: Symbol(),
};

const A = () => {
  const [page, setPage] = useState(PAGE.A);
  const [count, setCount] = useState(0); // 读取回传状态
  const handleChangeStatus = () => setCount(count + 1);
  const changePage = (page) => () => {
    setPage(page);
  };

  switch (page) {
    case PAGE.A:
      return (
        <div className="App">
          <header className="App-header">
            <h1>这里是 A 组件</h1>
            <p>当前计数:{count}</p>
            <br />
            <button onClick={handleChangeStatus}>点我 +1</button>
            <br />
            <button onClick={changePage(PAGE.B)}>跳转到 B 组件</button>
          </header>
        </div>
      );
    case PAGE.B:
      return <B onChangePage={changePage(PAGE.A)} />;
    default:
      return null;
  }
};

const B = ({ onChangePage }) => {
  return (
    <div className="App">
      <header className="App-header">
        <h1>这里是 B 组件</h1>
        <br />
        <button onClick={onChangePage}>点我返回</button>
      </header>
    </div>
  );
};
优点
  • 代码量少
  • 不需要考虑状态传递过程中的错误
缺点
  • 增加 A 组件维护成本
  • 需要传入额外的 prop 到 B 组件
  • 无法利用路由定位页面

2.单页面渲染 + 路由匹配

用法

在上一例的基础中的改进,利用路由做组件匹配。

代码
const A = ({ history }) => {
  const [count, setCount] = useState(0);
  const handleChangeStatus = () => setCount(count + 1);

  const { pathname } = history.location;

  const isPage = (path) => {
    const regExp = new RegExp(`^${path}`);
    return regExp.test(pathname);
  };

  if (isPage("/B")) {
    return <B />;
  }

  if (isPage("/")) {
    return (
      <div className="App">
        <header className="App-header">
          <h1>这里是 A 组件</h1>
          <p>当前计数:{count}</p>
          <br />
          <button onClick={handleChangeStatus}>点我 +1</button>
          <br />
          <button>
            <Link to="/B">跳转到 B 组件</Link>
          </button>
        </header>
      </div>
    );
  }

  return null;
};

const B = () => {
  return (
    <div className="App">
      <header className="App-header">
        <h1>这里是 B 组件</h1>
        <br />
        <button>
          <Link to="/">点我返回</Link>
        </button>
      </header>
    </div>
  );
};
优点
  • 不需要传入额外的 prop 到 B 组件
  • 可以使用路由定位页面
缺点
  • 组件存在耦合

总结

方案 优点 缺点
Storage 兼容性好,不需要额外的库或工具 不支持 Date 等特殊对象;额外代码较多
路由传值 支持特殊对象;不会污染 Storage 额外代码较多
单页面渲染 额外代码较少,不需要担心数据传递过程的错误 无法使用路由定位页面;组件耦合度较高
单页面渲染 + 路由匹配 可以使用路由定位页面、组件耦合度降低(和单页面渲染方法比较) 组件存在耦合问题

总而言之,最好的选择方案还是路由传值 或者 单页面渲染 + 路由匹配的方案。主要是还是看业务处理的复杂度,如果组件需要跳转到的页面比较少,完全可以使用单页面渲染 + 路由匹配的方案。如果反之,那还是老老实实使用 react-router 提供的传值方案吧。