ISSUE / 002
项目开发经验总结
项目总结 数字人生 19362 行JS代码 1667 行样式 yapi上显示258个接口 221 次commit,2 个贡献者 2018.06.25 第一次commit,至今已有5个月 经历过一次重写 预估项目进度完成四分之一 现存1个前端、1个后端、2个测试 遇到的问题 #### 前端项目没有任何的知…

1. 项目总结
数字人生
- 19362 行JS代码
- 1667 行样式
- yapi上显示258个接口
- 221 次commit,2 个贡献者
- 2018.06.25 第一次commit,至今已有5个月
- 经历过一次重写
- 预估项目进度完成四分之一
- 现存1个前端、1个后端、2个测试
遇到的问题
前端项目没有任何的知识积累
虽然项目代号已经叫4.0了,但是对于前端er来说,业务知识几乎为0。
每一位新的前端开发加入到这个项目进来,都要经历这样一个过程:
没有文档,更没有前辈来告诉你整个业务的设计,前人踩过的坑在你这可能又要踩一次了,更别提有代码给你指引了,况且三无(无注释、无文档、无测试)代码能给的指引也是有限的甚至是反作用。原型的缺乏完善性造成业务理解上的偏差
原型作为软件工程的建筑图纸,一份合格甚至是优秀的原型会大大减轻开发的压力。项目中的原型有以下几个问题:
- 原型的阅读性欠佳
- 前期原型缺少版本管理,开发中伴随着需求变更,经常出现已经实现的功能和原型对不上的问题
项目进度落后于项目经理预估的进度
现状是整个项目拖延了三四周。 首先我们先简化我们的生产模型:
项目进度 = 1单位劳动力 * 劳动力人数 * 预估时间根据这个公式,分析以下几点原因:
- 劳动力质量
由于缺乏大型项目的经验导致前期整个项目的失控,如果能有经验的前辈在带领的话应该会好很多,所以这也是拖慢项目进度的重要原因。 人员不足

图中
横轴L代表的是劳动力的投入量,纵轴TPL代表的是总产量,图中可以看到在6之前,每增加一单位劳动力都能有效带来生产力的增长(边际增长/每一个点的斜率),但是到了6之后,边际产量反而开始减少了。虽然图中的生产模型并非对应我们的项目生产模型,但这个曲线是必然存在的,而目前项目的人员投入可能会拖延一定的项目进度。预估时间
业务理解的偏差和过分乐观的态度导致预估时间的不准确。而在劳动力质量和人员不足短期内不变的情况下,只能延长我们的开发时间来完成开发,以致于项目进度拖延。
- 劳动力质量
软件工程开发建议
这里直接引用网友对《人月神话》一书的总结,关于软件工程开发过程中的一些建议:
- #### 提倡外科手术式的团队组织 在软件开发组织上的过份民主,往往带来的是没有效率和责任,参与其中的人想法太多,层面参差不齐。所以,软件开发的组织,应该借鉴外科手术式的团队方式,有一个主要的负责人,其他人都是分工协作的副手,这样效率最好,结果最好。
- #### 软件项目的核心概念要由很少的人来完成,以保证概念的完整性 少就是多,项目的定位需要和功能多少的权衡。太多的想法,使项目没有焦点,什么都要放进去,结果什么都做不象。
- #### 软件开发过程中必要的沟通手段 软件开发中最大的风险往往不是技术的缺陷,而是缺少沟通。
- #### 如何保持适度的文档 在开发中,保持适度的文档。喜欢过度多的文档的人,忘记了文档不是最终的产品,不是用户需要的,最后以为文档好,就是好的开发,其实完全不是。
- #### 在软件开发的过程中,只有适度改进,没有包治百病的银弹 在软件开发的过程中,重要的不是采用了什么工具,而是不论用何种工具,都要达到项目本身的客户需求。任何方法论之前,先要探求问题的来源,否则,对各种方法论的依赖或滥用,有害无益。
2. 领域驱动设计
现状分析
回想一下我们前端的主要工作,大部分都跟页面打交道:如何还原UI设计图、点击某个按钮发起某个请求。
当我们将重点放在了视图上的时候就很容易出现一个问题:我们极易被需求变更摧毁。
或许改改页面样式还是小问题,但是当你让将功能从A->B->C变成C-B-A的时候,你会发现自己的设计没有任何弹性:扩容性极差。
而有一个被我们前端容易轻视的很重要的一环节就是业务,甚至你会听到有人说前端不需要关注业务,你只要按着原型一个页面一个页面做下来就好啦。虽然从某种层面上来讲这并没有错,因为真正存数据到数据库里的并不是我们前端,而是后端小哥。但是如果你是这样工作的,我想你的开发流程一定是这样的:

