├── .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 | [![size](https://img.shields.io/bundlephobia/minzip/react-router-cache-route.svg)](https://github.com/CJY0208/react-router-cache-route) 6 | [![dm](https://img.shields.io/npm/dm/react-router-cache-route.svg)](https://github.com/CJY0208/react-router-cache-route) 7 | ![](https://komarev.com/ghpvc/?username=cjy0208-react-router-cache-route&label=VIEWS) 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 | [![size](https://img.shields.io/bundlephobia/minzip/react-router-cache-route.svg)](https://github.com/CJY0208/react-router-cache-route) 6 | [![dm](https://img.shields.io/npm/dm/react-router-cache-route.svg)](https://github.com/CJY0208/react-router-cache-route) 7 | ![](https://komarev.com/ghpvc/?username=cjy0208-react-router-cache-route&label=VIEWS) 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 | --------------------------------------------------------------------------------