从0到1设计一个react-spa后台应用

2,043 阅读10分钟

下面围绕下面这张图,谈谈如何构建一个基本的react-spa应用框架。

image.png

按需加载

webpack3 + react-router4 + react-loadable

使用SPA必然要说到按需加载,目前最简洁优雅的方案是使用webpack3 + react-router4 + react-loadable, 原理就是 webpack 的 Dynamic Imports。

通俗的讲,dynamic import,就是把JS代码分成N个页面份数的文件,不在用户刚进来就全部引入,而是等用户跳转路由的时候,再加载对应的JS文件。这样做的好处就是加速首屏显示速度,同时也减少了资源的浪费。

webpack 的 Dynamic Imports 实现主要是利用 ECMAScript的 import() 动态加载特性,用于完成动态加载即运行时加载,而 import() 目前只是一个草案,如果需要用此方法,需要引入对应的转换器,如 babel-plugin-syntax-dynamic-import。

react-loadable是一个高阶组件,参照官方文档Code Splitting,单页面的按需加载方案变得非常简洁:

  1. 安装 babel-plugin-syntax-dynamic-import,为babel配置"syntax-dynamic-import"插件;
  2. 使用react-loadable
import Loadable from 'react-loadable';

import LoadingIndicator from 'components/LoadingIndicator';

const DataSandBox = Loadable({
  //自从webpack2.4开始,可以在动态导入中使用魔术注释来指定模块的chunk名字
  loader: () => import(/* webpackChunkName: "chunckName" */'../routers/module/index'),
  loading: LoadingIndicator
});

LoadingIndicator是封装好的一个在异步加载阶段的loading展示,除此之外,react-loadable还提供了delay和timeout等配置项让按需加载的过程更加友好。

Magic Comment

上文demo代码中说到的魔术注释值得说一下,这个是在webpack3新加上的。Webpack 2+ 开始,引入了Code Splitting-Async的新方法import(),用于动态引入ES Module。webpack将传入import方法的模块打包到一个单独的代码块(chunk),但是却不能像require.ensure一样,为生成的chunk指定chunkName,因此在webpack3中提出了Magic Comment用于解决该问题。

publicPath

异步加载chunck文件需要利用publicPath来补全生产模式的cdn资源地址。参考城危同学在这篇文章中的观点

实践下来关于JSONP地址:"本地开发、日常开发、预发、线上”等环节有一个共同的特点,无论环境 怎么改变,chunk文件与主文件的相对路径是不会改变的,那获取runtime的JS地址即可确定JSONP地 址,脱离环境、version和项目仓库名。

通过在页面入口文件中增加如下代码,可以兼容开发环境和生产环境对chunk文件的引用

/**
 * 设置 __webpack_public_path__, 兼容日常、预发、线上环境
 */
const js = document.scripts;
const url = js[js.length - 1].src.split("?")[0];
const urlSplit = url.split("/");
urlSplit.pop();
urlSplit.pop();
__webpack_public_path__ = urlSplit.join("/") + "/"; 

Antd和React的版本

Antd 3.0其实是在看了SEECONF上它山前辈的分享而被种草的。我们希望使用Antd 3.0的视觉风格,让后台整体看起来更加明亮,因此,将组件库升级为Antd 3.0,同时使用react v16。这里需要注意的是,因为antd2.x 默认是用12px, 而3.0 使用的是14px,如果升级的话,对2.x系列业务组件尺寸挑战,对于旧组件可能会有一些兼容成本,比如需要组件内部对默认字体做一下设定。

样式方案

CSS modules

CSS Evolution: From CSS, SASS, BEM, CSS Modules to Styled Components 比较全面的介绍了css技术的进化过程。

image.png
我们需要寻求一个搭配当前的技术选型(React)的最优方案,解决两个问题:避免样式覆盖和便于实现样式的复用。

css不是程序语言,但如果说要给它加一个作用域的概念的话,那就是:只有全局作用域。

无论分拆为多少个css文件,无论用怎样的方式引入,所有的样式规则都位于同一作用域,只要选择符近似,就有发生覆盖的可能。

CSS Modules是一种技术流的组织css代码的策略,通过工具解决了BEM依靠开发人员选择唯一class名的工作,无法改变css全局作用域的本性,而是依靠动态生成class名这一手段(利用webpack的css-loader),来实现局部作用域。显然,这样的class名就可以是唯一的,不管原本的css代码写得有多随便,都可以这样转换得到不冲突的css代码。

