React路由鉴权

前言

上一篇文章中有同窗提到路由鉴权,因为时间关系没有写,本文将针对这一特性对vuereact作专门说明,但愿同窗看了之后可以受益不浅,对你的项目可以有所帮助,本文借鉴了不少大佬的文章篇幅也是比较长的。javascript

背景

单独项目中是但愿根据登陆人来看下这我的是否是有权限进入当前页面。虽然服务端作了进行接口的权限,可是每个路由加载的时候都要去请求这个接口太浪费了。有时候是经过SESSIONID来校验登录权限的。前端

在正式开始react路由鉴权以前咱们先看一下vue的路由鉴权是如何工做的:vue

1、vue之beforeEach路由鉴权

通常咱们会相应的把路由表角色菜单配置在后端,当用户未经过页面菜单,直接从地址栏访问非权限范围内的url时,拦截用户访问并重定向到首页。java

vue的初期是能够经过动态路由的方式,按照权限加载对应的路由表AddRouter,可是因为权限交叉,致使权限路由表要作判断结合,想一想仍是挺麻烦的,因此采用的是在beforeEach里面直判断用非动态路由的方式react

在使用 Vue的时候,框架提供了路由守卫功能,用来在进入某个路有前进行一些校验工做,若是校验失败,就跳转到 404 或者登录页面,好比 Vue 中的 beforeEnter 函数:webpack

...
router.beforeEach(async(to, from, next) => {
    const toPath = to.path;
    const fromPath = from.path;
})
...复制代码

一、路由概览

// index.js
import Vue from 'vue'
import Router from 'vue-router'

import LabelMarket from './modules/label-market'
import PersonalCenter from './modules/personal-center'
import SystemSetting from './modules/system-setting'

import API from '@/utils/api'

Vue.use(Router)

const routes = [
  {
    path: '/label',
    component: () => import(/* webpackChunkName: "index" */ '@/views/index.vue'),
    redirect: { name: 'LabelMarket' },
    children: [
      { // 基础公共页面
        path: 'label-market',
        name: 'LabelMarket',
        component: () => import(/* webpackChunkName: "label-market" */ '@/components/page-layout/OneColLayout.vue'),
        redirect: { name: 'LabelMarketIndex' },
        children: LabelMarket
      },
      { // 我的中心
        path: 'personal-center',
        name: 'PersonalCenter',
        redirect: '/label/personal-center/my-apply',
        component: () => import(/* webpackChunkName: "personal-center" */ '@/components/page-layout/TwoColLayout.vue'),
        children: PersonalCenter
      },
      { // 系统设置
        path: 'system-setting',
        name: 'SystemSetting',
        redirect: '/label/system-setting/theme',
        component: () => import(/* webpackChunkName: "system-setting" */ '@/components/page-layout/TwoColLayout.vue'),
        children: SystemSetting
      }]
  },
  {
    path: '*',
    redirect: '/label'
  }
]

const router = new Router({ mode: 'history', routes })
// personal-center.js
export default [
    ...
  { // 个人审批
    path: 'my-approve',
    name: 'PersonalCenterMyApprove',
    component: () => import(/* webpackChunkName: "personal-center" */ '@/views/personal-center/index.vue'),
    children: [
      { // 数据服务审批
        path: 'api',
        name: 'PersonalCenterMyApproveApi',
        meta: {
          requireAuth: true,
          authRole: 'dataServiceAdmin'
        },
        component: () => import(/* webpackChunkName: "personal-center" */ '@/views/personal-center/api-approve/index.vue')
      },
      ...
    ]
  }
]复制代码
export default [
    ...
  { // 数据服务设置
    path: 'api',
    name: 'SystemSettingApi',
    meta: {
      requireAuth: true,
      authRole: 'dataServiceAdmin'
    },
    component: () => import(/* webpackChunkName: "system-setting" */ '@/views/system-setting/api/index.vue')
  },
  { // 主题设置
    path: 'theme',
    name: 'SystemSettingTheme',
    meta: {
      requireAuth: true,
      authRole: 'topicAdmin'
    },
    component: () => import(/* webpackChunkName: "system-setting" */ '@/views/system-setting/theme/index.vue')
  },
    ...
]复制代码

二、鉴权判断

用户登录信息请求后端接口,返回菜单、权限、版权信息等公共信息,存入vuex。此处用到权限字段以下:git

_userInfo: {
    admin:false, // 是否超级管理员
    dataServiceAdmin:true, // 是否数据服务管理员
    topicAdmin:false // 是否主题管理员
}复制代码
  1. 判断当前路由是否须要鉴权(router中meta字段下requireAuth是否为true),让公共页面直接放行;
  2. 判断角色是超级管理员,直接放行;
  3. (本系统特殊逻辑)判断跳转路径是主题设置但角色不为主题管理员,继续判断角色是否为数据服务管理员,跳转数据服务设置页or重定向(‘系统设置’菜单'/label/system-setting'默认重定向到'/label/system-setting/theme',其余菜单默认重定向的都是基础公共页面,故须要对这里的重定向鉴权。系统设置的权限不是主题管理员就必定是数据服务管理员,因此能这样作);
  4. 判断路由需求权限是否符合,若不符合直接重定向。
// index.js
router.beforeEach(async (to, from, next) => {
  try {
    // get user login info
    const _userInfo = await API.get('/common/query/menu', {}, false)
    router.app.$store.dispatch('setLoginUser', _userInfo)

    if (_userInfo && Object.keys(_userInfo).length > 0 &&
      to.matched.some(record => record.meta.requireAuth)) {
      if (_userInfo.admin) { // super admin can pass
        next()
      } else if (to.fullPath === '/label/system-setting/theme' &&
        !_userInfo.topicAdmin) {
        if (_userInfo.dataServiceAdmin) {
          next({ path: '/label/system-setting/api' })
        } else {
          next({ path: '/label' })
        }
      } else if (!(_userInfo[to.meta.authRole])) {
        next({ path: '/label' })
      }
    }
  } catch (e) {
    router.app.$message.error('获取用户登录信息失败!')
  }
  next()
})复制代码

2、简介

一、路由简介

路由是干什么的?github

根据不一样的 url 地址展现不一样的内容或页面。web

单页面应用最大的特色就是只有一个 web 页面。于是全部的页面跳转都须要经过javascript实现。当须要根据用户操做展现不一样的页面时,咱们就须要根据访问路径使用js控制页面展现内容。算法

二、React-router 简介

