Umi@4、Ant Design Pro、多 Tabs 布局、状态保持、50 行代码、这些关键词能组合出一篇你感兴趣的文章吗?
缘由
在 Umi 社区的四年多,很多交流都是在聊天窗口中进行的,并没有留下太多有价值的内容,虽然我尽量整理了部分内容放到了官网的 FAQ 中,但其实还是有很多内容被遗忘了,所以一直想写这么一个系列。
刚好在 Umi 4 开发实战小册 中网友 笑神堕落 问我 Umi 新版会不会支持多标签页?看最近 Umi 团队内的讨论,没有这个需求的讨论,因此我回复了应该不会,但我会做。
其实多标签页这个需求,已经很久远了,在 pro issues #200 就有过这个讨论。
题外话,截止本文撰写时,现在 pro 的 issues 已经 9919。
如果你感兴趣,可以在 antd pro 的issues 上搜索 “tabs” 可以看到相当多的讨论,其实我在 umi@3 的时候,就实现过一个版本,因为和官方内置的 layout 插件,有点使用上的冲突,也因为用的人较少,我自己主要是在移动端h5上面的使用需求较多,所以 umi@3 中的多 tabs 实现,是从 keepalive 复制后修改的一个版本。
有几个历史因素,导致 umi@3 多 tabs 插件比想象中的难用,其中最主要的一个点就是 umi@3 中的 keepalive 插件本身的实现,也是不够优秀的。
但是到了 umi@4 ,react-route 更新到 6 版本之后,陈俊宇给我重写了一遍 keepalive demo,我修改后作为新版的 keepalive 插件,不论从代码的清晰度上还是方案的理想化上,都达到了让我很满意的程度。
因此,对于新版本的 tabs 插件,也是在我原定计划之内会完成的内容,借着今天修改一个 alita 框架的 bug 的空档,顺便把这个需求做了。
效果
先直接上效果吧,这是在 pro 中的实现效果,对了,近期 umi 和 pro 都会发布新版,大家可以持续关注官网信息哦。
思路
1、为了避免一些非技术上的问题,新版的 tabs 插件和 keepalive 插件合并在一起实现。因为 keepalive 插件我们内部用到的很多,可以借场景验证整个方案的健壮性。
2、实现上,只有被标记为“需要状态保持”的页面,才会被添加到多 tabs 标签中,可以解 “只有部分页面需要多 tabs 其他页面不需要” 的需求。
3、在状态保持方案的基础上,尽可能少的改动和增加代码来实现想要的需求。
4、为 keepalive 插件,增加一个新的配置用于开启多 tabs 布局
实现
因为 antd 的 Tabs
组件,本身是可以做到,Tabs
切换的时候保持页面数据不变更的。但是基本上平时我们在使用这个组件的时候,都是属于“组件级别”用法,就是在同一个页面中,做多 tabs 切换。因此我们需要将他提升到“页面级别”。
实现也很简单,那就是不把页面放到 Tabs
的 TabPane
中。
export function App() { return <> <div> <Tabs > {panels.map(() => ( <TabPane /> ))} </Tabs> </div> {children} </> } 复制代码
要让页面和 Tabs
产生关联,只要将当前页面的路由 location.pathname
和当前所有被状态保持的页面
作为 Tabs
的 activeKey
和渲染 TabPane
的 panels
数据即可,将组件级别的 change activeKey
直接用页面跳转方法代替。
export function App() { return <> <div> <Tabs onChange={(key: string) => { navigate(key); }} activeKey={location.pathname} > {Object.entries(keepElements.current).map(([pathname]: any) => ( <TabPane tab={`${local[pathname] || pathname}`} key={pathname} /> ))} </Tabs> </div> {children} </> } 复制代码
将 Tabs
的关闭行为和状态保持中的清除缓存方法关联到一起 dropByCacheKey(targetKey);
共同维护一份数据。在 Tabs
中的 onEdit
事件中处理即可,要注意如果关闭的是当前页面,那要自动跳转到上一个页面,如果只剩最后一个页面,则提升用户,“必须保留一个窗口”。实现比较简单,感兴趣的直接看 alita 的仓库吧。
需要注意的是我们清除缓存 dropByCacheKey
修改的是 React.useRef
的对象,直接修改它是不会导致页面重绘的,这会导致,我们的关闭 Tabs
事件虽然消除了缓存,但是需要在下一次进入页面的时候,Tab
才会被移除。这显然和我们的需求不符合。今天因为这个问题卡了我很久,最终只想到一个办法,能接问题,但写法很“丑”。如果有别的大哥有更好的解法,欢迎 PR 指教。
以上问题,已被陈大哥修复。
在多 Tabs 布局中放一个没用的 useState
当 keepElements.current
修改时,同步去 setState
,虽然你没用到 state
但是当 state
被修改时页面还是会执行一次重绘,这就可以更新我们的页面了。
最终实现的关键代码不到 50 行,并且逻辑非常的清晰,属于新手向非常友好的源码了,你看几遍就可以看懂的。
import React, { useState } from 'react'; import { useOutlet, useLocation, matchPath, useNavigate } from 'react-router-dom' import { Tabs, message } from 'antd'; import { getPluginManager } from '../core/plugin'; export const KeepAliveContext = React.createContext({}); const { TabPane } = Tabs; export function useKeepOutlets() { const location = useLocation(); const element = useOutlet(); const navigate = useNavigate(); const [panel, setPanel] = useState(); const runtime = getPluginManager().applyPlugins({ key: 'tabsLayout', type: 'modify', initialValue: {} }); const { local } = runtime; const { keepElements, keepalive, dropByCacheKey } = React.useContext<any>(KeepAliveContext); const isKeep = isKeepPath(keepalive, location.pathname); if (isKeep) { keepElements.current[location.pathname] = element; } return <> <div className="rumtime-keep-alive-tabs-layout" hidden={!isKeep} > <Tabs hideAdd onChange={(key: string) => { navigate(key); }} activeKey={location.pathname} type="editable-card" onEdit={(targetKey: string,) => { // 部分删除实现略 dropByCacheKey(targetKey); setPanel(Object.entries(keepElements.current)) }}> {Object.entries(keepElements.current).map(([pathname, element]: any) => ( <TabPane tab={`${local[pathname] || pathname}`} key={pathname} /> ))} </Tabs> </div> { Object.entries(keepElements.current).map(([pathname, children]: any) => ( <div key={pathname} style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }} className="rumtime-keep-alive-layout" hidden={!matchPath(location.pathname, pathname)}> {children} </div> )) } <div hidden={isKeep} style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }} className="rumtime-keep-alive-layout-no"> {!isKeep && element} </div> </> } 复制代码
用法
实现上如此的简单高效,用法上也是很简洁明了的。
安装:
pnpm i @umijs/plugins @alita/plugins // 版本需要大于'@alita/plugins': 3.0.0-rc.11 复制代码
Umi 配置:
import { defineConfig } from "umi"; export default defineConfig({ plugins: [ require.resolve("@umijs/plugins/dist/antd"), require.resolve("@alita/plugins/dist/keepalive"), require.resolve("@alita/plugins/dist/tabs-layout"), ], antd: {}, keepalive: [/users/, /foo/], tabsLayout: {}, }); 复制代码
以上配置,需要非常注意的是 plugins
配置,因为这和你框架中使用到的 Umi 的 Preset
you很大的关系,比如在 alita 中,上面的配置可以简化成
import { defineConfig } from "alita"; export default defineConfig({ appType: 'pc', antd: {}, keepalive: [/users/, /foo/], tabsLayout: {}, }); 复制代码
而在 Umi Max 中,则可以不而外引入 require.resolve("@umijs/plugins/dist/antd"),
配置,简化也好,不而外引入也好,其实并不是不需要引入了,只是因为这个插件在插件集中已经包含了。这个在使用 Umi 的时候,需要格外留意,当前项目所使用到的所有插件,有一个命令可以查,umi plugin list
可以列出你现在用到的所有的插件。
keepalive
配置接收一个 字符串或者正则表达式的数组,有个取巧的值是配置 keepalive: [/./]
这样所有的页面都会被状态保持。
因为使用到了 antd
的组件,所以需要使用 antd 的插件,并且 antd
包需要独立安装,因为新版的 Umi 没有内置 antd
了,这会让 Umi 包更小。你只需要执行 pnpm i antd
安装即可。
如果你喜欢这个文章,请一键三连,并分享给需要的朋友。如果你也有一些其他“需求”需要支撑,也可以提供给我,我是一个很乐于帮助网友的人。
后续
6月20号更新 支持自定义渲染 Tabs
用法
1、更新 @alita/plugins@3.0.0-rc.12
2、配置(config/config.ts)中增加
tabsLayout: { + hasCustomTabs: true, }, 复制代码
3、增加运行态(src/app.tsx)配置 getCustomTabs
会自动传入 isKeep, keepElements, navigate, dropByCacheKey, local, activeKey,
这几个属性用于页面渲染,以下是一个范例:
import { message, Tabs } from 'antd'; import React from 'react'; const { TabPane } = Tabs; export const tabsLayout = { local: { '/': '首页', '/users': '用户', '/foo': '其他', }, }; export const getCustomTabs = () => { return ({ isKeep, keepElements, navigate, dropByCacheKey, local, activeKey, }: any) => { return ( <div className="rumtime-keep-alive-tabs-layout" hidden={!isKeep}> <Tabs hideAdd onChange={(key: string) => { navigate(key); }} activeKey={activeKey} type="editable-card" onEdit={(targetKey: string) => { let newActiveKey = activeKey; let lastIndex = -1; const newPanel = Object.keys(keepElements.current); for (let i = 0; i < newPanel.length; i++) { if (newPanel[i] === targetKey) { lastIndex = i - 1; } } const newPanes = newPanel.filter((pane) => pane !== targetKey); if (newPanes.length && newActiveKey === targetKey) { if (lastIndex >= 0) { newActiveKey = newPanes[lastIndex]; } else { newActiveKey = newPanes[0]; } } if (lastIndex === -1 && targetKey === location.pathname) { message.info('至少要保留一个窗口'); } else { dropByCacheKey(targetKey); if (newActiveKey !== location.pathname) { navigate(newActiveKey); } } }} > {Object.entries(keepElements.current).map( ([pathname, element]: any) => ( <TabPane tab={`${local[pathname] || pathname}`} key={pathname} /> ), )} </Tabs> </div> ); }; }; 复制代码
为什么要增加
hasCustomTabs
而不是直接判断getCustomTabs
是否配置?
因为使用 node 层的配置,可以在构建阶段生成不含有任何默认引入的模版,如果是判断 getCustomTabs
是否配置的话,那需要到运行时才能区分,这意味着,不论你有没有自定义,模版中都会有 import Tabs form antd
。
补充
pro 已经升级到 Umi@4 了,你可以在 pro 直接使用多 tabs
// 如果安装最新的 pro-preset 就无需配置 plugins plugins:[require.resolve('@alita/plugins/dist/keepalive'), require.resolve('@alita/plugins/dist/tabs-layout'),], export default {\ keepalive: [/./],\ tabsLayout: {},\ }; 复制代码