我们称之为视图驱动设计。上述流程看不到有关于对业务的抽象,极有可能给自己埋下坑,因为页面的抽象真的是及其脆弱的,需求改变很容易让你骂娘,而且对新人也及其不友好,试想一位新同事在浏览你的代码的时候,他只看到了这些页面共同的样式,共同的请求,对业务的理解又只能继续从原型上获取了。
这是一个糟糕的开始,所以这时候极有必要引入一个概念领域模型。
初识领域模型
当然对页面的抽象没有任何错误,只是在这个环节前缺漏了一个很重要的环节:对业务的抽象,我们重新整理一下流程:

那么究竟什么是领域模型呢?
软件项目的核心概念要由很少的人来完成,以保证概念的完整性。
这里的核心概念指代的就是领域模型,也可以说是整个产品(项目)最核心的地方。这个核心概念理论上是建立之后就不应该去修改的,如果修改了,那又是一个新的产品或项目了。
领域模型是对业务的抽象,是贯穿整个项目的一个完整的业务知识体系。
建立领域模型
那么我们如何建立我们的领域模型呢?建立这个领域模型的工作究竟应该谁来做?
答案是各个环节的外科主刀人!
- 产品经理
最早建立的这个领域模型的其实应该是产品经理,因为产品是他设计的,他是最清楚这个产品的人,但很遗憾,并不是每个产品都能抽象出这个领域模型出来的,虽然他们很清楚他们的产品是什么,但是输出只会是原型,并不会多给你一份领域模型设计的文档。
- 后端设计人员
这个概念应该后端人员并不陌生,因为他们需要设计一个非常重要的东西:数据库。一个经验丰富的后端开发一定会设计一个灵活且合理的数据库来满足需求上的变更,而不是一味的堆叠数据库表。
- 前端设计人员
那前端如何去建立领域模型呢?当业务越简单时,这个领域模型越容易建立,前端开发可能也能直接设计出领域模型。但是当我们面临比较复杂项目时可能缺乏这方面的经验容易导致设计上的偏差,这时候我们主要去借鉴后端的领域模型设计。
博客系统的领域模型
- 文章模型
- 分类模型
- 标签模型
协同项目的领域模型
协同业务比较复杂,当初看原型看半天也看的相当片面,也是导致整个项目重写的原因之一。后面找后端设计人员拿了一张这样的图:

重新整理了一份领域模型:
- 用户相关模型
- 资源目录模型
- 资源信息模型
- 资源注册模型
- 资源申请模型
- 资源测试模型
- 分页表格模型
领域驱动设计
当领域模型设计出来之后,一切就变得有章可循了。
- #### 目录结构设计

