├── .gitignore
├── docs
└── CacheRoute.gif
├── .npmignore
├── .prettierrc
├── index.js
├── .babelrc
├── src
├── helpers
│ ├── index.js
│ ├── base
│ │ ├── is.js
│ │ ├── globalThis.js
│ │ └── try
│ │ │ ├── index.js
│ │ │ └── DOC.md
│ ├── utils.js
│ └── saveScrollPosition.js
├── index.js
├── components
│ ├── SwitchFragment.js
│ ├── CacheSwitch.js
│ └── CacheRoute.js
└── core
│ ├── context.js
│ ├── Updatable
│ ├── Freeze.js
│ └── index.js
│ ├── manager.js
│ └── CacheComponent.js
├── rollup.config.js
├── LICENSE
├── package.json
├── index.d.ts
├── .github
└── workflows
│ └── publish.yml
├── README_CN.md
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | lib
2 | node_modules
3 |
--------------------------------------------------------------------------------
/docs/CacheRoute.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CJY0208/react-router-cache-route/HEAD/docs/CacheRoute.gif
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | docs
2 | node_modules
3 | src
4 | .babelrc
5 | .gitignore
6 | .npmignore
7 | .prettierrc
8 | rollup.config.js
9 | yarn.lock
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false,
4 | "semi": false,
5 | "singleQuote": true,
6 | "bracketSpacing": true
7 | }
8 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | if (process.env.NODE_ENV === 'production') {
4 | module.exports = require('./lib/index.min.js');
5 | } else {
6 | module.exports = require('./lib/index.js');
7 | }
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | ["env", {
4 | "modules": false
5 | }],
6 | "stage-2",
7 | "react"
8 | ],
9 | "plugins": [
10 | "external-helpers",
11 | "transform-export-extensions"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/src/helpers/index.js:
--------------------------------------------------------------------------------
1 | export * from './base/is'
2 |
3 | export * from './base/try'
4 |
5 | export { default as globalThis } from './base/globalThis'
6 |
7 | export * from './utils'
8 |
9 | export { default as saveScrollPosition } from './saveScrollPosition'
10 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export default from './components/CacheRoute'
2 | export CacheRoute from './components/CacheRoute'
3 | export CacheSwitch from './components/CacheSwitch'
4 | export {
5 | dropByCacheKey,
6 | refreshByCacheKey,
7 | getCachingKeys,
8 | clearCache,
9 | getCachingComponents
10 | } from './core/manager'
11 | export { useDidCache, useDidRecover } from './core/context'
12 |
--------------------------------------------------------------------------------
/src/components/SwitchFragment.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes, Fragment } from 'react'
2 |
3 | import { isExist } from '../helpers'
4 |
5 | function getFragment() {
6 | if (isExist(Fragment)) {
7 | return ({ children }) => {children}
8 | }
9 |
10 | if (isExist(PropTypes)) {
11 | return ({ children }) =>
{children}
12 | }
13 |
14 | return ({ children }) => children
15 | }
16 |
17 | const SwitchFragment = getFragment()
18 | SwitchFragment.displayName = 'SwitchFragment'
19 |
20 | export default SwitchFragment
21 |
--------------------------------------------------------------------------------
/src/helpers/base/is.js:
--------------------------------------------------------------------------------
1 | // 值类型判断 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 | export const isUndefined = val => typeof val === 'undefined'
3 |
4 | export const isNull = val => val === null
5 |
6 | export const isFunction = val => typeof val === 'function'
7 |
8 | export const isString = val => typeof val === 'string'
9 |
10 | export const isExist = val => !(isUndefined(val) || isNull(val))
11 |
12 | export const isArray = val => val instanceof Array
13 |
14 | export const isNaN = val => val !== val
15 |
16 | export const isNumber = val => typeof val === 'number' && !isNaN(val)
17 | // 值类型判断 -------------------------------------------------------------
18 |
--------------------------------------------------------------------------------
/src/helpers/base/globalThis.js:
--------------------------------------------------------------------------------
1 | const getImplementation = () => {
2 | if (typeof self !== 'undefined') {
3 | return self
4 | }
5 | if (typeof window !== 'undefined') {
6 | return window
7 | }
8 | if (typeof global !== 'undefined') {
9 | return global
10 | }
11 |
12 | throw new Error('unable to locate global object')
13 | }
14 |
15 | const implementation = getImplementation()
16 |
17 | const getGlobal = () => {
18 | if (
19 | typeof global !== 'object' ||
20 | !global ||
21 | global.Math !== Math ||
22 | global.Array !== Array
23 | ) {
24 | return implementation
25 | }
26 | return global
27 | }
28 |
29 | const globalThis = getGlobal()
30 |
31 | export default globalThis
32 |
--------------------------------------------------------------------------------
/src/helpers/utils.js:
--------------------------------------------------------------------------------
1 | import { isArray } from './base/is'
2 |
3 | export const nextTick = func => Promise.resolve().then(func)
4 |
5 | export const flatten = array =>
6 | array.reduce(
7 | (res, item) => [...res, ...(isArray(item) ? flatten(item) : [item])],
8 | []
9 | )
10 |
11 | /**
12 | * [钳子] 用来将数字限制在给定范围内
13 | * @param {Number} value 被限制值
14 | * @param {Number} min 最小值
15 | * @param {Number} max 最大值
16 | */
17 | export const clamp = (value, min, max = Number.MAX_VALUE) => {
18 | if (value < min) {
19 | return min
20 | }
21 |
22 | if (value > max) {
23 | return max
24 | }
25 |
26 | return value
27 | }
28 |
29 | export const ObjectValues = (object) => {
30 | const res = []
31 | for (let key in object) {
32 | res.push(object[key])
33 | }
34 | return res
35 | }
36 |
--------------------------------------------------------------------------------
/src/core/context.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useContext, useRef } from 'react'
2 | import createContext from 'mini-create-react-context'
3 |
4 | import { isArray, isFunction, run } from '../helpers'
5 |
6 | const context = createContext()
7 |
8 | export default context
9 | export const { Provider, Consumer } = context
10 |
11 | function useCacheRoute(lifecycleName, effect, deps = []) {
12 | if (!isFunction(useContext)) {
13 | return
14 | }
15 |
16 | const effectRef = useRef(() => null)
17 | effectRef.current = effect
18 |
19 | const cacheLifecycles = useContext(context)
20 | useEffect(() => {
21 | const off = run(cacheLifecycles, 'on', lifecycleName, () => {
22 | run(effectRef.current)
23 | })
24 |
25 | return () => run(off)
26 | }, [])
27 | }
28 | export const useDidCache = useCacheRoute.bind(null, 'didCache')
29 | export const useDidRecover = useCacheRoute.bind(null, 'didRecover')
30 |
--------------------------------------------------------------------------------
/src/helpers/base/try/index.js:
--------------------------------------------------------------------------------
1 | import { isString, isExist, isUndefined, isFunction, isNumber } from '../is'
2 |
3 | export const get = (obj, keys = [], defaultValue) => {
4 | try {
5 | if (isNumber(keys)) {
6 | keys = String(keys)
7 | }
8 | let result = (isString(keys) ? keys.split('.') : keys).reduce(
9 | (res, key) => res[key],
10 | obj
11 | )
12 | return isUndefined(result) ? defaultValue : result
13 | } catch (e) {
14 | return defaultValue
15 | }
16 | }
17 |
18 | export const run = (obj, keys = [], ...args) => {
19 | keys = isString(keys) ? keys.split('.') : keys
20 |
21 | const func = get(obj, keys)
22 | const context = get(obj, keys.slice(0, -1))
23 |
24 | return isFunction(func) ? func.call(context, ...args) : func
25 | }
26 |
27 | export const value = (...values) =>
28 | values.reduce(
29 | (value, nextValue) => (isUndefined(value) ? run(nextValue) : run(value)),
30 | undefined
31 | )
32 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from 'rollup-plugin-node-resolve'
2 | import babel from 'rollup-plugin-babel'
3 | import { uglify } from 'rollup-plugin-uglify'
4 |
5 | export default [
6 | {
7 | input: 'src/index.js',
8 | output: {
9 | name: 'CacheRoute',
10 | file: 'lib/index.js',
11 | format: 'cjs',
12 | sourcemap: true
13 | },
14 | external: [
15 | 'react',
16 | 'prop-types',
17 | 'react-router-dom',
18 | 'mini-create-react-context',
19 | ],
20 | plugins: [
21 | resolve(),
22 | babel({
23 | exclude: 'node_modules/**'
24 | })
25 | ]
26 | },
27 | {
28 | input: 'src/index.js',
29 | output: {
30 | name: 'CacheRoute',
31 | file: 'lib/index.min.js',
32 | format: 'umd'
33 | },
34 | external: [
35 | 'react',
36 | 'prop-types',
37 | 'react-router-dom',
38 | 'mini-create-react-context',
39 | ],
40 | plugins: [
41 | resolve(),
42 | babel({
43 | exclude: 'node_modules/**'
44 | }),
45 | uglify()
46 | ]
47 | }
48 | ]
49 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 CJY
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 |
--------------------------------------------------------------------------------
/src/helpers/base/try/DOC.md:
--------------------------------------------------------------------------------
1 |
2 | # try 模块
3 |
4 | 值相关的安全尝试,防止**由于属性断层抛出报错打断程序运行**的情况发生
5 |
6 | ## get 安全取值
7 |
8 | ```javascript
9 | var obj = {
10 | a: {
11 | b: 1
12 | }
13 | }
14 |
15 | // 基础
16 | get(obj, 'a.b') // 1
17 | get(obj, ['a', 'b']) // 1
18 | get(obj, 'c.b') // undefined
19 |
20 | // 带默认值
21 | get(obj, 'c.b', 1) // 1
22 | ```
23 |
24 | ## run 安全运行(可保护上下文)
25 |
26 | ```javascript
27 | var obj = {
28 | deep: {
29 | deep: {
30 | add: (a, b) => a + b
31 | }
32 | },
33 |
34 | name: 'CJY',
35 | greet() {
36 | console.log(`hello, I'm ${this.name}`)
37 | }
38 | }
39 |
40 | // 函数存在时
41 | run(obj, 'deep.deep.add', 1, 2) // 3
42 |
43 |
44 | // 取值不是函数或查找结果不存在时,行为与 get 函数一致
45 | run(obj, 'deep.deep.reduce') // undefined
46 | run(obj, 'name') // CJY
47 |
48 | // 保护上下文
49 | run(obj, 'greet') // hello, I'm CJY
50 | ```
51 |
52 | ## value 多层默认值(只在值为`undefined`情况下生效)
53 |
54 | ```javascript
55 | var v1, v2, v3 = 'default'
56 |
57 | value(v1, v2, v3) // default
58 |
59 | value(v1, 0, v3) // 0
60 |
61 | // 可传递执行函数
62 | value(
63 | v1,
64 | () => {
65 | console.log('v1没有,尝试v2')
66 | return v2
67 | },
68 | () => {
69 | console.log('v2也没有,尝试v3')
70 | return v3
71 | },
72 | ) // default
73 | ```
74 |
--------------------------------------------------------------------------------
/src/core/Updatable/Freeze.js:
--------------------------------------------------------------------------------
1 | // Fork from react-freeze
2 | // https://github.com/software-mansion/react-freeze/blob/main/src/index.tsx
3 | import React, { Component, lazy, Suspense, Fragment } from 'react'
4 | import { globalThis, isUndefined, isFunction } from '../../helpers'
5 |
6 | const isSupported = isFunction(lazy) && !isUndefined(Suspense)
7 | const notSupportSuspense = !isSupported
8 |
9 | class Suspender extends Component {
10 | promiseCache = {}
11 | render() {
12 | const { freeze, children } = this.props
13 | const { promiseCache } = this
14 |
15 | if (freeze && !promiseCache.promise) {
16 | promiseCache.promise = new Promise((resolve) => {
17 | promiseCache.resolve = resolve
18 | })
19 | throw promiseCache.promise
20 | } else if (freeze) {
21 | throw promiseCache.promise
22 | } else if (promiseCache.promise) {
23 | promiseCache.resolve()
24 | promiseCache.promise = undefined
25 | }
26 |
27 | return {children}
28 | }
29 | }
30 |
31 | export default function Freeze({ freeze, children, placeholder = null }) {
32 | if (notSupportSuspense) return children
33 |
34 | return (
35 |
36 | {children}
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-router-cache-route",
3 | "version": "1.13.0",
4 | "description": "cache-route for react-router base on react v15+ and router v4+",
5 | "main": "index.js",
6 | "scripts": {
7 | "build": "rollup --config"
8 | },
9 | "keywords": [
10 | "cache",
11 | "cache route",
12 | "react",
13 | "react router",
14 | "keep alive",
15 | "keep alive route"
16 | ],
17 | "author": "CJY0208",
18 | "license": "ISC",
19 | "homepage": "https://github.com/CJY0208/react-router-cache-route",
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/CJY0208/react-router-cache-route.git"
23 | },
24 | "peerDependencies": {
25 | "prop-types": ">=15",
26 | "react": ">=15",
27 | "react-router-dom": ">=4"
28 | },
29 | "devDependencies": {
30 | "babel-core": "^6.26.3",
31 | "babel-plugin-external-helpers": "^6.22.0",
32 | "babel-plugin-transform-export-extensions": "^6.22.0",
33 | "babel-preset-env": "^1.7.0",
34 | "babel-preset-react": "^6.24.1",
35 | "babel-preset-stage-2": "^6.24.1",
36 | "rollup": "^0.61.2",
37 | "rollup-plugin-babel": "^3.0.5",
38 | "rollup-plugin-node-resolve": "^3.3.0",
39 | "rollup-plugin-uglify": "^4.0.0"
40 | },
41 | "dependencies": {
42 | "mini-create-react-context": "^0.4.1",
43 | "react-freeze": "^1.0.0"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/helpers/saveScrollPosition.js:
--------------------------------------------------------------------------------
1 | import root from './base/globalThis'
2 | import { get, run, value } from './base/try'
3 | import { isArray, isFunction, isExist } from './base/is'
4 | import { flatten } from './utils'
5 |
6 | const body = get(root, 'document.body')
7 | const screenScrollingElement = get(
8 | root,
9 | 'document.scrollingElement',
10 | get(root, 'document.documentElement', {})
11 | )
12 |
13 | function isScrollableNode(node = {}) {
14 | if (!isExist(node)) {
15 | return false
16 | }
17 |
18 | return (
19 | node.scrollWidth > node.clientWidth || node.scrollHeight > node.clientHeight
20 | )
21 | }
22 |
23 | function getScrollableNodes(from) {
24 | if (!isFunction(get(root, 'document.getElementById'))) {
25 | return []
26 | }
27 |
28 | return [...value(run(from, 'querySelectorAll', '*'), []), from].filter(
29 | isScrollableNode
30 | )
31 | }
32 |
33 | export default function saveScrollPosition(from) {
34 | const nodes = [
35 | ...new Set([
36 | ...flatten((!isArray(from) ? [from] : from).map(getScrollableNodes)),
37 | ...[screenScrollingElement, body].filter(isScrollableNode)
38 | ])
39 | ]
40 |
41 | const saver = nodes.map(node => [
42 | node,
43 | {
44 | x: node.scrollLeft,
45 | y: node.scrollTop
46 | }
47 | ])
48 |
49 | return function revert() {
50 | saver.forEach(([node, { x, y }]) => {
51 | node.scrollLeft = x
52 | node.scrollTop = y
53 | })
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/core/Updatable/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component, Suspense } from 'react'
2 | import ReactFreeze from './Freeze'
3 | import PropTypes from 'prop-types'
4 |
5 | import { run, get } from '../../helpers'
6 |
7 | const isSusSupported = !!Suspense
8 | const Freeze = isSusSupported ? ReactFreeze : ({ children }) => children
9 |
10 | class DelayFreeze extends Component {
11 | static propsTypes = {
12 | freeze: PropTypes.bool.isRequired
13 | }
14 | state = {
15 | freeze: false,
16 | }
17 | constructor(props) {
18 | super(props)
19 | this.state = {
20 | freeze: props.freeze,
21 | }
22 | }
23 |
24 | freezeTimeout = null
25 | shouldComponentUpdate = ({ freeze }) => {
26 | const currentFreeze = this.props.freeze
27 |
28 | if (freeze !== currentFreeze) {
29 | clearTimeout(this.freezeTimeout)
30 | this.freezeTimeout = setTimeout(() => {
31 | this.setState({
32 | freeze,
33 | })
34 | }, 1000)
35 | }
36 |
37 | return true
38 | }
39 | render = () => (
40 |
41 | {run(this.props, 'children')}
42 |
43 | )
44 | }
45 |
46 | class Updatable extends Component {
47 | static propsTypes = {
48 | when: PropTypes.bool.isRequired,
49 | }
50 |
51 | render = () => run(this.props, 'children')
52 | shouldComponentUpdate = ({ when }) => when
53 | }
54 |
55 | export default ({ autoFreeze = true, ...props }) => (
56 |
57 |
58 |
59 | )
60 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | import * as React from 'react'
4 | import { SwitchProps, RouteProps } from 'react-router-dom'
5 |
6 | export interface CacheRouteProps extends RouteProps {
7 | className?: string
8 | when?: 'forward' | 'back' | 'always' | ((props: CacheRouteProps) => boolean)
9 | behavior?: (isCached: boolean) => object | void
10 | cacheKey?: string | ((props: CacheRouteProps) => string),
11 | unmount?: boolean
12 | saveScrollPosition?: boolean
13 | multiple?: boolean | number
14 | autoFreeze?: boolean
15 | }
16 |
17 | export declare class CacheRoute extends React.Component {}
18 | export default CacheRoute
19 |
20 | export interface CacheSwitchProps extends SwitchProps {
21 | which?: (element: React.ElementType) => boolean
22 | autoFreeze?: boolean
23 | }
24 |
25 | export declare class CacheSwitch extends React.Component {}
26 |
27 | export function dropByCacheKey(cacheKey: string): void
28 | export function refreshByCacheKey(cacheKey: string): void
29 | export function getCachingKeys(): Array
30 | export function clearCache(): void
31 |
32 | export interface CachingComponent extends React.ComponentClass {
33 | __cacheCreateTime: number
34 | __cacheUpdateTime: number
35 | }
36 | export interface CachingComponentMap {
37 | [key: string]: CachingComponent
38 | }
39 | export function getCachingComponents(): CachingComponentMap
40 | export function useDidCache(effect: () => void, deps?: any[]): void
41 | export function useDidRecover(effect: () => void, deps?: any[]): void
42 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: npm-publish
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | npm-publish:
10 | name: npm-publish
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout repository
14 | uses: actions/checkout@master
15 |
16 | - name: Check if version has been updated
17 | id: check
18 | uses: EndBug/version-check@v1
19 | with:
20 | file-url: https://unpkg.com/react-router-cache-route/package.json
21 | static-checking: localIsNew
22 |
23 | - name: Output version check result
24 | run: 'echo "changed: ${{ steps.check.outputs.changed }}, version: ${{ steps.check.outputs.version }}"'
25 |
26 | - name: Log if version has been updated
27 | if: steps.check.outputs.changed == 'true'
28 | run: 'echo "Version change found in commit ${{ steps.check.outputs.commit }}! New version: ${{ steps.check.outputs.version }} (${{ steps.check.outputs.type }})"'
29 |
30 | - name: Set up Node.js
31 | if: steps.check.outputs.changed == 'true'
32 | uses: actions/setup-node@master
33 | with:
34 | node-version: 13.11.0
35 |
36 | - name: Install Dependencies
37 | if: steps.check.outputs.changed == 'true'
38 | uses: bahmutov/npm-install@v1
39 | with:
40 | useLockFile: false
41 | args: install
42 |
43 | - name: Build
44 | if: steps.check.outputs.changed == 'true'
45 | run: 'sudo npm run build'
46 |
47 | - name: Publish
48 | if: steps.check.outputs.changed == 'true'
49 | uses: pascalgn/npm-publish-action@1.3.9
50 | with:
51 | commit_pattern: "^Release (\\S+)"
52 | env:
53 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
54 |
--------------------------------------------------------------------------------
/src/core/manager.js:
--------------------------------------------------------------------------------
1 | import CacheComponent from './CacheComponent'
2 | import { get, run } from '../helpers'
3 |
4 | const __components = {}
5 |
6 | const getCachedComponentEntries = () =>
7 | Object.entries(__components).filter(
8 | ([, cache]) =>
9 | cache instanceof CacheComponent
10 | ? cache.state.cached
11 | : Object.values(cache).some(cache => cache.state.cached)
12 | )
13 |
14 | export const getCache = () => ({ ...__components })
15 |
16 | export const register = (key, component) => {
17 | __components[key] = component
18 | }
19 |
20 | export const remove = key => {
21 | delete __components[key]
22 | }
23 |
24 | const dropComponent = component => run(component, 'reset')
25 |
26 | export const dropByCacheKey = key => {
27 | const cache = get(__components, [key])
28 |
29 | if (!cache) {
30 | return
31 | }
32 |
33 | if (cache instanceof CacheComponent) {
34 | dropComponent(cache)
35 | } else {
36 | Object.values(cache).forEach(dropComponent)
37 | }
38 | }
39 |
40 | const refreshComponent = component => run(component, 'refresh');
41 |
42 | export const refreshByCacheKey = key => {
43 | const cache = get(__components, [key]);
44 |
45 | if (!cache) {
46 | return;
47 | }
48 |
49 | if (cache instanceof CacheComponent) {
50 | refreshComponent(cache);
51 | } else {
52 | Object.values(cache).forEach(refreshComponent);
53 | }
54 | };
55 |
56 | export const clearCache = () => {
57 | getCachedComponentEntries().forEach(([key]) => dropByCacheKey(key))
58 | }
59 |
60 | export const getCachingKeys = () =>
61 | getCachedComponentEntries().map(([key]) => key)
62 |
63 | export const getCachingComponents = () =>
64 | getCachedComponentEntries().reduce(
65 | (res, [key, cache]) => ({
66 | ...res,
67 | ...(cache instanceof CacheComponent
68 | ? { [key]: cache }
69 | : Object.entries(cache).reduce(
70 | (res, [pathname, cache]) => ({
71 | ...res,
72 | [`${key}.${pathname}`]: cache
73 | }),
74 | {}
75 | ))
76 | }),
77 | {}
78 | )
79 |
--------------------------------------------------------------------------------
/src/components/CacheSwitch.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import {
4 | Switch,
5 | matchPath,
6 | withRouter,
7 | useHistory,
8 | __RouterContext
9 | } from 'react-router-dom'
10 |
11 | import { COMPUTED_UNMATCH_KEY, isMatch } from '../core/CacheComponent'
12 | import Updatable from '../core/Updatable'
13 | import SwitchFragment from './SwitchFragment'
14 | import { get, isNull, isExist } from '../helpers'
15 |
16 | const isUsingNewContext = isExist(__RouterContext) || isExist(useHistory)
17 |
18 | class CacheSwitch extends Switch {
19 | getContext = () => {
20 | if (isUsingNewContext) {
21 | const { location, match } = this.props
22 |
23 | return { location, match }
24 | } else {
25 | const { route } = this.context.router
26 | const location = this.props.location || route.location
27 |
28 | return {
29 | location,
30 | match: route.match
31 | }
32 | }
33 | }
34 |
35 | render() {
36 | const { children, which, autoFreeze } = this.props
37 | const { location, match: contextMatch } = this.getContext()
38 |
39 | let __matchedAlready = false
40 |
41 | return (
42 |
43 | {() => (
44 |
45 | {React.Children.map(children, element => {
46 | if (!React.isValidElement(element)) {
47 | return null
48 | }
49 |
50 | const path = element.props.path || element.props.from
51 | const match = __matchedAlready
52 | ? null
53 | : path
54 | ? matchPath(
55 | location.pathname,
56 | {
57 | ...element.props,
58 | path
59 | },
60 | contextMatch
61 | )
62 | : contextMatch
63 |
64 | let child
65 |
66 | if (which(element)) {
67 | child = React.cloneElement(element, {
68 | location,
69 | computedMatch: match,
70 | /**
71 | * https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Route.js#L57
72 | *
73 | * Note:
74 | * Route would use computedMatch as its next match state ONLY when computedMatch is a true value
75 | * So here we have to do some trick to let the unmatch result pass Route's computedMatch check
76 | *
77 | * 注意:只有当 computedMatch 为真值时,Route 才会使用 computedMatch 作为其下一个匹配状态
78 | * 所以这里我们必须做一些手脚,让 unmatch 结果通过 Route 的 computedMatch 检查
79 | */
80 | ...(isNull(match)
81 | ? {
82 | computedMatchForCacheRoute: {
83 | [COMPUTED_UNMATCH_KEY]: true
84 | }
85 | }
86 | : null)
87 | })
88 | } else {
89 | child =
90 | match && !__matchedAlready
91 | ? React.cloneElement(element, {
92 | location,
93 | computedMatch: match
94 | })
95 | : null
96 | }
97 |
98 | if (!__matchedAlready) {
99 | __matchedAlready = !!match
100 | }
101 |
102 | return child
103 | })}
104 |
105 | )}
106 |
107 | )
108 | }
109 | }
110 |
111 | if (isUsingNewContext) {
112 | CacheSwitch.propTypes = {
113 | children: PropTypes.node,
114 | location: PropTypes.object.isRequired,
115 | match: PropTypes.object.isRequired,
116 | which: PropTypes.func
117 | }
118 |
119 | CacheSwitch = withRouter(CacheSwitch)
120 | } else {
121 | CacheSwitch.contextTypes = {
122 | router: PropTypes.shape({
123 | route: PropTypes.object.isRequired
124 | }).isRequired
125 | }
126 |
127 | CacheSwitch.propTypes = {
128 | children: PropTypes.node,
129 | location: PropTypes.object,
130 | which: PropTypes.func
131 | }
132 | }
133 |
134 | CacheSwitch.defaultProps = {
135 | which: element => get(element, 'type.__name') === 'CacheRoute'
136 | }
137 |
138 | export default CacheSwitch
139 |
--------------------------------------------------------------------------------
/src/components/CacheRoute.js:
--------------------------------------------------------------------------------
1 | import React, { Component, Fragment } from 'react'
2 | import PropTypes from 'prop-types'
3 | import { Route } from 'react-router-dom'
4 |
5 | import CacheComponent, { isMatch } from '../core/CacheComponent'
6 | import Updatable from '../core/Updatable'
7 | import { run, isExist, isNumber, clamp } from '../helpers'
8 |
9 | const isEmptyChildren = children => React.Children.count(children) === 0
10 | const isFragmentable = isExist(Fragment)
11 |
12 | export default class CacheRoute extends Component {
13 | static __name = 'CacheRoute'
14 |
15 | static propTypes = {
16 | component: PropTypes.elementType || PropTypes.any,
17 | render: PropTypes.func,
18 | children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
19 | computedMatchForCacheRoute: PropTypes.object,
20 | multiple: PropTypes.oneOfType([PropTypes.bool, PropTypes.number]),
21 | autoFreeze: PropTypes.bool
22 | }
23 |
24 | static defaultProps = {
25 | multiple: false
26 | }
27 |
28 | cache = {}
29 |
30 | render() {
31 | let {
32 | children,
33 | render,
34 | component,
35 | className,
36 | when,
37 | behavior,
38 | cacheKey,
39 | unmount,
40 | saveScrollPosition,
41 | computedMatchForCacheRoute,
42 | multiple,
43 | autoFreeze,
44 | ...restProps
45 | } = this.props
46 |
47 | /**
48 | * Note:
49 | * If children prop is a React Element, define the corresponding wrapper component for supporting multiple children
50 | *
51 | * 说明:如果 children 属性是 React Element 则定义对应的包裹组件以支持多个子组件
52 | */
53 | if (React.isValidElement(children) || !isEmptyChildren(children)) {
54 | render = () => children
55 | }
56 |
57 | if (computedMatchForCacheRoute) {
58 | restProps.computedMatch = computedMatchForCacheRoute
59 | }
60 |
61 | if (multiple && !isFragmentable) {
62 | multiple = false
63 | }
64 |
65 | if (isNumber(multiple)) {
66 | multiple = clamp(multiple, 1)
67 | }
68 |
69 | return (
70 | /**
71 | * Only children prop of Route can help to control rendering behavior
72 | * 只有 Router 的 children 属性有助于主动控制渲染行为
73 | */
74 |
75 | {props => {
76 | const { match, computedMatch, location } = props
77 | const isMatchCurrentRoute = isMatch(props.match)
78 | const { pathname: currentPathname, search: currentSearch } = location
79 | const maxMultipleCount = isNumber(multiple) ? multiple : Infinity
80 | const configProps = {
81 | when,
82 | className,
83 | behavior,
84 | cacheKey,
85 | unmount,
86 | saveScrollPosition
87 | }
88 |
89 | const renderSingle = props => (
90 |
91 | {cacheLifecycles => (
92 |
93 | {() => {
94 | Object.assign(props, { cacheLifecycles })
95 |
96 | if (component) {
97 | return React.createElement(component, props)
98 | }
99 |
100 | return run(render || children, undefined, props)
101 | }}
102 |
103 | )}
104 |
105 | )
106 |
107 | if (multiple && isMatchCurrentRoute) {
108 | const multipleCacheKey = currentPathname + currentSearch
109 | this.cache[multipleCacheKey] = {
110 | updateTime: Date.now(),
111 | href: multipleCacheKey,
112 | pathname: currentPathname,
113 | render: renderSingle
114 | }
115 |
116 | Object.entries(this.cache)
117 | .sort(([, prev], [, next]) => next.updateTime - prev.updateTime)
118 | .forEach(([multipleCacheKey], idx) => {
119 | if (idx >= maxMultipleCount) {
120 | delete this.cache[multipleCacheKey]
121 | }
122 | })
123 | }
124 |
125 | return multiple ? (
126 |
127 | {Object.entries(this.cache).map(([multipleCacheKey, { render, href, pathname }]) => {
128 | const recomputedMatch =
129 | multipleCacheKey === currentPathname + currentSearch ? match || computedMatch : null
130 |
131 | return (
132 |
133 | {render({
134 | ...props,
135 | ...configProps,
136 | cacheKey,
137 | pathname,
138 | href,
139 | multiple: true,
140 | key: multipleCacheKey,
141 | match: recomputedMatch
142 | })}
143 |
144 | )
145 | })}
146 |
147 | ) : (
148 | renderSingle({
149 | ...props,
150 | ...configProps,
151 | pathname: currentPathname,
152 | href: currentPathname,
153 | multiple: false
154 | })
155 | )
156 | }}
157 |
158 | )
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/README_CN.md:
--------------------------------------------------------------------------------
1 | # ⚠️⚠️⚠️ 不支持 React Router v6+ ⚠️⚠️⚠️
2 |
3 | # CacheRoute
4 |
5 | [](https://github.com/CJY0208/react-router-cache-route)
6 | [](https://github.com/CJY0208/react-router-cache-route)
7 | 
8 |
9 | [English](./README.md) | 中文说明
10 |
11 | [在线示例](https://codesandbox.io/s/cache-route-demo-2spfh)
12 |
13 | 搭配 `react-router` 工作的、带缓存功能的路由组件,类似于 `Vue` 中的 `keep-alive` 功能
14 |
15 | **如果只想要单纯的 `` 功能,试试 [react-activation](https://github.com/CJY0208/react-activation)**
16 |
17 | **React v15+**
18 |
19 | **React-Router v4/v5**
20 |
21 | ---
22 |
23 |
24 |
25 | ---
26 |
27 | ## 诞生初衷 or 问题场景
28 |
29 | 使用 `Route` 时,路由对应的组件在前进或后退无法被缓存,导致了 **数据和行为的丢失**
30 |
31 | 例如:列表页滚动到底部后,点击跳转到详情页,返回后会回到列表页顶部,丢失了滚动位置和数据的记录
32 |
33 | ---
34 |
35 | ## 原因 & 解决方案
36 |
37 | `Route` 中配置的组件在路径不匹配时会被卸载,对应的真实节点也将从 dom 树中删除
38 |
39 | 在阅读了 `Route` 的源码后我们发现可以将 `children` 当作方法来使用,以帮助我们手动控制渲染的行为
40 |
41 | **隐藏替代删除** 可以解决遇到的问题
42 |
43 | https://github.com/remix-run/react-router/blob/v5.3.4/packages/react-router/modules/Route.js#L46-L61
44 |
45 | ---
46 |
47 | ## 安装
48 |
49 | ```bash
50 | npm install react-router-cache-route --save
51 | # or
52 | yarn add react-router-cache-route
53 | ```
54 |
55 | ---
56 |
57 | ## 使用方法
58 |
59 | 使用 `CacheRoute` 替换 `Route`
60 |
61 | 使用 `CacheSwitch` 替换 `Switch`(因为 `Switch` 组件只保留第一个匹配状态的路由,卸载掉其他路由)
62 |
63 | ```javascript
64 | import React from 'react'
65 | import { HashRouter as Router, Route } from 'react-router-dom'
66 | import CacheRoute, { CacheSwitch } from 'react-router-cache-route'
67 |
68 | import List from './views/List'
69 | import Item from './views/Item'
70 |
71 | const App = () => (
72 |
73 |
74 |
75 |
76 | 404 未找到页面
} />
77 |
78 |
79 | )
80 |
81 | export default App
82 | ```
83 |
84 | ---
85 |
86 | ## CacheRoute 属性说明
87 |
88 | | 名称 | 类型 | 默认值 | 描述 |
89 | | ----------------------------- | --------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
90 | | when | `String` / `Function` | `"forward"` | 用以决定何时使用缓存功能 |
91 | | className | `String` | - | 作用于包裹容器上的样式类名 |
92 | | behavior | `Function` | `cached => cached ? { style: { display: "none" }} : undefined` | 返回一个作用于包裹容器的 `props`,控制包裹容器的渲染方式 |
93 | | cacheKey | `String` / `Function` | - | 增加此属性用于命令式控制缓存 |
94 | | multiple (React v16.2+) | `Boolean` / `Number` | `false` | 允许按动态路由参数区分不同缓存,值为数字时表示最大缓存份数,超出最大值时将清除最早更新的缓存 |
95 | | unmount (实验性) | `Boolean` | `false` | 缓存时是否卸载 dom 节点,用于节约性能(单独使用将导致恢复时滚动位置丢失,可配合 saveScrollPosition 修复) |
96 | | saveScrollPosition (实验性) | `Boolean` | `false` | 用以保存滚动位置 |
97 |
98 | `CacheRoute` 仅是基于 `Route` 的 `children` 属性工作的一个封装组件,不影响 `Route` 本身属性的功能
99 |
100 | 其余属性请参考 https://reacttraining.com/react-router/
101 |
102 | ---
103 |
104 | ### `when` 取值说明
105 |
106 | 类型为 `String` 时可取以下值:
107 |
108 | - **[forward]** 发生**前进**行为时缓存,对应 react-router 中的 `PUSH` 或 `REPLACE` 事件
109 | - **[back]** 发生**后退**行为时缓存,对应 react-router 中的 `POP` 事件
110 | - **[always]** 离开时一律缓存路由,无论前进或者后退
111 |
112 | 类型为 `Function` 时,将接受组件的 `props` 作为第一参数,返回 `true/false` 决定是否缓存
113 |
114 | ---
115 |
116 | ## CacheSwitch 属性说明
117 |
118 | | 名称 | 类型 | 默认值 | 描述 |
119 | | ----- | ---------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120 | | which | `Function` | `element => element.type === CacheRoute` | `` 默认只保存第一层子节点中类型为 `CacheRoute` 的节点, `which` 属性是一个将获得待渲染 React 节点实例的方法, 通过返回 `true/false` 来决定 `` 是否需要保存它,参考 [#55](https://github.com/CJY0208/react-router-cache-route/issues/55) |
121 |
122 | ---
123 |
124 | ## 生命周期
125 |
126 | #### Hooks
127 |
128 | 使用 `useDidCache` 和 `useDidRecover` 来对应 **被缓存** 和 **被恢复** 两种生命周期
129 |
130 | ```javascript
131 | import { useDidCache, useDidRecover } from 'react-router-cache-route'
132 |
133 | export default function List() {
134 |
135 | useDidCache(() => {
136 | console.log('List cached 1')
137 | })
138 |
139 | // support multiple effect
140 | useDidCache(() => {
141 | console.log('List cached 2')
142 | })
143 |
144 | useDidRecover(() => {
145 | console.log('List recovered')
146 | })
147 |
148 | return (
149 | // ...
150 | )
151 | }
152 | ```
153 |
154 | #### Class 组件
155 |
156 | 使用 `CacheRoute` 的组件将会得到一个名为 `cacheLifecycles` 的属性,里面包含两个额外生命周期的注入函数 `didCache` 和 `didRecover`,分别在组件 **被缓存** 和 **被恢复** 时触发
157 |
158 | ```javascript
159 | import React, { Component } from 'react'
160 |
161 | export default class List extends Component {
162 | constructor(props) {
163 | super(props)
164 |
165 | props.cacheLifecycles.didCache(this.componentDidCache)
166 | props.cacheLifecycles.didRecover(this.componentDidRecover)
167 | }
168 |
169 | componentDidCache = () => {
170 | console.log('List cached')
171 | }
172 |
173 | componentDidRecover = () => {
174 | console.log('List recovered')
175 | }
176 |
177 | render() {
178 | return (
179 | ...
180 | )
181 | }
182 | }
183 | ```
184 |
185 | ---
186 |
187 | ## 手动清除缓存
188 |
189 | 使用 `cacheKey` 和 `dropByCacheKey` 函数来手动控制缓存
190 |
191 | ```javascript
192 | import CacheRoute, { dropByCacheKey, getCachingKeys } from 'react-router-cache-route'
193 |
194 | ...
195 |
196 | ...
197 |
198 | console.log(getCachingKeys()) // 如果 `cacheKey` prop 为 'MyComponent' 的缓存路由已处于缓存状态,将得到 ['MyComponent']
199 | ...
200 |
201 | dropByCacheKey('MyComponent')
202 | ...
203 | ```
204 | ---
205 | ## 清空缓存
206 |
207 | 使用 `clearCache` 函数来清空缓存
208 |
209 | ```js
210 | import { clearCache } from 'react-router-cache-route'
211 |
212 | clearCache()
213 | ```
214 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ⚠️⚠️⚠️ React Router v6+ is NOT supported ⚠️⚠️⚠️
2 |
3 | # CacheRoute
4 |
5 | [](https://github.com/CJY0208/react-router-cache-route)
6 | [](https://github.com/CJY0208/react-router-cache-route)
7 | 
8 |
9 | English | [中文说明](./README_CN.md)
10 |
11 | Route with cache for `react-router` like `keep-alive` in Vue.
12 |
13 | [Online Demo](https://codesandbox.io/s/cache-route-demo-2spfh)
14 |
15 | **If you want `` only, try [react-activation](https://github.com/CJY0208/react-activation)**
16 |
17 | **React v15+**
18 |
19 | **React-Router v4/v5**
20 |
21 | ---
22 |
23 |
24 |
25 | ---
26 |
27 | ## Problem Scenarios
28 |
29 | Using `Route`, component can not be cached while going forward or back which lead to **losing data and interaction**
30 |
31 | ---
32 |
33 | ## Reason & Solution
34 |
35 | Component would be unmounted when `Route` was unmatched
36 |
37 | After reading source code of `Route` we found that using `children` prop as a function could help to control rendering behavior.
38 |
39 | **Hiding instead of Removing** would fix this issue.
40 |
41 | https://github.com/remix-run/react-router/blob/v5.3.4/packages/react-router/modules/Route.js#L46-L61
42 |
43 | ---
44 |
45 | ## Install
46 |
47 | ```bash
48 | npm install react-router-cache-route --save
49 | # or
50 | yarn add react-router-cache-route
51 | ```
52 |
53 | ---
54 |
55 | ## Usage
56 |
57 | Replace `Route` with `CacheRoute`
58 |
59 | Replace `Switch` with `CacheSwitch` (Because `Switch` only keeps the first matching state route and unmount the others)
60 |
61 | ```javascript
62 | import React from 'react'
63 | import { HashRouter as Router, Route } from 'react-router-dom'
64 | import CacheRoute, { CacheSwitch } from 'react-router-cache-route'
65 |
66 | import List from './views/List'
67 | import Item from './views/Item'
68 |
69 | const App = () => (
70 |
71 |
72 |
73 |
74 | 404 Not Found
} />
75 |
76 |
77 | )
78 |
79 | export default App
80 | ```
81 |
82 | ---
83 |
84 | ## CacheRoute props
85 |
86 | | name | type | default | description |
87 | | ----------------------------- | --------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
88 | | when | `String` / `Function` | `"forward"` | Decide when to cache |
89 | | className | `String` | - | `className` prop for the wrapper component |
90 | | behavior | `Function` | `cached => cached ? { style: { display: "none" }} : undefined` | Return `props` effective on the wrapper component to control rendering behavior |
91 | | cacheKey | `String` / `Function` | - | For imperative control caching |
92 | | multiple (React v16.2+) | `Boolean` / `Number` | `false` | Allows different caches to be distinguished by dynamic routing parameters. When the value is a number, it indicates the maximum number of caches. When the maximum value is exceeded, the oldest updated cache will be cleared. |
93 | | unmount (UNSTABLE) | `Boolean` | `false` | Whether to unmount the real dom node after cached, to save performance (Will cause losing the scroll position after recovered, fixed with `saveScrollPosition` props) |
94 | | saveScrollPosition (UNSTABLE) | `Boolean` | `false` | Save scroll position |
95 |
96 | `CacheRoute` is only a wrapper component that works based on the `children` property of `Route`, and does not affect the functionality of `Route` itself.
97 |
98 | For the rest of the properties, please refer to https://reacttraining.com/react-router/
99 |
100 | ---
101 |
102 | ### About `when`
103 |
104 | The following values can be taken when the type is `String`
105 |
106 | - **[forward]** Cache when **forward** behavior occurs, corresponding to the `PUSH` or `REPLACE` action in react-router
107 | - **[back]** Cache when **back** behavior occurs, corresponding to the `POP` action in react-router
108 | - **[always]** Always cache routes when leave, no matter forward or backward
109 |
110 | When the type is `Function`, the component's `props` will be accepted as the first argument, return `true/false` to determine whether to cache.
111 |
112 | ---
113 |
114 | ## CacheSwitch props
115 |
116 | | name | type | default | description |
117 | | ----- | ---------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
118 | | which | `Function` | `element => element.type === CacheRoute` | `` only saves the first layer of nodes which type is `CacheRoute` **by default**, `which` prop is a function that would receive a instance of React Component, return `true/false` to decide if `` need to save it, reference [#55](https://github.com/CJY0208/react-router-cache-route/issues/55) |
119 |
120 | ---
121 |
122 | ## Lifecycles
123 |
124 | ### Hooks
125 |
126 | use `useDidCache` and `useDidRecover` to inject customer Lifecycle `didCache` and `didRecover`
127 |
128 | ```javascript
129 | import { useDidCache, useDidRecover } from 'react-router-cache-route'
130 |
131 | export default function List() {
132 |
133 | useDidCache(() => {
134 | console.log('List cached 1')
135 | })
136 |
137 | // support multiple effect
138 | useDidCache(() => {
139 | console.log('List cached 2')
140 | })
141 |
142 | useDidRecover(() => {
143 | console.log('List recovered')
144 | })
145 |
146 | return (
147 | // ...
148 | )
149 | }
150 | ```
151 |
152 | ### Class Component
153 |
154 | Component with CacheRoute will accept one prop named `cacheLifecycles` which contains two functions to inject customer Lifecycle `didCache` and `didRecover`
155 |
156 | ```javascript
157 | import React, { Component } from 'react'
158 |
159 | export default class List extends Component {
160 | constructor(props) {
161 | super(props)
162 |
163 | props.cacheLifecycles.didCache(this.componentDidCache)
164 | props.cacheLifecycles.didRecover(this.componentDidRecover)
165 | }
166 |
167 | componentDidCache = () => {
168 | console.log('List cached')
169 | }
170 |
171 | componentDidRecover = () => {
172 | console.log('List recovered')
173 | }
174 |
175 | render() {
176 | return (
177 | // ...
178 | )
179 | }
180 | }
181 | ```
182 |
183 | ---
184 |
185 | ## Drop cache
186 |
187 | You can manually control the cache with `cacheKey` prop and `dropByCacheKey` function.
188 |
189 | ```javascript
190 | import CacheRoute, { dropByCacheKey, getCachingKeys } from 'react-router-cache-route'
191 |
192 | ...
193 |
194 | ...
195 |
196 | console.log(getCachingKeys()) // will receive ['MyComponent'] if CacheRoute is cached which `cacheKey` prop is 'MyComponent'
197 | ...
198 |
199 | dropByCacheKey('MyComponent')
200 | ...
201 | ```
202 | ---
203 | ## Clear cache
204 |
205 | You can clear cache with `clearCache` function.
206 |
207 | ```js
208 | import { clearCache } from 'react-router-cache-route'
209 |
210 | clearCache()
211 | ```
212 |
--------------------------------------------------------------------------------
/src/core/CacheComponent.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | import {
5 | run,
6 | get,
7 | value,
8 | isExist,
9 | isFunction,
10 | saveScrollPosition,
11 | ObjectValues
12 | } from '../helpers'
13 | import * as manager from './manager'
14 | import { Provider as CacheRouteProvider } from './context'
15 |
16 | const isUsingNewLifecycle = isExist(React.forwardRef)
17 |
18 | export const COMPUTED_UNMATCH_KEY = '__isComputedUnmatch'
19 | export const isMatch = match =>
20 | isExist(match) && get(match, COMPUTED_UNMATCH_KEY) !== true
21 |
22 | const getDerivedStateFromProps = (nextProps, prevState) => {
23 | let { match: nextPropsMatch, when = 'forward' } = nextProps
24 |
25 | /**
26 | * Note:
27 | * Turn computedMatch from CacheSwitch to a real null value
28 | *
29 | * 将 CacheSwitch 计算得到的 computedMatch 值转换为真正的 null
30 | */
31 | if (!isMatch(nextPropsMatch)) {
32 | nextPropsMatch = null
33 | }
34 |
35 | if (!prevState.cached && nextPropsMatch) {
36 | return {
37 | cached: true,
38 | matched: true
39 | }
40 | }
41 |
42 | /**
43 | * Determines whether it needs to cancel the cache based on the next unmatched props action
44 | *
45 | * 根据下个未匹配状态动作决定是否需要取消缓存
46 | */
47 | if (prevState.matched && !nextPropsMatch) {
48 | const nextAction = get(nextProps, 'history.action')
49 |
50 | let __cancel__cache = false
51 |
52 | if (isFunction(when)) {
53 | __cancel__cache = !when(nextProps)
54 | } else {
55 | switch (when) {
56 | case 'always':
57 | break
58 | case 'back':
59 | if (['PUSH', 'REPLACE'].includes(nextAction)) {
60 | __cancel__cache = true
61 | }
62 |
63 | break
64 | case 'forward':
65 | default:
66 | if (nextAction === 'POP') {
67 | __cancel__cache = true
68 | }
69 | }
70 | }
71 |
72 | if (__cancel__cache) {
73 | return {
74 | cached: false,
75 | matched: false
76 | }
77 | }
78 | }
79 |
80 | return {
81 | matched: !!nextPropsMatch
82 | }
83 | }
84 |
85 | export default class CacheComponent extends Component {
86 | static __name = 'CacheComponent'
87 |
88 | static propsTypes = {
89 | history: PropTypes.object.isRequired,
90 | match: PropTypes.object.isRequired,
91 | children: PropTypes.func.isRequired,
92 | className: PropTypes.string,
93 | when: PropTypes.oneOfType([
94 | PropTypes.func,
95 | PropTypes.oneOf(['forward', 'back', 'always'])
96 | ]),
97 | behavior: PropTypes.func,
98 | unmount: PropTypes.bool,
99 | saveScrollPosition: PropTypes.bool
100 | }
101 |
102 | static defaultProps = {
103 | when: 'forward',
104 | unmount: false,
105 | saveScrollPosition: false,
106 | behavior: cached =>
107 | cached
108 | ? {
109 | style: {
110 | display: 'none'
111 | }
112 | }
113 | : undefined
114 | }
115 |
116 | constructor(props, ...args) {
117 | super(props, ...args)
118 |
119 | this.__cacheCreateTime = Date.now()
120 | this.__cacheUpdateTime = this.__cacheCreateTime
121 | if (props.cacheKey) {
122 | const cacheKey = run(props.cacheKey, undefined, props)
123 | if (props.multiple) {
124 | const { href } = props
125 | manager.register(cacheKey, {
126 | ...manager.getCache()[cacheKey],
127 | [href]: this
128 | })
129 | } else {
130 | manager.register(cacheKey, this)
131 | }
132 | }
133 |
134 | if (typeof document !== 'undefined') {
135 | const cacheKey = run(props.cacheKey, undefined, props)
136 | this.__placeholderNode = document.createComment(
137 | ` Route cached ${cacheKey ? `with cacheKey: "${cacheKey}" ` : ''}`
138 | )
139 | }
140 |
141 | this.state = getDerivedStateFromProps(props, {
142 | cached: false,
143 | matched: false,
144 | key: Math.random()
145 | })
146 | }
147 |
148 | cacheLifecycles = {
149 | __listener: {},
150 | __didCacheListener: {},
151 | __didRecoverListener: {},
152 | on: (eventName, func) => {
153 | const id = Math.random()
154 | const listenerKey = `__${eventName}Listener`
155 | this.cacheLifecycles[listenerKey][id] = func
156 |
157 | return () => {
158 | delete this.cacheLifecycles[listenerKey][id]
159 | }
160 | },
161 | didCache: listener => {
162 | this.cacheLifecycles.__listener['didCache'] = listener
163 | },
164 | didRecover: listener => {
165 | this.cacheLifecycles.__listener['didRecover'] = listener
166 | }
167 | }
168 |
169 | /**
170 | * New lifecycle for replacing the `componentWillReceiveProps` in React 16.3 +
171 | * React 16.3 + 版本中替代 componentWillReceiveProps 的新生命周期
172 | */
173 | static getDerivedStateFromProps = isUsingNewLifecycle
174 | ? getDerivedStateFromProps
175 | : undefined
176 |
177 | /**
178 | * Compatible React 16.3 -
179 | * 兼容 React 16.3 - 版本
180 | */
181 | componentWillReceiveProps = !isUsingNewLifecycle
182 | ? nextProps => {
183 | const nextState = getDerivedStateFromProps(nextProps, this.state)
184 |
185 | this.setState(nextState)
186 | }
187 | : undefined
188 |
189 | __parentNode
190 | __placeholderNode
191 | __revertScrollPos
192 | injectDOM = () => {
193 | try {
194 | run(
195 | this.__parentNode,
196 | 'insertBefore',
197 | this.wrapper,
198 | this.__placeholderNode
199 | )
200 | run(this.__parentNode, 'removeChild', this.__placeholderNode)
201 | } catch (err) {
202 | // nothing
203 | }
204 | }
205 |
206 | ejectDOM = () => {
207 | try {
208 | const parentNode = get(this.wrapper, 'parentNode')
209 | this.__parentNode = parentNode
210 |
211 | run(
212 | this.__parentNode,
213 | 'insertBefore',
214 | this.__placeholderNode,
215 | this.wrapper
216 | )
217 | run(this.__parentNode, 'removeChild', this.wrapper)
218 | } catch (err) {
219 | // nothing
220 | }
221 | }
222 | componentDidUpdate(prevProps, prevState) {
223 | if (!prevState.cached || !this.state.cached) {
224 | return
225 | }
226 |
227 | if (prevState.matched === true && this.state.matched === false) {
228 | if (this.props.unmount) {
229 | this.ejectDOM()
230 | }
231 | this.__cacheUpdateTime = Date.now()
232 | ObjectValues(this.cacheLifecycles.__didCacheListener).forEach(func => {
233 | run(func)
234 | })
235 | return run(this, 'cacheLifecycles.__listener.didCache')
236 | }
237 |
238 | if (prevState.matched === false && this.state.matched === true) {
239 | if (this.props.saveScrollPosition) {
240 | run(this.__revertScrollPos)
241 | }
242 | this.__cacheUpdateTime = Date.now()
243 | ObjectValues(this.cacheLifecycles.__didRecoverListener).forEach(func => {
244 | run(func)
245 | })
246 | return run(this, 'cacheLifecycles.__listener.didRecover')
247 | }
248 | }
249 |
250 | shouldComponentUpdate(nextProps, nextState) {
251 | const willRecover =
252 | this.state.matched === false && nextState.matched === true
253 | const willDrop = this.state.cached === true && nextState.cached === false
254 | const shouldUpdate =
255 | this.state.matched ||
256 | nextState.matched ||
257 | this.state.cached !== nextState.cached
258 |
259 | if (shouldUpdate) {
260 | if ((this.props.unmount && willDrop) || willRecover) {
261 | this.injectDOM()
262 | }
263 |
264 | if (!(willDrop || willRecover) && this.props.saveScrollPosition) {
265 | this.__revertScrollPos = saveScrollPosition(
266 | this.props.unmount ? this.wrapper : undefined
267 | )
268 | }
269 | }
270 |
271 | return shouldUpdate
272 | }
273 |
274 | componentWillUnmount() {
275 | const { unmount, href, multiple } = this.props
276 | const cacheKey = run(this.props, 'cacheKey', this.props)
277 |
278 | if (multiple) {
279 | const cache = { ...manager.getCache()[cacheKey] }
280 |
281 | delete cache[href]
282 |
283 | if (Object.keys(cache).length === 0) {
284 | manager.remove(cacheKey)
285 | } else {
286 | manager.register(cacheKey, cache)
287 | }
288 | } else {
289 | manager.remove(cacheKey)
290 | }
291 |
292 | if (unmount) {
293 | this.injectDOM()
294 | }
295 | }
296 |
297 | reset = () => {
298 | delete this.__revertScrollPos
299 |
300 | this.setState({
301 | cached: false
302 | })
303 | }
304 |
305 | refresh = () => {
306 | delete this.__revertScrollPos;
307 |
308 | this.setState({
309 | key: Math.random()
310 | });
311 | };
312 |
313 | render() {
314 | const { matched, cached, key } = this.state
315 | const { className: propsClassName = '', behavior, children } = this.props
316 | const { className: behaviorClassName = '', ...behaviorProps } = value(
317 | run(behavior, undefined, !matched),
318 | {}
319 | )
320 | const className = run(`${propsClassName} ${behaviorClassName}`, 'trim')
321 | const hasClassName = className !== ''
322 |
323 | return cached ? (
324 | {
329 | this.wrapper = wrapper
330 | }}
331 | >
332 |
333 | {run(children, undefined, this.cacheLifecycles)}
334 |
335 |
336 | ) : null
337 | }
338 | }
339 |
--------------------------------------------------------------------------------