├── .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 | [![use-switch-tabs](https://nodei.co/npm/use-switch-tabs.png)](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 | 77 | 78 | closeCurrent 79 | 80 | 81 | closeOthers 82 | 83 | 84 | closeToRight 85 | 86 | 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 | 2 | 🚀 3 | -------------------------------------------------------------------------------- /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 | 57 | {getRoutesMenuData(route.routes).map((item) => { 58 | if (item.hideInMenu) { 59 | return null; 60 | } 61 | 62 | const subRoutes = item.routes; 63 | 64 | if (subRoutes && !item.hideChildrenInMenu) { 65 | return renderSubMenu(item); 66 | } 67 | return ( 68 | history.push(item.path)}> 69 | {item.path === '/' ? 'Home' : item.name || item.path} 70 | 71 | ); 72 | })} 73 | 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 | --------------------------------------------------------------------------------