告别keepalive,使用ReactRouter6原生组件实现状态保留的多标签微前端

一、引言

从Jquery、Extjs等JS UI库 的mvc时代过来的程序猿,多标签那会从来不是个问题。到react的csr项目发现都是清一色基于react-router单页应用,而现存的多标签解决方案都要么依赖框架加插件(umi-plugin-keep-alive)的加持,要么依赖额外的库(react-activationreact-keepalive-router等)。上框架心里接受不了,因为框架往往是基于当下技术的工程最佳实践,对于技术变化迅速的前端领域,入坑后要学不动了往往就意味着被后浪的洪流淘汰。不上框架,上额外库,又得学习一大堆看的一脸懵逼的晦涩概念。只基于react-router和原生组件来实现多标签效果,市场上没有解决方案。

笔者之前在webpack搭建的AntdFront 中,只采用路由算法也实现了多标签效果。于是心中就产生了很大的疑惑,既然react-router 可以获取到具体的组件,组件都拿到了,多标签这么困难么?

AntdFront定位是实验性的按钮级权限多标签微前端模板,自己挖的坑,还得自己填。本文就围绕该项目 2.0版基于路由的多标签微前端实践过程中的几个关键问题,谈谈自己的理解和心得体会。

二、实践过程中的几个关键问题

1.为什么要多标签

多标签首先得能状态保留才有意义,是个用户体验问题。因为多标签,用户就可以一边上传文件,一边填写表单,妈妈再也不用担心用户的某些误操作导致表单重填的问题。两功能页面间可以做到互不干扰,且对于一些临时数据不需要额外的数据持久化逻辑,解放开发者的心智大脑。

2.为什么要router

antdFront 在1.0版中,只采用路由算法实现的多标签,在实践过程中,没有正经的路由,导航新的URL就得通过一些莫名奇妙的约定函数来实现。对于开发者说,都是额外的心智负担。最终放弃该方案的理由还是嵌套路由。将url 手工对应到组件还挺好实现,但是要对应到组件里的组件,且让组件的工程学开发人性化就有点困难了。好在有了react-router6,让嵌套路由变得不在晦涩。

3.现存解决方案 keepalive的缺点

现存react的keepalive是一套缓存方案,缓存的页面是无法进行交互的。也就是解决了用户某些误操作导致表单重填,额外的数据持久化逻辑的问题。但是没有解决一边上传文件,一边填写表单的问题。

其次引入keepalive,需要额外学习一大堆概念和用法限定。react的keepalive非官方特性,往往由个人团队开发,API不稳定。造成后期升级代码维护成本增加。

4.实现多标签的关键点是什么

1、拿到经路由算法筛选后的组件。

react-router6react-routerreach-router的 合并版本,是大致借鉴了reach-router的成功实践而对react-router的重构。它非常巧妙的使用context,利用 useOutlet 接口拿到不同层级的路由嵌套组件,非常完美的解决了嵌套路由的开发体验问题。下面tabRoute.jsx核心代码为例:

import { useState } from "react";
import { Tabs } from "antd";
import { useOutlet,useNavigate,useLocation,generatePath,useParams } from "react-router-dom";
import { usePersistFn, useCreation } from "ahooks";
import memoized from "nano-memoize";
import { i18n } from "@lingui/core";

const { TabPane } = Tabs;

const getTabPath = (tab) => {
  return generatePath(tab.location.pathname,tab.params)
}

// tab的select key = location.pathname + , + matchpath 
// matchpath为 config里配置的路由路径
// 以此解决 微端情况下 tab 的 key 相同导致页面可能丢失的问题。
const generTabKey = memoized((location,matchpath) => {
  return `${location.pathname},${matchpath}`;
});

// 从key中返回 ,号后面的字符
const getTabMapKey = memoized((key) => {
  return key.substring(key.indexOf(',') + 1,key.length);
});

