0.0.7 • Published 2 years ago

sunny-pluggable-router v0.0.7

Weekly downloads
-
License
MIT
Repository
-
Last release
2 years ago

sunny-pluggable-router

npm version npm downloads license

基于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,也可以来自 视图所在目录。为了让默认嵌套关系可以被修改,当然也可以手动指定一个类似路径的属性,用来修改嵌套关系。

生成嵌套路由的过程

  1. 扫描所有视图入口,生成 一维数组路由信息表 ,包含视图所在目录信息
  2. 根据视图入口所在目录信息分析嵌套关系,生成 嵌套路由信息表
  3. 使用递归将嵌套路由信息表转换为 嵌套路由组件

嵌套路由需要解决4个问题

必须:

  1. 父组件可以决定props.children摆放位置,提供实现Layout布局的潜力

可选:

  1. 因嵌套Route不会主动向下传递props,需要支持父组件向子组件传递props
  2. 父级组件可以选择是否嵌套Switch
  3. 不同业务模块的嵌套路由可以共享视图

嵌套路由组件中,父子组件或跨组件通信可以选择

分析Route间嵌套关系,选择Route path,还是directory?

选择Route path需要面对的问题

  1. /, *, /:variable, 正则表达式等几种路径无法体现嵌套关系
    1. 无法确认嵌套关系
      1. 避免这类路径有嵌套关系
      2. 视为没有嵌套关系的独立视图
  2. 处理path中的动态参数,处理数组类型的path
  3. 要求UI视觉path的嵌套关系是一致的

选择directory需要面对的问题

  1. 要求UI视觉path目录的嵌套关系是一致的

共同的问题

  1. /parent/child, /parent/child/grandchild指向同一个视图,路径之间是别名关系
    1. 打破了嵌套path规则
      1. 使用path数组方式声明
  2. /parent/child, /parent/child/grandchild,URL前缀一样,但指向不同视图,且UI界面视觉上不是嵌套关系
    1. 打破了嵌套path规则
      1. 如果父级路由没有任何嵌套关系,可以声明Route exact属性
      2. 如果父级路由跟其他视图有嵌套关系,是个死结,则当前视图,需要脱离嵌套关系
        • 对于path的解法,需要声明额外路由配置属性来脱离嵌套关系
        • 对于directory的解法,在目录上可以脱离嵌套关系
        • path + directory脱离嵌套关系后,需要共同面对排序问题

path,directory分析嵌套关系方案的共同点

  1. 要求Route pathUI视觉的嵌套关系是一致的

Browser Compatibility

浏览器兼容性

Changelog

See CHANGELOG.md

Q & A

父层组件重复调用的情况下,嵌套 Route 组件也会跟着重复调用,可以怎么做来避免?

在使用以下方式创建组件后

  1. class 组件使用 PureComponent
  2. 函数组件使用 memo 直接使用 View.js 默认 export 作为 <Route> 的子组件,因旧应用的上层组件连续发送 4+次 以上的 dispatch,收到牵连产生连续调用 4+次以上,经过检查主要是 Route 组件传进来的 match 属性每次都会重新创建,这个是 React Router 设计的默认行为,详情可以查看 React Router 相关逻辑

应对建议:

  1. 在 memo 第二个参数或 shouldComponentUpdate 手动对比变化,查看 Tim Dorr 推荐做法
  2. 代码从 View.js 默认 export 中分离出去
  3. 从根源上层组件节点,避免重复调用 避免 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 相关逻辑

  • [switch 输出 match 属性的逻辑](https://github.com/ReactTraining/react-router/blob/ea44618e68f6a112e48404b2ea0da3e207daf4f0/packages/react-router/modules/Switch.js#L33)
  • [matchPath 函数定义](https://github.com/ReactTraining/react-router/blob/29e02a301a6d2f73f6c009d973f87e004c83bea4/packages/react-router/modules/matchPath.js#L28)

只要有 path 路径信息,match 就会重新创建。matchPath 函数每次都会返回新的 Object。 使用 withRouter 的场景,path 可能为空,就会直接使用上一次的计算的结果。

如何实现breadcrumbs面包屑组件?

这里提供一下思路,面包屑组件可能需要满足两种用例

  1. 根据视图嵌套关系自动生成
    • 为route config设置parent属性
    • 使用parent属性树藤摸瓜遍历父级节点信息
  2. 自定义一个路由配置信息的一维数组
    • 使用route key组成数组
    • 使用route config对象组成数组

可定制:

  • 自定义面包屑渲染方式
    • 默认使用route config name属性
    • 使用函数接受参数,自定义面包屑输出内容

相关讨论:

  • [Breadcrumbs Example in V4 Documentation](https://github.com/ReactTraining/react-router/issues/4556)