[关闭]
@Shel 2017-10-11T07:48:23.000000Z 字数 12730 阅读 881

项目文档

项目名称:hwa-fe
项目人员:xiaoqing liu
项目版本:2.4.0
注意事项:本文档作为项目的唯一文档使用,开发人员需保持文档更新。


版本规范

本项目采用 git flow 作为日常开发流程。
备注统一格式:【类型】【模块名】“详细说明”。


API&原型


程序架构

核心思想:

小模块,大集合。即将一个大的应用程序划分为多个小模块,一个模块是应用程序的一些功能。

小模块:对应着项目中features子目录。每一个features都有自己的routing、reducers、store、component,因此每一个feature都是相对独立的。它很小,而且很好解耦,所以很容易理解和维护。

大集合:通过对小模块的组合,获得最顶级的集合应用。

文件结构

  1. |-- src
  2. | +-- common
  3. | |-- features
  4. | | +-- common
  5. | | |-- home
  6. | | | +-- redux
  7. | | | |-- index.js
  8. | | | |-- route.js
  9. | | | |-- ...
  10. | | +-- feature-1
  11. | | +-- feature-2
  12. | +-- less
  13. | +-- images
  14. |-- ...

应用以features分组,通常对应产品业务。每个feature包含自己的组件,操作,路由,数据。如上所示,features子目录中存在一个较为特殊的common,它并非是一个实际存在的业务模块,而是被共享的公共模块。

基本概念

  1. Feature
    一个feature通常描述成一个功能点,通常可以和产品业务相对应。
    一个feature通常包含多个动作、组件或路由规则。
    common是一个特殊的feature,它不对应任何独立的功能点或者业务模块,相反的,它被用来存放所有跨功能的元素,例如组件、动作、状态等等。

  2. Component
    It's just React component,只是结合了redux与router。
    根据Redux的说法,组件可以分为两类:container and presentational。
    通过定义路由中的响应路由器规则,使得我们可以使用带响应路由器的组件。

    因此,在思考一个组件时,大致可以分成四种类型:

    • 无状态无路由
    • 有状态无路由
    • 无状态有路由
    • 有状态有路由

    通常情况下:有状态有路由的组件是页面级的;而无状态无路由的组件复用性最好,通常在common文件下定义,被多个功能页面引用;有状态无路由的组件,一般作为一个页面的一部分内容而存在;无状态有路由的组件,一般为纯静态的页面,比如404页。

  3. Action
    It's just Redux action.

    有两种类型的操作:sync和async。即同步和异步。通常来说异步操作是涉及API请求的,而同步操作通常是对某数据的写操作。

    在创建异步操作时,通常会建立请求等待,请求成功,请求失败动作类型。与之对应的状态requestPending,requestError用来反映这三种情况。

  4. Reducer
    It's just Redux reducer. 与官方的方式相比,本工程重新组织了代码结构,使得它更容易面向产品业务,与现实更为贴合。

    一个action一个文件,并将相应的reducer放入相同的文件中。这使得我们更直观的看到action与state之间的关系。

    并不对一个reducer赋予初始值,而是通过对整个Redux store赋予初始值的方式。


参考实践

实践一

