├── .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 |
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 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/src/views/no-cache.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from 'antd'
2 |
3 | export default function NoCache() {
4 | return (
5 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/src/views/role.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from 'antd'
2 |
3 | export default function Role() {
4 | return (
5 |
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 |
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 |
--------------------------------------------------------------------------------