下面围绕下面这张图,谈谈如何构建一个基本的react-spa应用框架。
按需加载
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,单页面的按需加载方案变得非常简洁:
- 安装 babel-plugin-syntax-dynamic-import,为babel配置"syntax-dynamic-import"插件;
- 使用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技术的进化过程。
我们需要寻求一个搭配当前的技术选型(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 标签在主应用程序中导入公共的样式文件。
覆盖组件样式
- 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,我们选择"侧边两列式布局。页面横向空间有限时,侧边导航可收起"的形式,同时自定义收起触发器。
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后台基本框架的设计过程,省略了很多设计细节,也不涉及状态管理方面的框架选型,只是对自己思考过程的一个回顾,希望对感兴趣的同学有帮助。