├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .postcssrc.yml ├── .prettierignore ├── .prettierrc ├── README.md ├── commitlint.config.js ├── index.html ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── src ├── assets │ └── 404.svg ├── components │ ├── KeepAlive │ │ └── index.tsx │ ├── Loading │ │ ├── customSpin.tsx │ │ ├── index.module.css │ │ └── index.tsx │ └── NotFound │ │ └── index.tsx ├── hooks │ ├── useUpdate.ts │ ├── useUpdateTagName.ts │ └── useView.tsx ├── layout │ ├── index.tsx │ └── tagsView │ │ ├── index.module.scss │ │ └── index.tsx ├── main.tsx ├── router │ ├── Children.tsx │ ├── configure.ts │ └── index.tsx ├── styles │ └── index.scss └── views │ ├── nesting │ └── list.tsx │ ├── no-cache.tsx │ ├── role.tsx │ ├── update.tsx │ └── user.tsx ├── tsconfig.json └── vite.config.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | >0.25% 2 | last 2 versions 3 | not dead 4 | not ie 11 5 | not op_mini all 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = space 4 | indent_size = 2 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | end_of_line = lf 8 | root = true 9 | 10 | [*.md] 11 | insert_final_newline = false 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | src/assets 3 | public/ 4 | dist/ 5 | static/ 6 | node_modules/ 7 | package-lock.json 8 | api/ 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:react/recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'prettier', 10 | 'plugin:prettier/recommended', 11 | 'plugin:react/jsx-runtime', 12 | 'plugin:react-hooks/recommended', 13 | ], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaVersion: 2020, 17 | }, 18 | globals: { 19 | __webpack_public_path__: true, 20 | }, 21 | plugins: ['react', '@typescript-eslint', 'react-hooks'], 22 | rules: { 23 | camelcase: 'off', 24 | 'react/jsx-uses-react': 'off', 25 | 'react/react-in-jsx-scope': 'off', 26 | 'react-hooks/rules-of-hooks': 'error', 27 | 'react-hooks/exhaustive-deps': 'warn', 28 | '@typescript-eslint/camelcase': ['off', { properties: 'always' }], 29 | '@typescript-eslint/explicit-module-boundary-types': 'off', 30 | '@typescript-eslint/no-explicit-any': 'off', 31 | 'prettier/prettier': [ 32 | 'error', 33 | {}, 34 | { 35 | usePrettierrc: true, 36 | }, 37 | ], 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /dist.zip 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | # Log files 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | yarn.lock 13 | # Editor directories and files 14 | .idea 15 | .vscode 16 | *.suo 17 | *.ntvs* 18 | *.njsproj 19 | *.sln 20 | *.sw? 21 | *.lock 22 | .mocks/ 23 | -------------------------------------------------------------------------------- /.postcssrc.yml: -------------------------------------------------------------------------------- 1 | map: false 2 | plugins: 3 | postcss-import: {} 4 | autoprefixer: {} 5 | postcss-preset-env: {} 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.svg 2 | package.json 3 | .umi 4 | .umi-production 5 | /dist 6 | .dockerignore 7 | .DS_Store 8 | .eslintignore 9 | *.png 10 | *.toml 11 | docker 12 | .editorconfig 13 | Dockerfile* 14 | .gitignore 15 | .prettierignore 16 | LICENSE 17 | .eslintcache 18 | *.lock 19 | yarn-error.log 20 | /api -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": true, 5 | "semi": false, 6 | "singleQuote": true, 7 | "TrailingCooma": "none", 8 | "bracketSpacing": true, 9 | "arrowParens": "always" 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 关于本项目 2 | >基于 createPortal 原生实现 KeepAlive 无需任何其他库或者其他插件 3 | 利用 react-routerV6 获取到路由信息 实现多页签
4 | 非 antd-pro umijs 5 | 6 | ## 工程 7 | >vite 8 | ## 语言 9 | >typeScript 10 | ## 技术 11 | >React > 16.8 支持18
12 | antd > 4
13 | react-router > 6
14 | ### 原理: 15 | #### 核心API 16 | >1:react-routerV6 的 useRoutes
17 | 2:React 的 createPortal
18 | 利用 useRoutes 动态匹配路由 存储每次匹配到的信息
19 | 利用 createPortal 将非当前渲染的路由 移动到一个 document.createElement('div') 当中 20 | 21 | ### 核心代码: 22 | >路由 渲染layout组件 在layout里面利用 useRoutes 匹配 路由信息跟vnode 23 | 然后传递给 /components/KeepAlive 组件 24 | KeepAlive 自己缓存一个数组对象 Array<{key:路由信息的pathname,value:vnode }> 25 | 然后匹配当前路由的 pathname 渲染对应的 vnode 26 | 没匹配的就利用createPortal渲染到DIV里面 27 | 28 | [预览](https://codesandbox.io/s/21972) 29 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | [ 8 | 'feature', // 引入新功能 或者 需求调整 需求调整 最好记录 如: 2021-06-16 23:11:21 产品(宋导)要求 采购可交货数量只做展示 不限制 已注释 9 | 'fix', //修复了bug 10 | 'docs', //文档 11 | 'style', //优化项目结构或者代码格式 12 | 'refactor', // 代码重构. 代码重构不涉及新功能和bug修复. 不应该影响原有功能, 包括对外暴露的接口 13 | 'test', //增加测试 14 | 'chore', //构建过程, 辅助工具升级. 如升级依赖, 升级构建工具 15 | 'perf', //性能优化 16 | 'revert', //revert之前的commit 17 | 'build', // 构建或发布版本 18 | 'safe', //修复安全问题 19 | 'api', //重新生成API 文件夹 20 | 'other', //其他 21 | 'ts', // typescript 优化 22 | ], 23 | ], 24 | 'subject-full-stop': [0, 'never'], 25 | 'subject-case': [0, 'never'], 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | react-keepAlive 9 | <%- polyfill %> 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-keepAlive", 3 | "version": "2.0.0", 4 | "description": "react18 + antd4 ", 5 | "author": "antes", 6 | "license": "MIT", 7 | "keywords": [ 8 | "keepAlive", 9 | "react" 10 | ], 11 | "scripts": { 12 | "createApi": "npx pont generate && npm run lint", 13 | "prepare": "husky install", 14 | "lint": "eslint src --fix --ext .ts,.tsx", 15 | "dev": "vite -m development", 16 | "build": "tsc --noEmit && vite build -m production", 17 | "preview": "vite preview", 18 | "changelog": "auto-changelog -p" 19 | }, 20 | "dependencies": { 21 | "antd": "^4.23.5", 22 | "ramda": "^0.27.1", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-router-dom": "^6.9.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.19.3", 29 | "@types/node": "^16", 30 | "@types/ramda": "^0.27.1", 31 | "@types/react": "^18.0.21", 32 | "@types/react-dom": "^18.0.6", 33 | "@types/react-router-dom": "^5.3.3", 34 | "@typescript-eslint/eslint-plugin": "^5.4.0", 35 | "@typescript-eslint/parser": "^5.4.0", 36 | "@vitejs/plugin-react": "^2.1.0", 37 | "auto-changelog": "^2.3.0", 38 | "autoprefixer": "^9.8.6", 39 | "babel-plugin-import": "^1.13.3", 40 | "babel-preset-react": "^6.24.1", 41 | "commitlint": "^12.1.4", 42 | "eslint": "^8.2.0", 43 | "eslint-config-prettier": "^6.11.0", 44 | "eslint-plugin-formatjs": "^4.3.1", 45 | "eslint-plugin-import": "^2.20.2", 46 | "eslint-plugin-node": "^11.1.0", 47 | "eslint-plugin-prettier": "^3.1.1", 48 | "eslint-plugin-promise": "^4.2.1", 49 | "eslint-plugin-react": "^7.20.3", 50 | "eslint-plugin-react-hooks": "^4.6.0", 51 | "eslint-plugin-standard": "^4.0.0", 52 | "husky": "^7.0.1", 53 | "less": "^4.1.1", 54 | "lint-staged": "^11.0.1", 55 | "pont-engine": "1.2.0", 56 | "postcss": "^8.4.16", 57 | "postcss-import": "^14.1.0", 58 | "postcss-preset-env": "^7.8.0", 59 | "prettier": "^2.4.1", 60 | "sass": "^1.35.2", 61 | "tslib": "^2.3.1", 62 | "typescript": "^5.0.0", 63 | "typescript-plugin-css-modules": "^3.4.0", 64 | "vite": "^3", 65 | "vite-plugin-checker": "^0.5.1", 66 | "vite-plugin-html": "^3.2.0", 67 | "vite-plugin-imp": "^2.3.0", 68 | "vite-plugin-style-import": "^2.0.0", 69 | "vite-plugin-svgr": "^2.2.1" 70 | }, 71 | "engines": { 72 | "node": ">=14.0.0" 73 | }, 74 | "auto-changelog": { 75 | "output": "HISTORY.md", 76 | "template": "keepachangelog.template", 77 | "unreleased": true, 78 | "commitLimit": false 79 | }, 80 | "lint-staged": { 81 | "*.{tsx,ts,js}": [ 82 | "eslint --fix", 83 | "git add" 84 | ] 85 | }, 86 | "husky": { 87 | "hooks": { 88 | "pre-commit": "npm run lint", 89 | "commit-msg": "commitlint -e $HUSKY_GIT_PARAMS" 90 | } 91 | }, 92 | "sideEffects": [ 93 | "*.css", 94 | "*.sass", 95 | "*.scss" 96 | ] 97 | } 98 | -------------------------------------------------------------------------------- /src/assets/404.svg: -------------------------------------------------------------------------------- 1 | page not found 2 | -------------------------------------------------------------------------------- /src/components/KeepAlive/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom' 2 | import { equals, isNil, map, filter, includes, length, append, slice } from 'ramda' 3 | import { useEffect, useRef, useState } from 'react' 4 | import type { ReactNode, RefObject } from 'react' 5 | export interface ComponentReactElement { 6 | children?: ReactNode | ReactNode[] 7 | } 8 | interface Props extends ComponentReactElement { 9 | activeName?: string 10 | include?: Array 11 | exclude?: Array 12 | maxLen?: number 13 | } 14 | function KeepAlive({ activeName, children, exclude, include, maxLen = 10 }: Props) { 15 | const containerRef = useRef(null) 16 | const [cacheReactNodes, setCacheReactNodes] = useState>([]) 17 | useEffect(() => { 18 | if (isNil(activeName)) { 19 | return 20 | } 21 | setCacheReactNodes((cacheReactNodes) => { 22 | // 缓存超过上限的 23 | if (length(cacheReactNodes) >= maxLen) { 24 | cacheReactNodes = slice(1, length(cacheReactNodes), cacheReactNodes) 25 | } 26 | // 添加 27 | const cacheReactNode = cacheReactNodes.find((res) => equals(res.name, activeName)) 28 | if (isNil(cacheReactNode)) { 29 | cacheReactNodes = append( 30 | { 31 | name: activeName, 32 | ele: children, 33 | }, 34 | cacheReactNodes 35 | ) 36 | } else { 37 | cacheReactNodes = map((res) => { 38 | return equals(res.name, activeName) ? { ...res, ele: children } : res 39 | }, cacheReactNodes) 40 | } 41 | return isNil(exclude) && isNil(include) 42 | ? cacheReactNodes 43 | : filter(({ name }) => { 44 | if (exclude && includes(name, exclude)) { 45 | return false 46 | } 47 | if (include) { 48 | return includes(name, include) 49 | } 50 | return true 51 | }, cacheReactNodes) 52 | }) 53 | }, [children, activeName, exclude, maxLen, include]) 54 | 55 | return ( 56 | <> 57 |
58 | {map( 59 | ({ name, ele }) => ( 60 | 61 | {ele} 62 | 63 | ), 64 | cacheReactNodes 65 | )} 66 | 67 | ) 68 | } 69 | export default KeepAlive 70 | interface ComponentProps extends ComponentReactElement { 71 | active: boolean 72 | name: string 73 | renderDiv: RefObject 74 | } 75 | 76 | function Component({ active, children, name, renderDiv }: ComponentProps) { 77 | const [targetElement] = useState(() => document.createElement('div')) 78 | const activatedRef = useRef(false) 79 | activatedRef.current = activatedRef.current || active 80 | useEffect(() => { 81 | if (active) { 82 | renderDiv.current?.appendChild(targetElement) 83 | } else { 84 | try { 85 | renderDiv.current?.removeChild(targetElement) 86 | } catch (e) {} 87 | } 88 | }, [active, renderDiv, targetElement]) 89 | useEffect(() => { 90 | targetElement.setAttribute('id', name) 91 | }, [name, targetElement]) 92 | return <>{activatedRef.current && ReactDOM.createPortal(children, targetElement)} 93 | } 94 | -------------------------------------------------------------------------------- /src/components/Loading/customSpin.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Chan 3 | * @Date: 2021-05-15 15:40:52 4 | * @LastEditTime: 2021-08-05 16:35:13 5 | * @LastEditors: Chan 6 | * @Description: 自定义图表spin 7 | */ 8 | import { Spin } from 'antd' 9 | interface Props { 10 | height?: number 11 | } 12 | function customSpin(props: Props) { 13 | const { height } = props 14 | return ( 15 |
25 | 26 |
27 | ) 28 | } 29 | 30 | const CustomSpin = customSpin 31 | export { CustomSpin } 32 | -------------------------------------------------------------------------------- /src/components/Loading/index.module.css: -------------------------------------------------------------------------------- 1 | .rantion_loading{ 2 | text-align: center; 3 | } 4 | .page404{ 5 | text-align: center; 6 | padding-top: 20vh; 7 | font-size: 100px; 8 | line-height: 1; 9 | } 10 | .subtitle { 11 | color: #00000073; 12 | font-size: 14px; 13 | line-height: 1.6; 14 | text-align: center; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Spin, Modal } from 'antd' 2 | import { Suspense } from 'react' 3 | import $style from './index.module.css' 4 | /** 5 | * 加载资源时候 显示的等待界面 6 | * @user: antes 7 | * @emial: tangzhlyd@gmail.com 8 | * @data: 2020/12/15 下午1:07 9 | */ 10 | export function Loading() { 11 | return ( 12 |
13 | 14 |
15 | ) 16 | } 17 | interface Props { 18 | children: JSX.Element 19 | } 20 | export function SuspenseLoading({ children }: Props) { 21 | return }>{children} 22 | } 23 | /** 24 | * 加载失败 25 | * @param props 26 | * @constructor 27 | */ 28 | export function LoadingError() { 29 | function reload() { 30 | localStorage.clear() 31 | location.reload() 32 | } 33 | function showConfirm() { 34 | Modal.confirm({ 35 | title: 'sorry', 36 | content: '程序猿认识到他的错误了, 确定将清理缓存,重新登录', 37 | onOk() { 38 | reload() 39 | }, 40 | }) 41 | } 42 | return ( 43 |
44 |
500
45 |
很抱歉,程序出现异常了。
46 |
47 | 50 | or 51 | 54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /src/components/NotFound/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 404 界面 3 | * @user: antes 4 | * @emial: tangzhlyd@gmail.com 5 | * @data: 2020/12/8 下午3:39 6 | */ 7 | import Img404 from '@/assets/404.svg' 8 | export const NotFound = () => { 9 | return ( 10 |
11 | 12 |
13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/useUpdate.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react' 2 | export function useUpdate() { 3 | const [, setSate] = useState() 4 | const update = useCallback(() => { 5 | setSate(() => { 6 | return undefined 7 | }) 8 | }, []) 9 | return update 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/useUpdateTagName.ts: -------------------------------------------------------------------------------- 1 | import { useView } from '@/hooks/useView' 2 | import { useCallback } from 'react' 3 | import { ActionType } from '@/layout/tagsView' 4 | import { useLocation } from 'react-router-dom' 5 | export default function useUpdateTagName(key?: string) { 6 | const { dispatch } = useView() 7 | const location = useLocation() 8 | return useCallback( 9 | (title: string) => { 10 | if (dispatch && (key || location)) { 11 | dispatch({ 12 | type: ActionType.update, 13 | payload: { 14 | key: key ?? location.pathname, 15 | title, 16 | }, 17 | }) 18 | } 19 | }, 20 | [key, location, dispatch] 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/useView.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import type { Dispatch } from 'react' 3 | import type { Action } from '@/layout/tagsView' 4 | interface ViewContext { 5 | name?: string 6 | dispatch?: Dispatch 7 | meta?: { title: string } 8 | } 9 | const ViewContext = createContext({}) 10 | const Provider = ViewContext.Provider 11 | export const useView = () => { 12 | // const routeContext = React.useContext(RouteContext) 13 | return useContext(ViewContext) 14 | } 15 | interface Props { 16 | children: JSX.Element 17 | value: ViewContext 18 | } 19 | export const ViewProvider = ({ value, children }: Props) => {children} 20 | -------------------------------------------------------------------------------- /src/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, Suspense, useEffect, useMemo, useReducer, useRef } from 'react' 2 | import type { FunctionComponent, Dispatch, JSXElementConstructor, ReactElement } from 'react' 3 | import { BackTop, Layout as ALayout, Menu } from 'antd' 4 | import { Link, useLocation, useNavigate, useRoutes } from 'react-router-dom' 5 | import { equals, filter, isEmpty, isNil, last, map, not, reduce } from 'ramda' 6 | import TagsView, { ActionType, reducer } from './tagsView' 7 | import type { Action } from './tagsView' 8 | import { Loading } from '@/components/Loading' 9 | import $styles from './tagsView/index.module.scss' 10 | import type { RouteMatch, NonIndexRouteObject } from 'react-router-dom' 11 | import KeepAlive from '@/components/KeepAlive' 12 | import { ViewProvider } from '@/hooks/useView' 13 | import type { RouteConfig } from '@/router/configure' 14 | import type { ItemType } from 'antd/lib/menu/hooks/useItems' 15 | export const MAXLEN = 10 16 | export interface RouteObjectDto extends NonIndexRouteObject { 17 | name: string 18 | meta?: { title: string } 19 | cache: boolean 20 | layout?: boolean // 嵌套二次自定义布局 21 | } 22 | function makeRouteObject(routes: RouteConfig[], dispatch: Dispatch): Array { 23 | return map((route) => { 24 | const cache = isNil(route.noCache) ? (isNil(route.cache) ? true : route.cache) : route.noCache 25 | return { 26 | path: route.path, 27 | name: route.name, 28 | meta: route.meta, 29 | cache, 30 | element: ( 31 | 32 | 33 | 34 | ), 35 | children: isNil(route.children) ? undefined : makeRouteObject(route.children, dispatch), 36 | } 37 | }, routes) 38 | } 39 | function getMatchRouteObj(ele: ReactElement | null) { 40 | if (isNil(ele)) { 41 | return null 42 | } 43 | const matchRoutes = getLatchRouteByEle(ele) 44 | if (isNil(matchRoutes)) { 45 | return null 46 | } 47 | const selectedKeys: string[] = reduce( 48 | (selectedKeys: string[], res) => { 49 | const route = res.route as RouteObjectDto 50 | if (route.name) { 51 | selectedKeys.push(route.name) 52 | } 53 | return selectedKeys 54 | }, 55 | [], 56 | matchRoutes 57 | ) 58 | const matchRoute = last(matchRoutes) 59 | const data = matchRoute?.route as RouteObjectDto 60 | return { 61 | key: data.layout ? matchRoute?.pathnameBase ?? '' : matchRoute?.pathname ?? '', 62 | title: data?.meta?.title ?? '', 63 | name: data?.name ?? '', 64 | selectedKeys, 65 | cache: data.cache, 66 | } 67 | } 68 | 69 | function mergePtah(path: string, paterPath = '') { 70 | // let pat = getGoto(path) 71 | path = path.startsWith('/') ? path : '/' + path 72 | return paterPath + path 73 | } 74 | // 渲染导航栏 75 | function renderMenu(data: Array, path?: string) { 76 | return reduce( 77 | (items, route) => { 78 | if (route.alwaysShow) { 79 | return items 80 | } 81 | const thisPath = mergePtah(route.path, path) 82 | const children = filter((route) => not(route.alwaysShow), route.children ?? []) 83 | const hasChildren = isNil(children) || isEmpty(children) 84 | items.push({ 85 | key: route.name, 86 | title: route.meta?.title, 87 | label: !hasChildren ? ( 88 | {route.meta?.title} 89 | ) : ( 90 | 91 | {route.meta?.title} 92 | 93 | ), 94 | children: hasChildren ? undefined : renderMenu(children, thisPath), 95 | }) 96 | return items 97 | }, 98 | [] as ItemType[], 99 | data 100 | ) 101 | } 102 | interface Props { 103 | route: RouteConfig 104 | } 105 | function getRouteContext(data: any): any { 106 | if (isNil(data.children)) { 107 | return null 108 | } 109 | return isNil(data.routeContext) ? getRouteContext(data.children.props) : data.routeContext 110 | } 111 | function getLatchRouteByEle(ele: ReactElement): RouteMatch[] | null { 112 | if (ele) { 113 | const data = getRouteContext(ele.props) 114 | return isNil(data?.outlet) ? (data?.matches as RouteMatch[]) : getLatchRouteByEle(data?.outlet) 115 | } 116 | return null 117 | } 118 | const Layout: FunctionComponent = ({ route }: Props) => { 119 | const eleRef = useRef> | null>() 120 | const location = useLocation() 121 | const navigate = useNavigate() 122 | const [tagsViewList, dispatch] = useReducer(reducer, []) 123 | // 生成子路由 124 | const [routeObject, items] = useMemo(() => { 125 | if (isNil(route.children)) { 126 | return [[], []] as [RouteObjectDto[], ItemType[]] 127 | } 128 | return [makeRouteObject(route.children, dispatch), renderMenu(route.children)] 129 | }, [route.children]) 130 | 131 | // 匹配 当前路径要渲染的路由 132 | const ele = useRoutes(routeObject, location) 133 | // 计算 匹配的路由name 134 | const matchRouteObj = useMemo(() => { 135 | eleRef.current = ele 136 | return getMatchRouteObj(ele) 137 | // eslint-disable-next-line 138 | }, [routeObject, location]) 139 | // 缓存渲染 & 判断是否404 140 | useEffect(() => { 141 | if (matchRouteObj) { 142 | dispatch({ 143 | type: ActionType.add, 144 | payload: { 145 | ...matchRouteObj, 146 | }, 147 | }) 148 | } else if (!equals(location.pathname, '/')) { 149 | navigate({ 150 | pathname: '/404', 151 | }) 152 | } 153 | }, [location.pathname, matchRouteObj, navigate]) 154 | const keepAliveList = useMemo( 155 | () => 156 | reduce( 157 | (list, item) => { 158 | if (item.cache) { 159 | list.push(item.key) 160 | } 161 | return list 162 | }, 163 | [] as Array, 164 | tagsViewList 165 | ), 166 | [tagsViewList] 167 | ) 168 | return ( 169 | 170 | 171 | 172 | 178 | 179 | 180 | 181 | 182 | }> 183 | <> 184 | 185 | {eleRef.current} 186 | 187 | <> {matchRouteObj?.cache ? null : eleRef.current} 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | ) 196 | } 197 | export default memo(Layout) 198 | -------------------------------------------------------------------------------- /src/layout/tagsView/index.module.scss: -------------------------------------------------------------------------------- 1 | .flex { 2 | display: flex; 3 | padding-left: 16px; 4 | align-items: center; 5 | } 6 | .flexSb { 7 | display: flex; 8 | justify-content: space-between; 9 | } 10 | .loggo { 11 | top: 17px; 12 | right: 17px; 13 | position: relative; 14 | width: 100px; 15 | height: 30px; 16 | cursor: pointer; 17 | } 18 | .trigger { 19 | font-size: 22px; 20 | } 21 | .select { 22 | top: 1px; 23 | color: #1890ff; 24 | border-color: #1890ff; 25 | } 26 | .closeIcon { 27 | margin-left: 10px; 28 | font-size: 10px; 29 | } 30 | 31 | .fixed { 32 | overflow: auto; 33 | height: 100vh; 34 | position: fixed; 35 | left: 0; 36 | } 37 | -------------------------------------------------------------------------------- /src/layout/tagsView/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | equals, 3 | filter, 4 | find, 5 | findIndex, 6 | isEmpty, 7 | last, 8 | map, 9 | mergeRight, 10 | pick, 11 | length, 12 | append, 13 | slice, 14 | pipe, 15 | not, 16 | curry, 17 | ifElse, 18 | is, 19 | } from 'ramda' 20 | import { useNavigate } from 'react-router-dom' 21 | import type { NavigateFunction } from 'react-router-dom' 22 | import { Tabs } from 'antd' 23 | import { MAXLEN } from '..' 24 | export interface TagsViewDto { 25 | key: string 26 | title: string 27 | name: string 28 | cache: boolean 29 | } 30 | export enum ActionType { 31 | del = 'DEL', 32 | add = 'ADD', 33 | update = 'UPDATE', 34 | clear = 'CLEAR', 35 | } 36 | interface ActionDel { 37 | type: ActionType.del 38 | payload: ActionDelDto 39 | } 40 | interface ActionDelDto { 41 | key: string 42 | activeKey?: string 43 | navigate: NavigateFunction 44 | } 45 | interface ActionClear { 46 | type: ActionType.clear 47 | payload: undefined 48 | } 49 | interface ActionTypeAddPayload { 50 | key: string 51 | title: string 52 | name: string 53 | selectedKeys: string[] 54 | cache: boolean 55 | } 56 | interface ActionAdd { 57 | type: ActionType.add 58 | payload: ActionTypeAddPayload 59 | } 60 | interface ActionUp { 61 | type: ActionType.update 62 | payload: Partial | TagsViewDto[] 63 | } 64 | const isArray = is(Array) 65 | function delKeepAlive(tagsViewList: Array, { key, navigate, activeKey }: ActionDelDto) { 66 | const index = findIndex((item) => equals(item.key, key), tagsViewList) 67 | if (equals(index, -1)) { 68 | return tagsViewList 69 | } 70 | let pathname = '' 71 | if (length(tagsViewList) > 1) { 72 | const data = tagsViewList[index] 73 | // 如果删除是 当前渲染 需要移动位置 74 | if (data && equals(data.key, activeKey)) { 75 | // 如果是最后一个 那么 跳转到上一个 76 | if (equals(index, tagsViewList.length - 1)) { 77 | pathname = tagsViewList[index - 1].key 78 | } else { 79 | // 跳转到最后一个 80 | pathname = last(tagsViewList)?.key ?? '' 81 | } 82 | } 83 | } 84 | if (!isEmpty(pathname)) { 85 | navigate({ pathname }) 86 | } 87 | return filter((item) => !equals(item.key, key), tagsViewList) 88 | } 89 | function addKeepAlive(state: Array, matchRouteObj: ActionTypeAddPayload) { 90 | if (state.some((item) => equals(item.key, matchRouteObj.key))) { 91 | return state 92 | } 93 | return append( 94 | pick(['key', 'title', 'name', 'path', 'cache'], matchRouteObj), 95 | length(state) >= MAXLEN ? slice(1, length(state), state) : state 96 | ) 97 | } 98 | const updateKeepAlive = curry((state: Array, keepAlive: Partial) => { 99 | return map((item) => (equals(item.key, keepAlive.key) ? mergeRight(item, keepAlive) : item), state) 100 | }) 101 | const updateKeepAliveList = curry((state: Array, keepAlive: Array) => { 102 | return map((item) => { 103 | const data = find((res) => equals(res.key, item.key), keepAlive) 104 | if (data) { 105 | item = mergeRight(item, data ?? {}) 106 | } 107 | return item 108 | }, state) 109 | }) 110 | export type Action = ActionDel | ActionAdd | ActionClear | ActionUp 111 | export const reducer = (state: Array, action: Action): TagsViewDto[] => { 112 | switch (action.type) { 113 | case ActionType.add: 114 | return addKeepAlive(state, action.payload) 115 | case ActionType.del: 116 | return delKeepAlive(state, action.payload) 117 | case ActionType.clear: 118 | return [] 119 | case ActionType.update: 120 | return ifElse(isArray, updateKeepAliveList(state), updateKeepAlive(state))(action.payload) as any 121 | default: 122 | return state 123 | } 124 | } 125 | interface Props { 126 | dispatch: (value: Action) => void 127 | tagsViewList: Array 128 | activeName?: string 129 | } 130 | const noIsNotActiveKey = pipe(equals('notActiveKey'), not) 131 | function TagsView({ dispatch, tagsViewList, activeName = 'notActiveKey' }: Props) { 132 | const navigate = useNavigate() 133 | function hdChange(key: string) { 134 | if (key && noIsNotActiveKey(key)) navigate({ pathname: key }) 135 | } 136 | function hdEdit(key: string) { 137 | if (key && noIsNotActiveKey(key)) { 138 | dispatch({ 139 | type: ActionType.del, 140 | payload: { 141 | key, 142 | navigate, 143 | activeKey: activeName, 144 | }, 145 | }) 146 | } 147 | } 148 | const closable = equals(1, length(tagsViewList)) 149 | return ( 150 |
151 | ({ 160 | key: tag.key, 161 | label: tag.title, 162 | closable: !closable, 163 | // 164 | }), 165 | tagsViewList 166 | )} 167 | /> 168 |
169 | ) 170 | } 171 | 172 | export default TagsView 173 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import { SyncRouter } from '@/router' 3 | import { StrictMode } from 'react' 4 | import './styles/index.scss' 5 | const dom = document.getElementById('app') 6 | if (dom) { 7 | const root = createRoot(dom) 8 | root.render( 9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/router/Children.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom' 2 | export default function Children() { 3 | // 嵌套路由中间 内容 我们这个组件 啥也不干 你也可以加一些布局内容 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /src/router/configure.ts: -------------------------------------------------------------------------------- 1 | import { NotFound } from '@/components/NotFound' 2 | import Children from '@/router/Children' 3 | import { lazy } from 'react' 4 | import type { ComponentType, LazyExoticComponent } from 'react' 5 | export type Component = ComponentType | LazyExoticComponent 6 | export interface RouteConfig { 7 | path: string 8 | models?: () => Array> 9 | component: Component 10 | exact?: boolean // 完全匹配 has routes 必须false 11 | name: string 12 | icon?: Component 13 | noCache?: boolean 14 | 15 | cache?: boolean // 不填默认缓存 16 | noTags?: boolean 17 | meta?: { title: string } 18 | alwaysShow?: boolean // 是否显示在导航栏 true 不显示 默认false 19 | children?: Array 20 | notLogin?: boolean // 是否需要登录 默认需要登录 不需要登录设置为true 21 | redirect?: string // 重定向 22 | } 23 | const routesOther: Array = [ 24 | { 25 | path: 'user', 26 | component: lazy(() => import('@/views/user')), 27 | meta: { title: '用户' }, 28 | name: 'User', 29 | }, 30 | { 31 | path: 'role', 32 | component: lazy(() => import('@/views/role')), 33 | meta: { title: '角色' }, 34 | name: 'Role', 35 | }, 36 | { 37 | path: 'update', 38 | component: lazy(() => import('@/views/update')), 39 | meta: { title: '修改页签名称' }, 40 | name: 'Update', 41 | }, 42 | { 43 | path: 'no-cache', 44 | component: lazy(() => import('@/views/no-cache')), 45 | cache: false, 46 | meta: { title: '我不缓存' }, 47 | name: 'NoCache', 48 | }, 49 | { 50 | path: 'nesting', 51 | component: Children, 52 | meta: { title: '嵌套路由' }, 53 | name: 'Nesting', 54 | children: [ 55 | { 56 | path: 'list', 57 | component: lazy(() => import('@/views/nesting/list')), 58 | meta: { title: '嵌套路由-列表' }, 59 | name: 'List', 60 | }, 61 | ], 62 | }, 63 | ] 64 | export const routes: Array = [ 65 | { 66 | path: '/404', 67 | component: NotFound, 68 | meta: { 69 | title: '404', 70 | }, 71 | name: '404', 72 | notLogin: true, 73 | }, 74 | { 75 | path: '/*', 76 | component: lazy(() => import('@/layout')), 77 | meta: { title: 'erp' }, 78 | name: 'erp', 79 | children: routesOther, 80 | }, 81 | ] 82 | -------------------------------------------------------------------------------- /src/router/index.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Route, Routes } from 'react-router-dom' 2 | import { routes } from './configure' //路由文件 3 | import { ConfigProvider } from 'antd' 4 | import zhCN from 'antd/es/locale/zh_CN' 5 | import { map } from 'ramda' 6 | import { SuspenseLoading } from '@/components/Loading' 7 | // 创建 同步路由文件 8 | export const SyncRouter = (): JSX.Element => { 9 | return ( 10 | 11 | 12 | 13 | 14 | {map( 15 | (route) => ( 16 | } /> 17 | ), 18 | routes 19 | )} 20 | 21 | 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | .tagsView{ 2 | background: #fff; 3 | padding-top: 16px; 4 | } 5 | .tagsView-tabs { 6 | .ant-tabs-nav { 7 | margin-bottom: 1px !important; 8 | margin-left: 16px !important; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/views/nesting/list.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from 'antd' 2 | 3 | export default function List() { 4 | return ( 5 |
6 | 7 |
List
8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/views/no-cache.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from 'antd' 2 | 3 | export default function NoCache() { 4 | return ( 5 |
6 | 7 |
NoCache
8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/views/role.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from 'antd' 2 | 3 | export default function Role() { 4 | return ( 5 |
6 | 7 |
Role
8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/views/update.tsx: -------------------------------------------------------------------------------- 1 | import { Input, Button } from 'antd' 2 | import { useRef } from 'react' 3 | import useUpdateTagName from '@/hooks/useUpdateTagName' 4 | 5 | export default function Role() { 6 | const name = useRef('') 7 | const updateTagName = useUpdateTagName() 8 | return ( 9 |
10 | { 13 | name.current = e.target.value 14 | }} 15 | /> 16 | 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/views/user.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from 'antd' 2 | 3 | export default function User() { 4 | return ( 5 |
6 | 7 |
User
8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "baseUrl": ".", 5 | "module": "ESNext", 6 | "target": "ESNext", 7 | "sourceMap": true, 8 | "jsx": "react-jsx", 9 | "types": ["node", "vite/client"], 10 | "alwaysStrict": true, 11 | "moduleResolution": "node", 12 | "rootDir": ".", 13 | "plugins": [ 14 | { 15 | "name": "typescript-plugin-css-modules", 16 | "options": { 17 | "customMatcher": "\\.(c|le||lle|sa|sc)ss$" 18 | } 19 | } 20 | ], 21 | "forceConsistentCasingInFileNames": false, 22 | "noImplicitReturns": true, 23 | "noImplicitThis": true, 24 | "noImplicitAny": true, 25 | "importHelpers": true, 26 | "strictNullChecks": true, 27 | "skipLibCheck": true, 28 | "allowSyntheticDefaultImports": true, 29 | "noUnusedLocals": true, 30 | "paths": { 31 | "@/*": ["src/*"] 32 | }, 33 | "lib": ["esnext", "dom"] 34 | }, 35 | "include": ["src"], 36 | "exclude": ["node_modules"] 37 | } 38 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import react from '@vitejs/plugin-react' 3 | import { ConfigEnv } from 'vite' 4 | import { createStyleImportPlugin, AntdResolve } from 'vite-plugin-style-import' 5 | import vitePluginImp from 'vite-plugin-imp' 6 | import svgr from 'vite-plugin-svgr' 7 | import checker from 'vite-plugin-checker' 8 | import { createHtmlPlugin } from 'vite-plugin-html' 9 | export default ({ mode, command }: ConfigEnv) => { 10 | const isTest = mode === 'beta' 11 | const alias = { 12 | '@': path.resolve(__dirname, './src'), 13 | moment: 'dayjs', 14 | } 15 | return { 16 | base: '/', 17 | root: './', // index.html 文件所在的位置 18 | envDir: './env', // 加载 .env 文件的目录 19 | resolve: { 20 | alias, 21 | extensions: ['.tsx', '.ts', '.js', 'jsx', '.mjs'], 22 | }, 23 | css: { 24 | modules: { 25 | scopeBehaviour: 'local', 26 | }, 27 | preprocessorOptions: { 28 | less: { 29 | javascriptEnabled: true, 30 | }, 31 | scss: { 32 | javascriptEnabled: true, 33 | }, 34 | }, 35 | }, 36 | server: { 37 | port: 3000, 38 | // proxy: {}, 39 | host: '0.0.0.0', 40 | cors: true, 41 | }, 42 | esbuild: { 43 | drop: command === 'build' ? ['console', 'debugger'] : [], 44 | }, 45 | build: { 46 | target: 'modules', // modules es6 es2015 esnext 47 | minify: isTest ? false : 'esbuild', // 是否进行压缩,boolean | 'terser' | 'esbuild', 48 | manifest: isTest, // 是否产出maifest.json 49 | sourcemap: isTest, // 是否产出soucemap.json 50 | outDir: './dist', // 产出目录 51 | terserOptions: { 52 | //生产环境时移除console 53 | compress: { 54 | drop_console: true, 55 | drop_debugger: true, 56 | }, 57 | }, 58 | }, 59 | plugins: [ 60 | react(), 61 | { 62 | ...createStyleImportPlugin({ 63 | resolves: [AntdResolve()], 64 | }), 65 | apply: 'serve', 66 | }, 67 | vitePluginImp({ 68 | libList: [ 69 | { 70 | libName: 'antd', 71 | libDirectory: 'es', 72 | style: (name) => { 73 | return command === 'serve' ? false : `antd/es/${name}/style/index` 74 | }, 75 | }, 76 | // { libName: 'ahooks', libDirectory: 'es', camel2DashComponentName: false }, 77 | { libName: 'ramda', libDirectory: 'es', camel2DashComponentName: false }, 78 | ], 79 | }), 80 | checker({ 81 | typescript: { 82 | tsconfigPath: path.resolve(__dirname, './tsconfig.json'), 83 | }, 84 | }), 85 | createHtmlPlugin({ 86 | minify: true, 87 | inject: { 88 | data: { 89 | polyfill: 90 | command === 'serve' 91 | ? '' 92 | : ``, 93 | }, 94 | }, 95 | }), 96 | 97 | // 转化svg 98 | svgr(), 99 | ], 100 | } 101 | } 102 | --------------------------------------------------------------------------------