类型: Features
要求: 新增 “来源跟踪”模块,包含(所有来源、搜索引擎、关键字、关键词、外部链接)5个子功能模块,子模块功能暂不实现,具体参见原型。
步骤

  1. src/features目录下创建“来源跟踪”功能模块,并添加子模块文件,文件结构如下:
  1. |-- src
  2. | +-- common
  3. | |-- features
  4. | | +-- common
  5. | | +-- ...
  6. | | |-- sources-rpt(来源跟踪)
  7. | | | |-- redux
  8. | | | | |-- actions.js
  9. | | | | |-- constants.js
  10. | | | | |-- initialState.js
  11. | | | | |-- reducer.js
  12. | | | |-- DefaultPage.js (所有来源)
  13. | | | |-- EnginePage.js (搜索引擎)
  14. | | | |-- ExternalLinksPage.js (外部链接)
  15. | | | |-- KeywordPage.js (关键字)
  16. | | | |-- KeywordsPage.js (关键词)
  17. | | | |-- index.js(入口文件)
  18. | | | |-- route.js(路由配置)
  19. | +-- less
  20. | +-- images
  21. |-- ...
  1. index.js(入口文件):用于模块的导入导出以及重命名,在这里我们可以看到一个功能节点下的所有组件。
  1. export { default as DefaultPage } from './DefaultPage';
  2. export { default as EnginePage } from './EnginePage';
  3. export { default as KeywordsPage } from './KeywordsPage';
  4. export { default as KeywordPage } from './KeywordPage';
  5. export { default as ExternalLinksPage } from './ExternalLinksPage';
  1. route.js(路由配置):配置一个模块及其子模块的路由,这里可以快速的查看一个模块下的页面以及各个页面在路径上区别。
    isIndex 属性表示是否为首页,它是一个拓展出来的属性。在项目的根路径文件中会将它处理成父组件的形式。
  1. // 导入子功能页面
  2. import {
  3. DefaultPage,
  4. EnginePage,
  5. KeywordsPage,
  6. KeywordPage,
  7. ExternalLinksPage,
  8. } from './';
  9. // 导出路由配置
  10. export default {
  11. path: '/sources-rpt',
  12. name: 'Sources rpt',
  13. childRoutes: [
  14. { path: 'default-page', name: 'Default page', component: DefaultPage, isIndex: true },
  15. { path: 'engine-page', name: 'Engine page', component: EnginePage },
  16. { path: 'keywords-page', name: 'Keywords page', component: KeywordsPage },
  17. { path: 'keyword-page', name: 'Keyword page', component: KeywordPage },
  18. { path: 'external-links-page', name: 'External links page', component: ExternalLinksPage },
  19. ],
  20. };
  1. initialState.js (初始化store):该模块用于表示一个模块的数据模型,即我们会使用的所有变量(包括且不限于:请求参数,响应数据,请求状态,错误信息……)。

按照传统的方式,初始值在reducer中写入。但当应用程序增长,reducer的数量越来越多,想要快速直观的掌握store管理的数据项,就变得非常困难了。

js的变量是松散的。这使得它非常灵活,同时也难以控制。ES6中的const弥补了这一点。在使用变量存储数据时,尽可能的在定义时描述出数据的类型,可以提高代码的可读性与可维护性。基于此,我们将初始状态定义提取到一个单独的模块中,这样就可以快速查看一个功能使用数据的情况。

  1. // 初始时可以用空对象表示,随着应用增长而逐步完善。
  2. const initialState = {
  3. };
  4. export default initialState;
  1. reducer.js:用于关联数据于数据操作,这里的数据指上一步定义的数据模型,数据操作则是关于数据的读、写行为。
  1. import initialState from './initialState';
  2. const reducers = [
  3. ];
  4. export default function reducer(state = initialState, action) {
  5. let newState;
  6. switch (action.type) {
  7. // Handle cross-topic actions here
  8. default:
  9. newState = state;
  10. break;
  11. }
  12. return reducers.reduce((s, r) => r(s, action), newState);
  13. }
  1. 修改src/common中的文件,将新增功能与根应用关联起来,在原来的基础上修改 rootReducer.jsrouteConfig.js

  2. rootReducer.js 汇总reducer

  1. ...
  2. import sourcesRptReducer from '../features/sources-rpt/redux/reducer';
  3. const reducerMap = {
  4. ...
  5. sourcesRpt: sourcesRptReducer,
  6. };
  7. ...
  1. routeConfig.js 汇总子路由生成根路由
  1. // routeConfig.js 汇总子路由生成根路由
  2. ...
  3. import sourcesRptRoute from '../features/sources-rpt/route';
  4. const childRoutes = [
  5. ...
  6. sourcesRptRoute,
  7. ];
  8. ...

小结
创建一个新功能模块的基本流程如上,即配置页面路由,构建数据模型,关联数据与数据操作,根结点中注册该模块。


实践二

