• 本项目地址:react-coat-helloworld
  • react-coat 同时支持浏览器渲染(SPA)服务器渲染(SSR),本 Demo 仅演示浏览器渲染,请先了解一下:react-coat

第一站:Helloworld

安装

git clone https://github.com/wooline/react-coat-helloworld.git
npm install

运行

  • npm start 以开发模式运行
  • npm run build 以产品模式编译生成文件
  • npm run prod-express-demo 以产品模式编译生成文件并启用一个 express 做 demo
  • npm run gen-icon 自动生成 iconfont 文件及 ts 类型

查看在线 Demo

  • 点击查看在线 Demo

关于脚手架

  • 采用 webpack 4.0 为核心搭建,无二次封装,干净透明
  • 采用 typescript 作开发语言,使用 Postcss 及 less 构建 css
  • 不使用 css module,用模块化命名空间保证 css 不冲突
  • 采用 editorconfig > prettier 作统一的风格配置,建议使用 vscode 作为 IDE,并安装 prettier 插件以自动格式化
  • 采用 tslint、eslint、stylelint 作代码检查

PeerDependencies

开发环境需要很多的 dependencies,你可以自行安装特定版本,如果特殊要求,建议本站提供的 react-coat-pkg 以及 react-coat-dev-pkg,它们已经包含了绝大部分 dependencies。

TS 类型的定义

使用 Typescript 意味着使用强类型,我们把业务实体中 TS 类型定义分两大类:API类型Entity类型

  • API 类型:指的是来自于后台 API 输入的类型,它们可能直接由 swagger 生成,或是机器生成。
  • Entity 类型:指的是本系统为业务实体建模而定义的类型,每个业务实体(resource)都会有定义。

理想状况下,API 类型和 Entity 类型会保持一致,因为业务逻辑是同一套,但实际开发中,可能因为前后端并行开发、或者前后端视角不同而出现两者各表。

为了充分的解耦,我们允许这种不一致,我们把 API 类型在源头就转化为 Entity 类型,而在本系统的代码逻辑中,不直接使用 API 类型,应当使用自已定义的 Entity 类型,以减少其它系统对本系统的影响。


假定项目:旅途 web app

主要页面:

  • 旅游路线展示
  • 旅途小视频展示
  • 站内信展示(需登录)
  • 评论展示 (访客可查看评论,发表则需登录)

项目要求

  • web SPA 单页应用
  • 主要用于 mobile 浏览器,也可以适应于桌面浏览器
  • 无 SEO 要求,但需要能将当前页面分享给他人
  • 初次进入本站时,显示 welcome 广告,并倒计时

路由规划

SPA 单页不就一个页面么?为什么还需要规划路由呢?

  • 其一,为了用户刷新时尽可能的保持当前展示
  • 其二,为了用户能将当前展示通过 url 分享给他人
  • 其三,为了后续的 SEO

path 规划

根据项目需求及 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 长度,本框架设计了参数默认值,如果某参数和默认值相同,可以省去。我们需要做两项工作:

  • 生成 Url 查询条件时,对比默认值,如果相同,则省去

原值:{title:"张家界",page:1,pageSize:20} 默认值: {title:"",page:1,pageSize:20},省去后为:{title:"张家界"}

原值:{title:"",page:1,pageSize:20} 默认值: {title:"",page:1,pageSize:20},省去后为:空

  • 收到 Url 查询条件时,将查询条件和默认值 merge

/photos?search={page:2} === photos?search={title:"",page:2,pageSize:20}

/photos === photos?search={title:"",page:1,pageSize:20}

  • 处理 null、undefined

由于接收 Url 参数时,如果某 key 为 undefined,我们会用相应的默值将其填充,所以不能将 undefined 作为路由参数值定义,改为使用 null。也就是说,路由参数中的每一项,都是必填的,比如:

// 路由参数定义时,每一项都必填,以下为错误示例
interface ListSearch{title?:string,age?:number
}
// 改为如下正确定义:
interface ListSearch{title:string | null,age:number | null
}
  • 区分:原始路由参数(SearchData) 默认路由参数(SearchData) 和 完整路由参数(WholeSearchData)。完整路由参数(WholeSearchData) = merage(默认路由参数(SearchData), 原始路由参数(SearchData))

    • 原始路由参数(SearchData)每一项都是可选的,用 TS 类型表示为:Partial<WholeSearchData>
    • 完整路由参数(WholeSearchData)每一项都是必填的,用 TS 类型表示为:Required<SearchData>
    • 默认路由参数(SearchData)和完整路由参数(WholeSearchData)类型一致

不直接使用路由状态

路由及其参数本质上也是一种 Store,与 Redux Store 一样,反映当前程序的某些状态。但它是片面的,是瞬时的,是不稳定的,我们把它看作是 Redux Store 的一种冗余。所以最好不要在程序中直接依赖和使用它,而是控制住它的入口和出口,第一时间在其源头进行消化转换,让其成为整个 Redux Store 的一部分,后续的运行中,我们直接依赖 Redux Store。这样,我们就将程序与路由设计解耦了,程序有更大的灵活度甚至可以迁移到无 URL 概念的其它运行环境中。

模块规划

模块与 Page 无关

划分模块可以很好的拆解功能,化繁为简,并且对内隐藏细节,对外暴露少量接口。划分模块的标准是高内聚,低耦合,而不是以 Page 或是 View,一个模块包含某些完整的业务功能,这些功能可能涉及到多个 Page 或多个 View。

所以回过头,看我们的项目需求和 UI 图,大体上可以分为三个模块:

  • photos //旅游线路展示
  • videos //分享视频展示
  • messages //站内消息展示

这三个模块显而易见,但是我们注意到:“图片详情”和“视频详情”都包含“评论展示”,而“评论展示”本身又具有分页、排序、详情展示、创建回复等功能,它具有自已独立的逻辑,只不过在 view 上被 photoDetail 和 videoDetail 嵌套了,所以将“评论展示”独立划分成一个模块是合适的。

另个,整个程序应当有个启动模块,它是“上帝视角模块”,它可以做一些公共事业,必要的时候也可以用来做多个模块之间的协调和调度,我们叫把它叫做 applicatioin 模块。

所以最终,本 Demo 被划分为 5 个模块:

  • app // 启动模块
  • photos //旅游线路展示
  • videos //分享视频展示
  • messages //站内消息展示
  • comments //评论展示

为模块划分 View

每个模块可能包含一组 View,View 反映某些特定的业务逻辑。View 就是 React 中的 Component,那反过来 Component 就是 View 么?非也,它们之间还是有些区别的:

  • view 展现的是 Store 数据,更偏重于表现特定的具体的业务逻辑,所以它的 props 一般是直接用 mapStateToProps connect 到 store。
  • component 体现的是一个没有业务逻辑上下文的纯组件,它的 props 一般来源于父级传递。
  • component 通常是公共的,而 view 通常非公用

回过头,看我们的项目需求和 UI 图,大体上划分以下 view:

  • app views:Main、TopNav、BottomNav、LoginPop、Welcome、Loading
  • photos views:Main、List、Details
  • videos views:Main、List、Details
  • messages views:Main、List
  • comments views:Main、List、Details、Editor

目录结构

经过上面的分析,我们有了项目大至的骨架,由于模块比较少,所以我们就不再用二级目录分类了:

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 启动入口

facade.ts

其它目录都好理解,注意到每个 module 目录中,有一个 facade.ts 的文件,冒似它与 index.ts 一样都是导出本模块,那为什么不合并成一个呢?

  • index.ts 导出的是整个模块的物理代码,因为模块是较为独立的,所以我们一般希望将整个模块的代码打包成一个独立的 chunk 文件。
  • facade.ts 仅导出本模块的一些类型和逻辑接口,我们知道 TS 类型在编译之后是会被彻底抹去的,而接口仅仅是一个空的句柄。假如在 ModuleA 中需要 dispatch ModuleB 的 action,我们仅需要 import ModuleB 的 facade.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 步:

  • 1.载入模块对应的 JS Chunk 包
  • 2.初始化模块 Model,派发 module/INIT Action
  • 3.模块可以监听自已的 module/INIT Action,作出初始化行为,如获取远程数据等

