作者|张小俊
起源|尔达 Erda 公众号

导读:其实在前端畛域,还有很多根底的货色有待深刻去做。不为造轮子而造轮子,才是在做有意义的事件。所以,咱们决定撰写《Erda 前端之声》系列文章,深刻分析咱们在前端摸索过程中的一些落地教训,以此助力在前端之路上奋进的开发者们,可能早日挖掘属于本人的精彩。

系列文章举荐:

  • 《灵魂拷问:咱们该如何写一个适宜本人的状态治理库?》
  • 《浅谈:前端路由原理解析及实际》(本文)

前言

大家好,这里是 Erda 技术团队。作为 Erda 我的项目的前端,Erda-UI 我的项目从最后开发到当初开源,业务复杂度在一直递增,我的项目的代码文件曾经近 2000,我的项目外部的路由配置曾经超过 500 个。本文会先简略介绍一下前端路由原理,以及 React-Router 的根底应用,接着会次要分享 Erda-UI 我的项目在路由上实际的一些拓展性能。

背景

在单页面利用(SPA)曾经十分成熟的当下,路由也成了前端我的项目的次要配置,咱们应用路由来治理我的项目页面的组成构造,各大前端框架也都有各自成熟的路由解决方案(React: React-Router、Vue: Vue-Router)。而在简单的业务零碎中,往往存在很多跟路由相干的其余逻辑,比方权限、面包屑等。咱们心愿这部分逻辑能整合到路由的配置当中,这样能无效的加重开发和保护的累赘。Erda-UI 我的项目应用 React 框架,所以上面的内容都基于 React-Router。

路由原理

路由的基本原理,就是在不刷新浏览器的状况下批改浏览器链接,同时监听链接的变动并找到匹配的组件渲染。满足这两个条件即可实现。