const TabRoute = (props) => {
  // routeConfig 为 自定义route的item项信息。
  // matchPath 为当前url 匹配的 config里路由配置路径
  const  { routeConfig,matchPath } = props;

  const ele = useOutlet();

  const location = useLocation();

  const params = useParams();

  const navigate = useNavigate();

  const tabList = useRef(new map());

  // 确保location 变化后,tab要计算下
  const updateTabList = useCreation(() => {
    const tab = tabList.current.get(matchPath);
    const newTab = {
      name:routeConfig.name,
      key:generTabKey(location,matchPath),
      page:ele,
      // access:routeConfig.access,
      location,
      params
    };
   
    if (tab) {
      // 处理微前端情况,如发生路径修改则替换
      // 微端路由更新 如果key把key更新下
      if(tab.location.pathname !== location.pathname){
        tabList.current.set(matchPath,newTab)
      }
    }else{
      tabList.current.set(matchPath,newTab)
    }

  },[location]);

  const closeTab = usePersistFn((selectKey) => {
    // 记录原真实路由,微前端可能修改
    if(tabList.current.size >= 2 ){
      tabList.current.delete(getTabMapKey(selectKey));
      const nextKey = _.last(Array.from(tabList.current.keys()));
      navigate(getTabPath(tabList.current.get(nextKey)),{replace:true});
    }
  });

  const selectTab = usePersistFn((selectKey) => {
    // 记录原真实路由,微前端可能修改
    navigate(getTabPath(tabList.current.get(getTabMapKey(selectKey))),{replace:true});
  });

  return (
      <Tabs
        // className={styles.tabs}
        activeKey={generTabKey(location,matchPath)}
        onChange={(key) => selectTab(key)}
        // tabBarExtraContent={operations}
        tabBarStyle={{ background: "#fff" }}
        tabPosition="top"
        animated
        tabBarGutter={-1}
        hideAdd
        type="editable-card"
        onEdit={(targetKey) => closeTab(targetKey)}
      >
        {[...tabList.current.values()].map(item => (
            <TabPane tab={i18n._(item.name)} key={item.key} >
              {item.page}
            </TabPane>
        ))}
      </Tabs>
  )
}

export default TabRoute;

tabList 使用map来管理,主要是因为删除相应的tab十分方便。其次使用的是 const tabList =useRef(new map()), 而不是const [tabList,setTabList] =useState(new map())。因为在函数执行的过程中,ref是即时更新的,不会引发组件的二次渲染,又能确保函数返回时总能拿到当下tabList的计算后最新数据。

使用微前端,微前端子应用的url总是随着用户的交互而不断变化。所以为了确保微端应用页面的更新,返回原tab就需要相应的地址记录和地址恢复操作。

2、使用UI的原生tab组件,保持组件key的一致

利用react的原生diff特性,来确保react复用页面。达到"自动缓存"且依然可以"交互"的的效果。

5.react context的本质:虚拟的组件变量作用域

笔者在多标签的实践过程中,曾想以recoil代替context为基础来做个路由库。设计的结构如下图所示:

实践的过程中发现,在嵌套路由的场景下,因为atom是全局的,所以需要三个atom:路由组件层级关系routeRelationAtom,当前页面名curPageAtom和当前激活的路由数组(activeRoutesAtom)三者才能确定当前组件的嵌套组件。这样导致在组件层的使用上,不仅需要指明当前组件是哪个,还需要与config 保持一致。对后期修改维护非常困难。对比react-router6的context用法:

let element = matches.reduceRight((outlet, { params, pathname, route }) => {
 return (
   <RouteContext.Provider
     children={route.element}
     value={{
     outlet,
     params: readOnly<Params>({ ...parentParams, ...params }),
     pathname: joinPaths([basename, pathname]),
     route
    }}
   />);
 }, null as React.ReactElement | null);
....
export function useOutlet(): React.ReactElement | null {
  return React.useContext(RouteContext).outlet;
}

直接在context.provider中包裹了当前组件各种参数和嵌套路由的组件。在组件使用时,只需使用const ele = useOutlet()即可拿到当前配置下的嵌套路由组件。使用十分方便、优雅。

这个特性,js函数特性十分相似,函数创建自己的作用域。函数内部变量不会影响函数外部的同名变量。非常好的解决了上面的recoil-router中,需要curPageAtom来参与计算的问题。

再联想react 技术的本身就大量采用采用虚拟技术。觉得dom的直接操作慢,于是搞了个虚拟的dom的diff算法。觉得js的原生任务调度影响性能、优化不了不必要的渲染,帧速抬不上,就搞了个Fiber数据结构的虚拟任务调度算法。context 就像组件的变量作用域,确保了组件里的 变量不会影响组件外的同名变量。所以context是一个很好的设计,底层库使用它可以设计出很多优雅的特性。

三、总结

该项目展示了recoil 结合 react-router6实现在单页与多标签之间的自由切换能力,也在实践中,感受到了react-router6的改进空间和组合路由潜在bug。实践出真知,没有对比就没有伤害,context相对于其他的数据流,在react中,有了虚拟的组件变量作用域的原生特性加持,所以可以预见的是,其他数据流会随着新的想法而不断迭代,而context的方式则会作为虚拟的组件变量作用域基建一直存在react中。对于还在观望是否升级路由的朋友们,还犹豫啥了....

编辑于 2021-04-23 07:14