文件(夹) | 说明 ---|--- webpack | webpack相关配置 .babelrc/postcssconfig.js/.browserslist/.eslintrc等 | 不在将这些配置写到webpack中,而是通过最直观的方式展示项目配置,因为比如当你如果要修改babel编译的配置时,文档上一定是告诉你在.babelrc上修改。 global.d.ts | 一些模块声明提供给编辑器做智能提示 src/assets | 项目中用到的媒体资源文件 src/config | 定义一些项目中用到的常量或者其他配置 src/models | 项目的领域模型 src/components | 项目中的公用组件 src/services | 项目中的接口请求管理 src/utils | 项目中使用到一些工具类函数 src/pages | 项目中多入口,一个入口以一个pages下的文件夹存在 src/pages//routes | 项目中的页面 src/pages//routes/config.js | 路由声明文件
着重介绍下pages、services、models、components
pages
resource-register ├─audit │ │ Audit.js │ │ index.js │ │ │ ├─logic │ └─styles ├─audit-list └─index ├─database ├─databuffer ├─file └─service- services
同领域模型一一对应js component.js resource-apply.js resource-catalog.js resource-register.js resource-test.js resource.js user.js models
所有的数据的CRUD都存放在各自的文件下cache.js component.js resource-apply.js resource-catalog.js resource-register.js resource-test.js resource.js table.js user.jscomponents
// ...ui components ├─component ├─resource ├─resource-apply ├─resource-register ├─resource-test- #### 路由设计 设计思路主要有两种
- 只有一级路由,所有的路由配置都在一个文件下显式声明
- 允许有二级路由(子路由)
这边以第二种允许有二级路由(子路由)做介绍,第一种设计方式只要去除子路由即可
// 此处声明所有一级路由的配置,如果有子路由,参照react-router的实践方式
const ROUTES = [
// resource
{
path: "/resource/profile",
name: "资源概况",
models: [],
component: () => import("./resource/profile"),
exact: true
},
// resource-apply
{
path: "/resource-apply",
name: "资源申请",
models: [],
component: () => import("./resource-apply"),
exact: true
},
{
path: "/resource-apply/audit",
name: "资源申请审核",
models: [],
component: () => import("./resource-apply/audit"),
exact: true
},
// resource-register
{
path: "/resource-register",
name: "资源注册",
models: [],
component: () => import("./resource-register"),
exact: true
},
{
path: "/resource-register/audit-list",
name: "资源注册审核列表",
models: [],
component: () => import("./resource/profile"),
exact: true
},
// component
{
path: "/component",
name: "组件CreateUpdate",
models: [],
component: () => import("./component"),
exact: true
},
{
path: "/component/list",
name: "组件相关列表",
models: [],
component: () => import("./component/list"),
exact: true
},
// fallback
{
path: "*",
name: "not found",
component: () => import("@/components/404")
}
];
如此一来,我们就可以将路由同目录结构直接联系起来,当我们在开发中就可以直接通过浏览器中url来快速定位代码位置。
URL | 目录结构 | 说明 ---|--- | --- /resource/profile | routes/resource/profile | 资源概况 /resource-register/file | routes/resource-register/index/file | 文件资源注册 /resource-register/audit | routes/resource-register/audit | 资源注册审核 /resource-apply/file | routes/resource-apply/index/file | 文件资源申请 /resource-apply/audit | routes/resource-apply/audit | 资源申请审核
- #### 主导接口设计
后端的接口是为前端服务的,而前端需要什么样的服务前端最清楚。当我们在未建立领域模型的时候,我们可能由于对业务理解欠缺,无法做到主导接口的设计,但是当建立起领域模型的时候,我们很清楚的知道我们需要什么。
举个栗子,现有的注册接口是这样的:
/resource/register/file POST BODY
/resource/register/database POST BODY
因为我们有一个叫做resource-register的模型,完全只需要一个接口就足够了
/resource/register POST BODY({type: 'file'})
这样的情况在该项目中很多,看似两个不同的接口完全可以抽象成一个接口,而这种事如果没有一步到位的后,后面在调整就可能得费点神了,特别是在没有测试覆盖的情况下。
3. 其他
命名约定
最重要的一致性规则是命名管理。命名的风格能让我们在不需要去查找类型声明的条件下快速地了解某个名字代表的含义: 类型、变量、函数、常量、宏等等, 甚至,我们大脑中的模式匹配引擎非常依赖这些命名规则。
总结一些我自己在使用的命名约定:
- 变量和函数的命名都以驼峰式命名
- 所有的页面及组件都以文件夹形式存在,并且文件夹名称都使用小写英文单词并以
-连接,并且遵循名词-动词的结构命名 - 组件暴露给外部使用的句柄全部以
on*开头,内部自己使用的句柄全部以handle*开头 - 杜绝在条件判断中直接使用未定义常量
- 杜绝非普遍性缩写
- 不要拒绝长命名,语义第一,参考react的api设计
- 表单选择项都以
Options结尾
我们尽量在变量命名上达成共识,方便团队协作。
React中Key的原理及使用
Key的原理
我们经常会在开发中开到react在控制台给你这样的警告:
Warning: Each child in an array or iterator should have a unique "key" prop. Check the render method of `App`. See https://fb.me/react-warning-keys for more information.
为什么在jsx中必须为组数的每一项添加一个key呢?React官方文档是这样写的:
Keys help React identify which items have changed, are added, or are removed.
所以这个key是给react自身用来更新元素用的,并不是给我们用的。
我们来看一下,如果我们有这样一个数组:
const options = [
{
label: '选项1',
value: 'v1'
},
{
label: '选项2',
value: 'v2'
},
{
label: '选项3',
value: 'v3'
}
]
现在我们改变数组变成:
const options = [
{
label: '选项3',
value: 'v3'
},
{
label: '选项1',
value: 'v1'
},
{
label: '选项2',
value: 'v2'
}
]
第二个数组相对于第一个数组,所以的元素索引都变了,如果是你来更新元素你要怎么做?重新渲染一遍吗?这是一种办法,但这个方法是肉眼可见的简单粗暴,必定存在着性能问题,所以这时候react就借助key来唯一标识某个元素,react只要将第三个元素append到第一个位置就好了,这样就完成了更新。
再来思考一个问题,为什么只有数组里的元素需要添加key? 看代码说话:
const flag = false;
<section>
<h1>aaa</h1>
{flag && <p>hhh</p>}
{[<p key={1}>aaa</p>, <p key={2}>bbb</p>]}
footer
</section>
var flag = false;
React.createElement(
"section",
null,
React.createElement(
"h1",
null,
"aaa"
),
flag && React.createElement(
"p",
null,
"hhh"
),
[React.createElement(
"p",
{ key: 1 },
"aaa"
), React.createElement(
"p",
{ key: 2 },
"bbb"
)],
"footer"
);
可以看到非数组元素的元素,他所在位置就是他天然的key,并且当用变量控制一个元素的显隐的时候,也必须用null占据一个位置,这也是为什么在jsx中不能if的原因。
Key的另类使用
上面提到,key是提供给react使用的,当一个组件的key改变时,这个组件会被重新渲染,利用这一点,我们在一些特殊场景可以处理的非常优雅。
想像一下有这样一个场景:
- 你有一个比较复杂的业务组件
- 组件根据外部传来的某个id去做一些数据的拉取,即在
componentDidMount中去获取数据 - 当id改变的时候,你又监听
componentWillReceiveProps去监听 - 可能还会更复杂的需求,而这些需求你其实在
componentDidMounut其实已经做过一次了 - 虽然你可以将这些逻辑抽象出来,在
componentDidMount和componentWillReceiveProps中分别执行
如果这时候我们借助key的原理,当组件的key改变的时候,重新触发组件的挂载,那我们在处理这件事来变得更优雅了。
高阶组件的使用
高阶组件的出现是为了替代Mixins而出现的,最早在react中,一些通用的代码可以抽象到Mixins中,来进行逻辑的复用,比如受控表单的value, onChange
这里以改变组件的key举一个简单的例子:
import React from 'react';
const withKey = mapPropsToKey => WrappedComponent => props => {
const key = mapPropsToKey(props);
return <WrappedComponent {...props} key={key} _componentKey={key} />;
};
export default withKey;