Redux Store 结构

module 的划分不仅体现在工程目录上,而体现在 Redux Store 中:

  router: { // 由 connected-react-router 生成location: {pathname: '/photos',search: '',hash: '#refresh=true',key: 'gb9ick'},action: 'PUSH'},app: {...}, // app ModuleStatephotos: { // photos ModuleStateisModule: true, // 框架自动生成,标明该节点为一个ModuleStatelistSearch: { // 列表搜索条件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 ModuleStatecomments: {...},  // comments ModuleState
}

具体实现

见 Demo 源码,有注释

美中不足

路由规划的不足

到目前为止,本 Demo 完成了项目要求中的内容,接下来,业务看了之后提出了几个问题:

  • 无法分享指定的“评论”,评论是很重要的吸引眼球的内容,我们希望分享链接时,可以指定评论。

目前可以分享的路由只有 5 种:

- /photos
- /photos/1
- /videos
- /videos/1
- /messages

看样子,我们得增加:

/photos/1/comments/3  //展示id为3的评论
  • 评论内容对以后的 SEO 很重要,我们希望路由能控制评论列表翻页和排序:
/photos/1?comments-search={page:2,sort:"createDate"}
  • 目前我们的项目主要用于移动浏览器访问,很多 android 用户习惯用手机下面的返回键,来撤消操作,如关闭弹窗等,能否模拟一下原生 APP?

思考:android 用户点击手机下面的返回键会引起浏览器的后退,后退关闭弹窗,那就需要在弹出弹窗时增加一条 URL 记录
结论:Url 路由不只用来记录展示哪个 Page、哪个 View,还得标识一些交互操作,完全颠覆了传统的路由观念了。

路由效验的不足

看样子,路由会越来越复杂,到目前为止,我们还没有在 TS 中很好的管理路由参数,拼接 URL 时没有做 TS 类型的校验。对于 pathname 我们都是直接用字符串写死在程序中,比如:

if(pathname === "/photos"){....
}const arr = pathname.match(/^\/photos\/(\d+)$/);

这样直接 hardcode 似利不是很好,如果后其产品想换一下名称怎么搞。

Model 中重复写同样的代码

注意到,photos/model.ts、videos/model.ts 中,90%的代码是一样的,为什么?因为它们两个模块基本上功能都是差不多的:列表展示、搜索、获取详情...

其实不只是 photos 和 videos,套用 RestFul 的理念,我们用网页交互的过程就是在对“资源 Resource”进行维护,无外乎“增删改查”这些基本操作,大部分情况下,它们的逻辑是相似的。由其是在后台系统中,基本上连 UI 界面也可以标准化,如果将这部分“增删改查”的逻辑提取出来,模块可以省去不少重复的代码。

下一个 Demo

既然有这么多美中不足,那我们就期待在下一个 Demo 中一步步解决它吧

进阶:SPA(单页应用)

