├── .fatherrc.ts
├── .github
├── FUNDING.yml
└── workflows
│ └── demo.yaml
├── .gitignore
├── .yarnrc
├── LICENSE
├── README.md
├── example
├── .gitignore
├── config
│ └── routes.ts
├── index.html
├── src
│ ├── components
│ │ ├── PageLoading
│ │ │ └── index.tsx
│ │ └── SwitchTabs
│ │ │ └── index.tsx
│ ├── favicon.svg
│ ├── global.ts
│ ├── layouts
│ │ ├── BasicLayout.tsx
│ │ └── RootLayout.tsx
│ ├── pages
│ │ ├── Control
│ │ │ └── index.tsx
│ │ ├── Dynamic
│ │ │ └── index.tsx
│ │ ├── Parent
│ │ │ ├── Child1
│ │ │ │ └── index.tsx
│ │ │ ├── Child2
│ │ │ │ └── index.tsx
│ │ │ ├── Child3
│ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── Profile
│ │ │ ├── Advanced
│ │ │ │ └── index.tsx
│ │ │ └── Basic
│ │ │ │ └── index.tsx
│ │ ├── Query
│ │ │ └── index.tsx
│ │ ├── Result
│ │ │ └── index.tsx
│ │ ├── Search
│ │ │ ├── Applications
│ │ │ │ └── index.tsx
│ │ │ ├── Projects
│ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ └── Welcome
│ │ │ └── index.tsx
│ └── types.d.ts
├── tsconfig.json
└── vite.config.ts
├── package.json
├── src
├── config.ts
├── index.ts
├── useSwitchTabs.ts
└── utils.tsx
├── tsconfig.json
└── yarn.lock
/.fatherrc.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | cjs: 'babel',
3 | esm: { type: 'babel', importLibToEs: true },
4 | };
5 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | open_collective: yuns
2 | custom: ['https://afdian.net/@yunslove']
3 |
--------------------------------------------------------------------------------
/.github/workflows/demo.yaml:
--------------------------------------------------------------------------------
1 | name: github pages
2 | on:
3 | workflow_dispatch:
4 | push:
5 | branches:
6 | - master # default branch
7 | jobs:
8 | deploy:
9 | runs-on: ubuntu-18.04
10 | steps:
11 | - uses: actions/checkout@v2
12 | - run: yarn
13 | - run: yarn build:demo
14 | - name: Deploy
15 | uses: peaceiris/actions-gh-pages@v3
16 | with:
17 | github_token: ${{ secrets.GITHUB_TOKEN }}
18 | publish_dir: ./example/dist
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | es
3 | lib
4 | *.log
5 | .vit
6 |
--------------------------------------------------------------------------------
/.yarnrc:
--------------------------------------------------------------------------------
1 | registry "https://registry.npmmirror.com"
2 | # proxy "http://127.0.0.1:8999"
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 云深
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🚀 use-switch-tabs
2 |
3 | [](https://npmjs.org/package/use-switch-tabs)
4 |
5 | React hook used to convert Switch-like component to Tabs-like component state. 用于将类 Switch 组件转换为 Tabs 组件状态的 React hook。
6 |
7 | - 支持页面的嵌套路由渲染
8 | - 两种标签页模式可选
9 | - 基于路由,每个路由只渲染一个标签页
10 | - 基于路由参数,计算出每个路由的所有参数的哈希值,不同的哈希值渲染不同的标签页
11 | - 快捷操作
12 | - 刷新标签页 - `actionRef.reloadTab()`
13 | - 关闭标签页 - `actionRef.closeTab()`
14 | - 返回之前标签页 - `actionRef.goBackTab()`
15 | - 关闭并返回之前标签页 - `actionRef.closeAndGoBackTab()`
16 | - 获取 location 对应的 tabKey,如果没有入参,返回当前激活的 tabKey - `actionRef.getTabKey()`
17 | - 监听 activeKey 变化事件 - `actionRef.listenActiveChange()`
18 | - `follow`,路由定义中新增配置,默认打开方式是添加到所有标签页最后面,可通过配置该属性,使得一个标签页在 `follow` 指定的标签页后面打开
19 | - `persistent`,支持页面刷新之后恢复上次的标签页状态
20 |
21 | 注:返回默认只会返回上次的路由,所以如果上次的路由没有关闭,会在两个路由之前反复横跳,当删除上次打开的标签页之后再调用该返回方法时只会打印警告。
22 |
23 | ## 如何使用?
24 |
25 | - 基于 useSwitchTabs 封装 [SwitchTabs](./example/src/components/SwitchTabs/index.tsx#L35) 组件
26 | - 在 Layout 层[使用 SwitchTabs 组件](./example/src/layouts/BasicLayout.tsx#L88)
27 |
28 | > 细节可参考 example 中的用法,也可参考 [ant-design-pro-plus](https://github.com/yunsii/ant-design-pro-plus/blob/master/src/layouts/BasicLayout.tsx)
29 |
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | dist
4 | dist-ssr
5 | *.local
6 |
--------------------------------------------------------------------------------
/example/config/routes.ts:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | path: '/',
4 | component: './layouts/RootLayout',
5 | routes: [
6 | {
7 | path: '/',
8 | component: './layouts/BasicLayout',
9 | routes: [
10 | {
11 | path: '/',
12 | redirect: '/welcome',
13 | },
14 | {
15 | path: '/welcome',
16 | icon: 'smile',
17 | name: '欢迎页',
18 | component: './pages/Welcome',
19 | },
20 | {
21 | path: '/control',
22 | icon: 'control',
23 | name: '控制台',
24 | component: './pages/Control',
25 | },
26 | {
27 | path: '/query',
28 | icon: 'question',
29 | name: '查询页',
30 | component: './pages/Query',
31 | },
32 | {
33 | path: '/result',
34 | icon: 'control',
35 | name: '结果页',
36 | component: './pages/Result',
37 | hideInMenu: true,
38 | },
39 | {
40 | path: '/profile',
41 | icon: 'profile',
42 | name: '详情页',
43 | routes: [
44 | {
45 | path: '/profile/basic',
46 | name: '基础详情页',
47 | component: './pages/Profile/Basic',
48 | },
49 | {
50 | path: '/profile/advanced',
51 | name: '高级详情页',
52 | component: './pages/Profile/Advanced',
53 | },
54 | ],
55 | },
56 | {
57 | path: '/search',
58 | icon: 'table',
59 | name: '搜索列表',
60 | component: './pages/Search',
61 | routes: [
62 | {
63 | path: '/search/projects',
64 | name: '搜索列表(项目)',
65 | component: './pages/Search/Projects',
66 | },
67 | {
68 | path: '/search/applications',
69 | name: '搜索列表(应用)',
70 | component: './pages/Search/Applications',
71 | },
72 | ],
73 | },
74 | {
75 | path: '/parent',
76 | icon: 'table',
77 | name: '嵌套路由',
78 | component: './pages/Parent',
79 | hideChildrenInMenu: true,
80 | routes: [
81 | {
82 | path: '/parent',
83 | redirect: '/parent/child1',
84 | },
85 | {
86 | path: '/parent/child1',
87 | component: './pages/Parent/Child1',
88 | },
89 | {
90 | path: '/parent/child2',
91 | component: './pages/Parent/Child2',
92 | },
93 | {
94 | path: '/parent/child3',
95 | component: './pages/Parent/Child3',
96 | },
97 | ],
98 | },
99 | {
100 | path: '/dynamic/:anyStr',
101 | icon: 'table',
102 | name: '动态路由',
103 | component: './pages/Dynamic',
104 | hideInMenu: true,
105 | },
106 | ],
107 | },
108 | ],
109 | },
110 | ];
111 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | use-switch-tabs
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/example/src/components/PageLoading/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function PageLoading() {
4 | return Loading...
;
5 | }
6 |
--------------------------------------------------------------------------------
/example/src/components/SwitchTabs/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import { Tabs, Dropdown, Menu } from 'antd';
3 | import { TabsProps } from 'antd/lib/tabs';
4 | import { MenuProps } from 'antd/lib/menu';
5 | import * as H from 'history-with-query';
6 | import { useMemoizedFn } from 'ahooks';
7 | import classNames from 'classnames';
8 | import _get from 'lodash/get';
9 | import { history, useLocation } from '@vitjs/runtime';
10 |
11 | import useSwitchTabs, { UseSwitchTabsOptions, ActionType } from '../../../../src';
12 |
13 | enum CloseTabKey {
14 | Current = 'current',
15 | Others = 'others',
16 | ToRight = 'toRight',
17 | }
18 |
19 | export interface RouteTab {
20 | /** tab's title */
21 | tab: React.ReactNode;
22 | key: string;
23 | content: JSX.Element;
24 | closable?: boolean;
25 | /** used to extends tab's properties */
26 | location: Omit;
27 | }
28 |
29 | export interface RouteTabsProps
30 | extends Omit,
31 | Omit {
32 | fixed?: boolean;
33 | }
34 |
35 | export default function SwitchTabs(props: RouteTabsProps): JSX.Element {
36 | const { mode, fixed, originalRoutes, setTabName, persistent, children, ...rest } = props;
37 |
38 | const location = useLocation();
39 | const actionRef = useRef();
40 |
41 | const { tabs, activeKey, handleSwitch, handleRemove, handleRemoveOthers, handleRemoveRightTabs } = useSwitchTabs({
42 | children,
43 | originalRoutes,
44 | mode,
45 | persistent,
46 | location,
47 | history,
48 | actionRef,
49 | setTabName,
50 | });
51 |
52 | const remove = useMemoizedFn((key: string) => {
53 | handleRemove(key);
54 | });
55 |
56 | const handleTabEdit = useMemoizedFn((targetKey: string, action: 'add' | 'remove') => {
57 | if (action === 'remove') {
58 | remove(targetKey);
59 | }
60 | });
61 |
62 | const handleTabsMenuClick = useMemoizedFn((tabKey: string): MenuProps['onClick'] => (event) => {
63 | const { key, domEvent } = event;
64 | domEvent.stopPropagation();
65 |
66 | if (key === CloseTabKey.Current) {
67 | handleRemove(tabKey);
68 | } else if (key === CloseTabKey.Others) {
69 | handleRemoveOthers(tabKey);
70 | } else if (key === CloseTabKey.ToRight) {
71 | handleRemoveRightTabs(tabKey);
72 | }
73 | });
74 |
75 | const setMenu = useMemoizedFn((key: string, index: number) => (
76 |
87 | ));
88 |
89 | const setTab = useMemoizedFn((tab: React.ReactNode, key: string, index: number) => (
90 | event.preventDefault()}>
91 |
92 | {tab}
93 |
94 |
95 | ));
96 |
97 | useEffect(() => {
98 | window.tabsAction = actionRef.current!;
99 | }, [actionRef.current]);
100 |
101 | return (
102 |
115 | {tabs.map((item, index) => (
116 |
122 | {item.content}
123 |
124 | ))}
125 |
126 | );
127 | }
128 |
--------------------------------------------------------------------------------
/example/src/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/src/global.ts:
--------------------------------------------------------------------------------
1 | console.log('[global.ts] Here is a global script.');
2 |
3 | export {};
4 |
--------------------------------------------------------------------------------
/example/src/layouts/BasicLayout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { history, useLocation } from '@vitjs/runtime';
3 | import { Divider, Layout, Menu, Space, Typography } from 'antd';
4 | import { GithubOutlined } from '@ant-design/icons';
5 | import * as H from 'history';
6 |
7 | import SwitchTabs from '@/components/SwitchTabs';
8 | import { Mode } from '../../../src';
9 |
10 | export interface IRoute {
11 | component: React.ComponentType<{ location: H.Location }>;
12 | icon?: React.ReactNode;
13 | name?: string;
14 | path: string;
15 | redirect: string;
16 | routes: IRoute[];
17 | hideInMenu?: boolean;
18 | hideChildrenInMenu?: boolean;
19 | }
20 |
21 | export interface BasicLayoutProps {
22 | children: JSX.Element;
23 | /** 完整路由表 */
24 | routes: IRoute[];
25 | /** 当前层级路由表 */
26 | route: IRoute;
27 | }
28 |
29 | export default function BasicLayout(props: BasicLayoutProps) {
30 | const { children, route } = props;
31 | const location = useLocation();
32 |
33 | const getRoutesMenuData = (routes: IRoute[]) => {
34 | return routes.filter((item) => {
35 | return !item.redirect && item.path;
36 | });
37 | };
38 |
39 | const renderSubMenu = (route: IRoute) => {
40 | const subRoutes = getRoutesMenuData(route.routes);
41 | return (
42 |
43 | {getRoutesMenuData(subRoutes).map((item) => {
44 | return (
45 | history.push(item.path)}>
46 | {item.path === '/' ? 'Home' : item.name || item.path}
47 |
48 | );
49 | })}
50 |
51 | );
52 | };
53 |
54 | const renderMenu = () => {
55 | return (
56 |
74 | );
75 | };
76 |
77 | return (
78 |
79 |
89 | 🚀 use-switch-tabs
90 | {renderMenu()}
91 |
92 |
93 |
94 | {
101 | // if (path === '/search/applications') {
102 | // return `${name} - 自定义`;
103 | // }
104 | // }}
105 | >
106 | {children}
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | yunsii
119 |
120 |
121 |
122 |
123 |
124 | );
125 | }
126 |
--------------------------------------------------------------------------------
/example/src/layouts/RootLayout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Layout: React.FC = ({ children }) => {
4 | return {children}
;
5 | };
6 |
7 | export default Layout;
8 |
--------------------------------------------------------------------------------
/example/src/pages/Control/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card, Alert, Button, Space, Input } from 'antd';
3 | import { history } from '@vitjs/runtime';
4 |
5 | export default function Control() {
6 | const renderCount = React.useRef(0);
7 | renderCount.current += 1;
8 |
9 | return (
10 |
11 |
12 |
21 | {/* 仅开发环境可用,部署到 GitHub Pages 后不能使用动态路由 */}
22 | {process.env.NODE_ENV === 'development' && (
23 |
24 | {
26 | history.push(`/dynamic/${value}`);
27 | }}
28 | enterButton='go to /dynamic/:inputValue'
29 | />
30 |
31 | )}
32 |
33 |
41 |
48 |
55 |
62 |
69 |
70 | renderCount: {renderCount.current}
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/example/src/pages/Dynamic/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useParams } from '@vitjs/runtime';
3 | import { Card } from 'antd';
4 |
5 | export default function Dynamic() {
6 | const params = useParams();
7 |
8 | return (
9 |
10 | match params:
11 | {JSON.stringify(params, null, 2)}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/example/src/pages/Parent/Child1/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default () => {
4 | return child 1
;
5 | };
6 |
--------------------------------------------------------------------------------
/example/src/pages/Parent/Child2/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default () => {
4 | return child 2
;
5 | };
6 |
--------------------------------------------------------------------------------
/example/src/pages/Parent/Child3/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default () => {
4 | return child 3
;
5 | };
6 |
--------------------------------------------------------------------------------
/example/src/pages/Parent/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card, Steps } from 'antd';
3 | import type * as H from 'history-with-query';
4 | import { history } from '@vitjs/runtime';
5 |
6 | export default ({ children, location }: { children: React.ReactChildren; location: H.Location }) => {
7 | const setCurrentByLocation = () => {
8 | if (location.pathname.endsWith('1')) {
9 | return 0;
10 | }
11 | if (location.pathname.endsWith('2')) {
12 | return 1;
13 | }
14 | return 2;
15 | };
16 |
17 | return (
18 |
19 | {
22 | history.push(`/parent/child${_current + 1}`);
23 | }}
24 | >
25 |
26 |
27 |
28 |
29 | {children}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/example/src/pages/Profile/Advanced/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card, Form, Input } from 'antd';
3 |
4 | export default function Hello() {
5 | const renderCount = React.useRef(0);
6 | renderCount.current += 1;
7 |
8 | return (
9 |
10 | Advanced Profile Page
11 |
12 |
13 |
14 | renderCount: {renderCount.current}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/example/src/pages/Profile/Basic/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card, Form, Input } from 'antd';
3 |
4 | export default function Hello() {
5 | const renderCount = React.useRef(0);
6 | renderCount.current += 1;
7 |
8 | return (
9 |
10 | Basic Profile Page
11 |
12 |
13 |
14 | renderCount: {renderCount.current}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/example/src/pages/Query/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from 'react';
2 | import { Card, Input, Checkbox, Button, Form, message } from 'antd';
3 | import { history, useLocation } from '@vitjs/runtime';
4 |
5 | export default function Query() {
6 | const [text, setText] = useState();
7 | const [options, setOptions] = useState([]);
8 |
9 | const pageLocation = useLocation();
10 | const pageTabKeyRef = useRef(window.tabsAction.getTabKey(pageLocation));
11 | const [active, setActive] = useState(pageTabKeyRef.current === window.tabsAction.getTabKey());
12 |
13 | useEffect(() => {
14 | const disposer = window.tabsAction.listenActiveChange((tabKey) => {
15 | setActive(pageTabKeyRef.current === tabKey);
16 | });
17 |
18 | return () => disposer();
19 | }, []);
20 |
21 | const handleSearch = () => {
22 | history.push({
23 | pathname: `/result`,
24 | state: options.includes('withState') ? { state: 'yes', text } : null,
25 | query: options.includes('withQuery') ? { query: 'yes', text: text || null } : { text: text || null },
26 | });
27 | };
28 |
29 | return (
30 |
31 |
37 | {
51 | setOptions(_options);
52 | }}
53 | />
54 |
61 | >
62 | }
63 | >
64 | setText(e.target.value)}
67 | onPressEnter={() => {
68 | handleSearch();
69 | }}
70 | />
71 |
72 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/example/src/pages/Result/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card } from 'antd';
3 | import { match as Match } from 'react-router';
4 | import * as H from 'history-with-query';
5 |
6 | export default function Result({ match, location }: { match: Match; location: H.LocationDescriptorObject }) {
7 | return (
8 |
9 |
10 | match: {JSON.stringify(match, null, 2)}
11 |
12 |
13 | location: {JSON.stringify(location, null, 2)}
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/example/src/pages/Search/Applications/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card } from 'antd';
3 |
4 | export default function Hello() {
5 | const renderCount = React.useRef(0);
6 | renderCount.current += 1;
7 |
8 | return (
9 |
10 | Applications Search Page
11 | renderCount: {renderCount.current}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/example/src/pages/Search/Projects/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card } from 'antd';
3 |
4 | export default function Hello() {
5 | const renderCount = React.useRef(0);
6 | renderCount.current += 1;
7 |
8 | return (
9 |
10 | Projects Search Page
11 | renderCount: {renderCount.current}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/example/src/pages/Search/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card, Form, Input } from 'antd';
3 |
4 | export default function Hello({ children }: React.PropsWithChildren<{}>) {
5 | return (
6 |
7 | Search Page
8 |
9 |
10 |
11 | {children}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/example/src/pages/Welcome/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Alert, Card, Form, Input, Tag } from 'antd';
3 |
4 | import { withSwitchTab } from '../../../../src';
5 |
6 | function Hello() {
7 | const renderCount = React.useRef(0);
8 | renderCount.current += 1;
9 |
10 | return (
11 |
12 |
13 |
14 | Welcome Page
15 |
16 |
17 |
18 | renderCount: {renderCount.current}
19 |
20 |
21 | );
22 | }
23 |
24 | export default withSwitchTab(Hello);
25 |
--------------------------------------------------------------------------------
/example/src/types.d.ts:
--------------------------------------------------------------------------------
1 | interface Window {
2 | tabsAction: import('../../src').ActionType;
3 | }
4 |
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "types": ["vite/client", "node"],
6 | "allowJs": false,
7 | "skipLibCheck": false,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react",
18 | "baseUrl": ".",
19 | "paths": {
20 | "@/*": ["./src/*"],
21 | "@@/*": ["./src/.vit/*"]
22 | }
23 | },
24 | "include": ["./src"]
25 | }
26 |
--------------------------------------------------------------------------------
/example/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import reactRefresh from '@vitejs/plugin-react-refresh';
3 | import tsconfigPaths from 'vite-tsconfig-paths';
4 | import vitePluginImp from 'vite-plugin-imp';
5 |
6 | import vitApp from '@vitjs/vit';
7 | import routes from './config/routes';
8 |
9 | // https://vitejs.dev/config/
10 | export default defineConfig({
11 | base: '/use-switch-tabs/',
12 | plugins: [
13 | reactRefresh(),
14 | tsconfigPaths(),
15 | vitePluginImp({
16 | libList: [
17 | {
18 | libName: 'antd',
19 | style: (name) => `antd/es/${name}/style`,
20 | },
21 | ],
22 | }),
23 | vitApp({
24 | debug: true,
25 | routes,
26 | dynamicImport: { loading: './components/PageLoading' },
27 | exportStatic: {},
28 | }),
29 | ],
30 | css: {
31 | modules: {
32 | localsConvention: 'camelCaseOnly',
33 | },
34 | preprocessorOptions: {
35 | less: {
36 | // modifyVars: { 'primary-color': '#13c2c2' },
37 | javascriptEnabled: true,
38 | },
39 | },
40 | },
41 | });
42 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-switch-tabs",
3 | "version": "0.2.3",
4 | "description": "React hook used to convert Switch-like component to Tabs-like component state. 用于将类 Switch 组件转换为 Tabs 组件状态的 React hook。",
5 | "main": "./lib/index.js",
6 | "module": "./es/index.js",
7 | "types": "./lib/index.d.ts",
8 | "repository": "https://github.com/yunsii/use-switch-tabs.git",
9 | "author": "yunsii ",
10 | "license": "MIT",
11 | "files": [
12 | "es",
13 | "lib"
14 | ],
15 | "scripts": {
16 | "build": "father build",
17 | "dev": "cd example && vite --host",
18 | "build:demo": "cd example && vite build",
19 | "release": "release-it --npm.skipChecks"
20 | },
21 | "publishConfig": {
22 | "registry": "https://registry.npmjs.org"
23 | },
24 | "dependencies": {
25 | "@qixian.cs/path-to-regexp": "^6.1.0",
26 | "ahooks": "^3.3.0",
27 | "hash-string": "^1.0.0",
28 | "history": "^5.0.0",
29 | "lodash": "^4.17.21",
30 | "react": "^17.0.2"
31 | },
32 | "devDependencies": {
33 | "@ant-design/icons": "^4.6.2",
34 | "@types/lodash": "^4.14.170",
35 | "@types/react": "^17.0.9",
36 | "@vitejs/plugin-react-refresh": "^1.3.3",
37 | "@vitjs/runtime": "^0.6.0",
38 | "@vitjs/vit": "^0.6.2",
39 | "antd": "^4.16.2",
40 | "father": "^2.30.6",
41 | "react-dom": "^17.0.2",
42 | "release-it": "^14.14.1",
43 | "typescript": "^4.3.2",
44 | "vite": "^2.9.9",
45 | "vite-plugin-imp": "^2.0.7",
46 | "vite-tsconfig-paths": "^3.3.13"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | export enum Mode {
2 | /** 使用页面路由定义作为标签页 ID ,形如 /path/:name 的路由定义只打开一个标签页 */
3 | Route = 'route',
4 | /** 使用页面路由参数作为标签页 id ,因此,可能需要再在 PageTabs 组件中动态设置标签页的标题 */
5 | Dynamic = 'dynamic',
6 | }
7 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as H from 'history';
2 |
3 | import { default as useSwitchTabs } from './useSwitchTabs';
4 | import { withSwitchTab, isSwitchTab } from './utils';
5 | import { Mode } from './config'
6 | import type { UseSwitchTabsOptions, ActionType, RouteConfig, RenderRoute } from './useSwitchTabs';
7 |
8 | interface SwitchTab {
9 | title: React.ReactNode;
10 | key: string;
11 | content: JSX.Element;
12 | closable?: boolean;
13 | location: Omit;
14 | }
15 |
16 | type RoughLocation = Omit;
17 |
18 | export type { UseSwitchTabsOptions, SwitchTab, RoughLocation, ActionType, RouteConfig, RenderRoute };
19 |
20 | export { useSwitchTabs, withSwitchTab, isSwitchTab, Mode };
21 |
22 | export default useSwitchTabs;
23 |
--------------------------------------------------------------------------------
/src/useSwitchTabs.ts:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useMemo, useRef } from 'react';
2 | import { useLocalStorageState, useMemoizedFn, usePrevious } from 'ahooks';
3 | import _find from 'lodash/find';
4 | import _findIndex from 'lodash/findIndex';
5 | import _isEqual from 'lodash/isEqual';
6 | import _omit from 'lodash/omit';
7 | import _get from 'lodash/get';
8 | import _isArray from 'lodash/isArray';
9 | import * as H from 'history';
10 |
11 | import { getRenderRoute, getRenderRouteKey } from './utils';
12 | import { Mode } from './config';
13 | import { SwitchTab, RoughLocation } from '.';
14 |
15 | export interface RouteConfig {
16 | /** 子路由 */
17 | children?: RouteConfig[];
18 | /** 子路由 */
19 | routes?: RouteConfig[];
20 | /** 在菜单中隐藏子节点 */
21 | hideChildrenInMenu?: boolean;
22 | /** 在菜单中隐藏自己和子节点 */
23 | hideInMenu?: boolean;
24 | /** 菜单的名字 */
25 | name?: string;
26 | /** 路径 */
27 | path?: string;
28 | /** 配置该路由标签页紧跟指定的某个路由 */
29 | follow?: string;
30 | /** 重定向 */
31 | redirect?: string;
32 | component?: React.ComponentType<{ location: H.Location }>;
33 | }
34 |
35 | export interface RenderRoute extends Omit {
36 | renderKey: string;
37 | /** Mode.Dynamic 会计算路由参数的 hash 值 */
38 | hash?: string;
39 | name?: React.ReactNode;
40 | }
41 |
42 | export interface SetTabNamePayload {
43 | path: string;
44 | name?: string;
45 | params: any;
46 | location: RoughLocation;
47 | }
48 |
49 | export type SetTabNameFn = (payload: SetTabNamePayload) => React.ReactNode | void;
50 |
51 | export type ListenActiveChangeCallback = (activeKey: string) => void;
52 |
53 | export interface ActionType {
54 | reloadTab: (path?: string) => void;
55 | /** 如果已经打开的标签页会触发 callback ,如果 force 为 true ,总会调用 callback */
56 | goBackTab: (path?: string, callback?: () => void, force?: boolean) => void;
57 | /** 关闭后自动切换到附近的标签页,如果是最后一个标签页不可删除 */
58 | closeTab: (path?: string, callback?: () => void, force?: boolean) => void;
59 | closeAndGoBackTab: (path?: string, callback?: () => void, force?: boolean) => void;
60 | /** 获取 location 对应的 tabKey,如果没有入参,返回当前激活的 tabKey */
61 | getTabKey: (location?: RoughLocation) => string;
62 | /** 监听 activeKey 变化事件 */
63 | listenActiveChange: (callback: ListenActiveChangeCallback) => () => void;
64 | }
65 |
66 | export interface UseSwitchTabsOptions {
67 | mode?: Mode;
68 | /** tabs 持久化 */
69 | persistent?:
70 | | {
71 | /** 是否强制渲染,参考 [Tabs.TabPane.forceRender](https://ant.design/components/tabs-cn/#Tabs.TabPane) */
72 | force?: boolean;
73 | /**
74 | * 持久化时在 localStorage 中的名称,默认为 tabLocations。
75 | * 已知多个项目部署在非根目录需要通过 cacheName 区分,否则会在导致标签页渲染异常。
76 | */
77 | cacheName?: string;
78 | }
79 | | boolean;
80 | children: JSX.Element;
81 | originalRoutes: RouteConfig[];
82 | location: H.Location;
83 | history: Pick;
84 | actionRef?: React.MutableRefObject | ((actionRef: ActionType) => void);
85 | onCreateTab?: (tabKey: string) => void;
86 |
87 | /** Mode.Dynamic 时可用 */
88 | setTabName?: SetTabNameFn;
89 | }
90 |
91 | function useSwitchTabs(options: UseSwitchTabsOptions) {
92 | const {
93 | mode = Mode.Route,
94 | originalRoutes,
95 | persistent,
96 | location,
97 | history,
98 | children,
99 | actionRef: propsActionRef,
100 | setTabName,
101 | } = options;
102 | const currentTabLocation = _omit(location, ['key']);
103 | console.log('currentTabLocation', currentTabLocation);
104 | const cacheName = _get(persistent, 'cacheName', 'tabLocations');
105 |
106 | const actionRef = useRef();
107 | const [tabLocations, setTabLocations] = useLocalStorageState(cacheName, {
108 | defaultValue: [],
109 | });
110 | const [tabs, setTabs] = useState(() => {
111 | if (persistent && _isArray(tabLocations) && tabLocations.length) {
112 | return tabLocations.map((tabLocation) => {
113 | const renderRoute = getRenderRoute({
114 | location: tabLocation,
115 | mode,
116 | originalRoutes,
117 | setTabName,
118 | });
119 | return {
120 | title: renderRoute.name,
121 | key: getRenderRouteKey(renderRoute, mode),
122 | content: React.cloneElement(children!, {
123 | location: tabLocation,
124 | }),
125 | location: tabLocation,
126 | };
127 | });
128 | }
129 | return [];
130 | });
131 | const listenChangeEventsRef = useRef([]);
132 |
133 | const currentRenderRoute = getRenderRoute({
134 | location: currentTabLocation,
135 | mode,
136 | originalRoutes,
137 | setTabName,
138 | });
139 | console.log('currentRenderRoute', currentRenderRoute);
140 |
141 | const currentTabKey = useMemo(() => {
142 | return getRenderRouteKey(currentRenderRoute, mode);
143 | }, [mode, currentRenderRoute]);
144 |
145 | const prevActiveKey = usePrevious(currentTabKey, (prev, next) => prev !== next);
146 |
147 | const getTab = useMemoizedFn((tabKey: string) => _find(tabs, { key: tabKey }));
148 |
149 | const processTabs = useMemoizedFn((_tabs: SwitchTab[]) => {
150 | return _tabs.map((item) => (_tabs.length === 1 ? { ...item, closable: false } : item));
151 | });
152 |
153 | /** 获取激活标签页的相邻标签页 */
154 | const getNextTab = useMemoizedFn(() => {
155 | const removeIndex = _findIndex(tabs, { key: currentTabKey });
156 | const nextIndex = removeIndex >= 1 ? removeIndex - 1 : removeIndex + 1;
157 | return tabs[nextIndex];
158 | });
159 |
160 | /**
161 | * force: 是否在目标标签页不存在的时候强制回调函数
162 | */
163 | const handleSwitch = useMemoizedFn((keyToSwitch: string, callback?: () => void, force: boolean = false) => {
164 | if (!keyToSwitch) {
165 | return;
166 | }
167 |
168 | /**
169 | * `keyToSwitch` 有值时,`targetTab` 可能为空。
170 | *
171 | * 如:一个会调用 `window.closeAndGoBackTab(path)` 的页面在 F5 刷新之后
172 | */
173 | const targetTab = getTab(keyToSwitch);
174 | if (targetTab) {
175 | history.push(targetTab.location);
176 | } else {
177 | history.push(keyToSwitch);
178 | }
179 |
180 | if (force) {
181 | callback?.();
182 | } else {
183 | targetTab && callback?.();
184 | }
185 | });
186 |
187 | /** 删除标签页处理事件,可接收一个 `nextTabKey` 参数,自定义需要返回的标签页 */
188 | const handleRemove = useMemoizedFn(
189 | (removeKey: string, nextTabKey?: string, callback?: () => void, force?: boolean) => {
190 | if (tabs.length === 1) {
191 | console.warn('the final tab, can not remove.');
192 | return;
193 | }
194 |
195 | const getNextTabKeyByRemove = () => (removeKey === currentTabKey ? getNextTab()?.key : currentTabKey);
196 |
197 | handleSwitch(nextTabKey || getNextTabKeyByRemove(), callback, force);
198 | setTabs((prevTabs) => processTabs(prevTabs.filter((item) => item.key !== removeKey)));
199 | }
200 | );
201 |
202 | const handleRemoveOthers = useMemoizedFn((currentKey: string, callback?: () => void) => {
203 | handleSwitch(currentKey, callback);
204 | setTabs((prevTabs) => {
205 | return processTabs(prevTabs.filter((item) => item.key === currentKey));
206 | });
207 | });
208 |
209 | const handleRemoveRightTabs = useMemoizedFn((currentKey: string, callback?: () => void) => {
210 | handleSwitch(getTab(currentKey)!.key, callback);
211 | setTabs((prevTabs) => {
212 | return processTabs(prevTabs.slice(0, _findIndex(prevTabs, { key: currentKey }) + 1));
213 | });
214 | });
215 |
216 | /**
217 | * 新增第一个 tab 不可删除
218 | *
219 | * @param newTab
220 | */
221 | const addTab = useMemoizedFn((newTab: SwitchTab, follow?: string) => {
222 | setTabs((prevTabs) => {
223 | let result = [...prevTabs];
224 | if (follow) {
225 | const targetIndex = _findIndex(prevTabs, (tab) => {
226 | if (mode === Mode.Route) {
227 | return tab.key === follow;
228 | }
229 | const followReg = new RegExp(`^${follow}`);
230 | return followReg.test(tab.key);
231 | });
232 | if (targetIndex >= 0) {
233 | result.splice(targetIndex + 1, 0, newTab);
234 | } else {
235 | result = [...result, newTab];
236 | }
237 | } else {
238 | result = [...result, newTab];
239 | }
240 |
241 | return result.map((item, index) =>
242 | tabs.length === 0 && index === 0 ? { ...item, closable: false } : { ...item, closable: true }
243 | );
244 | });
245 | });
246 |
247 | /**
248 | * 重载标签页,传入参数重写相关属性
249 | *
250 | * @param reloadKey 需要刷新的 tab key
251 | * @param tabTitle 需要刷新的 tab 标题
252 | * @param location 需要刷新的 tab location
253 | * @param content 需要刷新的 tab 渲染的内容
254 | */
255 | const reloadTab = useMemoizedFn(
256 | (
257 | reloadKey: string = currentTabKey,
258 | tabTitle?: React.ReactNode,
259 | tabLocation?: SwitchTab['location'],
260 | content?: JSX.Element
261 | ) => {
262 | if (tabs.length < 1) {
263 | return;
264 | }
265 |
266 | if (process.env.NODE_ENV === 'development') {
267 | console.log(`reload tab key: ${reloadKey}`);
268 | }
269 | const updatedTabs = tabs.map((item) => {
270 | if (item.key === reloadKey) {
271 | const { title: prevTabTitle, location: prevLocation, content: prevContent, ...rest } = item;
272 | return {
273 | ...rest,
274 | title: tabTitle || prevTabTitle,
275 | location: tabLocation || prevLocation,
276 | content: content || React.cloneElement(item.content as JSX.Element, { key: new Date().valueOf() }),
277 | } as SwitchTab;
278 | }
279 | return item;
280 | });
281 |
282 | setTabs(updatedTabs);
283 | }
284 | );
285 |
286 | const goBackTab = useMemoizedFn((path?: string, callback?: () => void, force?: boolean) => {
287 | if (!path && (!prevActiveKey || !getTab(prevActiveKey))) {
288 | console.warn('go back failed, no previous activated key or previous tab is closed.');
289 | return;
290 | }
291 |
292 | handleSwitch(path || prevActiveKey!, callback, force);
293 | });
294 |
295 | /** 关闭后自动切换到附近的标签页,如果是最后一个标签页不可删除 */
296 | const closeTab = useMemoizedFn((path?: string, callback?: () => void, force?: boolean) => {
297 | if (path && !getTab(path)) {
298 | console.warn('close failed, target tab is closed.');
299 | return;
300 | }
301 |
302 | handleRemove(path || currentTabKey, undefined, callback, force);
303 | });
304 |
305 | /** 关闭当前标签页并返回到上次打开的标签页 */
306 | const closeAndGoBackTab = useMemoizedFn((path?: string, callback?: () => void, force?: boolean) => {
307 | if (!path && (!prevActiveKey || !getTab(prevActiveKey))) {
308 | console.warn('close and go back failed, no previous activated key or previous tab is closed.');
309 | return;
310 | }
311 |
312 | handleRemove(currentTabKey, path || prevActiveKey, callback, force);
313 | });
314 |
315 | const getTabKey = useMemoizedFn((roughLocation: RoughLocation = currentTabLocation) => {
316 | const roughRenderRoute = getRenderRoute({
317 | location: roughLocation,
318 | mode,
319 | originalRoutes,
320 | setTabName,
321 | });
322 |
323 | return getRenderRouteKey(roughRenderRoute, mode);
324 | });
325 |
326 | const listenActiveChange = useMemoizedFn((callback: ListenActiveChangeCallback) => {
327 | listenChangeEventsRef.current.push(callback);
328 |
329 | return () => {
330 | listenChangeEventsRef.current.filter((item) => item !== callback);
331 | };
332 | });
333 |
334 | useEffect(() => {
335 | if (persistent) {
336 | setTabLocations(tabs.map((item) => item.location));
337 | return;
338 | }
339 | if (tabLocations) {
340 | setTabLocations();
341 | }
342 | }, [persistent, tabs]);
343 |
344 | useEffect(() => {
345 | actionRef.current = {
346 | reloadTab,
347 | goBackTab,
348 | closeTab,
349 | closeAndGoBackTab,
350 | getTabKey,
351 | listenActiveChange,
352 | };
353 |
354 | return () => {
355 | const hint = () => {
356 | console.warn(`useSwitchTabs had unmounted.`);
357 | };
358 |
359 | actionRef.current = {
360 | reloadTab: hint,
361 | goBackTab: hint,
362 | closeTab: hint,
363 | closeAndGoBackTab: hint,
364 | getTabKey: () => {
365 | hint();
366 | return '';
367 | },
368 | listenActiveChange: () => {
369 | hint();
370 | return () => {};
371 | },
372 | };
373 | };
374 | }, []);
375 |
376 | useEffect(() => {
377 | const activatedTab = getTab(currentTabKey);
378 |
379 | if (activatedTab) {
380 | const { location: prevTabLocation } = activatedTab;
381 | if (!_isEqual(currentTabLocation, prevTabLocation)) {
382 | reloadTab(currentTabKey, currentRenderRoute.name, currentTabLocation, children);
383 | } else {
384 | if (process.env.NODE_ENV === 'development') {
385 | console.log(`no effect of tab key: ${currentTabKey}`);
386 | }
387 | }
388 |
389 | listenChangeEventsRef.current.map((callback) => {
390 | callback(currentTabKey);
391 | });
392 | } else {
393 | const newTab = {
394 | title: currentRenderRoute.name,
395 | key: currentTabKey,
396 | content: children as any,
397 | location: currentTabLocation,
398 | };
399 |
400 | const { follow } = currentRenderRoute || {};
401 |
402 | if (process.env.NODE_ENV === 'development') {
403 | console.log(`add tab key: ${currentTabKey}`);
404 | }
405 | addTab(newTab, follow);
406 | }
407 |
408 | // 不可将当前 location 作为依赖,否则在操作非当前 location 对应的标签页时会有异常
409 | // 比如在非当前 location 对应的标签页的标签菜单中触发删除其他标签页,会导致本应只有一个标签页时,
410 | // 但会再次创建一个当前 location 对应的标签页
411 | }, [children, currentTabKey]);
412 |
413 | useEffect(() => {
414 | if (!propsActionRef) {
415 | return;
416 | }
417 |
418 | if (typeof propsActionRef === 'function') {
419 | propsActionRef(actionRef.current);
420 | } else {
421 | propsActionRef.current = actionRef.current;
422 | }
423 | }, []);
424 |
425 | return {
426 | tabs,
427 | activeKey: currentTabKey,
428 | handleSwitch,
429 | handleRemove,
430 | handleRemoveOthers,
431 | handleRemoveRightTabs,
432 | };
433 | }
434 |
435 | export default useSwitchTabs;
436 |
--------------------------------------------------------------------------------
/src/utils.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import _find from 'lodash/find';
3 | import _isEqual from 'lodash/isEqual';
4 | import _isEmpty from 'lodash/isEmpty';
5 | import _mapValues from 'lodash/mapValues';
6 | import hash from 'hash-string';
7 | import { pathToRegexp, match as pathMatch } from '@qixian.cs/path-to-regexp';
8 |
9 | import { Mode } from './config';
10 | import { RouteConfig, RenderRoute, SetTabNameFn } from './useSwitchTabs';
11 | import { RoughLocation } from '.';
12 |
13 | /** 判断给定 location 是否在 originalRoutes 中,可作为标签页展示 */
14 | export function isSwitchTab(location: RoughLocation, originalRoutes: RouteConfig[]): boolean {
15 | function isInMenus(menuData: RouteConfig[]) {
16 | const targetMenuItem = _find(menuData, (item) => pathToRegexp(`${item.path}(.*)`).test(location.pathname));
17 |
18 | return !!targetMenuItem;
19 | }
20 |
21 | return isInMenus(originalRoutes);
22 | }
23 |
24 | const pathnameMapCache: {
25 | [k: string]: any;
26 | } = {};
27 |
28 | /**
29 | * 解析 `RenderRoute`,核心是算出合适的 `renderKey`
30 | *
31 | * @param location
32 | * @param originalRoutes 原始路由数据,未经过滤处理
33 | */
34 | function getOriginalRenderRoute(location: RoughLocation, originalRoutes: RouteConfig[]): RenderRoute {
35 | const { pathname } = location;
36 |
37 | if (pathnameMapCache[pathname]) {
38 | return pathnameMapCache[pathname];
39 | }
40 |
41 | function getMetadata(menuData: RouteConfig[], parent: RouteConfig | null): RenderRoute {
42 | let result: any;
43 |
44 | // 当存在重定向时,直接返回结果且不缓存计算结果
45 | const redirectRoute = menuData.find((item) => item.path === pathname && item.redirect);
46 | if (redirectRoute) {
47 | // TODO: 优化重复点击重定向路由导致的闪烁问题
48 | return { ...redirectRoute, renderKey: parent?.hideChildrenInMenu ? parent.path : redirectRoute.redirect! };
49 | }
50 |
51 | /**
52 | * 根据前缀匹配菜单项,因此,`BasicLayout` 下的 **一级路由** 只要配置了 `name` 属性,总能找到一个 `path` 和 `name` 的组合
53 | *
54 | * 上述说法有误,可能存在 `redirect` 的情况,此时没有 `name` 字段
55 | */
56 | const targetRoute = _find(menuData, (item) => !item.redirect && pathToRegexp(`${item.path}(.*)`).test(pathname));
57 |
58 | /** 如果为 **一级路由** 直接写入 `result` ,否则父级没有 `component` 时才能写入 `result` */
59 | if ((!parent && targetRoute) || (parent && !parent.component && targetRoute)) {
60 | result = {
61 | ...targetRoute,
62 | renderKey: targetRoute.redirect || targetRoute.path!,
63 | };
64 | }
65 | /** 如果父级配置了 `hideChildrenInMenu` ,子级配置了 `name` 则重写 `result` */
66 | if (parent?.hideChildrenInMenu && targetRoute) {
67 | result = {
68 | ...targetRoute,
69 | name: targetRoute.name || parent.name,
70 | renderKey: parent.path!,
71 | };
72 | }
73 |
74 | /** 递归设置 `renderKey` */
75 | if (Array.isArray(targetRoute?.children) && targetRoute?.children.length) {
76 | result = getMetadata(targetRoute!.children!, targetRoute!) || result;
77 | }
78 | if (Array.isArray(targetRoute?.routes) && targetRoute?.routes.length) {
79 | result = getMetadata(targetRoute!.routes!, targetRoute!) || result;
80 | }
81 |
82 | return result;
83 | }
84 |
85 | const result = getMetadata(originalRoutes, null);
86 |
87 | /** 在存在页面子路由,如果不赋值 pathname,path 始终指向父路由 */
88 | pathnameMapCache[pathname] = { ...result, path: pathname };
89 | return pathnameMapCache[pathname];
90 | }
91 |
92 | /**
93 | * 解析路由定义中参数
94 | *
95 | * 如: `path = /user/:id` ,`pathname = /user/48` ,可解析得到 `{ id: "48" }`
96 | *
97 | * @param path 路由定义
98 | * @param pathname 当前的页面路由
99 | */
100 | export function getParams(path: string, pathname: string): { [key: string]: string } {
101 | const match = pathMatch(path);
102 | const result = match(pathname) as {
103 | index: number;
104 | params: { [k: string]: string };
105 | path: string;
106 | };
107 | return result.params;
108 | }
109 |
110 | /**
111 | * 获取要激活的标签页信息
112 | *
113 | * @param options 其中,`location` 必须是 `react-router` 注入的 `location`,否则部署到非根目录时功能异常
114 | * @returns
115 | */
116 | export function getRenderRoute(options: {
117 | location: RoughLocation;
118 | mode: Mode;
119 | originalRoutes: RouteConfig[];
120 | setTabName?: SetTabNameFn;
121 | }): RenderRoute {
122 | const { location, mode, originalRoutes, setTabName } = options;
123 |
124 | const renderRoute = getOriginalRenderRoute(location, originalRoutes);
125 |
126 | if (!renderRoute || mode === Mode.Route) {
127 | return renderRoute;
128 | }
129 |
130 | // 以下为 **路径** 模式的处理逻辑:
131 | // 核心是根据路由中所带的参数算出参数的哈希值,之后可将其与算出的 `renderKey` 拼成一个标签页的唯一 id
132 | // 这样,不同的参数就能得到不同的标签页了
133 |
134 | const params = getParams(renderRoute.renderKey, location.pathname!);
135 | const { search, state = {} } = location;
136 |
137 | let hashString = '';
138 |
139 | if (!_isEmpty(params) || !_isEmpty(search) || !_isEmpty(state)) {
140 | const hashObject = {
141 | ...params,
142 | /**
143 | * 如果在 router.push 的时候设置 query ,可能导致查询参数为 number 类型,在点击标签页标题的时候又会变为 string 类型
144 | * 导致了计算的 hash 值可能不唯一,故统一转换为 string 类型的 search 字段来处理,而不是使用 query 字段
145 | *
146 | * 又当前 React Router Dom 在 history.push() 的时候如果对象中存在 search 字段时,如果 search 首字母不是 ? 会被插入一个 ?
147 | * 故统一处理判断首字母是否为问号
148 | */
149 | search: search.charAt(0) === '?' ? search.slice(1) : search,
150 | ...(state as any),
151 | };
152 | hashString = hash(JSON.stringify(hashObject));
153 | }
154 |
155 | return {
156 | ...renderRoute,
157 | hash: hashString,
158 | name:
159 | setTabName?.({ path: renderRoute.path, name: renderRoute.name as string, params, location }) || renderRoute.name,
160 | };
161 | }
162 |
163 | export const routePagePropsAreEqual = (prevProps: any, nextProps: any) => {
164 | const {
165 | children: prevChildren,
166 | computedMatch: prevComputedMatch,
167 | history: prevHistory,
168 | location: prevLocation,
169 | match: prevMatch,
170 | route: prevRoute,
171 | staticContext: prevStaticContext,
172 | ...prevRest
173 | } = prevProps;
174 | const {
175 | children: nextChildren,
176 | computedMatch: nextComputedMatch,
177 | history: nextHistory,
178 | location: nextLocation,
179 | match: nextMatch,
180 | route: nextRoute,
181 | staticContext: nextStaticContext,
182 | ...nextRest
183 | } = nextProps;
184 | if (!_isEqual(prevRest, nextRest)) {
185 | console.log(`${prevLocation.pathname}: update by props`);
186 | // console.log(prevRest);
187 | // console.log(nextRest);
188 | return false;
189 | }
190 |
191 | const { pathname: prevPathname, search: prevSearch, state: prevState } = prevLocation || {};
192 | const { pathname: nextPathname, search: nextSearch, state: nextState } = nextLocation || {};
193 | const isLocationChange =
194 | prevPathname !== nextPathname || prevSearch !== nextSearch || !_isEqual(prevState, nextState);
195 | if (isLocationChange) {
196 | console.log(`${prevLocation.pathname} -> ${nextPathname}: update by route or params`);
197 | // console.log({ prevPathname, prevSearch, prevState });
198 | // console.log({ nextPathname, nextSearch, nextState });
199 | return false;
200 | }
201 |
202 | console.log(`without re-render: ${prevPathname}`);
203 | return true;
204 | };
205 |
206 | export function withSwitchTab(
207 | WrappedComponent: React.ComponentType
208 | ): React.MemoExoticComponent {
209 | const WithRoutePage = React.memo((props: any) => {
210 | // useConsole(props.location.pathname);
211 |
212 | return ;
213 | }, routePagePropsAreEqual);
214 |
215 | WithRoutePage.displayName = `WithRoutePage(${getDisplayName(WrappedComponent)})`;
216 |
217 | return WithRoutePage;
218 | }
219 |
220 | function getDisplayName(WrappedComponent: React.ComponentType) {
221 | return WrappedComponent.displayName || WrappedComponent.name || 'Component';
222 | }
223 |
224 | export function getRenderRouteKey(renderRoute: RenderRoute, mode: Mode) {
225 | if (mode === Mode.Dynamic && renderRoute?.hash) {
226 | return `${renderRoute.renderKey}-${renderRoute.hash}`;
227 | }
228 | return renderRoute.renderKey;
229 | }
230 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "moduleResolution": "node",
5 | "baseUrl": "./",
6 | "jsx": "react",
7 | "declaration": true,
8 | "skipLibCheck": true,
9 | "esModuleInterop": true
10 | },
11 | "exclude": ["example"]
12 | }
13 |
--------------------------------------------------------------------------------