React Router 是专为 React 设计的路由解决方案。它利用HTML5 的history API,来操做浏览器的 session history (会话历史)。

三、使用

React Router被拆分红四个包:react-router,react-router-dom,react-router-native和react-router-config。react-router提供核心的路由组件与函数。react-router-config用来配置静态路由(还在开发中),其他两个则提供了运行环境(浏览器与react-native)所需的特定组件。

进行网站(将会运行在浏览器环境中)构建,咱们应当安装react-router-dom。由于react-router-dom已经暴露出react-router中暴露的对象与方法,所以你只须要安装并引用react-router-dom便可。

四、相关组件

4-一、

使用了 HTML5 的 history API (pushState, replaceState and the popstate event) 用于保证你的地址栏信息与界面保持一致。

主要属性:

basename:设置根路径

getUserConfirmation:获取用户确认的函数

forceRefresh:是否刷新整个页面

keyLength:location.key的长度

children:子节点(单个)

4-二、

为旧版本浏览器开发的组件,一般简易使用BrowserRouter。

4-三、

为项目提供声明性的、可访问的导航

主要属性:

to:能够是一个字符串表示目标路径,也能够是一个对象,包含四个属性:

pathname:表示指向的目标路径

search: 传递的搜索参数

hash:路径的hash值

state: 地址状态

replace:是否替换整个历史栈

innerRef:访问部件的底层引用

同时支持全部a标签的属性例如className,title等等

4-四、

React-router 中最重要的组件,最主要的职责就是根据匹配的路径渲染指定的组件

主要属性:

path:须要匹配的路径

component:须要渲染的组件

render:渲染组件的函数

children :渲染组件的函数,经常使用在path没法匹配时呈现的’空’状态即所谓的默认显示状态

4-五、

重定向组件

主要属性: to:指向的路径

<Switch>

嵌套组件:惟一的渲染匹配路径的第一个子 <Route> 或者 <Redirect>

3、react-router-config之路由鉴权

引言

在以前的版本中,React Router 也提供了相似的 onEnter 钩子,但在 React Router 4.0 版本中,取消了这个方法。React Router 4.0 采用了声明式的组件,路由即组件,要实现路由守卫功能,就得咱们本身去写了。

一、react-router-config 是一个帮助咱们配置静态路由的小助手。其源码就是一个高阶函数 利用一个map函数生成静态路由

import React from "react";
import Switch from "react-router/Switch";
import Route from "react-router/Route";
const renderRoutes = (routes, extraProps = {}, switchProps = {}) =>
routes ? (
    <Switch {...switchProps}>
        {routes.map((route, i) => ( 
        <Route
          key={route.key || i}
          path={route.path}
          exact={route.exact}
          strict={route.strict}
          render={props => (
            <route.component {...props} {...extraProps} route={route} />
          )}
        />
      ))}
    </Switch>
  ) : null;
 export default renderRoutes;复制代码

//router.js 假设这是咱们设置的路由数组(这种写法和vue很类似是否是?)

const routes = [
    { path: '/',
        exact: true,
        component: Home,
    },
    {
        path: '/login',
        component: Login,
    },
    {
        path: '/user',
        component: User,
    },
    {
        path: '*',
        component: NotFound
    }
]复制代码

//app.js 那么咱们在app.js里这么使用就能帮我生成静态的路由了

import { renderRoutes } from 'react-router-config'
import routes from './router.js'
const App = () => (
   <main>
      <Switch>
         {renderRoutes(routes)}
      </Switch>
   </main>
)

export default App复制代码

用过vue的小朋友都知道,vue的router.js 里面添加 meta: { requiresAuth: true }

而后利用导航守卫

router.beforeEach((to, from, next) => {
  // 在每次路由进入以前判断requiresAuth的值,若是是true的话呢就先判断是否已登录
})复制代码

二、基于相似vue的路由鉴权想法,咱们稍稍改造一下react-router-config

// utils/renderRoutes.js

import React from 'react'
import { Route, Redirect, Switch } from 'react-router-dom'
const renderRoutes = (routes, authed, authPath = '/login', extraProps = {}, switchProps = {}) => routes ? (
  <Switch {...switchProps}>
    {routes.map((route, i) => (
      <Route
        key={route.key || i}
        path={route.path}
        exact={route.exact}
        strict={route.strict}
        render={(props) => {
          if (!route.requiresAuth || authed || route.path === authPath) {
            return <route.component {...props} {...extraProps} route={route} />
          }
          return <Redirect to={{ pathname: authPath, state: { from: props.location } }} />
        }}
      />
    ))}
  </Switch>
) : null
export default renderRoutes复制代码

修改后的源码增长了两个参数 authed 、 authPath 和一个属性 route.requiresAuth

而后再来看一下最关键的一段代码

if (!route.requiresAuth || authed || route.path === authPath) {
    return <route.component {...props} {...extraProps} route={route} />
    }
    return <Redirect to={{ pathname: authPath, state: { from: props.location } }} />复制代码

很简单 若是 route.requiresAuth = false 或者 authed = true 或者 route.path === authPath(参数默认值'/login')则渲染咱们页面,不然就渲染咱们设置的authPath页面,并记录从哪一个页面跳转。

相应的router.js也要稍微修改一下

const routes = [
    { path: '/',
        exact: true,
        component: Home,
        requiresAuth: false,
    },
    {
        path: '/login',
        component: Login,
        requiresAuth: false,
    },
    {
        path: '/user',
        component: User,
        requiresAuth: true, //须要登录后才能跳转的页面
    },
    {
        path: '*',
        component: NotFound,
        requiresAuth: false,
    }
]复制代码

//app.js

import React from 'react'
import { Switch } from 'react-router-dom'
//import { renderRoutes } from 'react-router-config'
import renderRoutes from './utils/renderRoutes'
import routes from './router.js'
const authed = false // 若是登录以后能够利用redux修改该值(关于redux不在咱们这篇文章的讨论范围以内)
const authPath = '/login' // 默认未登陆的时候返回的页面,能够自行设置
const App = () => (
   <main>
      <Switch>
         {renderRoutes(routes, authed, authPath)}
      </Switch>
   </main>
)
export default App复制代码
//登录以后返回原先要去的页面login函数
login(){
    const { from } = this.props.location.state || { from: { pathname: '/' } }
     // authed = true // 这部分逻辑本身写吧。。。
    this.props.history.push(from.pathname)
}复制代码