React单页如何规划路由、设计Store、划分模块、按需加载相关推荐

  1. php单页应用,前端单页应用的路由系统 – 介绍篇

    单页应用 为了你能看懂这篇博客之后的所有内容,你必须知道什么是单页应用. 简单的讲,链接跳转时界面不会刷新应用就是单页应用.它的特点是,用户在浏览或者点击跳转的时候感觉不到界面的跳转过程,因为无需等待 ...

  2. 学生个人单页面网页作业 学生网页设计成品 静态HTML网页单页制作 dreamweaver网页设计与制作代码 web前端期末大作业

    HTML实例网页代码, 本实例适合于初学HTML的同学.该实例里面有设置了css的样式设置,有div的样式格局,这个实例比较全面,有助于同学的学习,本文将介绍如何通过从头开始设计个人网站并将其转换为代 ...

  3. react 动态路 嵌套动子路由_react 路由动态加载组件,实现按需加载

    默认情况下,当在项目根路径下执行npm run build时,create-react- app内部使用webpack将src/路径下的所有代码打包成一个JS文件和一个 CSS文件. 当项目代码量不多 ...

  4. react路由按需加载方法

    使用router4之后以前的按需加载方法require.ensure 是不好使了. 所以我们改用react-loadable插件做按需加载. 第一步: yarn add react-loadable ...

  5. 快速搭建react项目骨架(按需加载、redux、axios、项目级目录等等)

    一.前言 最近整理了一下项目骨架,顺便自定义了一个脚手架,方便日后使用.我会从头开始,步骤一步步写明白,如果还有不清楚的可以评论区留言.先大致介绍一下这个骨架,我们采用 create-react-ap ...

  6. div 重新加载_使用React的HOC来完成模块的异步加载

    原文地址​medium.com 在单页面应用的时代,你可以使用React来完成几乎所有的Web应用甚至大型的Web引用.你甚至可以使用React开发一个Facebook. 当前正如你所知,Webpac ...

  7. Vue根据菜单json数据动态按需加载路由Vue-router

    每个菜单项对应一个页面组件,根据菜单项动态按需加载路由 路由配置的正确写法: /*router/index.js*/ import Vue from 'vue' import Router from ...

  8. react按需加载(getComponent优美写法),并指定输出模块名称解决缓存(getComponent与chunkFilename)...

    react配合webpack进行按需加载的方法很简单,Route的component改为getComponent,组件用require.ensure的方式获取,并在webpack中配置chunkFil ...

  9. vue 路由按需加载

    在vue项目中,一般引入组件都是用import import 组件名 from '组件路径' webpack在npm run build的时候会打包成一个整个的js文件,如果页面一多,会导致这个文件非 ...

最新文章

  1. python 把字母转数字
  2. 【赠书】深入浅出embedding:原理解析与应用实践
  3. 获取了网站源码有什么用_角点科技:用 Wordpress 建设企业网站需要准备些什么...
  4. mysql 查询分析器中使用if_查询分析器中开发代码测试检查_MySQL
  5. mybatis+spring报错PropertyAccessException 1: org.springframework.beans.MethodInvocationException
  6. circle loss代码实现_Python全栈之路-23-使用Python实现Logistic回归算法
  7. LeetCode--33. 搜索旋转排序数组(二分法)
  8. python如何下载pandas、时间延长_大pandas,python – 如何在时间表中选择具体时间
  9. hdu1257----贪心最长上升子序列(最少拦截系统)
  10. Openstack学习笔记(十五)-Horizon源代码学习笔记(五)
  11. 申宝投资-昨日三大指数缩量探底回升
  12. 使用nginx配置服务器80端口指向多个服务,解决微信公众号等平台只能绑定80端口问题。
  13. 几乎全面的食品英文总结 (吃遍英文单词
  14. 中小型企业常用的进销存系统有哪些?
  15. 【C++实验】模拟实现一个ATM自助存取款机
  16. 众安保险首推数据安全险 专治黑客盗取数据隐患
  17. 一款典型的智慧电力物联网关的技术要求
  18. 微信养号教程预防封号
  19. 利用时钟坐标系计算阿基米德曲线周长和面积,心形线周长和面积
  20. 别开玩笑了!特种兵式旅游,胆小勿进!

热门文章

  1. linux下 tar解压 gz解压 bz2等各种解压文件使用方法
  2. 如何对shell脚本进行批量注释
  3. Linux Shell常用技巧(八)
  4. Android下EditText中的字体不统一问题
  5. Android Studio:64K问题com.android.dex.DexIndexOverflowException: method ID not in [0, 0xffff]: 65536
  6. myeclispe快捷键一\(≧▽≦)/终于也收藏了
  7. Unity 2018.3.1 SyncVar没有同步服务器变量
  8. Dlib学习笔记:解决dlib array2d转 OpenCV Mat时颜色失真
  9. JAVA获取项目工程下的文件
  10. Python中的生成器与yield