类型: State
要求: 结合原型与API文档,为“来源跟踪-关键词”子页面定义数据模型。
步骤

  1. 分析API: 将涉及的API有3个,分别用于“获取搜索引擎列表”、“关键词 total”、“关键词 report”。

    其中,“搜索引擎列表”将用于显示多选控件;“关键词 total”用于汇总面板;“关键词 report”用于数据表格, 表格汇总和分页信息。

    “关键词 total”查询参数可以在common下的state里获取,这里无需额外存储。“关键词 report”查询参数排除开始日期、结束日期、报告类型,需要额外存储。

  2. 修改 initialState.js,这里应当尽可能的描述出数据结构,并赋初始值。

  1. const initialState = {
  2. ...
  3. sourcesEngineList: [],
  4. paramKeywords: {
  5. page: 1,
  6. pagesize: 30,
  7. orderType: 'desc',
  8. orderColumn: 'pageViews',
  9. searchEngineTypeId: [],
  10. word: '',
  11. wordType: 'ALL',
  12. },
  13. keywordsReports: [],
  14. keywordsReportsSummary: {
  15. pageViews: null,
  16. uniqueVisitors: null,
  17. visitViews: null
  18. },
  19. keywordsReportsPage: {
  20. current: 1,
  21. total: 0,
  22. pagesize: 30
  23. },
  24. keywordsTotal: {
  25. pageViews: null,
  26. avgDayUniqueVisitors: null,
  27. visitViews: null
  28. },
  29. };
  30. export default initialState;

实践三

