diff --git a/README.md b/README.md index 8c8e064..838d2d9 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,48 @@ -# react-routers +# React Routers + +

fre logo

+

React Routers

+

🌠A React Component for quick configuring route

+

+Build Status +Code Coverage +npm-v +npm-d +brotli +

+ +## ⭐ Features + +- Static Routes like [`react-router-config`](https://github.com/ReactTraining/react-router/tree/master/packages/react-router-config) +- Route Guard and `keep-alive` like `Vue` +- Auto Lazy Load +- Simple Transition Animation +- Change `document.title` with Configuration +- Tiny Size, unpacked 13KB +- Full Typescript Support -[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Bert0324/react-routers/blob/main/LICENCE) -[![npm version](https://badge.fury.io/js/react-routers.svg)](https://www.npmjs.com/package/react-routers) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/Bert0324/react-routers/pulls) -[![star this repo](https://githubbadges.com/star.svg?user=bert0324&repo=react-routers&style=default)](https://github.com/bert0324/react-routers) -[![fork this repo](https://githubbadges.com/fork.svg?user=bert0324&repo=react-routers&style=default)](https://github.com/bert0324/react-routers/fork) +## 🏠 Installation -A React Component for quick configuring route. +- `yarn add react-routers` -## Features +## 🎠 Example & Playground -- Route Configuration like `react-router-config` -- Route Guard and Keep Alive like `Vue` -- Simple Lazy Load -- Change `document.title` with Configuration -- Tiny, zlib packed Only 1KB -- Full Typescript Support +An example and playground of `react-routers` in [HERE](https://stackblitz.com/edit/react-routers-demo). -## Documents +## 📑 API + +### Props of `Routers` ```ts -export type IBeforeRoute = (from: string, to: string) => boolean | undefined | void | Promise; -export type IAfterRoute = (from: string, to: string) => void; - -/** - * Router Configuration - * the path in children will be jointed with the path in parent - */ -export interface IPageRouter { +import { Routers } from 'react-routers'; +``` + +#### `routers` + +The Router configuration, the path in children will be jointed with the path in parent. Its type is as below: + +```ts +interface IPageRouter { /** * route path */ @@ -62,111 +77,113 @@ export interface IPageRouter { * - its priority is higher than `keepAlive` in props */ keepAlive?: boolean; -} - -/** - * `react-routers` props - */ -export interface IRouterProps { - /** - * routers config - */ - routers: IPageRouter[]; /** - * A fallback react tree to show when a Suspense child (like React.lazy) suspends, and before entering the route + * transition animation */ - fallback?: ComponentType<{ from: string; to: string }>; - /** - * redirect path - */ - redirect?: string; - /** - * css style - */ - style?: CSSProperties; + transition?: ITransition; +} +``` + +#### `fallback` + +A fallback react tree to show when a Suspense child (like React.lazy) suspends, and before entering the route. It must be a React Component. + +#### `redirect` + +redirect path. + +#### `beforeEach` + +triggered before entering route + +- if return false, deny to enter route +- ahead of any `beforeRoute` + +#### `afterEach` + +triggered after entering route + +- if return false, deny to enter route +- after any `afterRoute` + +#### `keepAlive` + +do maintains component state and avoids repeated re-rendering for each route + +- default is `false` + +#### `switchRoute` + +Do select only one route like `` + +- default is `true` + +#### `transition` + +transition animation. Its type is as below: + +```ts +type ITransition = { /** - * triggered before entering route - * - if return false, deny to enter route - * - ahead of any `beforeRoute` + * the css style after matched */ - beforeEach?: IBeforeRoute; + match: CSSProperties; /** - * triggered after entering route - * - if return false, deny to enter route - * - after any `afterRoute` + * the css style after unmatched */ - afterEach?: IAfterRoute; + notMatch: CSSProperties; /** - * do maintains component state and avoids repeated re-rendering for each route - * - default is `false` + * the css style of transition */ - keepAlive?: boolean; + trans: CSSProperties; /** - * switch - * - default is `true` + * keep component after unmatched + * - default is `500`ms */ - switchRoute?: boolean; -} + delay?: number; +}; ``` -## Demo +or directly use embedded animation objects. -Install `react-routers`: +### Hooks -- `yarn add react-routers` +#### `useActive` -```tsx -import { FC } from 'react'; -import { Link, BrowserRouter } from 'react-router-dom'; -import { Routers } from 'react-routers'; -import { Skeleton } from 'antd'; - -const asyncTask = () => new Promise(resolve => setTimeout(() => resolve(), 1000)); - -export const IRouters: FC = () => { - return ( - - (await import('./PageComponent')).PageComponent, - afterRoute: (from, to) => { - console.log(from ,to); - } - }, - { - path: '/page2', // test/page2 - name: 'page2', - Component: () => () => page2, - beforeRoute: (from, to) => { - console.log(from ,to); - return false; - }, - children: [ - { - path: '/:page', // test/page2/:page - name: 'page3', - Component: async () => () => <>page3, - } - ] - } - ]} - beforeEach={async (from, to) => { - await asyncTask(); - console.log('beforeEach', from, to); - }} - redirect='/page1' - fallback={() => } - /> - - ); -}; +The hook triggered when the route match the component's route in configuration. + +```ts +import { useActive } from 'react-routers'; + +useActive(() => { + /* Called when the component is activated. */ + return () => { + /* Called when the component is deactivated. */ + } +}); +``` + +#### `useParams` + +A wrapped function of [`useParams`](https://reactrouter.com/web/api/Hooks/useroutematch). Notice, if you use `useParams` of `react-router` in a `react-routers` controlled component, you can't get correct match, as `react-router` don't the configuration configured in `react-routers`. + +```ts +import { useParams } from 'react-routers'; + +// /blog/:slug +const { slug } = useParams<{ slug?:string }>(); ``` -## Development +### Embedded Animation + +The objects which can be put in `transition`, includes `LeftFade`, `RightFade`, `TopFade`, `BottomFade`, `LeftSlide`, `RightSlide`, `TopSlide`, `BottomSlide`. + +## 💻 Development - `yarn` - `preview=true yarn dev` - `yarn build` + +## 🍧 License + +React Routers is [MIT licensed](https://github.com/Bert0324/react-routers/blob/main/LICENCE). diff --git a/assets/icon.jpeg b/assets/icon.jpeg new file mode 100644 index 0000000..56d77a5 Binary files /dev/null and b/assets/icon.jpeg differ diff --git a/index.d.ts b/index.d.ts index 83e78bf..4952ca5 100644 --- a/index.d.ts +++ b/index.d.ts @@ -9,6 +9,9 @@ interface IConfig { path: string; switchRoute: boolean; transition?: ITransition; + delay: number; + haveBeforeEach: boolean; + ready: boolean; } export interface IRefObj { @@ -20,10 +23,14 @@ export interface IRefObj { [path: string]: IConfig; }; actives: { - [path: string]: (() => void)[]; + [path: string]: { + [id: string]: ActiveHook + }; }; deactives: { - [path: string]: (() => void)[]; + [path: string]: { + [id: string]: ActiveHook; + } }; matched: boolean[]; } @@ -34,7 +41,14 @@ export type ITransition = { match: CSSProperties; notMatch: CSSProperties; trans: CSSProperties; + /** + * + * - default is `500`ms + */ + delay?: number; }; +export type EffectHook = () => void; +export type ActiveHook = () => EffectHook | void | undefined; /** * Router Configuration @@ -129,7 +143,7 @@ export interface IRouterProps { transition?: ITransition; /** * loading delay - * - default is `500`ms + * - default is `100`ms */ delay?: number; } @@ -143,11 +157,7 @@ declare module 'react-routers' { /** * triggered when first entering route and every time active it */ - const useActive: (effect: () => void) => void; - /** - * triggered every time unmount route - */ - const useDeActive: (effect: () => void) => void; + const useActive: (effect: ActiveHook) => void; /** * `useParams` like */ @@ -156,10 +166,26 @@ declare module 'react-routers' { * get current configuration */ const useRefContext: () => IRefObj | null; - export { Routers, useActive, useDeActive, useParams, useRefContext }; + const LeftFade: ITransition; + const RightFade: ITransition; + const TopFade: ITransition; + const BottomFade: ITransition; + const LeftSlide: ITransition; + const RightSlide: ITransition; + const TopSlide: ITransition; + const BottomSlide: ITransition; + export { + Routers, + useActive, + useParams, + useRefContext, + LeftFade, + RightFade, + TopFade, + BottomFade, + LeftSlide, + RightSlide, + TopSlide, + BottomSlide + }; } - -declare module '*.module.less' { - const styles: { readonly [key: string]: string }; - export default styles; -} \ No newline at end of file diff --git a/package.json b/package.json index b265a1a..63aa197 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-routers", - "version": "2.0.0", + "version": "2.0.2", "description": "router component", "author": "yuchen.huang <'yuchenhuang0324@gmail.com'>", "main": "dist/main.min.js", @@ -75,6 +75,7 @@ "react-dom": "^17.0.1", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", + "react-routers": "^2.0.2", "style-loader": "^2.0.0", "thread-loader": "^3.0.1", "ts-loader": "^8.0.12", diff --git a/src/context/hooks.ts b/src/context/hooks.ts index 96a860e..473ae1c 100644 --- a/src/context/hooks.ts +++ b/src/context/hooks.ts @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { useHistory } from "react-router"; +import { ActiveHook } from "../../index.d"; import { notExistPath } from "../utils/constants"; import { findMatch, findMatchPath } from "../utils/utils"; import { useRefContext } from "./context"; @@ -8,48 +9,30 @@ import { useRefContext } from "./context"; * push active callback to ref * @param effect */ -export const useActive = (effect: () => void) => { +export const useActive: (effect: ActiveHook) => void = effect => { const data = useRefContext()!; const history = useHistory(); useEffect(() => { const key = findMatchPath(data.map, history.location.pathname); + const id = `${Math.random()}`; if (key !== notExistPath) { if (!data.actives[key]) { - data.actives[key] = []; + data.actives[key] = {}; } - data.actives[key].push(effect); + data.actives[key][id] = effect; } return () => { if (key !== notExistPath) { - data.actives[key] = data.actives[key]?.filter(item => item !== effect); + delete data.actives[key]?.[id]; + delete data.deactives[key]?.[id]; } }; }, []); }; /** - * push deactive callback to ref - * @param effect + * replacement for `useParams` */ -export const useDeActive = (effect: () => void) => { - const { deactives, map } = useRefContext()!; - const history = useHistory(); - useEffect(() => { - const key = findMatchPath(map, history.location.pathname); - if (key !== notExistPath) { - if (!deactives[key]) { - deactives[key] = []; - } - deactives[key].push(effect); - } - return () => { - if (key !== notExistPath) { - deactives[key] = deactives[key]?.filter(item => item !== effect) - } - }; - }, []); -}; - export const useParams = () => { const history = useHistory(); const { map } = useRefContext()!; diff --git a/src/core/KeepAlive.tsx b/src/core/KeepAlive.tsx index 0b87d5c..7a9818f 100644 --- a/src/core/KeepAlive.tsx +++ b/src/core/KeepAlive.tsx @@ -1,19 +1,20 @@ import React, { FC, memo, useEffect, useState } from 'react'; import { matchPath, useHistory } from 'react-router'; import { useRefContext } from '../context/context'; -import { IConfig } from '../../index.d'; -import { filterMatchRoutes } from '../utils/utils'; +import { EffectHook } from '../../index.d'; +import { filterMatchRoutes, getWithinTime } from '../utils/utils'; -export const KeepAlive: FC<{ config: IConfig }> = memo(({ children, config }) => { +export const KeepAlive: FC<{ path: string }> = memo(({ children, path }) => { const history = useHistory(); const [match, setMatch] = useState(false); const [firstMatched, setFirstMatched] = useState(false); const [delayMatch, setDelayMatch] = useState(false); const data = useRefContext()!; + const config = data.map[path]; const checkMatch = () => { // after history change callback in router - setTimeout(() => { + setTimeout(async () => { let currentMatch = !!matchPath(history.location.pathname, { path: config.path, exact: true @@ -32,15 +33,35 @@ export const KeepAlive: FC<{ config: IConfig }> = memo(({ children, config }) => if (currentMatch && !firstMatched) { setFirstMatched(true); } - - if (!firstMatched) { + + // wait until async component is ready + const ready = config?.ready || await getWithinTime(() => config?.ready); + + if (ready) { + // call active hooks if (currentMatch) { - filterMatchRoutes(data.actives, config.path).forEach(effects => effects.forEach(effect => effect())); + let collectDeactives = false; + if (!data.deactives[config.path]) { + collectDeactives = true; + data.deactives[config.path] = {}; + } + filterMatchRoutes(data.actives, config.path).forEach( + effects => Object.keys(effects).forEach(key => { + const deactive = effects[key]?.(); + if (collectDeactives && Object.prototype.toString.call(deactive) === '[object Function]') { + data.deactives[config.path][key] = deactive as EffectHook; + } + }) + ); } else if (lastMatched) { - filterMatchRoutes(data.deactives, config.path).forEach(effects => effects.forEach(effect => effect())); + filterMatchRoutes(data.deactives, config.path).forEach( + effects => Object.keys(effects).forEach( + key => data.deactives[config.path]?.[key]?.() + ) + ); } } - }); + }, (config.haveBeforeEach || !!config.beforeRoute) ? config.delay : 0); }; useEffect(() => { @@ -49,9 +70,7 @@ export const KeepAlive: FC<{ config: IConfig }> = memo(({ children, config }) => }, []); useEffect(() => { - setTimeout(() => { - setDelayMatch(match); - }, 500); + setTimeout(() => setDelayMatch(match), config.transition?.delay || 500); }, [match]); const transitionStyle = { @@ -59,24 +78,24 @@ export const KeepAlive: FC<{ config: IConfig }> = memo(({ children, config }) => ...(match ? config.transition?.match : config.transition?.notMatch) }; + const actualDisplay = config.transition ? delayMatch : match; + return ( <> {config.alive ? <> {firstMatched ?
{children}
: null} : -
- {(config.transition ? delayMatch : match) ? children : null} +
+ {actualDisplay ? children : null}
} ); -}, (prev, next) => JSON.stringify(prev.config) === JSON.stringify(next.config)); \ No newline at end of file +}, (prev, next) => prev.path === next.path); \ No newline at end of file diff --git a/src/core/Router.tsx b/src/core/Router.tsx index 2ad829b..9d2bcd5 100644 --- a/src/core/Router.tsx +++ b/src/core/Router.tsx @@ -1,7 +1,7 @@ import React, { lazy, Suspense, FC, memo, useState, useMemo, useEffect, useCallback } from 'react'; import { Route, withRouter, useHistory } from 'react-router-dom'; import { throttle } from 'lodash-es'; -import { IPageRouter, IRouterProps } from '../..'; +import { IPageRouter, IRouterProps } from '../../index.d'; import { Provider, useRefContext } from '../context/context'; import { KeepAlive } from './KeepAlive'; import { findMatchRoute } from '../utils/utils'; @@ -14,9 +14,11 @@ const Router: FC = memo(({ routers, fallback, redirect, beforeEach const [loading, _setLoading] = useState(true); const data = useRefContext()!; + const delayLoad = delay || 100; + const setLoading = useCallback(throttle((_loading: boolean) => { _setLoading(_loading); - }, (delay || 500), { leading: false, trailing: true }), [_setLoading]); + }, delayLoad, { leading: false, trailing: true }), [_setLoading]); const Loading = useMemo(() => { const Fallback = fallback; @@ -37,14 +39,10 @@ const Router: FC = memo(({ routers, fallback, redirect, beforeEach */ const Page = (params: IPageRouter) => { if (!params.Component) return false; - const waitForComponent = async () => { - const component = await params.Component!(); - return component; - }; - const Component = lazy(async () => ({ default: withRouter(await waitForComponent()) })); let alive = false; if (keepAlive !== undefined) alive = keepAlive; if (params.keepAlive !== undefined) alive = params.keepAlive; + data.map[params.path] = { name: params.name || '', beforeRoute: params.beforeRoute, @@ -53,11 +51,23 @@ const Router: FC = memo(({ routers, fallback, redirect, beforeEach selfMatched: [], path: params.path, switchRoute, - transition: params.transition || transition + transition: params.transition || transition, + delay: delayLoad, + haveBeforeEach: !!beforeEach, + ready: false }; + + const waitForComponent = async () => { + const asyncTask = () => new Promise(resolve => setTimeout(() => resolve(), 2000)); + await asyncTask(); + const component = await params.Component!(); + setTimeout(() => data.map[params.path].ready = true); + return component; + }; + const Component = lazy(async () => ({ default: withRouter(await waitForComponent()) })); return ( - + @@ -78,7 +88,7 @@ const Router: FC = memo(({ routers, fallback, redirect, beforeEach return acc; }; return routers.reduce((acc, crr) => createPage(acc, crr), []); - }, [JSON.stringify({ keepAlive, routers, switchRoute })]); + }, [keepAlive, routers, switchRoute]); const notEnterHandler = (from: string, redirect?: boolean) => { data.isReplace = true && !redirect; diff --git a/src/index.ts b/src/index.ts index b087e46..9a26912 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export { Routers } from './core/Router'; -export { useActive, useDeActive, useParams } from './context/hooks'; -export { useRefContext } from './context/context'; \ No newline at end of file +export { useRefContext } from './context/context'; +export * from './context/hooks'; +export * from './utils/transition'; \ No newline at end of file diff --git a/src/utils/transition.ts b/src/utils/transition.ts new file mode 100644 index 0000000..b5fae92 --- /dev/null +++ b/src/utils/transition.ts @@ -0,0 +1,82 @@ +import { ITransition } from "../../index.d"; + +const Slide = { + match: { + transform: 'translateX(0)' + }, + trans: { + transition: 'all 500ms ease' + }, + delay: 500 +}; + +const Fade = { + ...Slide, + match: { + ...Slide.match, + opacity: 1, + }, + notMatch: { + opacity: 0, + } +}; + +export const LeftFade: ITransition = { + ...Fade, + notMatch: { + ...Fade.notMatch, + transform: 'translateX(-30vw)' + } +}; + +export const RightFade: ITransition = { + ...Fade, + notMatch: { + ...Fade.notMatch, + transform: 'translateX(30vw)' + } +}; + +export const TopFade: ITransition = { + ...Fade, + notMatch: { + ...Fade.notMatch, + transform: 'translateY(-30vh)' + } +}; + +export const BottomFade: ITransition = { + ...Fade, + notMatch: { + ...Fade.notMatch, + transform: 'translateY(30vh)' + } +}; + +export const LeftSlide: ITransition = { + ...Slide, + notMatch: { + transform: 'translateX(-100vw)' + } +}; + +export const RightSlide: ITransition = { + ...Slide, + notMatch: { + transform: 'translateX(100vw)' + } +}; + +export const TopSlide: ITransition = { + ...Slide, + notMatch: { + transform: 'translateY(-100vh)' + } +}; + +export const BottomSlide: ITransition = { + ...Slide, + notMatch: { + transform: 'translateY(100vh)' + } +}; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index d3ba205..c38ea8f 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,7 +1,7 @@ import { match, matchPath } from "react-router"; import { notExistPath } from "./constants"; -const getOptions = path => ({ +const getOptions = (path: string) => ({ path, exact: true }); @@ -30,3 +30,16 @@ export const findMatch = (map: { [key: string]: any }, path: string) => return acc; }, undefined as unknown as match); }; + +export const setTimeoutTask = (task: () => T) => new Promise(resolve => setTimeout(() => { + resolve(task()); +}, 100)); + +export const getWithinTime = async (task: () => T) => { + const timeout = 10000; + const start = Number(new Date()); + while (Number(new Date()) - start < timeout) { + const res = await setTimeoutTask(task); + if (res) return res; + } +}; \ No newline at end of file diff --git a/test/async.tsx b/test/async.tsx index 89770de..0ff9db6 100644 --- a/test/async.tsx +++ b/test/async.tsx @@ -1,6 +1,6 @@ import React, { FC, useState } from 'react'; import { Link } from 'react-router-dom'; -import { useActive, useDeActive } from '../src/index'; +import { useActive, useRefContext } from '../src/index'; const Sub: FC = () => { @@ -18,19 +18,22 @@ export const AsyncComponent: FC = () => { const data = Number(new Date()); useActive(() => { console.log('page1 active', data); + return () => { + console.log('page1 deactive', data); + } }); - useDeActive(() => { - console.log('page1 deactive', data); - }); + + const ref = useRefContext(); + console.log(ref) return ( - <> +
page1 page2
{show && }
- +
) } \ No newline at end of file diff --git a/test/index.tsx b/test/index.tsx index 70d9d90..d467867 100644 --- a/test/index.tsx +++ b/test/index.tsx @@ -1,7 +1,7 @@ import React, { FC, useEffect, useState } from 'react'; import { render } from 'react-dom'; import { Link, BrowserRouter } from 'react-router-dom'; -import { Routers, useParams } from '../src'; +import { TopSlide, Routers, useParams, useRefContext } from '../src'; import { LoadingPage } from './loading'; const asyncTask = () => new Promise(resolve => setTimeout(() => resolve(), 2000)); @@ -17,6 +17,7 @@ const App: FC = () => { return ( { }, { path: '/page2', // test/page2 - Component: async () => () => page2, + Component: async () => () =>
page2
, children: [ { path: '/:page', // test/page2/page3 @@ -51,7 +52,7 @@ const App: FC = () => { } ]} beforeEach={async (from, to) => { - await asyncTask(); + // await asyncTask(); console.log('beforeEach', from, to, data); }} redirect='/page1' diff --git a/yarn.lock b/yarn.lock index 9deb311..ac93c28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8132,6 +8132,13 @@ react-router@5.2.0, react-router@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-routers@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/react-routers/-/react-routers-2.0.2.tgz#7ac6f616c7101ae420b37740976ba185e0cbd55a" + integrity sha512-4zSKHT9KDTtXrlloo9X1OGZ3KwJdviiYJVCfvEc8S+xkMBqcjOwI0UTbS7WmNh5ZpdlR+ZELYTXXQPsGlrh5WQ== + dependencies: + lodash-es "^4.17.20" + react@^17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127"