浏览器渲染(SPA)
和服务器渲染(SSR)
,本 Demo 仅演示浏览器渲染
,请先了解一下:react-coat v4.0react-coat使用Typescript开发,集成Redux,由浅入深请看3个Demo:css
进阶:SPA(单页应用)android
升级:SPA(单页应用)+SSR(服务器渲染)webpack
git clone https://github.com/wooline/react-coat-helloworld.git
npm install
复制代码
npm start
以开发模式运行npm run build
以产品模式编译生成文件npm run prod-express-demo
以产品模式编译生成文件并启用一个 express 作 demonpm run gen-icon
自动生成 iconfont 文件及 ts 类型本 Demo 并不采用 CSS Module 来进行 css 模块化,由于编译以后可读性很差,并且增长复杂度和编译时间。使用统一的 css 命名空间约定,咱们也能够很简单的防止 css 命名冲突。git
咱们将 css 分为三大类:全局(global)CSS、模块(Module)CSS、组件(Component)CSS
github
全局(global)CSS
:跨模块、跨组件使用的公共 css,咱们约定以"g-
"开头,存放到/src/asset/css/global.css模块(Module)CSS
:某模块私有使用的 css,咱们约定以"模块名-
"开头,跟随模块文件夹存放
视图(View)CSS
:在模块 css 中,若是某些 css 仅为某个 view 私有使用,咱们约定以"模块名-视图名-
"开头,跟随视图文件夹存放组件(Component)CSS
:某组件私有使用的 css,咱们约定以"comp-组件名-
"开头,跟随组件文件夹存放相似的,对于项目中用到的图片,若是是跨模块、跨组件使用的,咱们放到/src/asset/imgs/,而对于其它模块私有、视图私有、组件私有
,咱们跟随它们各自的文件夹存放web
使用 Typescript 意味着使用强类型,咱们把业务实体中 TS 类型定义分两大类:API类型
和Entity类型
。typescript
理想情况下,API 类型和 Entity 类型会保持一致,由于业务逻辑是同一套,但实际开发中,可能由于先后端并行开发、或者先后端视角不一样而出现二者各表。express
为了充分的解耦,咱们容许这种不一致,咱们把 API 类型在源头就转化为 Entity 类型,而在本系统的代码逻辑中,不直接使用 API 类型,应当使用自已定义的 Entity 类型,以减小其它系统对本系统的影响。npm
SPA 单页不就一个页面么?为何还须要规划路由呢?
根据项目需求及 UI 图,咱们初步规划主要路由 path 以下:
旅行路线列表 photosList
:/photos旅行路线详情 photosItem
:/photos/:photoId分享小视频列表 videosList
:/videos分享小视频详情 videosItem
:/videos/:videoId站内信列表 messagesList
:/messages由于列表页是有分页、有搜索的,因此列表类型的路由是有参数的,好比:
/photos?title=张家界&page=3&pageSize=20
咱们估且将这部分查询列表条件叫"ListSearch",但除了ListSearch
以外,也可能会出现别的路由参数,用来控制其它条件(本 demo 暂未涉及),好比:
/photos?title=张家界&page=3&pageSize=20&showComment=true
因此,若是参数一多,用扁平的一维结构就变得很差表达。并且,利用 URL 参数存数据,数据将全变成为字符串。好比id=2
,你没法知道 2 是数字型仍是字符型,这样会让后续接收处理变得繁重。因此,咱们使用 JSON 来序列化第二级参数,好比:
/photos?search={title:"张家界",page:3,pageSize:20}&showComment=true
这样作也有个很差的地方,就是须要 encodeURI,而后特殊字符会变得比较丑。
为了缩短 URL 长度,本框架设计了参数默认值,若是某参数和默认值相同,能够省去。咱们须要作两项工做:
原值:{title:"张家界",page:1,pageSize:20} 默认值: {title:"",page:1,pageSize:20},省去后为:{title:"张家界"}
原值:{title:"",page:1,pageSize:20} 默认值: {title:"",page:1,pageSize:20},省去后为:空
/photos?search={page:2} === photos?search={title:"",page:2,pageSize:20}
/photos === photos?search={title:"",page:1,pageSize:20}
因为接收 Url 参数时,若是某 key 为 undefined,咱们会用相应的默值将其填充,因此不能将 undefined 做为路由参数值定义,改成使用 null。也就是说,路由参数中的每一项,都是必填的,好比:
// 路由参数定义时,每一项都必填,如下为错误示例
interface ListSearch{
title?:string,
age?:number
}
// 改成以下正肯定义:
interface ListSearch{
title:string | null,
age:number | null
}
复制代码
Partial<WholeSearchData>
Required<SearchData>
路由及其参数本质上也是一种 Store,与 Redux Store 同样,反映当前程序的某些状态。但它是片面的,是瞬时的,是不稳定的,咱们把它看做是 Redux Store 的一种冗余。因此最好不要在程序中直接依赖和使用它,而是控制住它的入口和出口,第一时间在其源头进行消化转换,让其成为整个 Redux Store 的一部分,后续的运行中,咱们直接依赖 Redux Store。这样,咱们就将程序与路由设计解耦了,程序有更大的灵活度甚至能够迁移到无 URL 概念的其它运行环境中。
划分模块能够很好的拆解功能,化繁为简,而且对内隐藏细节,对外暴露少许接口。划分模块的标准是高内聚,低耦合
,而不是以 Page 或是 View,一个模块包含某些完整的业务功能,这些功能可能涉及到多个 Page 或多个 View。
因此回过头,看咱们的项目需求和 UI 图,大致上能够分为三个模块:
这三个模块显而易见,可是咱们注意到:“图片详情”和“视频详情”都包含“评论展现”,而“评论展现”自己又具备分页、排序、详情展现、建立回复等功能,它具备自已独立的逻辑,只不过在 view 上被 photoDetail 和 videoDetail 嵌套了,因此将“评论展现”独立划分红一个模块是合适的。
另个,整个程序应当有个启动模块,它是“上帝视角模块”,它能够作一些公共事业,必要的时候也能够用来作多个模块之间的协调和调度,咱们叫把它叫作 applicatioin 模块。
因此最终,本 Demo 被划分为 5 个模块:
每一个模块可能包含一组 View,View 反映某些特定的业务逻辑。View 就是 React 中的 Component,那反过来 Component 就是 View 么?非也,它们之间仍是有些区别的:
回过头,看咱们的项目需求和 UI 图,大致上划分如下 view:
通过上面的分析,咱们有了项目大至的骨架,因为模块比较少,因此咱们就再也不用二级目录分类了:
src
├── asset // 存放公共静态资源
│ ├── css
│ ├── imgs
│ └── font
├── entity // 存放业务实体TS类型定义
├── common // 存放公共代码
├── components // 存放React公共组件
├── modules
│ ├── app
│ │ ├── views
│ │ │ ├── TopNav
│ │ │ ├── BottomNav
│ │ │ ├── ...
│ │ │ └── index.ts //导出给其它模块使用的view
│ │ ├── model.ts //定义ModuleState和ModuleActions
│ │ ├── api //将本模块须要的后台api封装一下
│ │ ├── facade.ts //导出本模块对外的逻辑接口(类型、Actions、路由默认参数)
│ │ └── index.ts //导出本模块实体(view和model)
│ ├── photos
│ │ ├── views
│ │ ├── model.ts
│ │ ├── api
│ │ ├── facade.ts
│ │ └── index.ts
│ ├── videos
│ ├── messages
│ ├── comments
│ ├── names.ts //定义模块名,使用枚举类型来保证不重复
│ └── index.ts //导出模块的全局设置,如RootState类型、模块载入方式等
└──index.tsx 启动入口
复制代码
其它目录都好理解,注意到每一个 module 目录中,有一个 facade.ts 的文件,冒似它与 index.ts 同样都是导出本模块,那为何不合并成一个呢?
问:在 react-coat 中怎么配置一个模块?包括打包、加载、注册、管理其生命周期等?
答:./src/modules 根目录下的 index.ts 文件为模块总的配置文件,增长一个模块,只须要在此配置一下
// ./src/modules/index.ts
// 一个验证器,利用TS类型来确保增长一个module时,相关的配置都同时增长了
type ModulesDefined<T extends {[key in ModuleNames]: any}> = T;
// 定义模块的加载方案,同步或者异步都可
export const moduleGetter = {
[ModuleNames.app]: () => {
return import(/* webpackChunkName: "app" */ "modules/app");
},
[ModuleNames.photos]: () => {
return import(/* webpackChunkName: "photos" */ "modules/photos");
},
[ModuleNames.videos]: () => {
return import(/* webpackChunkName: "videos" */ "modules/videos");
},
[ModuleNames.messages]: () => {
return import(/* webpackChunkName: "messages" */ "modules/messages");
},
[ModuleNames.comments]: () => {
return import(/* webpackChunkName: "comments" */ "modules/comments");
},
};
export type ModuleGetter = ModulesDefined<typeof moduleGetter>; // 验证一下是否有模块忘了配置
// 定义整站Module States
interface States {
[ModuleNames.app]: AppState;
[ModuleNames.photos]: PhotosState;
[ModuleNames.videos]: VideosState;
[ModuleNames.messages]: MessagesState;
[ModuleNames.comments]: CommentsState;
}
// 定义整站的Root State
export type RootState = BaseState & ModulesDefined<States>; // 验证一下是否有模块忘了配置
复制代码
本 Demo 直接使用 react-router V4
,路由即组件,因此并不须要什么特别的路由配置,直接在./app/views/Main.tsx 中:
const PhotosView = loadView(moduleGetter, ModuleNames.photos, "Main");
const VideosView = loadView(moduleGetter, ModuleNames.videos, "Main");
const MessagesView = loadView(moduleGetter, ModuleNames.messages, "Main");
<Switch>
<Redirect exact={true} path="/" to="/photos" />
<Route exact={false} path="/photos" component={PhotosView} />
<Route exact={false} path="/videos" component={VideosView} />
<Route exact={false} path="/messages" component={MessagesView} />
<Route component={NotFound} />
</Switch>
复制代码
使用 loadView()表示异步按需加载一个 View,若是你不想按需加载,彻底能够直接 import:
import {Main as PhotosView} from "modules/photos/views"
复制代码
载入 View 时自动载入其相关的模块并初始化 Model。没有 Model,view 是没有“灵魂”的,因此在载入 View 时,框架会自动载入其 Model 并完成初始化,这个过程包含 3 步:
module 的划分不只体如今工程目录上,而体如今 Redux Store 中:
router: { // 由 connected-react-router 生成
location: {
pathname: '/photos',
search: '',
hash: '#refresh=true',
key: 'gb9ick'
},
action: 'PUSH'
},
app: {...}, // app ModuleState
photos: { // photos ModuleState
isModule: true, // 框架自动生成,标明该节点为一个ModuleState
listSearch: { // 列表搜索条件
title: '',
page: 1,
pageSize: 10
},
listItems: [ // 列表数据
{
id: '1',
title: '新加坡+吉隆坡+马六甲6或7日跟团游',
departure: '无锡',
type: '跟团游',
price: 2499,
hot: 265,
coverUrl: '/imgs/1.jpg'
},
...
],
listSummary: {
page: 1,
pageSize: 5,
totalItems: 10,
totalPages: 2
}
},
messages: {...}, // messages ModuleState
comments: {...}, // comments ModuleState
}
复制代码
见 Demo 源码,有注释
到目前为止,本 Demo 完成了项目要求中的内容,接下来,业务看了以后提出了几个问题:
目前能够分享的路由只有 5 种:
- /photos
- /photos/1
- /videos
- /videos/1
- /messages
复制代码
看样子,咱们得增长:
/photos/1/comments/3 //展现id为3的评论
复制代码
/photos/1?comments-search={page:2,sort:"createDate"}
复制代码
思考:android 用户点击手机下面的返回键会引发浏览器的后退,后退关闭弹窗,那就须要在弹出弹窗时增长一条 URL 记录 结论:Url 路由不仅用来记录展现哪一个 Page、哪一个 View,还得标识一些交互操做,彻底颠覆了传统的路由观念了。
看样子,路由会愈来愈复杂,到目前为止,咱们尚未在 TS 中很好的管理路由参数,拼接 URL 时没有作 TS 类型的校验。对于 pathname 咱们都是直接用字符串写死在程序中,好比:
if(pathname === "/photos"){
....
}
const arr = pathname.match(/^\/photos\/(\d+)$/);
复制代码
这样直接 hardcode 似利不是很好,若是后其产品想换一下名称怎么搞。
注意到,photos/model.ts、videos/model.ts 中,90%的代码是同样的,为何?由于它们两个模块基本上功能都是差很少的:列表展现、搜索、获取详情...
其实不仅是 photos 和 videos,套用 RestFul 的理念,咱们用网页交互的过程就是在对“资源 Resource”进行维护,无外乎“增删改查”这些基本操做,大部分状况下,它们的逻辑是类似的。由其是在后台系统中,基本上连 UI 界面也能够标准化,若是将这部分“增删改查”的逻辑提取出来,模块能够省去很多重复的代码。
既然有这么多美中不足,那咱们就期待在下一个 Demo 中一步步解决它吧