路由的实现通常有以下两种模式:

  • hash ( /#path )
  • history ( /path )

hash 在浏览器中默认是作为锚点来应用的,在 hash 模式中,url 里始终会有 #,没有传统 url 写法那么好看,所以在不思考兼容性的状况下应用 history 的模式是更好的抉择。

hash

hash 模式下,url 中 # 前面的局部只是一个客户端状态,当这部分变动时,浏览器自身就不会刷新,天生具备第一个条件(即在不刷新浏览器的状况下批改浏览器链接),同时通过监听 hashChange 事件或注册 onhashchange 回调函数来监听 url 中 hash 值的变动。

window.addEventListener('hashchange', hashChangeHandler); // or window.onhashchange = hashChangeHandler;

history

history 模式,是利用了 HTML5 中 history 的 API,history.pushState 和 history.replaceState 这两个办法,能够在不刷新页面的状况下,操作浏览器的历史记录,前者为新增一条记录,后者为替换最初一条记录。同时通过监听 popState 事件或注册 onpopstate 回调函数来监听 url 的变动。

window.addEventListener('popState', locationChangeHandler); // or window.onpopstate = locationChangeHandler;

然而这里有一点须要留神,history.pushState 和 history.replaceState 是不会主动触发 popState 的。只有在做出浏览器动作时,才会触发该事件,比方用户点击浏览器的回退按钮。通常路由库里会封装一个监听办法,不论是调用 history.pushState、history.replaceState,还是用户触发浏览器动作导致的路由变动,都可能触发监听函数。以 react-router-dom 中的 listen(局部为伪代码)为例:

function setState(nextState) {  _extends(history, nextState);  history.length = history.entries.length;  // 将路由变动应用 state 治理,在变动时,告诉所有监听者  transitionManager.notifyListeners(history.location, history.action);}// 封装 push、replace 等办法function push(path, state) {  // ...  globalHistory.pushState({    key: key,    state: state  }, null, href);  // ...  setState({ // 手动触发监听    action: action,    location: location  })}// popState 事件监听,监听事件同时 setState,告诉 transitionManager 中的 listeners;function handlePopState(location){  // ...  setState(location)  // ...}// 封装 listen。function listen(listener) {  var unlisten = transitionManager.appendListener(listener);  window.addEventListener('popState', handlePopState); // 监听浏览器事件。  // ...}

React-Router 路由根底

为了不便开展上面的内容探讨,本章节先简略介绍一下 React-Router 相干根底。

根底库

React-Router 相干的库次要有以下几个:

  • react-router 外围库
  • react-router-dom 基于 DOM 的路由实现,外部蕴含 react-router 的实现,应用时无需再引 react-router
  • react-router-native 基于 React Native 的路由实现
  • react-router-redux 路由和 Redux 的集成,不再保护
  • react-router-config 用于配置动态路由

react-router-dom

对应了路由的两种实现形式,react-router-dom 库也提供了两个路由组件:BrowserRouter、HashRouter。

  • Route : 路由单元,配置一个 path 以及对应的渲染组件,其中 exact 示意准确匹配
  • Switch: 管制渲染第一个匹配的路由组件
  • Link: 链接组件,相当于 标签
  • Redirect: 重定向组件

应用

路由根本的应用如下:

import { BrowserRouter, Link, Route, Switch, Redirect } from 'react-router-dom'function App(){  return (     <BrowserRouter>         <Link to="/home">home</Link>        <Link to="/about">About</Link>        <Switch>          <Route path="/home" exact component={Home} />          <Route path="/about" exact component={About} />          <Redirect to="/not-found" component={NotFound} />        </Switch>     </BrowserRouter>  )}

除此之外,还能够嵌套应用,即在组件外部再配置路由。在路由过多的状况下,能够通过这种形式将 Router 拆分,这让 Router 更具备个别组件的个性,能够随便嵌套。而组件中能够失去一个 math 的 props 来获取下级路由的相干信息。

import { BrowserRouter, Link, Route, Switch, Redirect } from 'react-router-dom'function App(){  return (     <BrowserRouter>         <Link to="/home">home</Link>          <Link to="/settings">Settings</Link>        <Switch>          <Route path="/home" exact component={Home} />          <Route path="/settings" exact component={Settings} />        </Switch>     </BrowserRouter>  )}const Setting = (props) => {  const matchPath = props.match.path;  return (    <div>      <Link to={`${matchPath}/a`}>a</Link>      <Link to={`${matchPath}/b`}>b</Link>      <Switch>        <Route path={`${matchPath}/a`} component={AComp} />        <Route path={`${matchPath}/b`} component={BComp} />      </Switch>    </div>  )}

然而,我的项目中的路由除了数量比拟多外,通常还会有一些须要集中处理的逻辑,扩散的路由配置形式显然不太适宜,而 react-router-config 为咱们提供了不便的动态路由配置,其本质就是将一份 config 转换为 Route 组件,而在组件渲染的办法 render 中,则能够依据业务状况来做一些对立的解决。

function renderRoutes(routes, extraProps, switchProps) {  // ...  return routes ? React.createElement(reactRouter.Switch, switchProps, routes.map(function (route, i) {    return React.createElement(reactRouter.Route, {      key: route.key || i,      path: route.path,      exact: route.exact,      strict: route.strict,      render: function render(props) {        return route.render ? route.render(_extends({}, props, {}, extraProps, {          route: route        })) : React.createElement(route.component, _extends({}, props, extraProps, {          route: route        }));      }    });  })) : null;}

Erda-UI 我的项目路由实际

路由配置

const routers = {  path: ':orgName',  mark: 'org',  breadcrumbName: '{orgName}'  routes: [    {      path: 'workBench',      breadcrumbName: 'DevOps平台',      mark: 'workBench',      routes: [          {          path: 'projects/:projectId',          breadcrumbName: '',          mark: 'project',          AuthContainer: ProjectAuth,          routes: [            {              path: 'apps',              pageTitle: '利用列表',              getComp: cb => cb(import('/xx/xx')),              routes: [                {                  path: 'apps/:appId',                  mark: 'application',                  breadcrumbName: '利用',                  AuthContainer: AppAuth,                }              ]            },          ]        }      ],    },  ]}

由上咱们能够看到,在配置中除了 path 之外,其余的字段仿佛都和 React-Router 没什么太大关系,这些字段也正是咱们实现跟路由相干逻辑的配置,上面咱们会一一介绍。

路由状态治理:routeInfoStore

为了拓展路由相干性能,咱们首先须要有一个路由对象为咱们提供数据反对,之所以须要这个对象,是因为单个的路由信息不足以实现其余相干逻辑,咱们须要更多路由信息,比方路由层级上的链路记录,前后路由的状态比照等。

咱们应用一个 routeInfoStore 对象来治理路由相干的数据和状态。这个对象能够在组件之间共享路由状态(相似 Redux 中 store)。

咱们通过在 browserHistory.listen 中监听并调用 routeInfoStore 中解决路由变动的办法($_updateRouteInfo)来更新路由数据和状态。

browserHistory.listen((loc) => {  // 监听路由变动触发 routerStore 的更新,相似 Redux 中 dispatch;  // 此处应用公布订阅模式 来实现触发调用事件  emit('@routeChange', routerStore.reducers.$_updateRouteInfo(loc));});// routeStore 中的数据const initRouteInfo: IRouteInfo = {  routes: [], // 以后路由所通过的层级,若路由在子模块,则改子模块所有的父模块也会被记录在内  params: {}, // 以后 url 中门路里的所有变量  query: {}, // 以后 url 中 search(?前面)的参数  currentRoute: {}, // 以后匹配上的路由配置  routeMarks: [], // 标记了 mark 的路由层级  isIn: () => false,  // 扩大办法:用于判断是否在以后路由内  isMatch: () => false,// 扩大办法:用于判断是否匹配以后路由  isEntering: () => false,// 扩大办法:用于判断是否正在进入以后路由  isLeaving: () => false,// 扩大办法:用于判断是否来到以后路由  prevRouteInfo: {}, // 上一次路由的信息};

路由监听扩大:mark

通常咱们须要监听路由在进入或来到某个范畴内,主动进行的一些前置初始化操作,比方进模块 A,首先要获取模块 A 的权限,或者模块 A 的一些根底信息。来到模块 A 时,须要去清空相干的信息。为了做到这些监听和初始化,咱们须要两个条件:

  • 标记范畴的字段。
  • 在路由变动的时候,判断路由是否来到或进入相应的范畴。

咱们在路由配置中增加了 mark 字段,用于标记以后路由的范畴,相似路由范畴的 id,须要保障全局惟一。而上文有说到 routeInfoStore 中,routeMarks 中会记录路由链路层级的 mark 汇合,prevRouteInfo 会记录上一次路由信息。借此,咱们能够在 routerInfoStore 里增加一些路由范畴判断的函数 isIn、isEntering、isLeaving、isMatch。

isIn($mark) => boolean

示意以后路由是否在某个范畴内。传入一个 mark 值,通过 routeInfoStore 中 routeMarks 中是否蕴含来判断:

// routeMarks 内记录了路由通过的所有 mark 标记,通过判断 mark 是否被蕴含isIn: (mark: string) => routeMarks.includes(mark), 

isEntering($mark) => boolean

示意以后路由正在进入某个范畴,区别于 isIn, 这是一个正在进行时的判断,示意上一次路由并不在该范畴,而以后这次在该范畴内。

//通过判断 mark 被蕴含,同时上一次的路由不被蕴含,判断是正在进入以后 mark。isEntering: (mark: string) => routeMarks.includes(mark) && !prevRouteInfo.routeMarks.includes(mark),

isLeaving($mark) => boolean

跟 isEntering 相同,isLeaving 示意上一次路由在范畴内,而下一次路由来到范畴,即正在来到。

//通过判断 mark 不被蕴含,同时上一次的路由被蕴含,判断是正在来到以后 mark。  isLeaving: (mark: string) => !routeMarks.includes(mark) && prevRouteInfo.routeMarks.includes(mark),

isMatch($pattern) => boolean

传入一个正则,判断路由是否匹配正则,个别用于对以后路由的直接判断:

//通过正则判断isMatch: (pattern: string) => !!pathToRegexp(pattern, []).exec(pathname),

注册监听

咱们提供了一个监听的办法,能够在我的项目启动时,由各个模块注册本人的路由监听函数,而监听函数中,则能够方便使用以上办法判断路由的范畴。

// 路由监听注册export const listenRoute = (cb: Function) => {  // getState 返回routeInfoStore 对象,其中蕴含了以上的判断办法  cb(routeInfoStore.getState(s => s));     // 路由变动时,调用监听办法  on('@routeChange', cb);};// 模块 A 注册listenRoute((_routeInfo) => {  const { isEntering, isLeaving } = _routeInfo;    if(isEntering('markA')){    // 初始化模块 A  }    if(isLeaving('markA')) {    // 革除模块 A 信息  }})

路由拆分:toMark

当路由数量过大,一份路由数据嵌套可能很深,因而必然须要反对路由配置的拆分。

咱们提供了路由注册的办法 registerRouter,不同模块能够只注册本人的路由,而后通过 toMark 字段来建设路由之间的所属关联,toMark 的值是另一个路由的标记 mark 值。在 registerRouter 外部,将所有路由整合成一份残缺的配置。

// 注册 org 路由registerRouter({  path: ':orgName',  mark: 'org',  breadcrumbName: '{orgName}'});// 注册 workBench 路由registerRouter({  path: 'workBench',  breadcrumbName: 'DevOps平台',  mark: 'workBench',  toMark: 'org', // 配置 workBench 路由属于 org 的子路由});// 注册 project 路由registerRouter({  path: 'projects/:projectId',  breadcrumbName: '',  mark: 'project',  toMark: 'workBench', // 配置 project 路由属于 workBench 的子路由  AuthContainer: ProjectAuth,  routes: [    {      path: 'apps',      pageTitle: '利用列表',      getComp: cb => cb(import('/xx/xx')),    },  ]});// 注册 application 路由registerRouter({  path: 'apps/:appId',  mark: 'application',  toMark: 'project', // 配置 application 路由属于 project 的子路由  breadcrumbName: '利用',  AuthContainer: AppAuth,})

路由组件异步加载:getComp

咱们应用 getComp 的形式给单个路由配置组件,getComp 是一个异步办法引入一个组件,而后咱们通过一个异步加载的高阶组件来实现路由组件的加载。

// 重写 rendermap(router, route => {  return {    ...route,    render: (props) => asyncComponent(()=>route.getComp());  }})// 异步组件export const asyncComponent = (getComponent: Function) => {  return class AsyncComponent extends React.Component {    static Component: any = null;    state = { Component: AsyncComponent.Component };    componentDidMount() {      if (!this.state.Component) {        getComponent().then((Component: any) => {          AsyncComponent.Component = Component;          this.setState({ Component });        });      }    }    render() {      const { Component } = this.state;      if (Component) { // 当组件加载实现后,渲染        return <Component {...this.props} />;      }      return null;    }  };};

面包屑:breadcrumbName

Erda-UI 的业务中,路由的配置是一个树形构造,进入子模块路由则肯定通过了父模块路由,通过对路由数据的解析,咱们能失去从根路由到以后路由所通过的层级链路,而路由层级链路刚好映射了面包屑的层级。

咱们通过在路由配置中增加 breadcrumbName 字段,并在 routeInfoStore 的 routes 存储路由的层级链路数据。因而面包屑的数据能够间接通过 routers 中失去。

map(routes, route => {   return {      name: route.breadcrumbName,      path: route.path,   }})

在配置中, breadcrumbName 能够是文字,也能够是字符串模板 {temp} 。这里是利用了另一份 store 的数据来治理所有字符串模板对应的数据,渲染的时候,通过匹配 key 值获取相应的展现文字。

路由鉴权: AuthContainer

在我的项目中,路由是否能拜访,往往须要对应一些条件判断(用户权限、模块是否凋谢等)。不同路由的鉴权条件可能不一样,而且鉴权失败的提醒也可能须要个性化,或者可能存在鉴权不通过后页面须要重定向等场景。这些都须要路由上的鉴权能个性化。就如 react-router-config 中的一样,咱们能够通过调整 Route 组件的 render 函数来达到这个目标。

咱们通过在路由上配置 AuthContainer 组件来给路由做权限拦挡,大抵过程分两步:

  • 提供一个鉴权组件 AuthComp,外部封装鉴权相干逻辑及提醒。
  • 在渲染路由前,获取这个鉴权组件 AuthComp,并重写 render。

// AuthComp const AuthComp = (props) => {  const { children } = props;  const [auth, setAuth] = React.useState(undefined);  useMount(()=>{    doSomeAuthCheck().then(()=>{        setAuth(true)    })  })    if( auth === undefined ){    return <div>加载中</div>  }  return auth ? children : <div>您无权拜访,请分割管理员...</div>}// 重写 rendermap(router, route => {  return {    ...route,    render: (props) => {      const AuthComp = route.AuthContainer;      const Comp = route.components;      return (          <AuthComp {...props} route={route}> // 增加路由鉴权拦挡          {Comp ? <Comp {...props} /> : Comp }        </AuthComp>       )    }  }})

总结及后续思考

Erda-UI 我的项目中,咱们通过以上的一些配置扩大,来集中管理所有的路由。这种形式能够简略高效的保护路由自身以及扩大关联业务逻辑。除此之外还能够做一些更灵便的事件,比方通过剖析整个路由构造,生成可视化的路由树,反对路由的动静调整等等。通过漫长的业务演进和内容欠缺,咱们验证了这种形式带来的益处。

同时咱们也在一直思考还能够改良的中央,比方:

  • 在有链路层级的模块之间,路由的监听如何做到异步串联?

如:模块 A 蕴含模块 B,在模块 A 中注册监听初始化办法 initA,在模块 B 中注册 initB,如何管制 initB 在 initA 实现之后执行(若 initB 中须要应用到 initA 返回的后果时,则须要严格控制执行程序)。

结语

本文中的内容都是很常见的一些场景,为了贴合业务的须要,Erda 我的项目也在不断更新迭代。咱们也会时刻放弃对社区的关注以及对本身业务倒退的剖析,将这一块做到更好,也欢送大家增加小助手微信(Erda202106)进入交换群探讨

  • Erda Github 地址:https://github.com/erda-project/erda
  • Erda Cloud 官网:https://www.erda.cloud/