到此react-router-config就结束了并完成了咱们想要的效果

三、注意⚠️

不少人会发现,有时候达不到咱们想要的效果,那么怎么办呢,接着往下看

一、设计全局组建来管理是否登录

configLogin.js

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { withRouter } from 'react-router-dom'

class App extends Component {
  static propTypes = {
    children: PropTypes.object,
    location: PropTypes.object,
    isLogin: PropTypes.bool,
    history: PropTypes.object
  };
  componentDidMount () {
    if (!this.props.isLogin) {
      setTimeout(() => {
        this.props.history.push('/login')
      }, 300)
    }
    if (this.props.isLogin && this.props.location.pathname === '/login') {
      setTimeout(() => {
        this.props.history.push('/')
      }, 300)
    }
  }

  componentDidUpdate () {
    if (!this.props.isLogin) {
      setTimeout(() => {
        this.props.history.push('/login')
      }, 300)
    }
  }
  render () {
    return this.props.children
  }
}

export default withRouter(App)
复制代码

经过在主路由模块index.js中引入

import {
  BrowserRouter as Router,
  Redirect,
  Route,
  Switch
} from 'react-router-dom'

<Router
   history={ history }
   basename="/"
   getUserConfirmation={ getConfirmation(history, 'yourCallBack') }
   forceRefresh={ !supportsHistory }
 >
  <App isLogin={ isLogin ? true : false }>
    <Switch>
     <Route
     exact
     path="/"
     render={ () => <Redirect to="/layout/dashboard" push /> }
     />
     <Route path="/login" component={ Login } />
     <Route path="/layout" component={ RootLayout } />
     <Route component={ NotFound } />
   </Switch>
  </App>
 </Router>复制代码

不少时候咱们是能够经过监听路由变化实现的好比getUserConfirmation钩子就是作这件事情的