要使用CSS Modules,必须想办法把变量风格的class名注入到html中,这时,虚拟DOM风格的React,搭配CSS Modules会很容易:有了CSS “本地作用域”,所有的 React 组件都可以在逻辑和呈现状态上进行完全的隔离。

使用CSS Modules只需要在webpack中给css-loader加上如下两个参数:

名称 类型 默认值 描述
modules {Boolean} false 启用/禁用 CSS modules
localIdentName {String} [hash:base64] 配置生成的标识符(ident),推荐设置[local]___[hash:base64:5]

js 文件的改变就是在设置 className 时,用一个对象属性取代了原来的字符串。

import classNames from 'classnames';
import styles from './dialog.css';

export default class Dialog extends React.Component {
  render() {
    const cx = classNames({
      [styles.confirm]: !this.state.disabled,
      [styles.disabledConfirm]: this.state.disabled
    });

    return <div className={styles.root}>
      <a className={cx}>Confirm</a>
      ...
    </div>
  }
}

如何与全局样式共存

在实际工程中,需要诸如reset/normalize,Settings等一些通用的全局设置。开启css modules设置后,所有的样式默认都是local模式,这时,可以使用:global 标签在主应用程序中导入公共的样式文件。

image.png

覆盖组件样式

  • CSS Modules 不会覆盖属性选择器,所以可以利用属性选择器来解决这个问题;
  • 引入的 antd 组件类名没有被 CSS Modules 转化,所以被覆盖的类名,如 .ant-select-selection 必须放到 :global 中,为了防止对其他同类组件造成影响,可以给组件添加 className,只对这类组件进行覆盖,也可以利用外层类名实现这种限制。

路由与布局

数据驱动的路由配置

我们需要两个对应关系,菜单和路由的关系以及路由和组件的关系,即通过url找到menu再加载组件这样一个过程。

url到组件的转换包括两个入口,一个是通过menu点击,一个是通过Link跳转。

Route可以帮我们解决url到component的转换,即根据path来加载对应的component。那剩下的工作就是定义一个对象来存储关系,并实现一个通过url找到对应菜单项的方法。

参考Antd pro刚对内发布时候的源代码,可以设计一个公共的nav.js用来管理url、菜单和路由(模块组件)三者的关系。结合前文提到的按需加载策略,基本结构如下:

import BasicLayout from "components/Layouts/BasicLayout.js";
// 按路由拆分代码
import Loadable from "react-loadable";
import LoadingIndicator from "components/LoadingIndicator/LoadingIndicator";

//概览页
const DashBoard = Loadable({
  loader: () => import(/* webpackChunkName: "DashBoard" */"../routers/DashBoard/index"),
  loading: LoadingIndicator
});

/*将需要的路由组件包装成动态加载的形式,然后配置到navData数据结构里面*/
......

const navData = [
  {
    component: BasicLayout,
    layout: "BasicLayout",
    name: "首页", // for breadcrumb
    path: "",
    children: [
      {
        name: "概览",
        icon: "dashboard",
        path: "dashboard",
        component: DashBoard
      },
      {
        name: "特征管理",
        icon: "bars",
        path: "feature",
        children: [
          {
            name: "明星人脸库",
            icon: "star",
            path: "face",
            component: StarFaceManage,
          }
        ]
      },
      {
        name: "数据沙盘",
        icon: "play-circle",
        path: "sandbox",
        component: DataSandBox,
        isLink:true
      }
    ]
  }
];

export function getNavData() {
  return navData;
}

export { navData };

name,icon是菜单的展示属性,path代表其对应的url片段,children提供菜单无限向下扩展的能力,只有叶子节点才有对应的component。通过这种结构,可以递归地渲染出对应的菜单结果。针对Link形式的跳转,将isLink设置为true,不在菜单的结构中显示,但可以通过path让route识别到。这样,形成了一个通过数据驱动的路由配置。

url到菜单的映射

url到菜单的映射就是:不同的url对应的openKeys和selectedKeys属性是啥。 下面是一个基本(只提供一种布局)的主页面的代码结构:

import BasicLayout from "components/Layouts/BasicLayout";
import { Router, Switch, Route } from "react-router-dom";
import { createBrowserHistory } from "history";
const history = createBrowserHistory();

/**
 * 设置 __webpack_public_path__, 兼容日常、预发、线上环境
 */
const js = document.scripts;
const url = js[js.length - 1].src.split("?")[0];
const urlSplit = url.split("/");
urlSplit.pop();
urlSplit.pop();
__webpack_public_path__ = urlSplit.join("/") + "/";

/**
 * 基础信息配置 window.GV通过diamond配置
 */