类型: Action
要求: 为“来源跟踪-关键词”的数据paramKeywords创建sync action。
步骤

  1. 分析:页面中,用户通过操纵多个控件改变查询参数,获取新的数据。查询参数的改变应该是局部的修改,而不是整体的替换。

  2. 更改 sources-rpt/redux 下的 constants.js (常量文件)。这里使用大写加下划线的命名方式。并以所在的功能模块打头

  1. ...
  2. export const SOURCES_RPT_SET_PARAM_KEYWORDS = 'SOURCES_RPT_SET_PARAM_KEYWORDS';
  1. sources-rpt/redux 下新建 setParamKeywords.js ,用于定义并导出action以及reducer
  1. import {
  2. SOURCES_RPT_SET_PARAM_KEYWORDS,
  3. } from './constants';
  4. export function setParamKeywords(args = {}) {
  5. return {
  6. type: SOURCES_RPT_SET_PARAM_KEYWORDS,
  7. args,
  8. };
  9. }
  10. export function reducer(state, action) {
  11. switch (action.type) {
  12. case SOURCES_RPT_SET_PARAM_KEYWORDS:
  13. return {
  14. ...state,
  15. paramKeywords: {
  16. ...state.paramKeywords,
  17. ...action.args,
  18. }
  19. };
  20. default:
  21. return state;
  22. }
  23. }
  1. 更改 sources-rpt/redux 下的 actions.js
  1. ...
  2. export { setParamKeywords } from './setParamKeywords';
  1. 更改 sources-rpt/redux 下的 reducer.js
  1. import initialState from './initialState';
  2. import { reducer as setParamKeywordsReducer } from './setParamKeywords'; // 重命名以避免冲突并增加可读性
  3. const reducers = [
  4. setParamKeywordsReducer,
  5. ];
  6. export default function reducer(state = initialState, action) {
  7. let newState;
  8. ...

实践四

类型: Action
要求: 为“来源跟踪-关键词”的数据keywordsTotal创建Async action。数据来源于API。
步骤

  1. 分析:Async action 的数据来源于API,而sync action 的数据来源多半是“用户”。对于异步请求,我们需要额外注意请求的状态与结果。
  2. 修改 initialState.js,用以描述请求的状态与错误。
  1. const initialState = {
  2. ...
  3. getKeywordsTotalPending: false,
  4. getKeywordsTotalError: null,
  5. };
  6. export default initialState;
  1. 更改 sources-rpt/redux 下的 constants.js (常量文件)。对于一次异步请求,有开始与结束,而结束又分为成功或失败。另外,对于错误信息,配上清空错误信息的操作。
  1. ...
  2. export const SOURCES_RPT_GET_KEYWORDS_TOTAL_BEGIN = 'SOURCES_RPT_GET_KEYWORDS_TOTAL_BEGIN';
  3. export const SOURCES_RPT_GET_KEYWORDS_TOTAL_SUCCESS = 'SOURCES_RPT_GET_KEYWORDS_TOTAL_SUCCESS';
  4. export const SOURCES_RPT_GET_KEYWORDS_TOTAL_FAILURE = 'SOURCES_RPT_GET_KEYWORDS_TOTAL_FAILURE';
  5. export const SOURCES_RPT_GET_KEYWORDS_TOTAL_DISMISS_ERROR = 'SOURCES_RPT_GET_KEYWORDS_TOTAL_DISMISS_ERROR';
  1. sources-rpt/redux 下新建 getKeywordsTotal.js ,用于定义并导出action以及reducer
  1. import axios from 'axios'; // 用于创建请求
  2. import {
  3. SOURCES_RPT_GET_KEYWORDS_TOTAL_BEGIN,
  4. SOURCES_RPT_GET_KEYWORDS_TOTAL_SUCCESS,
  5. SOURCES_RPT_GET_KEYWORDS_TOTAL_FAILURE,
  6. SOURCES_RPT_GET_KEYWORDS_TOTAL_DISMISS_ERROR,
  7. } from './constants';
  8. import { formatParams } from '../../../common/formatUtils'; // 用于格式化时间参数
  9. export function getKeywordsTotal(args = {}, channelId = null) {
  10. return (dispatch) => { // optionally you can have getState as the second argument
  11. dispatch({
  12. type: SOURCES_RPT_GET_KEYWORDS_TOTAL_BEGIN,
  13. });
  14. const uri = `/api/channels/${channelId}/sources/keywords/total`;
  15. const promise = new Promise((resolve, reject) => {
  16. axios.get(uri, {
  17. params: formatParams(args),
  18. })
  19. .then(
  20. (res) => {
  21. const {
  22. code,
  23. result = {},
  24. message = '',
  25. } = res.data;
  26. if (code === '200000') {
  27. dispatch({
  28. type: SOURCES_RPT_GET_KEYWORDS_TOTAL_SUCCESS,
  29. keywordsTotal: result,
  30. });
  31. resolve();
  32. } else {
  33. dispatch({
  34. type: SOURCES_RPT_GET_KEYWORDS_TOTAL_FAILURE,
  35. data: { error: message },
  36. });
  37. reject(message);
  38. }
  39. }
  40. )
  41. .catch(
  42. (err) => {
  43. dispatch({
  44. type: SOURCES_RPT_GET_KEYWORDS_TOTAL_FAILURE,
  45. data: { error: err },
  46. });
  47. reject(err.message);
  48. }
  49. );
  50. });
  51. return promise;
  52. };
  53. }
  54. // Async action saves request error by default, this method is used to dismiss the error info.
  55. // If you don't want errors to be saved in Redux store, just ignore this method.
  56. export function dismissGetKeywordsTotalError() {
  57. return {
  58. type: SOURCES_RPT_GET_KEYWORDS_TOTAL_DISMISS_ERROR,
  59. };
  60. }
  61. export function reducer(state, action) {
  62. switch (action.type) {
  63. case SOURCES_RPT_GET_KEYWORDS_TOTAL_BEGIN:
  64. // Just after a request is sent
  65. return {
  66. ...state,
  67. getKeywordsTotalPending: true,
  68. getKeywordsTotalError: null,
  69. };
  70. case SOURCES_RPT_GET_KEYWORDS_TOTAL_SUCCESS:
  71. // The request is success
  72. return {
  73. ...state,
  74. getKeywordsTotalPending: false,
  75. getKeywordsTotalError: null,
  76. keywordsTotal: action.keywordsTotal,
  77. };
  78. case SOURCES_RPT_GET_KEYWORDS_TOTAL_FAILURE:
  79. // The request is failed
  80. return {
  81. ...state,
  82. getKeywordsTotalPending: false,
  83. getKeywordsTotalError: action.data.error,
  84. };
  85. case SOURCES_RPT_GET_KEYWORDS_TOTAL_DISMISS_ERROR:
  86. // Dismiss the request failure error
  87. return {
  88. ...state,
  89. getKeywordsTotalError: null,
  90. };
  91. default:
  92. return state;
  93. }
  94. }
  1. 更改 sources-rpt/redux 下的 actions.js
  1. ...
  2. export { getKeywordsTotal, dismissGetKeywordsTotalError } from './getKeywordsTotal';
  1. 更改 sources-rpt/redux 下的 reducer.js
  1. ...
  2. import { reducer as getKeywordsTotalReducer } from './getKeywordsTotal';
  3. const reducers = [
  4. ...
  5. getKeywordsTotalReducer,
  6. ];

实践五

类型: Component
要求: 在“来源跟踪”下,创建一个汇总面板组件,该组件在“关键词”、“关键字”、“外部链接”页面复用,UI参见原型。
步骤

  1. 分析:由需求及原型,汇总面板共包含三项指标pv-uv-vv。由于该组件会在多个子功能页面中使用,我们考虑将它定义成无状态组件,即,不直接关联store,从父组件接收参数。

    另外,参数的复杂程度与组件的灵活通用性成正比,参数结构越复杂,复用组件就越是繁琐,不必要的使用store以外的数据传递给组件。这里,为了让组件使用更加方便,我们考虑仅仅传递“汇总数据”,标题数量、顺序、文字显示固定不变。

  2. sources-rpt/ 下新建 SummaryPanel.js 。命名方式采用首字母大写。

  1. import React, { Component } from 'react';
  2. import PropTypes from 'prop-types';
  3. import { formatInt, formatFloat } from '../../common/formatUtils';
  4. /**
  5. * @class 汇总面板(pv:uv:vv)
  6. */
  7. export default class SummaryPanel extends Component {
  8. // 在开始处定义参数的结构,
  9. // 1. 行文风格统一,系统整体一致
  10. // 2. 便于他人日后维护和使用,快速查看可配参数
  11. static propTypes = {
  12. data: PropTypes.object,
  13. };
  14. // 为参数设置默认值是非常必要的,
  15. // 1. 提高稳定性,
  16. // 2. 进一步描述参数的配置方式。
  17. static defaultProps = {
  18. data: {},
  19. };
  20. render() {
  21. const data = {
  22. pageViews: null,
  23. avgDayUniqueVisitors: null,
  24. visitViews: null,
  25. ...this.props.data,
  26. };
  27. return (
  28. <div className="sources-rpt-summary-panel">
  29. <div className="summer-row" >
  30. <div className="summer-item">
  31. <p className="summer-title">浏览量(PV)</p>
  32. <div className="summer-data">{formatInt(data.pageViews)}</div>
  33. </div>
  34. <div className="summer-item">
  35. <p className="summer-title">日均独立访问者(UV)</p>
  36. <div className="summer-data">{formatFloat(data.avgDayUniqueVisitors)}</div>
  37. </div>
  38. <div className="summer-item">
  39. <p className="summer-title">访问次数(VV)</p>
  40. <div className="summer-data">{formatInt(data.visitViews)}</div>
  41. </div>
  42. </div>
  43. </div>
  44. );
  45. }
  46. }
  1. 更改 index.js
  1. ...
  2. export { default as SummaryPanel } from './SummaryPanel';
  1. 更改,添加必要样式。
  1. ...
  2. .sources-rpt-summary-panel {
  3. display: table;
  4. min-width: 100%;
  5. background-color: @B050;
  6. .summer-row {
  7. display: table-row;
  8. .summer-item {
  9. display: table-cell;
  10. padding: 18px;
  11. &:hover {
  12. background-color: @B200;
  13. }
  14. .summer-title {
  15. color: @B600;
  16. margin-bottom: 18px;
  17. }
  18. .summer-data {
  19. font: 30px lighter;
  20. line-height: 1.4em;
  21. color: @H500;
  22. }
  23. }
  24. }
  25. }

实践六

类型: Component
要求: 将“来源跟踪-关键词”中的表头工具栏拆成子组件,它可读、写store的数据,导出功能暂不实现,UI参见原型。
步骤

  1. 分析:由需求及原型,通过操控表头工具栏右侧搜索控件,使得下方数据表格内容发生改变。由于该组件仅在“来源跟踪-关键词”中使用,我们考虑将它定义成有状态组件,即,直接关联store。

  2. sources-rpt/ 下新建 KeywordsPageTableToolbar.js 。命名方式采用首字母大写。与实践五不同的是,我们在同一个文件内关联store。

  1. import React, { Component } from 'react';
  2. import PropTypes from 'prop-types';
  3. import { bindActionCreators } from 'redux';
  4. import { connect } from 'react-redux';
  5. import { Dropdown, InputGroup, FormControl, } from 'rsuite';
  6. import { Alert } from 'rsuite-notification';
  7. import * as actions from './redux/actions';
  8. export class KeywordsPageTableToolbar extends Component {
  9. static propTypes = {
  10. common: PropTypes.object.isRequired, // 项目公用数据
  11. sourcesRpt: PropTypes.object.isRequired, // 模块专用数据
  12. actions: PropTypes.object.isRequired,
  13. };
  14. render() {
  15. const {
  16. channelId,
  17. reportConfig,
  18. } = this.props.common;
  19. const { paramKeywords } = this.props.sourcesRpt;
  20. const {
  21. setParamKeywords,
  22. getKeywordsReport,
  23. } = this.props.actions;
  24. /**
  25. * @function handleQueryReport 获取报告数据
  26. * @param nextParam
  27. */
  28. const handleQueryReport = (nextParam) => {
  29. getKeywordsReport({ ...reportConfig, ...paramKeywords, ...nextParam }, channelId)
  30. .catch((message) => { Alert.error(message); });
  31. setParamKeywords({ ...nextParam });
  32. };
  33. return (
  34. <div className="sources-rpt-keywords-page-table-toolbar">
  35. <div>
  36. <Dropdown shape="default" title="导出报告">
  37. <Dropdown.Item>汇总报告</Dropdown.Item>
  38. <Dropdown.Item>细分报告</Dropdown.Item>
  39. </Dropdown>
  40. </div>
  41. <div className="flex-item-auto" />
  42. <div style={{ width: '300px' }}>
  43. <InputGroup>
  44. <Dropdown shape="default" activeKey={paramKeywords.wordType} onSelect={(eventKey) => { handleQueryReport({ wordType: eventKey }); }} select componentClass={InputGroup.Button}>
  45. <Dropdown.Item eventKey="ALL" >全部 </Dropdown.Item>
  46. <Dropdown.Item eventKey="KEYWORDS" >关键词 </Dropdown.Item>
  47. </Dropdown>
  48. <FormControl type="text" value={paramKeywords.word} onChange={(value) => { handleQueryReport({ word: value }); }} />
  49. </InputGroup>
  50. </div>
  51. </div>
  52. );
  53. }
  54. }
  55. /* 关联数据 */
  56. function mapStateToProps(state) {
  57. return {
  58. sourcesRpt: state.sourcesRpt,
  59. common: state.common,
  60. };
  61. }
  62. /* 关联数据操作 */
  63. function mapDispatchToProps(dispatch) {
  64. return {
  65. actions: bindActionCreators({ ...actions }, dispatch)
  66. };
  67. }
  68. export default connect(
  69. mapStateToProps,
  70. mapDispatchToProps
  71. )(KeywordsPageTableToolbar);
  1. 更改 index.js
  1. ...
  2. export { default as KeywordsPageTableToolbar } from './KeywordsPageTableToolbar';
  1. 更改,添加必要样式。
  1. ...
  2. .sources-rpt-keywords-page-table-toolbar {
  3. background-color: @B050;
  4. padding: 18px;
  5. display: inline-flex;
  6. line-height: 36px;
  7. width: 100%;
  8. .flex-item-auto { flex: auto }
  9. }

小结
对比实践五与实践六,后者比前者多了关联数据的工作,未用独立的文件保存这部分工作。采用这种行文方式能够很好的区分 container and presentational 两种类型的组件,同时组件关联的数据区域也得到很好的体现,这能为你带来良好的开发体验。其改良思想类似于“一叶知秋”。后续开发请沿用。


添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注