const getConfirmation = (message, callback) => {
  if (!isLogin) {
    message.push('/login')
  } else {
    message.push(message.location.pathname)
  }复制代码

接下来咱们看一下react-acl-router又是怎么实现的

4、权限管理机制

本节参考代码:

  1. react-acl-router
  2. react-boilerplate-pro/src/app/init/router.js
  3. react-boilerplate-pro/src/app/config/routes.js

image.png

权限管理做为企业管理系统中很是核心的一个部分,一直以来由于业务方不少时候没法使用准确的术语来描述需求成为了困扰开发者们的一大难题。这里咱们先来介绍两种常见的权限管理设计模式,即基于角色的访问控制以及访问控制列表。

一、布局与路由

在讨论具体的布局组件设计前,咱们首先要解决一个更为基础的问题,那就是如何将布局组件与应用路由结合起来。

下面的这个例子是 react-router 官方提供的侧边栏菜单与路由结合的例子,笔者这里作了一些简化:

const SidebarExample = () => (
  <Router>
    <div style={{ display: "flex" }}>
      <div
        style={{
          padding: "10px",
          width: "40%",
          background: "#f0f0f0"
        }}
      >
        <ul style={{ listStyleType: "none", padding: 0 }}>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/bubblegum">Bubblegum</Link>
          </li>
          <li>
            <Link to="/shoelaces">Shoelaces</Link>
          </li>
        </ul>
      </div>

      <div style={{ flex: 1, padding: "10px" }}>
        {routes.map((route, index) => (
          <Route
            key={index}
            path={route.path}
            exact={route.exact}
            component={route.main}
          />
        ))}
      </div>
    </div>
  </Router>
);复制代码

抽象为布局的思想,写成简单的伪代码就是:

<Router>
  <BasicLayout>                   // with sidebar
    {routes.map(route => (
      <Route {...route} />
    ))}
  </BasicLayout>
</Router>复制代码

这样的确是一种很是优雅的解决方案,但它的局限性在于没法支持多种不一样的布局。受限于一个 Router 只能包含一个子组件,即便咱们将多个布局组件包裹在一个容器组件中,如:

<Router>
  <div>
    <BasicLayout>                 // with sidebar
      {routes.map(route => (
        <Route {...route} />
      )}
    </BasicLayout>
    <FlexLayout>                  // with footer
      {routes.map(route => (
        <Route {...route} />
      )}
    </FlexLayout>
  </div>
</Router>复制代码

路由在匹配到 FlexLayout 下的页面时,BasicLayout 中的 sidebar 也会同时显示出来,这显然不是咱们想要的结果。换个思路,咱们可不能够将布局组件当作 children 直接传给更底层的 Route 组件呢?代码以下:

<Router>
  <div>
    {basicLayoutRoutes.map(route => (
      <Route {...route}>
        <BasicLayout component={route.component} />
      </Route>
    ))}
    {flexLayoutRoutes.map(route => (
      <Route {...route}>
        <FlexLayout component={route.component} />
      </Route>
    ))}
  </div>
</Router>复制代码

这里咱们将不一样的布局组件当作高阶组件,相应地包裹在了不一样的页面组件上,这样就实现了对多种不一样布局的支持。还有一点须要注意的是,react-router 默认会将 matchlocationhistory 等路由信息传递给 Route 的下一级组件,因为在上述方案中,Route 的下一级组件并非真正的页面组件而是布局组件,于是咱们须要在布局组件中手动将这些路由信息传递给页面组件,或者统一改写 Routerender 方法为:

<Route
  render={props => (                 // props contains match, location, history
    <BasicLayout {...props}>          
      <PageComponent {...props} />
    </BasicLayout>
  )}
/>复制代码

另一个可能会遇到的问题是,connected-react-router 并不会将路由中很是重要的 match 对象(包含当前路由的 params 等数据 )同步到 redux store 中,因此咱们必定要保证布局及页面组件在路由部分就能够接收到 match 对象,不然在后续处理页面页眉等与当前路由参数相关的需求时就会变得很是麻烦。

二、页眉 & 页脚

解决了与应用路由相结合的问题,具体到布局组件内部,其中最重要的两部分就是页面的页眉和页脚部分,而页眉又能够分为应用页眉与页面页眉两部分。

应用页眉指的是整个应用层面的页眉,与具体的页面无关,通常来讲会包含用户头像、通知栏、搜索框、多语言切换等这些应用级别的信息与操做。页面页眉则通常来说会包含页面标题、面包屑导航、页面通用操做等与具体页面相关的内容。

在以往的项目中,尤为是在项目初期许多开发者由于对项目自己尚未一个总体的认识,不少时候会倾向于将应用页眉作成一个展现型组件并在不一样的页面中直接调用。这样作固然有其方便之处,好比说页面与布局之间的数据同步环节就被省略掉了,每一个页面均可以直接向页眉传递本身内部的数据。

但从理想的项目架构角度来说这样作倒是一个反模式(anti-pattern)。由于应用页眉实际是一个应用级别的组件,但按照上述作法的话却变成了一个页面级别的组件,伪代码以下:

<App>
  <BasicLayout>
    <PageA>
      <AppHeader title="Page A" />
    </PageA>
  </BasicLayout>
  <BasicLayout>
    <PageB>
      <AppHeader title="Page B" />
    </PageB>
  </BasicLayout>
</App>复制代码

从应用数据流的角度来说也存在着一样的问题,那就是应用页眉应该是向不一样的页面去传递数据的,而不是反过来去接收来自页面的数据。这致使应用页眉丧失了控制本身什么时候 rerender(重绘) 的机会,做为一个纯展现型组件,一旦接收到的 props 发生变化页眉就须要进行一次重绘。

另外一方面,除了通用的应用页眉外,页面页眉与页面路由之间是有着严格的一一对应的关系的,那么咱们能不能将页面页眉部分的配置也作到路由配置中去,以达到新增长一个页面时只须要在 config/routes.js 中多配置一个路由对象就能够完成页面页眉部分的建立呢?理想状况下的伪代码以下:

<App>
  <BasicLayout>                    // with app & page header already
    <PageA />
  </BasicLayout>
  <BasicLayout>
    <PageB />
  </BasicLayout>
</App>复制代码

一、配置优于代码

在过去关于组件库的讨论中咱们曾经得出过代码优于配置的结论,即须要使用者自定义的部分,应该尽可能抛出回调函数让使用者可使用代码去控制自定义的需求。这是由于组件做为极细粒度上的抽象,配置式的使用模式每每很难知足使用者多变的需求。但在企业管理系统中,做为一个应用级别的解决方案,能使用配置项解决的问题咱们都应该尽可能避免让使用者编写代码。

配置项(配置文件)自然就是一种集中式的管理模式,能够极大地下降应用复杂度。以页眉为例来讲,若是咱们每一个页面文件中都调用了页眉组件,那么一旦页眉组件出现问题咱们就须要修改全部用到页眉组件页面的代码。除去 debug 的状况外,哪怕只是修改一个页面标题这样简单的需求,开发者也须要先找到这个页面相对应的文件,并在其 render 函数中进行修改。这些隐性成本都是咱们在设计企业管理系统解决方案时须要注意的,由于就是这样一个个的小细节形成了自己并不复杂的企业管理系统在维护、迭代了一段时间后应用复杂度陡增。理想状况下,一个优秀的企业管理系统解决方案应该能够作到 80% 以上非功能性需求变动均可以使用修改配置文件的方式解决。

二、配置式页眉

import { matchRoutes } from 'react-router-config';

// routes config
const routes = [{
  path: '/outlets',
  exact: true,
  permissions: ['admin', 'user'],
  component: Outlets,
  unauthorized: Unauthorized,
  pageTitle: '门店管理',
  breadcrumb: ['/outlets'],
}, {
  path: '/outlets/:id',
  exact: true,
  permissions: ['admin', 'user'],
  component: OutletDetail,
  unauthorized: Unauthorized,
  pageTitle: '门店详情',
  breadcrumb: ['/outlets', '/outlets/:id'],
}];

// find current route object
const pathname = get(state, 'router.location.pathname', '');
const { route } = head((matchRoutes(routes, pathname)));复制代码

基于这样一种思路,咱们能够在通用的布局组件中根据当前页面的 pathname 使用 react-router-config 提供的 matchRoutes 方法来获取到当前页面 route 对象的全部配置项,也就意味着咱们能够对全部的这些配置项作统一的处理。这不只为处理通用逻辑带来了方便,同时对于编写页面代码的同事来讲也是一种约束,可以让不一样开发者写出的代码带有更少的我的色彩,方便对于代码库的总体管理。

三、页面标题

renderPageHeader = () => {
  const { prefixCls, route: { pageTitle }, intl } = this.props;

  if (isEmpty(pageTitle)) {
    return null;
  }

  const pageTitleStr = intl.formatMessage({ id: pageTitle });
  return (
    <div className={`${prefixCls}-pageHeader`}>
      {this.renderBreadcrumb()}
      <div className={`${prefixCls}-pageTitle`}>{pageTitleStr}</div>
    </div>
  );
}复制代码

四、面包屑导航

renderBreadcrumb = () => {
  const { route: { breadcrumb }, intl, prefixCls } = this.props;
  const breadcrumbData = generateBreadcrumb(breadcrumb);

  return (
    <Breadcrumb className={`${prefixCls}-breadcrumb`}>
      {map(breadcrumbData, (item, idx) => (
        idx === breadcrumbData.length - 1 ?
          <Breadcrumb.Item key={item.href}>
            {intl.formatMessage({ id: item.text })}
          </Breadcrumb.Item>
          :
          <Breadcrumb.Item key={item.href}>
            <Link href={item.href} to={item.href}>
              {intl.formatMessage({ id: item.text })}
            </Link>
          </Breadcrumb.Item>
      ))}
    </Breadcrumb>
  );
}复制代码

三、设计策略

一、基于角色的访问控制

基于角色的访问控制不直接将系统操做的各类权限赋予具体用户,而是在用户与权限之间创建起角色集合,将权限赋予角色再将角色赋予用户。这样就实现了对于权限和角色的集中管理,避免用户与权限之间直接产生复杂的多对多关系。

二、访问控制列表

具体到角色与权限之间,访问控制列表指代的是某个角色所拥有的系统权限列表。在传统计算机科学中,权限通常指的是对于文件系统进行增删改查的权力。而在 Web 应用中,大部分系统只须要作到页面级别的权限控制便可,简单来讲就是根据当前用户的角色来决定其是否拥有查看当前页面的权利。

下面就让咱们按照这样的思路实现一个基础版的包含权限管理功能的应用路由。

四、实战代码

一、路由容器

在编写权限管理相关的代码前,咱们须要先为全部的页面路由找到一个合适的容器,即 react-router 中的 Switch 组件。与多个独立路由不一样的是,包裹在 Switch 中的路由每次只会渲染路径匹配成功的第一个,而不是全部符合路径匹配条件的路由。

<Router>
  <Route path="/about" component={About}/>
  <Route path="/:user" component={User}/>
  <Route component={NoMatch}/>
</Router>复制代码
<Router>
  <Switch>
    <Route path="/about" component={About}/>
    <Route path="/:user" component={User}/>
    <Route component={NoMatch}/>
  </Switch>
</Router>复制代码

以上面两段代码为例,若是当前页面路径是 /about 的话,由于 <About /><User /><NoMatch /> 这三个路由的路径都符合 /about,因此它们会同时被渲染在当前页面。而将它们包裹在 Switch 中后,react-router 在找到第一个符合条件的 <About /> 路由后就会中止查找直接渲染 <About /> 组件。

在企业管理系统中由于页面与页面之间通常都是平行且排他的关系,因此利用好 Switch 这个特性对于咱们简化页面渲染逻辑有着极大的帮助。

另外值得一提的是,在 react-router 做者 Ryan Florence 的新做 @reach/router 中,Switch 的这一特性被默认包含了进去,并且 @reach/router 会自动匹配最符合当前路径的路由。这就使得使用者没必要再去担忧路由的书写顺序,感兴趣的朋友能够关注一下。

二、权限管理

如今咱们的路由已经有了一个大致的框架,下面就让咱们为其添加具体的权限判断逻辑。

对于一个应用来讲,除去须要鉴权的页面外,必定还存在着不须要鉴权的页面,让咱们先将这些页面添加到咱们的路由中,如登陆页。

<Router>
  <Switch>
    <Route path="/login" component={Login}/>
  </Switch>
</Router>复制代码

对于须要鉴权的路由,咱们须要先抽象出一个判断当前用户是否有权限的函数来做为判断依据,而根据具体的需求,用户能够拥有单个角色或多个角色,抑或更复杂的一个鉴权函数。这里笔者提供一个最基础的版本,即咱们将用户的角色以字符串的形式存储在后台,如一个用户的角色是 admin,另外一个用户的角色是 user。

import isEmpty from 'lodash/isEmpty';
import isArray from 'lodash/isArray';
import isString from 'lodash/isString';
import isFunction from 'lodash/isFunction';
import indexOf from 'lodash/indexOf';

const checkPermissions = (authorities, permissions) => {
  if (isEmpty(permissions)) {
    return true;
  }

  if (isArray(authorities)) {
    for (let i = 0; i < authorities.length; i += 1) {
      if (indexOf(permissions, authorities[i]) !== -1) {
        return true;
      }
    }
    return false;
  }

  if (isString(authorities)) {
    return indexOf(permissions, authorities) !== -1;
  }

  if (isFunction(authorities)) {
    return authorities(permissions);
  }

  throw new Error('[react-acl-router]: Unsupport type of authorities.');
};

export default checkPermissions;复制代码

在上面咱们提到了路由的配置文件,这里咱们为每个须要鉴权的路由再添加一个属性 permissions,即哪些角色能够访问该页面。

const routes = [{
  path: '/outlets',
  exact: true,
  permissions: ['admin', 'user'],
  component: Outlets,
  unauthorized: Unauthorized,
  pageTitle: 'Outlet Management',
  breadcrumb: ['/outlets'],
}, {
  path: '/outlets/:id',
  exact: true,
  permissions: ['admin'],
  component: OutletDetail,
  redirect: '/',
  pageTitle: 'Outlet Detail',
  breadcrumb: ['/outlets', '/outlets/:id'],
}];复制代码

在上面的配置中,admin 和 user 均可以访问门店列表页面,但只有 admin 才能够访问门店详情页面。

对于没有权限查看当前页面的状况,通常来说有两种处理方式,一是直接重定向到另外一个页面(如首页),二是渲染一个无权限页面,提示用户由于没有当前页面的权限因此没法查看。两者是排他的,即每一个页面只须要使用其中一种便可,因而咱们在路由配置中能够根据须要去配置 redirectunauthorized 属性,分别对应无权限重定向无权限显示无权限页面两种处理方式。具体代码你们能够参考示例项目 react-acl-router 中的实现,这里摘录一小段核心部分。

renderRedirectRoute = route => (
  <Route
    key={route.path}
    {...omitRouteRenderProperties(route)}
    render={() => <Redirect to={route.redirect} />}
  />
);

renderAuthorizedRoute = (route) => {
  const { authorizedLayout: AuthorizedLayout } = this.props;
  const { authorities } = this.state;
  const {
    permissions,
    path,
    component: RouteComponent,
    unauthorized: Unauthorized,
  } = route;
  const hasPermission = checkPermissions(authorities, permissions);

  if (!hasPermission && route.unauthorized) {
    return (
      <Route
        key={path}
        {...omitRouteRenderProperties(route)}
        render={props => (
          <AuthorizedLayout {...props}>
            <Unauthorized {...props} />
          </AuthorizedLayout>
        )}
      />
    );
  }

  if (!hasPermission && route.redirect) {
    return this.renderRedirectRoute(route);
  }

  return (
    <Route
      key={path}
      {...omitRouteRenderProperties(route)}
      render={props => (
        <AuthorizedLayout {...props}>
          <RouteComponent {...props} />
        </AuthorizedLayout>
      )}
    />
  );
}复制代码

因而,在最终的路由中,咱们会优先匹配无需鉴权的页面路径,保证全部用户在访问无需鉴权的页面时,第一时间就能够看到页面。而后再去匹配须要鉴权的页面路径,最终若是全部的路径都匹配不到的话,再渲染 404 页面告知用户当前页面路径不存在。

<Switch>
  {map(normalRoutes, route => (
    this.renderNormalRoute(route)
  ))}
  {map(authorizedRoutes, route => (
    this.renderAuthorizedRoute(route)
  ))}
  {this.renderNotFoundRoute()}
</Switch>复制代码

须要鉴权的路由和不须要鉴权的路由做为两种不一样的页面,通常而言它们的页面布局也是不一样的。如登陆页面使用的就是普通页面布局:

在这里咱们能够将不一样的页面布局与鉴权逻辑相结合以达到只须要在路由配置中配置相应的属性,新增长的页面就能够同时得到鉴权逻辑和基础布局的效果。这将极大地提高开发者们的工做效率,尤为是对于项目组的新成员来讲纯配置的上手方式是最友好的。

五、应用集成

至此一个包含基础权限管理的应用路由就大功告成了,咱们能够将它抽象为一个独立的路由组件,使用时只须要配置须要鉴权的路由和不须要鉴权的路由两部分便可。

const authorizedRoutes = [{
  path: '/outlets',
  exact: true,
  permissions: ['admin', 'user'],
  component: Outlets,
  unauthorized: Unauthorized,
  pageTitle: 'pageTitle_outlets',
  breadcrumb: ['/outlets'],
}, {
  path: '/outlets/:id',
  exact: true,
  permissions: ['admin', 'user'],
  component: OutletDetail,
  unauthorized: Unauthorized,
  pageTitle: 'pageTitle_outletDetail',
  breadcrumb: ['/outlets', '/outlets/:id'],
}, {
  path: '/exception/403',
  exact: true,
  permissions: ['god'],
  component: WorkInProgress,
  unauthorized: Unauthorized,
}];

const normalRoutes = [{
  path: '/',
  exact: true,
  redirect: '/outlets',
}, {
  path: '/login',
  exact: true,
  component: Login,
}];

const Router = props => (
  <ConnectedRouter history={props.history}>
    <MultiIntlProvider
      defaultLocale={locale}
      messageMap={messages}
    >
      // the router component
      <AclRouter
        authorities={props.user.authorities}
        authorizedRoutes={authorizedRoutes}
        authorizedLayout={BasicLayout}
        normalRoutes={normalRoutes}
        normalLayout={NormalLayout}
        notFound={NotFound}
      />
    </MultiIntlProvider>
  </ConnectedRouter>
);

const mapStateToProps = state => ({
  user: state.app.user,
});

Router.propTypes = propTypes;
export default connect(mapStateToProps)(Router);复制代码

在实际项目中,咱们可使用 react-redux 提供的 connect 组件将应用路由 connect 至 redux store,以方便咱们直接读取当前用户的角色信息。一旦登陆用户的角色发生变化,客户端路由就能够进行相应的判断与响应。

六、组合式开发:权限管理

对于页面级别的权限管理来讲,权限管理部分的逻辑是独立于页面的,是与页面中的具体内容无关的。也就是说,权限管理部分的代码并不该该成为页面中的一部分,而是应该在拿到用户权限后建立应用路由时就将没有权限的页面替换为重定向或无权限页面。

这样一来,页面部分的代码就能够实现与权限管理逻辑的完全解耦,以致于若是抽掉权限管理这一层后,页面就变成了一个无需权限判断的页面依然能够独立运行。而通用部分的权限管理代码也能够在根据业务需求微调后服务于更多的项目。

七、小结

文中咱们从权限管理的基础设计思想讲起,实现了一套基于角色的页面级别的应用权限管理系统并分别讨论了无权限重定向及无权限显示无权限页面两种无权限查看时的处理方法。

接下来咱们来看一下多级菜单是如何实现的

5、菜单匹配逻辑

本节参考代码:

react-sider

image.png

在大部分企业管理系统中,页面的基础布局所采起的通常都是侧边栏菜单加页面内容这样的组织形式。在成熟的组件库支持下,UI 层面想要作出一个漂亮的侧边栏菜单并不困难,但由于在企业管理系统中菜单还承担着页面导航的功能,因而就致使了两大难题,一是多级菜单如何处理,二是菜单项的子页面(如点击门店管理中的某一个门店进入的门店详情页在菜单中并无对应的菜单项)如何高亮其隶属于的父级菜单。

一、多级菜单

为了加强系统的可扩展性,企业管理系统中的菜单通常都须要提供多级支持,对应的数据结构就是在每个菜单项中都要有 children 属性来配置下一级菜单项。

const menuData = [{
  name: '仪表盘',
  icon: 'dashboard',
  path: 'dashboard',
  children: [{
    name: '分析页',
    path: 'analysis',
    children: [{
      name: '实时数据',
      path: 'realtime',
    }, {
      name: '离线数据',
      path: 'offline',
    }],
  }],
}];复制代码

递归渲染父菜单及子菜单

想要支持多级菜单,首先要解决的问题就是如何统一不一样级别菜单项的交互。

在大多数的状况下,每个菜单项都表明着一个不一样的页面路径,点击后会触发 url 的变化并跳转至相应页面,也就是上面配置中的 path 字段。

image.png

但对于一个父菜单来讲,点击还意味着打开或关闭相应的子菜单,这就与点击跳转页面发生了冲突。为了简化这个问题,咱们先统一菜单的交互为点击父菜单(包含 children 属性的菜单项)为打开或关闭子菜单,点击子菜单(不包含 children 属性的菜单项)为跳转至相应页面。

首先,为了成功地渲染多级菜单,菜单的渲染函数是须要支持递归的,即若是当前菜单项含有 children 属性就将其渲染为父菜单并优先渲染其 children 字段下的子菜单,这在算法上被叫作深度优先遍历

renderMenu = data => (
  map(data, (item) => {
    if (item.children) {
      return (
        <SubMenu
          key={item.path}
          title={
            <span>
              <Icon type={item.icon} />
              <span>{item.name}</span>
            </span>
          }
        >
          {this.renderMenu(item.children)}
        </SubMenu>
      );
    }

    return (
      <Menu.Item key={item.path}>
        <Link to={item.path} href={item.path}>
          <Icon type={item.icon} />
          <span>{item.name}</span>
        </Link>
      </Menu.Item>
    );
  })
)复制代码

这样咱们就拥有了一个支持多级展开、子菜单分别对应页面路由的侧边栏菜单。细心的朋友可能还发现了,虽然父菜单并不对应一个具体的路由但在配置项中依然还有 path 这个属性,这是为何呢?

二、处理菜单高亮

在传统的企业管理系统中,为不一样的页面配置页面路径是一件很是痛苦的事情,对于页面路径,许多开发者惟一的要求就是不重复便可,如上面的例子中,咱们把菜单数据配置成这样也是能够的。

const menuData = [{
  name: '仪表盘',
  icon: 'dashboard',
  children: [{
    name: '分析页',
    children: [{
      name: '实时数据',
      path: '/realtime',
    }, {
      name: '离线数据',
      path: '/offline',
    }],
  }],
}];

<Router>
  <Route path="/realtime" render={() => <div />}
  <Route path="/offline" render={() => <div />}
</Router>复制代码

用户在点击菜单项时同样能够正确地跳转到相应页面。但这样作的一个致命缺陷就是,对于 /realtime 这样一个路由,若是只根据当前的 pathname 去匹配菜单项中 path 属性的话,要怎样才能同时也匹配到「分析页」与「仪表盘」呢?由于若是匹配不到的话,「分析页」和「仪表盘」就不会被高亮了。咱们能不能在页面的路径中直接体现出菜单项之间的继承关系呢?来看下面这个工具函数。

import map from 'lodash/map';

const formatMenuPath = (data, parentPath = '/') => (
  map(data, (item) => {
    const result = {
      ...item,
      path: `${parentPath}${item.path}`,
    };
    if (item.children) {
      result.children = formatMenuPath(item.children, `${parentPath}${item.path}/`);
    }
    return result;
  })
);复制代码

这个工具函数把菜单项中可能有的 children 字段考虑了进去,将一开始的菜单数据传入就能够获得以下完整的菜单数据。

[{
  name: '仪表盘',
  icon: 'dashboard',
  path: '/dashboard',  // before is 'dashboard'
  children: [{
    name: '分析页',
    path: '/dashboard/analysis', // before is 'analysis'
    children: [{
      name: '实时数据',
      path: '/dashboard/analysis/realtime', // before is 'realtime'
    }, {
      name: '离线数据',
      path: '/dashboard/analysis/offline', // before is 'offline'
    }],
  }],
}];复制代码

而后让咱们再对当前页面的路由作一下逆向推导,即假设当前页面的路由为 /dashboard/analysis/realtime,咱们但愿能够同时匹配到 ['/dashboard', '/dashboard/analysis', '/dashboard/analysis/realtime'],方法以下:

import map from 'lodash/map';

const urlToList = (url) => {
  if (url) {
    const urlList = url.split('/').filter(i => i);
    return map(urlList, (urlItem, index) => `/${urlList.slice(0, index + 1).join('/')}`);
  }
  return [];
};复制代码

上面的这个数组表明着不一样级别的菜单项,将这三个值分别与菜单数据中的 path 属性进行匹配就能够一次性地匹配到全部当前页面应当被高亮的菜单项了。

这里须要注意的是,虽然菜单项中的 path 通常都是普通字符串,但有些特殊的路由也多是正则的形式,如 /outlets/:id。因此咱们在对两者进行匹配时,还须要引入 path-to-regexp 这个库来处理相似 /outlets/1/outlets/:id 这样的路径。又由于初始时菜单数据是树形结构的,不利于进行 path 属性的匹配,因此咱们还须要先将树形结构的菜单数据扁平化,而后再传入 getMeunMatchKeys 中。

import pathToRegexp from 'path-to-regexp';
import reduce from 'lodash/reduce';
import filter from 'lodash/filter';

const getFlatMenuKeys = menuData => (
  reduce(menuData, (keys, item) => {
    keys.push(item.path);
    if (item.children) {
      return keys.concat(getFlatMenuKeys(item.children));
    }
    return keys;
  }, [])
);

const getMeunMatchKeys = (flatMenuKeys, paths) =>
  reduce(paths, (matchKeys, path) => (
    matchKeys.concat(filter(flatMenuKeys, item => pathToRegexp(item).test(path)))
  ), []);复制代码

在这些工具函数的帮助下,多级菜单的高亮也再也不是问题了。

三、知识点:记忆化(Memoization)

在侧边栏菜单中,有两个重要的状态:一个是 selectedKeys,即当前选定的菜单项;另外一个是 openKeys,即多个多级菜单的打开状态。这两者的含义是不一样的,由于在 selectedKeys 不变的状况下,用户在打开或关闭其余多级菜单后,openKeys 是会发生变化的,以下面二图所示,selectedKeys 相同但 openKeys 不一样。

image.png image.png

对于 selectedKeys 来讲,因为它是由页面路径(pathname)决定的,因此每一次 pathname 发生变化都须要从新计算 selectedKeys 的值。又由于经过 pathname 以及最基础的菜单数据 menuData 去计算 selectedKeys 是一件很是昂贵的事情(要作许多数据格式处理和计算),有没有什么办法能够优化一下这个过程呢?

Memoization 能够赋予普通函数记忆输出结果的功能,它会在每次调用函数以前检查传入的参数是否与以前执行过的参数彻底相同,若是彻底相同则直接返回上次计算过的结果,就像经常使用的缓存同样。

import memoize from 'memoize-one';

constructor(props) {
  super(props);

  this.fullPathMenuData = memoize(menuData => formatMenuPath(menuData));
  this.selectedKeys = memoize((pathname, fullPathMenu) => (
    getMeunMatchKeys(getFlatMenuKeys(fullPathMenu), urlToList(pathname))
  ));

  const { pathname, menuData } = props;

  this.state = {
    openKeys: this.selectedKeys(pathname, this.fullPathMenuData(menuData)),
  };
}复制代码

在组件的构造器中咱们能够根据当前 props 传来的 pathnamemenuData 计算出当前的 selectedKeys 并将其当作 openKeys 的初始值初始化组件内部 state。由于 openKeys 是由用户所控制的,因此对于后续 openKeys 值的更新咱们只须要配置相应的回调将其交给 Menu 组件控制便可。

import Menu from 'antd/lib/menu';

handleOpenChange = (openKeys) => {
  this.setState({
    openKeys,
  });
};

<Menu
  style={{ padding: '16px 0', width: '100%' }}
  mode="inline"
  theme="dark"
  openKeys={openKeys}
  selectedKeys={this.selectedKeys(pathname, this.fullPathMenuData(menuData))}
  onOpenChange={this.handleOpenChange}
>
  {this.renderMenu(this.fullPathMenuData(menuData))}
</Menu>复制代码

这样咱们就实现了对于 selectedKeysopenKeys 的分别管理,开发者在使用侧边栏组件时只须要将应用当前的页面路径同步到侧边栏组件中的 pathname 属性便可,侧边栏组件会自动处理相应的菜单高亮(selectedKeys)和多级菜单的打开与关闭(openKeys)。

四、知识点:正确区分 prop 与 state

上述这个场景也是一个很是经典的关于如何正确区分 prop 与 state 的例子。

selectedKeys 由传入的 pathname 决定,因而咱们就能够将 selectedKeyspathname 之间的转换关系封装在组件中,使用者只须要传入正确的 pathname 就能够得到相应的 selectedKeys 而不须要关心它们之间的转换是如何完成的。而 pathname 做为组件渲染所需的基础数据,组件没法从自身内部得到,因此就须要使用者经过 props 将其传入进来。

另外一方面, openKeys 做为组件内部的 state,初始值能够由 pathname 计算而来,后续的更新则与组件外部的数据无关而是会根据用户的操做在组件内部完成,那么它就是一个 state,与其相关的全部逻辑均可以完全地被封装在组件内部而不须要暴露给使用者。

简而言之,一个数据若是想成为 prop 就必须是组件内部没法得到的,并且在它成为了 prop 以后,全部能够根据它的值推导出来的数据都再也不须要成为另外的 props,不然将违背 React 单一数据源的原则。对于 state 来讲也是一样,若是一个数据想成为 state,那么它就不该该再可以被组件外部的值所改变,不然也会违背单一数据源的原则而致使组件的表现不可预测,产生难解的 bug。

五、组合式开发:应用菜单

严格来讲,在这一小节中着重探讨的应用菜单部分的思路并不属于组合式开发思想的范畴,更多地是如何写出一个支持无限级子菜单及自动匹配当前路由的菜单组件。组件固然是能够随意插拔的,但前提是应用该组件的父级部分不依赖于组件所提供的信息。这也是咱们在编写组件时所应当遵循的一个规范,即组件能够从外界获取信息并在此基础上进行组件内部的逻辑判断。但当组件向其外界抛出信息时,更多的时候应该是以回调的形式让调用者去主动触发,而后更新外部的数据再以 props 的形式传递给组件以达到更新组件的目的,而不是强制须要在外部再配置一个回调的接收函数去直接改变组件的内部状态。

从这点上来讲,组合式开发与组件封装实际上是有着殊途同归之妙的,关键都在于对内部状态的严格控制。不论一个模块或一个组件须要向外暴露多少接口,在它的内部都应该是解决了某一个或某几个具体问题的。就像工厂产品生产流水线上的一个环节,在通过了这一环节后产品相较于进入前必定产生了某种区别,不管是增长了某些功能仍是被打上某些标签,产品必定会变得更利于下游合做者使用。更理想的状况则是即便删除掉了这一环节,原来这一环节的上下游依然能够无缝地衔接在一块儿继续工做,这就是咱们所说的模块或者说组件的可插拔性。

6、后端路由服务的意义

在先后端分离架构的背景下,前端已经逐渐代替后端接管了全部固定路由的判断与处理,但在动态路由这样一个场景下,咱们会发现单纯前端路由服务的灵活度是远远不够的。在用户到达某个页面后,可供下一步逻辑判断的依据就只有当前页面的 url,而根据 url 后端的路由服务是能够返回很是丰富的数据的。

常见的例子如页面的类型。假设应用中营销页和互动页的渲染逻辑并不相同,那么在页面的 DSL 数据以外,咱们就还须要获取到页面的类型以进行相应的渲染。再好比页面的 SEO 数据,建立和更新时间等等,这些数据都对应用可以在前端灵活地展现页面,处理业务逻辑有着巨大的帮助。

甚至咱们还能够推而广之,完全抛弃掉由 react-router 等提供的前端路由服务,转而写一套本身的路由分发器,即根据页面类型的不一样分别调用不一样的页面渲染服务,以多种类型页面的方式来组成一个完整的前端应用。

7、组合式开发

为了解决大而全的方案在实践中不够灵活的问题,咱们是否是能够将其中包含的各个模块解耦后,独立发布出来供开发者们按需取用呢?让咱们先来看一段理想中完整的企业管理系统应用架构部分的伪代码:

const App = props => (
  <Provider>                                        // react-redux bind
    <ConnectedRouter>                               // react-router-redux bind
      <MultiIntlProvider>                           // intl support
        <AclRouter>                                 // router with access control list
          <Route path="/login">                     // route that doesn't need authentication <NormalLayout> // layout component <View /> // page content (view component) </NormalLayout> <Route path="/login"> ... // more routes that don't need authentication
          <Route path="/analysis">                  // route that needs authentication
            <LoginChecker>                          // hoc for user login check
              <BasicLayout>                         // layout component
                <SiderMenu />                       // sider menu
                <Content>
                  <PageHeader />                    // page header
                  <View />                          // page content (view component)
                  <PageFooter />                    // page footer
                </Content>
              </BasicLayout>
            </LoginChecker>
          </Route>
          ...                                       // more routes that need authentication
          <Route render={() => <div>404</div>} />   // 404 page
        </AclRouter>
      </MultiIntlProvider>
    </ConnectedRouter>
  </Provider>
);复制代码

在上面的这段伪代码中,咱们抽象出了多语言支持、基于路由的权限管理、登陆鉴权、基础布局、侧边栏菜单等多个独立模块,能够根据需求添加或删除任意一个模块,并且添加或删除任意一个模块都不会对应用的其余部分产生不可接受的反作用。这让咱们对接下来要作的事情有了一个大致的认识,但在具体的实践中,如 props 如何传递、模块之间如何共享数据、如何灵活地让用户自定义某些特殊逻辑等都仍然面临着巨大的挑战。咱们须要时刻注意,在处理一个具体问题时哪些部分应当放在某个独立模块内部去处理,哪些部分应当暴露出接口供使用者自定义,模块与模块之间如何作到零耦合以致于使用者能够随意插拔任意一个模块去适应当前项目的须要。

8、学习路线

从一个具体的前端应用直接切入开发技巧与理念的讲解,因此对于刚入门 React 的朋友来讲可能存在着必定的基础知识部分梳理的缺失,这里为你们提供一份较为详细的 React 开发者学习路线图,但愿可以为刚入门 React 的朋友提供一条规范且便捷的学习之路。

image.png

总结

到此react的路由鉴权映梳理完了欢迎你们转发交流分享转载请注明出处 ,附带一个近期相关项目案例代码给你们一个思路:

react-router-config

同时,欢迎小伙伴们加微信群一块儿探讨:

微信号 

image.png

微信交流群image.png

钉钉交流群

image.png

你可能感兴趣的

一、https://juejin.im/post/5d1f1e595188254b732b60a3 使用 husky、commitlint 和 lint-staged 来构建你的前端工做流(vue、react、dva)二、https://juejin.im/post/5d11ae8b6fb9a07ee4637047 React项目国际化(antd)多语言开发三、https://segmentfault.com/a/1190000015282620四、https://www.yuque.com/runarale/dbvxi9/bk7idg五、https://www.yuque.com/runarale/gau4ci/wa35g1六、https://www.yuque.com/runarale/gau4ci/ds14vy七、https://www.yuque.com/runarale/gau4ci/vi3q85

相关文章
相关标签/搜索