const Globol_Values = window.GV || {};
//登陆用户
const user = (Globol_Values.user && JSON.parse(Globol_Values.user)) || {};
const baseConfigs = {
  //平台logo
  siteLogo: Globol_Values.siteLogo || "",
  .......
};

const App = () => (
  <Router history={history}>
    <Switch>
      <Route
        path="/"
        render={props =>
           (
            <BasicLayout {...props} currentUser={user} {...baseConfigs} />
           )
        }
      />
    </Switch>
  </Router>
);

ReactDOM.render(<App />, document.getElementById("app"));

Router会创建一个history对象并用其保持追踪当前location,在location有变化时对网页进行重新渲染。通过渲染的元素会被传入一些参数。分别是match对象,当前location对象以及history对象(由router创建)。locations 是一个含有描述URL不同部分属性的对象,结构如下:

// 一个基本的location对象
{ pathname: '/', search: '', hash: '', key: 'abc123' state: {} }

利用这个特性,BasicLayout在每次url变化时,可以接收父组件传入的props中的location对象,并通过pathname属性来进行menu的匹配。

基于 React Router 4 的可复用 Layout 组件

结合前文的设计,我们希望能够设计一个可复用 Layout 组件。

动态标题设置

React-document-title提供了一种声明式的方法来设置单页应用的的文档标题

基本布局

antd的Layout提供了基本的布局能力。仿照pro,我们选择"侧边两列式布局。页面横向空间有限时,侧边导航可收起"的形式,同时自定义收起触发器。

undefined

const layout = (
   <Layout>
	 <Sider></Sider>
	 <Layout>
	    <Header></Header>
		<Content></Content>
	 </Layout>
    </Layout>
)

Sider

Sider是侧边栏,功能就是展示菜单,同时可以根据横向空间展开收起。自定义触发器首先需要把trigger属性设置为null。breakpoint这个属性很有意思,是触发响应式布局的断点,

//antd中对breakpoint 的规范定义 也是响应式栅格的边界
{
  xs: '480px',
  sm: '576px',
  md: '768px',
  lg: '992px',
  xl: '1200px',
  xxl: '1600px',
}
<Sider
   trigger={null}
   collapsible
   collapsed={this.state.collapsed}
   breakpoint="md"
   onCollapse={this.onCollapse}
   width={256}
   className={styles.sider}
>
</Sider>

breakpoint="md"即body的宽度大于768时,sider就会收起。样式上,sider的min-height需要设置为100vh,即默认高度占满整个浏览器的视窗。

参考pro的源码,我们可以得到启发,sider可以通过breakpointer来动态的改变布局,那么根据antd的栅格规范,使用 react-container-query 动态给 layout 根据不同的宽度加 classname,那么里面包含的所有dom都可以根据这个来调整样式。

import DocumentTitle from "react-document-title";
import { ContainerQuery } from "react-container-query";
//定义ContainerQuery的参数
const query = {
  "screen-xs": {
    maxWidth: 575
  },
  "screen-sm": {
    minWidth: 576,
    maxWidth: 767
  },
  "screen-md": {
    minWidth: 768,
    maxWidth: 991
  },
  "screen-lg": {
    minWidth: 992,
    maxWidth: 1199
  },
  "screen-xl": {
    minWidth: 1200
  }
};

一个有动态标题和自适应能力的基本布局结构

<DocumentTitle title={this.getPageTitle()}>
   <ContainerQuery query={query}>
       {params => <div className={classNames(params)}>{layout}</div>}
    </ContainerQuery>
</DocumentTitle>

Content

Content内展示路由组件的内容,我们使用<Switch>组件来包裹一组<Route><Switch>会遍历自身的子元素(即路由)并对第一个匹配当前路径的元素进行渲染。将nav.js中定义的关系数据传入,生成这组Route结构。

      <Content style={{ margin: "24px 24px 0", height: "100%" }}>
            <Switch>
              {getRouteData("BasicLayout").map(item => (
                <Route
                  exact={item.exact}
                  key={item.path}
                  path={item.path}
                  component={item.component}
                />
              ))}
              <Route
                path={"/forbidden/:routerName"}
                component={ForbiddenPage}
              />
              <Redirect exact from="/" to={defaultRoute} />
              <Route component={PageNotFound} />
            </Switch>
        </Content>

总结

本文总结了一个react-SPA后台基本框架的设计过程,省略了很多设计细节,也不涉及状态管理方面的框架选型,只是对自己思考过程的一个回顾,希望对感兴趣的同学有帮助。