某大型企业物流管理系统替代产品开发踩坑总结

项目背景介绍

    该项目是国内某大型科技公司为解决现有物流系统每年租用价格高昂、使用不便的问题而起,原计划是在半年内在之前公司一以基本弃用的产品基础上进行优化,替换部分核心功能并增加新功能以替换现在租用的物流管理系统。但最后因为资金问题项目延后到了11月才正式启动,此时离该公司现有系统租用到期的时间已经只有四个半月左右。

项目难点(只做前端,只提前端)

  1. 使用了angular2,缺少足够的技术支持
  2. angular2没有较为成熟而全面的插件,为实现客户需要的效果需要实现专用的插件
  3. 项目涉及到世界时区,前端所有跟时间有关的控件都需要结合时区进行处理

项目问题

  1. 项目依赖客户方内网与原有系统,浪费了大量时间在环境搭建上
  2. 因为前端的TL不在现场,导致项目前期代码结构混乱,后期进行了大量的重构
  3. 客户需求不明确,功能反复推翻重写,导致进度失控
  4. 项目缺乏测试,导致后期功能修改成本高
  5. 人员变动大,代码风格因为没有code review导致没有统一,代码质量有较大波动
  6. 项目难度预测偏差大,项目人手不足

项目踩坑

1.时区问题

    项目涉及到世界时区,用户可以设置自己先看见的时间是基于某一时区下的时间,并在用户切换时区的时候时间自动进行变化,前期团队提出的方案有三个。使用UTC时间、使用时间戳、统一使用北京时间,然后前端进行时间的格式化就能够满足需求,但是问题在于,数据库里已经有了大量的原始数据,这些时间使用的时间是不确定的,而前端所选用的primeng插件在初始化或者传值的时候使用的时间格式是不带时区的,必须传new Date()这样的时间格式才能进行渲染否则会报错。而该问题还涉及到了选择时间后的时间输出,插件输出的时间,以日历为例,输入2016-12-12这样的值需要进行一次转换才能传入组件,而选择了一个日期也同样需要处理才能显示,再加上两个时间选择控件间的联动效果,这个逻辑就更加复杂了。

    解决这个问题的方案是,将显示与保存的时间剥离开,在显示时展示年月日将传入的时间的时区直接去掉做成年月日相同的系统时间,为了实现这步引入了moment插件,利用该插件将数后台传过来的时间全部进行了转换,转换成了满足插件的时间格式进行展示,然后再利用moment实现了时间格式的互转,日历的联动则是手写了大量的逻辑来实现的。在最后提交表单的时候又利用moment的utcoffset()将不带时区的时间与从用户信息里获取到的时区偏移值进行处理,格式化出满足后台需求的时间。所以用户操作的和最后保存的实际上并不是同一个数据。这样就保证了时间的显示与储存的时间在对应的环境下都是满足其需求的。

2.变量全局订阅

    提到全局变量订阅这个问题,其实是类似于angular1.x里的广播和订阅,在这个项目里应用到的场景是在各种遮盖层,弹出框以及之前提到的用户设置的时区值变更的情况。在ng2里我们一开始是通过订阅一个subject的方式来实现的这中场景,但是后期研究的时候发现,如果只是使用subject会导致的如果这个subject在赋值前被subscribe了,那么当它被第一次赋值的时候其实是不会触发subscribe它之后触发的事件的,为了避免这种情况发生应该使用rxjs里的ReplaySubject。而弹窗这种需要内部信息变动较多的场景我们则是使用的BeHaviorSubject来实现。

    这里简单介绍下Subject

