sunny-pluggable-router
v0.0.7
Published
![npm version](https://img.shields.io/npm/v/sunny-pluggable-router) ![npm downloads](https://img.shields.io/npm/dm/sunny-pluggable-router) ![license](https://img.shields.io/npm/l/sunny-pluggable-router)
Downloads
5
Readme
sunny-pluggable-router
基于react router再封装,从Plug-in Architecture设计模式中获得部分灵感,用嵌套特性提供一种"pluggable pages"实践。
Installation
使用npm安装
npm i sunny-pluggable-router
使用yarn安装
yarn add sunny-pluggable-router
使用pnpm安装
pnpm add sunny-pluggable-router
Usages
创建第一个集成用例
使用create-react-app创建一个react应用工程
yarn create react-app my-app --template typescript
进入项目根目录添加依赖
cd my-app
yarn add react-router-dom@5 sunny-pluggable-router
修改 src/App.tsx 文件,完成应用的基本配置
import React, { FC } from 'react'
import { render } from 'react-dom'
import { BrowserRouter, Switch } from 'react-router-dom'
import { clearCache, implement, routeComponents } from 'sunny-pluggable-router'
/**
* hot reload场景
* PluggableRouter默认会使用memory cache缓存路由配置以及关联的页面级组件的引用信息。
* 当开启webpack hmr,在无刷新更新js的情况下,更新页面级组件时
* 需要清除PluggableRouter的memory cache缓存,让其重建路由信息达到更新的目的
*
* 任何有使用memory cache的逻辑都有可能会阻挡热更新的正常工作
* 更多被限制的场景,请查阅readme文档limitation关键词进行了解
* react-hot-loader readme文档 https://github.com/gaearon/react-hot-loader
*/
if (module.hot) {
clearCache()
}
/**
* 实现PluggableRouter部分公开接口
*/
/**
* require.context必须在module context下使用,不可以放在function context下
* 是为了兼容react hot loader/babel代码注入逻辑,使能够正常热更新
*/
const req = require.context('./', true, /(\w+View\/index|\/route)\.[a-z]+$/i)
implement({
scanRoutes: () => req,
})
/**
* 创建一个应用容器
*/
const App: FC = () => {
return <BrowserRouter>
<Switch>{routeComponents()}</Switch>
</BrowserRouter>
}
export default App
创建一个home页路由配置 src/home/route.ts 文件
mkdir -p src/home
touch src/home/route.ts
内容为
import { RouteConfig } from 'sunny-pluggable-router'
export default {
path: '/',
exact: true,
content: () => import('./View')
} as RouteConfig
创建home页面组件
touch src/home/View.tsx
内容为
import React, { FC } from 'react'
import { PluggableRouteComponentProps } from 'sunny-pluggable-router'
const View: FC<PluggableRouteComponentProps> = ({ route }) => (
<div data-page={route.key}>Hello world!</div>
)
export default View
至此已完成hello world集成用例所需的最少代码。
最后,启动开发服务,进行预览
yarn start
了解如何完成登录认证?更多集成用例见 examples 目录里的storybook文档
嵌套用例介绍
常规嵌套
父级视图路由配置 parent/route.ts
import { RouteConfig } from 'sunny-pluggable-router'
export default {
path: '/parent',
content: () => import('./View')
} as RouteConfig
父级视图 parent/View.tsx
import React, { FC } from 'react'
import { PluggableRouteComponentProps } from 'sunny-pluggable-router'
const View: FC<PluggableRouteComponentProps> = ({ children }) => {
return <ul>
<li>
This is Parent View
{/* 子路由组件会放在children中 */}
{children}
</li>
</ul>
}
export default View
子级视图路由配置 parent/child/route.ts
import { RouteConfig } from 'sunny-pluggable-router'
export default {
path: '/parent/child',
content: () => import('./View')
} as RouteConfig
子级视图 parent/child/View.tsx
import React, { FC } from 'react'
import { PluggableRouteComponentProps } from 'sunny-pluggable-router'
const View: FC<PluggableRouteComponentProps> = () => {
return <ul>
<li>
This is Child View
</li>
</ul>
}
export default View
使用parent属性进行嵌套
parent字段将会协助PluggableRouter组合父子路由配置中的path、nest等属性
父级路由配置需要声明key
// parent/route.ts
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'parent',
path: '/parent',
// ...
} as RouteConfig
子级路由配置path采用相对路径,且需要声明parent指向父级路由key
// parent/child/route.ts
import { RouteConfig } from 'sunny-pluggable-router'
export default {
path: 'child',
parent: 'parent', // 指向父级路由key
// ...
} as RouteConfig
使用自定义布局
直接作为父组件
import React, { FC } from 'react'
import { Switch } from 'react-router'
import { routeComponents } from 'sunny-pluggable-router'
const Layout: FC = ({ children }) => <>{children}</>
const Container: FC = () => {
return <Layout>
<Switch>
{routeComponents()}
</Switch>
</Layout>
}
export default Container
使用parent + nest配置路由来模拟类似嵌套布局的特性
/**
我假设你在开发一个后台项目,你有个整体布局框架BaseLayout,
你可以将这个组件作为一个布局组件,可以被其他页面组件通过嵌套路由关系继承。
*/
// route.ts
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'Layout',
path: '/',
nest: '/layout',
content: () => import('./View')
// ...
} as RouteConfig
// View.tsx
import React, { FC } from 'react'
import { PluggableRouteComponentProps } from 'sunny-pluggable-router'
const Layout: FC<PluggableRouteComponentProps> = ({ children }) => {
// children将会是一个子路由组件集合
return <>{children}</>
}
export default Layout
/**
增加一个欢迎视图,它的路由配置如下
*/
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'Welcome',
path: 'welcome', // path会被组合成/welcome
parent: 'Layout', // 等同于 nest: '/layout/welcome'
// ...
} as RouteConfig
/**
如果你还想在Welcome视图继续嵌套,增加一个Hello视图,它的路由配置如下
*/
import { RouteConfig } from 'sunny-pluggable-router'
export default {
path: 'hello', // 注意用的是相对路径,会被组合成/welcome/hello
parent: 'Welcome',
// ...
} as RouteConfig
自由决定子路由关联的视图组件在DOM中的位置
import React, { FC } from 'react'
import { PluggableRouteComponentProps } from 'sunny-pluggable-router'
const Layout: FC = ({ children }) => <>{children}</>
const View: FC<PluggableRouteComponentProps> = ({ childrenRouteNodes }) => {
/**
* 如果你有三个视图,他们route.key分别是leftSidebar、rightSidebar、myContent
* 你可以通过访问childrenRouteNodes对象来决定它们在你布局中的位置
*/
return <Layout>
<div className="left sidebar">{childrenRouteNodes.leftSidebar}</div>
{childrenRouteNodes.myContent}
<div className="right sidebar">{childrenRouteNodes.rightSidebar}</div>
</Layout>
}
export default View
嵌套Switch组件
import React, { FC } from 'react'
import { Switch } from 'react-router'
import { routeComponents } from 'sunny-pluggable-router'
const AppContainer: FC = ({ children }) => {
return <Switch>{routeComponents()}</Switch>
}
export default AppContainer
使用PluggableRouter Switch组件,满足全局No Match(404)场景
import React, { PropsWithChildren } from 'react'
import { Switch } from 'sunny-pluggable-router'
export function AppContainer ({ children }: PropsWithChildren) {
/**
* 如果没有匹配到URL,默认会跳转至/404
* 使用noMatch属性,可以改写跳转路径,比如跳转至首页
*/
return <Switch noMatch={'/home'}>{children}</Switch>
}
export default AppContainer
向子视图传递参数。也可以考虑使用React Context特性传参
import React, { Children, cloneElement, FC, ReactElement } from 'react'
import { PluggableRouteComponentProps } from 'sunny-pluggable-router'
const Layout: FC = ({ children }) => <>{children}</>
const View: FC<PluggableRouteComponentProps> = props => {
// 子视图可以通过props接受该参数
return <Layout>{Children.map(children, (child: ReactElement) =>
cloneElement<{ forwardProps: { hello: string }}>(
child,
{
forwardProps: { hello: 'hello from Parent' },
}
)
)}</Layout>
}
export default View
不同路由组件共享同一个视图。可以通过两种方式来声明。实现类似视图多重继承
第一种:使用route path以数组值类型
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'son',
name: '儿子',
path: ['/parent/son', '/parent/brother/son'],
// 注意:path和nest(用来重写path继承规则)属性同时使用时,会有如下情况需要注意
// nest: '/parent/son/index', // 仅仅只影响route.path[0]
// nest: ['/parent/son/index'] // 等同于以上写法
// 如果nest也是数组,就比较容易理解。nest[0]作用于path[0],往后以此类推
// nest: ['/parent/son/index', '/parent/brother/son/index'],
content: () => import('./View'),
} as RouteConfig
第二种:使用import()引用同一个视图View
第一个视图路由配置 ParentView/SonView/route.ts
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'son',
name: '儿子',
path: '/parent/son',
content: () => import('./View'),
} as RouteConfig
第二个视图路由配置 ParentView/BrotherView/SonView/route.ts
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'brotherSon',
name: '兄弟的儿子',
path: '/parent/brother/son',
// 直接引用ParentView/SonView/View.jsx
content: () => import('../../SonView/View.jsx'),
} as RouteConfig
如果需要组件级更细致地控制,将共享视图抽象成组件会是更合适地解决方案
改写视图嵌套关系的应对方法
通常默认规则已经能够解决大部分问题,而下列路由规则的之间的关系则很微妙
场景一:两个兄弟路由的path前缀是相同的,UI视觉上也不是嵌套关系
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'parent',
name: '父亲',
path: '/parent',
} as RouteConfig
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'brother',
name: '兄弟',
path: '/parent/:id',
// 提高路由解析优先级
exact: true,
} as RouteConfig
场景二:为有父子嵌套关系的视图,单独创建独立的落地页
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'parent',
name: '父亲',
path: '/parent',
} as RouteConfig
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'son',
name: '儿子',
path: '/parent/son', // 与/parent是嵌套关系
} as RouteConfig
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'parentHome',
name: '父亲的落地页',
// parentHome虽然与parent使用同一个path,但它却是独立视图,
// 跟parent没有继承关系
path: '/parent',
// 因为path跟parent相同,需要使用精确匹配属性
exact: true,
} as RouteConfig
如果你想为parent的创建一个有嵌套关系的首页,可以使用常规办法
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'parentHome',
name: '父亲的落地页',
path: '/parent/landing', // path路径中包含了嵌套关系
} as RouteConfig
或者在不改动path的情况下,修改嵌套属性,重定义嵌套关系
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'parentHome',
name: '父亲的落地页',
path: '/parent', // 跟parent使用相同path
nest: '/parent/index', // 表示该视图从parent继承
exact: true, // 因为path跟parent相同,需要使用精确匹配属性
} as RouteConfig
场景三:父级路由和其他子级路由有嵌套关系,但其中一个路由对应的视图在UI视觉上没有嵌套关系
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'parent',
name: '父亲',
path: '/parent',
} as RouteConfig
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'child',
name: '孩子',
path: '/parent/child', // 跟/parent有嵌套关系
} as RouteConfig
import { RouteConfig } from 'sunny-pluggable-router'
export default {
key: 'brother',
name: '兄弟',
path: '/parent/brother',
// brother视图通过改写nest嵌套属性,来拒绝被parent视图嵌套,变成parent的兄弟节点
// nest属性会提高路由解析优先级
nest: '/parentBrother',
} as RouteConfig
场景四: 路由 path: /, nest: /navbar 作为layout, path: /home, nest: /navbar/home 作为第一个标签页, path: /help 作为独立的帮助页
/ 可以被 /home 继承。但这里有个问题需要解决,path: / 这个layout在路由排序中默认是比 /help 高的。
在PluggableRouter生成的路由数组,它们在栈中的顺序如下
/
/home
/help
如果让我们使用Route组件进行排序,那肯定会把 /help 放在第一位,这样三个路由都可按照期望运行
/help
/
/home
那么在PluggableRouter中我们可以使用 sort 属性来控制路由排序优先级
import { RouteConfig } from 'sunny-pluggable-router'
export default {
path: '/help',
sort: 1, // 设置路由排序的优先级
content: () => import('./View')
} as RouteConfig
import { RouteConfig } from 'sunny-pluggable-router'
export default {
path: '/',
nest: '/navbar',
sort: 2, // 设置路由排序的优先级
content: () => import('./View')
} as RouteConfig
默认情况下,一般不需要特别声明 sort 属性。当你使用nest或exact属性时,PluggableRouter会默认将优先级提升到 sort: -1 的位置。除非两个路由都用了nest或exact,且Switch中对path的解析非得分个优先级关系的情况下,才不得不用。
配合webpack HMR热更新场景使用
import { clearCache } from 'sunny-pluggable-router'
/**
* hot reload场景
* PluggableRouter默认会使用memory cache缓存路由配置以及关联的页面级组件的引用信息。
* 当开启webpack hmr,在无刷新更新js的情况下,更新页面级组件时
* 需要清除PluggableRouter的memory cache缓存,让其重建路由信息达到更新的目的
*/
if (module.hot) {
clearCache()
}
API Docs
customLoading
使用自定义Loading组件,将会在动态加载module时,为用户展示Loading UI
这个Loading组件将会被loadable所用,组件的入参可以参考 LoadingComponent
import { implement } from 'sunny-pluggable-router'
import Loading from '../components/Loading'
implement({
// 使用自定义loading组件
customLoading: () => Loading,
})
customRoute
为PluggableRouter模块,使用自定义路由组件。 自定义选择路由组件的逻辑,可以为路由增加特别功能。 这里我们为路由配置增加了一个auth认证功能。
import { implement } from 'sunny-pluggable-router'
import { AuthRoute } from 'sunny-auth'
implement({
customRoute: route => {
const isAdmin = false
if (isAdmin) {
// 适用于后台管理,后台管理项目默认启用认证路由
// eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare
return route.auth === false ? Route : AuthRoute
} else {
// 适用于前端web应用
return route.auth ? AuthRoute : Route
}
},
})
routeConfig
访问路由配置信息
import { routeConfig } from 'sunny-pluggable-router'
import { generatePath } from 'react-router'
// 返回所有路由配置信息
routeConfig()
// => [{ ... }, { ... }, ...]
// 访问一个key为login的路由配置信息
routeConfig('login')
// => { key: 'login', ... }
/**
* 如果路由path中有动态参数,可以使用generatePath进行解析,输出转义后的path
* 假设path为/login/:welcome?
*/
generatePath(routeConfig('login').path.toString(), { welcome: 'hello' })
// => /login/hello
routeComponents
为了演示routeComponents所有用法,我们需要至少定义两种角色的路由
import { RouteConfig } from 'sunny-pluggable-router'
// home/route.js
export default {
path: '/home',
// group: 'normal', // 路由分组名称默认为normal,是内置的
} as RouteConfig
为站点右下角添加一个小挂件,可以是客服链接。
import { RouteConfig } from 'sunny-pluggable-router'
// CustomerService/route.js
export default {
path: '/', // 设定组件可以展示的作用域。这里表示为全局作用域
// path: '/home', // 表示组件只可以在/home路径下展示
group: 'widget', // 除了内置分组normal,其他分组名称均由你来自定义。很像是对路由组件进行分组
} as RouteConfig
根据group分组好的路由,将由你来决定它们该如何使用
import React, { FC } from 'react'
import { routeComponents } from 'sunny-pluggable-router'
import { Switch } from 'react-router'
export const Content: FC = () => {
return <>
<Switch>
{/* 角色默认为normal的一组路由,可以受控于Switch组件。这是常规用例 */}
{routeComponents()}
</Switch>
{/* 角色为widget路由组件,用来展示为站点设计的挂件。由于使用目的不同,不能放入Switch组件中。 */}
{routeComponents('widget')}
</>
}
props.route
import React, { FC } from 'react'
export const Component: FC<PluggableRouteComponentProps> = ({
route // 当前路由信息
}) => {
return <>{route.path}</>
}
props.routes
import React, { FC } from 'react'
export const Component: FC<PluggableRouteComponentProps> = ({
routes // 所有路由信息表。以route.key为键名,路由信息为键值
}) => {
// 比如我们访问key为login的路由信息
return <>{routes.login.path}</>
}
props.children
import React, { FC } from 'react'
export const Component: FC = ({
children // 如果当前组件为父级路由关联的组件,将会返回所有子一级路由
}) => {
return <>{children}</>
}
props.childrenRouteNodes
import React, { FC } from 'react'
import { PluggableRouteComponentProps } from 'sunny-pluggable-router'
export const Component: FC<PluggableRouteComponentProps> = ({
childrenRouteNodes // 所有子一级路由,以route.key为键名,对应路由组件为键值
}) => {
// 比如当前组件为父级路由组件/app
// login路由组件为子一级路由/app/login
return <>{childrenRouteNodes.login}</>
}
How it works
如何让 PluggableRouter 支持嵌套路由?
可以利用路径信息来辅助分析Route组件的嵌套关系,这个路径信息可以来自Route path,也可以来自 视图所在目录。为了让默认嵌套关系可以被修改,当然也可以手动指定一个类似路径的属性,用来修改嵌套关系。
生成嵌套路由的过程
嵌套路由需要解决4个问题
必须:
- 父组件可以决定props.children摆放位置,提供实现Layout布局的潜力
可选:
- 因嵌套Route不会主动向下传递props,需要支持父组件向子组件传递props
- 父级组件可以选择是否嵌套Switch
- 不同业务模块的嵌套路由可以共享视图
嵌套路由组件中,父子组件或跨组件通信可以选择
- 可以考虑使用 React Context 特性
- 了解如何向下传递 props 的问题 How to pass props to {this.props.children}
分析Route间嵌套关系,选择Route path,还是directory?
选择Route path需要面对的问题
/
,*
,/:variable
, 正则表达式等几种路径无法体现嵌套关系- 无法确认嵌套关系
- 避免这类路径有嵌套关系
- 视为没有嵌套关系的独立视图
- 无法确认嵌套关系
- 处理path中的动态参数,处理数组类型的path
- 要求
UI视觉
和path
的嵌套关系是一致的
选择directory需要面对的问题
- 要求
UI视觉
、path
、目录
的嵌套关系是一致的
共同的问题
- /parent/child, /parent/child/grandchild指向同一个视图,路径之间是别名关系
- 打破了嵌套path规则
- 使用path数组方式声明
- 打破了嵌套path规则
- /parent/child, /parent/child/grandchild,URL前缀一样,但指向不同视图,且UI界面视觉上不是嵌套关系
- 打破了嵌套path规则
- 如果父级路由没有任何嵌套关系,可以声明Route exact属性
- 如果父级路由跟其他视图有嵌套关系,是个死结,则当前视图,需要脱离嵌套关系
- 对于path的解法,需要声明额外路由配置属性来脱离嵌套关系
- 对于directory的解法,在目录上可以脱离嵌套关系
- path + directory脱离嵌套关系后,需要共同面对排序问题
- 兄弟路由,搭配Switch组件切换,如果path前缀相同,需要考虑两个Route排列先后顺序问题
- 打破了嵌套path规则
path,directory分析嵌套关系方案的共同点
- 要求
Route path
、UI视觉
的嵌套关系是一致的
Browser Compatibility
Changelog
See CHANGELOG.md
Q & A
父层组件重复调用的情况下,嵌套 Route 组件也会跟着重复调用,可以怎么做来避免?
在使用以下方式创建组件后
- class 组件使用 PureComponent
- 函数组件使用 memo
直接使用 View.js 默认 export 作为
<Route>
的子组件,因旧应用的上层组件连续发送 4+次 以上的 dispatch,收到牵连产生连续调用 4+次以上,经过检查主要是 Route 组件传进来的 match 属性每次都会重新创建,这个是 React Router 设计的默认行为,详情可以查看 React Router 相关逻辑
应对建议:
- 在 memo 第二个参数或 shouldComponentUpdate 手动对比变化,查看 Tim Dorr 推荐做法
- 代码从 View.js 默认 export 中分离出去
- 从根源上层组件节点,避免重复调用
避免 connect 全局 state,只使用和组件相关 state。redux 对全局 state 使用建议
Don’t do this! It kills any performance optimizations because TodoApp will rerender after every state change. It’s better to have more granular connect() on several components in your view hierarchy that each only listen to a relevant slice of the state.
React Router 相关逻辑
只要有 path 路径信息,match 就会重新创建。matchPath 函数每次都会返回新的 Object。 使用 withRouter 的场景,path 可能为空,就会直接使用上一次的计算的结果。
如何实现breadcrumbs面包屑组件?
这里提供一下思路,面包屑组件可能需要满足两种用例
- 根据视图嵌套关系自动生成
- 为route config设置parent属性
- 使用parent属性树藤摸瓜遍历父级节点信息
- 自定义一个路由配置信息的一维数组
- 使用route key组成数组
- 使用route config对象组成数组
可定制:
- 自定义面包屑渲染方式
- 默认使用route config name属性
- 使用函数接受参数,自定义面包屑输出内容
相关讨论: