企业级多 Tab 页需求,50 行代码就搞定!

495次阅读  |  发布于2年以前

缘由

在 Umi 社区的四年多,很多交流都是在聊天窗口中进行的,并没有留下太多有价值的内容,虽然我尽量整理了部分内容放到了官网的 FAQ 中,但其实还是有很多内容被遗忘了,所以一直想写这么一个系列。刚好在 Umi 4 开发实战小册[1] 中网友 笑神堕落[2] 问我 Umi 新版会不会支持多标签页?看最近 Umi 团队内的讨论,没有这个需求的讨论,因此我回复了应该不会,但我会做。

01.jpg

其实多标签页这个需求,已经很久远了,在 pro issues #200 就有过这个讨论。

200.jpg

题外话,截止本文撰写时,现在 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 都会发布新版,大家可以持续关注官网信息哦。

layout.png

思路

1、为了避免一些非技术上的问题,新版的 tabs 插件和 keepalive 插件合并在一起实现。因为 keepalive 插件我们内部用到的很多,可以借场景验证整个方案的健壮性。

2、实现上,只有被标记为“需要状态保持”的页面,才会被添加到多 tabs 标签中,可以解 “只有部分页面需要多 tabs 其他页面不需要” 的需求。

3、在状态保持方案的基础上,尽可能少的改动和增加代码来实现想要的需求。

4、为 keepalive 插件,增加一个新的配置用于开启多 tabs 布局

实现

因为 antd 的 Tabs 组件,本身是可以做到,Tabs 切换的时候保持页面数据不变更的。但是基本上平时我们在使用这个组件的时候,都是属于“组件级别”用法,就是在同一个页面中,做多 tabs 切换。因此我们需要将他提升到“页面级别”。

实现也很简单,那就是不把页面放到 TabsTabPane 中。

export function App() {
    return <>
        <div>
            <Tabs >
                {panels.map(() => (
                    <TabPane />
                ))}
            </Tabs>
        </div>
        {children}
    </>
}

要让页面和 Tabs 产生关联,只要将当前页面的路由 location.pathname 和当前所有被状态保持的页面 作为 TabsactiveKey 和渲染 TabPanepanels 数据即可,将组件级别的 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 布局中放一个没用的 useStatekeepElements.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 安装即可。

layout.png

如果你喜欢这个文章,请一键三连,并分享给需要的朋友。如果你也有一些其他“需求”需要支撑,也可以提供给我,我是一个很乐于帮助网友的人。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8