(部分内容摘自翻译的官方文档https://mcxiaoke.gitbooks.io/rxdocs/content/Subject.html),

    Subject可以看成是一个桥梁或者代理,在某些ReactiveX实现中(如RxJava),它同时充当了Observer和Observable的角色。因为它是一个Observer,它可以订阅一个或多个Observable;又因为它是一个Observable,它可以转发它收到(Observe)的数据,也可以发射新的数据。

    针对不同的场景一共有四种类型的Subject,

    AsyncSubject: AsyncSubject只在原始Observable完成后,发射来自原始Observable的最后一个值。(如果原始Observable没有发射任何值,AsyncObject也不发射任何值)它会把这最后一个值发射给任何后续的观察者。

    BeHaviorSubject: 当观察者订阅BehaviorSubject时,它开始发射原始Observable最近发射的数据(如果此时还没有收到任何数据,它会发射一个默认值),然后继续发射其它任何来自原始Observable的数据。

    PublishSubject: PublishSubject只会把在订阅发生的时间点之后来自原始Observable的数据发射给观察者。需要注意的是,PublishSubject可能会一创建完成就立刻开始发射数据(除非你可以阻止它发生),因此这里有一个风险:在Subject被创建后到有观察者订阅它之前这个时间段内,一个或多个数据可能会丢失。

    ReplaySubject: ReplaySubject会发射所有来自原始Observable的数据给观察者,无论它们是何时订阅的。也有其它版本的ReplaySubject,在重放缓存增长到一定大小的时候或过了一段时间后会丢弃旧的数据(原始Observable发射的)。如果你把ReplaySubject当作一个观察者使用,注意不要从多个线程中调用它的onNext方法(包括其它的on系列方法),这可能导致同时(非顺序)调用,这会违反Observable协议,给Subject的结果增加了不确定性。

3.PrimeNG,神坑的组件

    在进行项目需求分析的inception的阶段,我的老师选用了这个插件作为我们这个项目的主要插件进行使用,进行具体功能开发前使用它的demo其实也并没有发现什么问题,而其也能够提供较为全面的组件,一开始的时候我对这个组件是很满意的,但是用到项目里立即就发现并不是这样一会事了,因为PrimeNG是其JQuery版本改过来的,所以其实现某些效果用的还是JQuery对Dom直接进行操作的方式,体现最为明显的就是,PrimeNG对样式的修改是通过添加和删除class来进行的,包括可以通过伪类来实现的样式调整,这直接造成我想定制某个组件的样式的时候非常麻烦才能找到对应的类,而且这些类其实还会被不同的组件重复利用,经常改了这里,那里就挂了。最最过分的是,PrimeNG里的样式和Bootstrap不停的在冲突...

   除了这个问题组件本身也存在一些bug,比如dataTable组件内有属性相互冲突导致有的效果没法使用,又比如因为组件内的渲染都是在OnInit时去做的,很多需要在OnChanges时才生效的东西是没办法做的。

    所以后期我和老师基本是在自己写插件来满足需求,老师是用的他写的angular ui model,我用的自己写的low逼组件。

 

补充:好像最近primeNG又有点火了,它确实是当前市场上angular组件最全最多的插件库,这点我不否认,但其主要问题是底层代码依旧有大量之前JQuery版本的影子在定制化需求的时候会显得不方便比较重,其次是其样式与boorstrap有不少冲突的地方,样式上的定制也非常麻烦,这里推荐破狼老师的rebirth-ng,使用后相对比较灵活,特别是在样式的定制上,可以直接通过修改对应的样式文件进行个性化的定制,底层代码也比较清晰,我经常是api文档没有看懂的时候就看底层代码去了解实现原理然后就能很轻松的了解到组件的用法,特别是表单这块在用rxjs进行优化后对大量数据更新非常的流畅。

rebirth-ng的地址是:https://greengerong.github.io/rebirth-ng/#/gettingStarted

 

4.生命周期的坑

    103852_aDg5_2839400.png

    angular的生命在项目里最常用的是ngOnInit和ngOnChanges这两个状态,但是使用时经常会发现这两个状态会很容易混淆,而且很多需要用到ngOnChanges的场景会因为误用了ngOnInit而导致无法实现相应的渲染效果。primeNG里其实很多问题就是这样造成的,很多需要反复赋值的变量其实是不应该只放在ngOnInit里的,多数情况这些变量是一些Object。

   ngOnChanges(changes)的changes里有一个判断是否为第一次触发ngOnChanges的属性isFirstChange,可以通过这个属性避免在ngOnInit()之前对属性做一些操作。

   而ngAfterViewInit这个钩子笔者自己其实没有用到过,但是项目完成后其实发现有的地方其实是可以通过这个钩子进行优化的。项目中因为有的场景下需要子组件的某个值在其ngOnInit中被赋值后才去触发,或者是同级的组件互相之间产生了变量间的关联,这两种情况我在项目中前者是通过在子组件中添加Output,父组件监听其emit事件来触发对应的事件来实现,而后者多是通过subject来实现的。但现在看来其实前者应该用ngAfterViewInit来实现更为标准。

5.lodash中的function在组件路由改变的时候是不生效的坑

    这个问题是我和我的tech leader在解决一个请求会多次提交时发现的,但是因为代码的问题,表单提交后会在父组件进行查询和保存查询条件,但是我们发现表单会在大概1000ms的时间内被提交两次,造成页面的两次刷新,我们尝试用lodash里的debounce去限制这个事件触发的次数,但是发现并没有作用,一开始我们认为是debounce的问题,尝试自己写了类似的方法也没有解决问题,最后定位问题是,当子组件将form提交到父级的时候因为代码逻辑的原因,这里其实是做了一次路由的变化,触发了父级的ngOnInit()事件中保存搜索信息的function,多次提交时其实直接绕过了前端的重名检测,而因为后端没有添加重名检测所以保存了两次完全一样的东西,所以这里其实并不是debounce没有生效,而是产生了两个独立的debounce而它们各自独立且生效,所以function还是会触发两次。

6.管道的非纯与纯

  这里管道的知识其实直接看官方文档就好,老师的中文文档也翻译的很明白。https://angular.cn/docs/ts/latest/guide/pipes.html

    angular的管道在应用中也是特别方便的,在项目里有时甚至可以用来充当黑科技,项目中有一次需要对一个特别复杂的对象组,当然我也不知道后端为啥一定要给我返一段这么难用的数据,为了用value替换其最底层的code属性名我尝试用lodash的各种处理都没能成功,而且过程中发现一个很奇怪的问题,我可以正常的添加一个value属性并以code的值去赋值这个属性,但是我没法把code直接改成value,也没替换掉这个code(知道原因的coder可以给我说一下这可能是什么原因造成的),我初步估计是lodash在处理的过程中在什么地方clone了一个对象但是修改时没有改到这部分就return了这个值造成的。最后妥协的黑科技就是写了一个pipe在使用到这个属性的时候将其进行了转换,目前为止没有发现造成了其他的什么问题。

7.组件的通信

  同样附上官方文档的地址https://angular.cn/docs/ts/latest/cookbook/component-communication.html

    这块比较大,我准备之后单独写一篇来介绍angular2的各种组件通信,个人最习惯使用的是Input和Output的来进行父子组件的双向绑定,文档中提到的ngOnChanges()里面其实监听的都是组件Input的值,如果是组件内部的值发生变化的时候是不会触发ngOnChanges()的。个人只在调试时使用到过setter和getter所以就不班门弄斧讨论这个了。而另外一种则是之前在说时区提到过的通过订阅subject,或设置所订阅的subject的next方法复制去触发组件事件的方式,这种文章在很多大神的博客上都能找到我就不做累述了。最后是使用ViewChild,使用ViewChild 首先要在子组件中添加exportAs属性,这样在父组件里就可以通过文档里的方式调用自己组件里的方法了。

8.组件结构设计的坑

    这个坑其实真是这个项目最大的毒瘤了,项目中的advance Search,一开始是准备由多人开发所以切分出了几个模块,但是子组件和父组件之间的数据绑定不可避免的变得很死,一开始我自己做的时候因为这个模块还不涉及到表单信息的保存因为需要赶进度,所以这些所有的输入框(各种类型和组件统称输入框,不是只有input)的值一开始并不是全部保存的,只会提交修改了的部分,这样做的结果就是在做编辑一个保存的form的时候父组件接收到的是一个不全的表单,而这个表单需要传给全部的子组件进行判断处理后才能完成整个模块的正确渲染,当然这样也能做,但是要知道子组件中还有子组件,考虑到使用的插件,这个表单可能会传递3-4层,而每次传递都需要写function进行处理,后期维护和bug的查找都会有很大的影响,于是权衡利弊之后,我们的方案是将每个模块做成了一个input输入框,具体的做法是引入了NG_VALUE_ACCESSOR, ControlValueAccessor, NG_VALIDATORS, Validator, AbstractControl这些东西然后讲一个模块的数据视作一个整体,为其添加验证等input框的属性,这些方法其实就是实现一个输入框的内容。

change: (value: SupplyChain) => void;
  touched: () => void;

  changed(): void {
    if (this.change) {
      this.change(this.model);
    }
  }

  writeValue(value: SupplyChain): void {
    this.model = value;
    if (this.model && this.model.destGeosAndSubGeos) {
      this.selection = _.map(this.model.destGeosAndSubGeos.split(';'), (item)=> {
        return {
          label: item,
          value: item,
        }
      });
    }
  }

  registerOnChange(fn: (value: SupplyChain) => void): void {
    this.change = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.touched = fn;
  }

  validate(c: AbstractControl): {[p: string]: any} {
    const valid = this.model;
    if (!valid) {
      return {SupplyChain: this.model};
    }
  }

    然后,通过在每个模块都定义类型将这个模块的值全部设定初值,完成了该模块的重构,数据只需要在最上层传入,通过组件的ngModel就能完成双向绑定,不需要再写对应的代码进行复杂的处理。

9.form的Dirty验证

    因为在advance Search这块的表单中一些初值的设定是在其子组件渲染阶段已经进行了赋值,所以最上层的表单的Dirty就已经是true即已修改,现在考虑可能在子组件渲染完成后(ngAfterViewInit)再将这个dirty手动设置成false就能满足效果。但是开始的时候其实我个人是完全跑偏了的,具体做法是我希望去深度克隆一个对象作为originForm然后将这个对象跟提交的表单作对比判断是否为Dirty,但是做的过程中发现使用loadsh的cloneDeep,clone出来的对象是Form初始化修改之前的原始形态,当时我考虑的方向是cloneDeep可能clone的是clone对象的原始状态,并在如何克隆当前状态上花了比较多的时间去实现,但是自己实现一个方法后发现原因并不是cloneDeep会克隆到对象的原始状态,而是在我克隆的时候虽然进行到了父组件的OnInit周期,但是子组件的OnInit周期还没有进入,所以我在子组件将表单状态修改后又进行了一次emit,并在父组件去将其捕获后再进行clone,这样就成功的clone到了想要的对象,但是做到这里其实并没有完全做完这个功能,因为这个比较结果是要绑定在disabled属性上的,但是对象不能直接进行对比,所以我希望将其拍成json的字符串来进行对比,而这个方法不能直接在标签里使用所以这里我手写了一个新的pipe,但我随即便发现,这个pipe并没有按照我的预期在form改变的时候触发,原因是改变的form是作为一个对象改变的,而纯的pipe是不能监测到这个变化的,为了实现这个功能还需要讲pipe修改为非纯,至此算是使用一个和科技解决了这个问题,目前也没有看出有什么bug。


  tryLoadViewForm() {
    this.route.params.subscribe((params: {mode: string, viewCode: string}) => {
      if (params.viewCode) {
        this.loadingService.show();
        this.quickSearchService.getViewList().subscribe(views => {
          this.pageEditable = _.find(views, item=>{
              return item.value === params.viewCode
          }).editable;
        });

        this.quickSearchService.getViewForm(params.viewCode).subscribe(form => {
          this.form = AdvanceSearchForm.mkfrom(form);
          this.loadingService.close();
        });
      }
    });
  }

  tryCloneOriginForm(){
    this.originForm = _.cloneDeep(this.form);
  }

    这里其实有个隐患就是太容易LoadViewForm如果比TryCloneOriginForm执行的慢会造成编辑时clone的对象出错,改成之前提到的标准方式不会有这种问题,但是就算这样运行的时候也没有出现bug

<app-panel header="Supply Chain" [toggleable]="true">
      <app-supply-chain name="supplyChain" [(ngModel)]="form.supplyChain"
                        (onOptionsLoaded)="tryLoadViewForm()"></app-supply-chain>
    </app-panel>
ngOnInit() {
    this.advanced.getGeoAndSubgeo().subscribe(res => {
      this.geoAndSubgeoOptions = res;
    });

    Observable.from(Object.keys(this.supplyChainOptions))
      .mergeMap(k => {
        var config = this.supplyChainOptions[k];
        let observable = this.advanced.getOptions(config.url);
        observable.subscribe(res => {
          config.options = res;
          config.defaultAll && this.initTransportationForm(k, res);
        });
        return observable;
      }).subscribe(()=> {
    }, ()=> {
    }, ()=> {
      this.onOptionsLoaded.emit({});
    });

    this.advanced.getAllSupplyChainData().subscribe(res=> {
      this.allData = res;
      this.originCountryOptions = this.allData.getCountrySOS();
      this.originSosOptions = this.allData.getAllSOS();
      this.dischargeCountryOptions = this.loadCountryOptions = this.allData.getCountryPort();
      this.dischargePortOptions = this.loadPortOptions = this.allData.getAllPort();
      this.shipToCtryCdOptions = this.allData.getAllGeoSubGeoCountries();
    });
  }

项目中用到的一些小技巧和我老师的code

1.判断点击范围超出dom对象区域

import { Directive, Output, EventEmitter, OnDestroy, OnInit, ElementRef } from '@angular/core';

@Directive({
  selector: '[appClickOutside]'
})
export class ClickOutsideDirective implements OnInit, OnDestroy {
  @Output('appClickOutside') onCancel: EventEmitter<any> = new EventEmitter();

  constructor(private element: ElementRef) {

  }

  ngOnInit(): void {
    document.addEventListener('click', (event) => {
      if (!isSelfOrAncestorNode(this.element.nativeElement, (event.target || event.srcElement) as Node)) {
        this.onCancel.emit();
      }
    });
  }

  ngOnDestroy(): void {
    document.removeEventListener('click');
  }
}

function isSelfOrAncestorNode(ancestor: Node, node: Node): boolean {
  while (node) {
    if (node === ancestor) {
      return true;
    }
    node = node.parentNode;
  }
  return false;
}

2.超出部分打省略号

    display:block;white-space:nowrap; overflow:hidden; text-overflow:ellipsis    

3.设置svg图颜色

    直接去改对应的fill、stroke

svg.icon {
  height: 1em;
  width: 1em;
  position: relative;
  bottom: -0.1em;
  * {
    stroke: currentcolor;
  }
}

    这里的stroke可以根据情况改成fill,作用都是用来设置svg颜色等于父级的color

4.时区的处理

moment(moment(this.defaultDate).utcOffset(res).format('YYYY-MM-DD')).toDate();

注意utcOffset里有两个参数,第二个参数是用来设置这个时间的年月日时分秒是否会根据时区做相应的偏移。

总结

      项目让我学到了很多。

       

转载于:https://my.oschina.net/twleo2016/blog/853612

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值