告别keepalive,使用ReactRouter6原生组件实现状态保留的多标签微前端
一、引言
从Jquery、Extjs等JS UI库 的mvc时代过来的程序猿,多标签那会从来不是个问题。到react的csr项目发现都是清一色基于react-router单页应用,而现存的多标签解决方案都要么依赖框架加插件(umi-plugin-keep-alive)的加持,要么依赖额外的库(react-activation、react-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-router6 是 react-router与 reach-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中。对于还在观望是否升级路由的朋友们,还犹豫啥了....