原文出处:https://m.imooc.com/article/19369  (应该是《Angular从零到一》作者)

我们在构建企业级应用时,通常会遇到各种各样的定制化功能,因为每个企业都有自己独特的流程、思维方式和行为习惯。有很多时候,软件企业是不太理解这种情况,习惯性的会给出一个诊断,『你这么做不对,按逻辑应该这样这样』。但企业往往不会接受这种说法,习惯的力量是强大的,我们一定要尊重这种事实。所以在构建企业应用的时候,我们不仅仅要了解对方的基本需求,也要了解他们习惯于怎么处理流程,在设计的时候需要予以充分重视。当然这也不是说客户说怎么改我们就怎么改,而是要了解到对方真正的诉求和背后的原因,在产品规划设计的时候,将这种因素考虑进去,才能在维持产品统一的框架下满足不同用户的需求。

那么这里我们举一个例子,比如我们正在开发一个医疗卫生领域的企业软件,客户要求提供一个出生日期的控件,但这个控件不光可以输入年月日,而且可以输入年龄数值以及选择年龄单位。客户的希望是:

  1. 填写日期时,年龄和年龄单位随之变化
  2. 填写年龄和选择年龄单位时出生日期也随之变化

看起来好像很无用的一个需求,这个在面向互联网的应用中确实如此。但在特定领域,其实有其背景原因,比如客户提出这个需求是由于很多人,尤其是小城镇的,是不记公历生日的,这样会导致出生日期不是很准确,另外还会有一些人的身份证日期和真实年龄是不一致的。这种情况对于成人来说还好,但对于儿童来说就偏差很大,但一般人会记得孩子现在是多少天或多少个月大。这样的话是不是觉得这个需求还有些道理?

那么我们就接着来看一下这个需求应该怎样实现,首先分析一下:

  1. 无论是输入出生日期还是年龄,其实最终要得到一个日期,也就是说年龄只是得到日期的一个辅助手段。
  2. 年龄单位的转换我们需要有一个界定,否则切换起来没有规则的话会导致逻辑的混乱。那这里我们定义一下:以天为单位时的上限为:90,下限为 0,也就是只有小于等于 90 天的婴儿我们会使用天作为年龄单位。类似的,以月为单位的上限为 24,下限为 1;以年为单位的上限为 150,下限为 1。
  3. 同样的出生日期的验证规则为:这个日期不能是未来的时间,一定是小于等于当前时间的,再有就是年龄的上限既然是 150,那么出生日期也不能比当前日期减去 150 年更早,对吗?
  4. 联动的规则应该是调整出生日期时,会将日期按上面规则转换成年龄和单位,改变控件中的值;而调整年龄或者单位的时候,我们会根据年龄推算出出生日期,当然这里是估算,以当前日期减去年龄得出,然后更新出生日期输入框中的值。

但这里面有几个值得注意的地方:

  1. 可能存在反复联动的问题,比如改变出生日期后,年龄和单位随之改变,这又引发了由年龄和单位的变化而导致的出生日期的重算。
  2. 如果输入非法的值,可能导致计算出现异常,因而控件状态出现不正确的状态值,进一步影响未来的计算。
  3. 如果每次输入改动都会引发重新计算,会带来大量的过程中无用计算,耗费资源,因此需要进行对输入事件的『整流』控制。

搭建自定义表单控件的框架

首先为什么要实现一个自定义表单控件?我们当然可以直接把这个逻辑放在表单中,但问题是表单真的需要关心这几个框的联动吗?

其实从表单的角度看,它只要一个值:那就是经过计算的出生日期。至于你是手动输入的还是按年龄和单位计算的,表单根本就不应该关心。另外一点是随着表单的复杂化,如果我们不把这些逻辑剥离出去的话,我们的表单本身的逻辑就会越来越复杂。最后是,封装成表单控件意味着我们以后可以复用这个控件了。

知道了 why,我们看看 how。在 Angular 中实现一个自定义的表单控件还是比较简单的,下面是一个表单控件的骨架。

import {ChangeDetectionStrategy, Component, forwardRef, OnInit, OnDestroy, Input} from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';@Component({selector: 'app-age-input',template: `// 省略`,styles: [`// 省略`],providers: [{provide: NG_VALUE_ACCESSOR,useExisting: forwardRef(() => AgeInputComponent),multi: true,},{provide: NG_VALIDATORS,useExisting: forwardRef(() => AgeInputComponent),multi: true,}],changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgeInputComponent implements ControlValueAccessor {private propagateChange = (_: any) => {};constructor() { }// 提供值的写入方法public writeValue(obj: Date) }// 当表单控件值改变时,函数 fn 会被调用// 这也是我们把变化 emit 回表单的机制public registerOnChange(fn: any) {this.propagateChange = fn;}// 这里没有使用,用于注册 touched 时的回调函数public registerOnTouched() {}// 验证表单,验证结果正确返回 null 否则返回一个验证结果对象validate(c: FormControl): {[key: string]: any} {// 省略}
}

我们可以看到要实现一个表单控件的话,要实现 ControlValueAccessor 这样一个接口。这个接口顾名思义是用于写入控件值的,它是一个控件和原生 DOM 元素之间的桥梁,通过实现这个接口,我们可以对原生 DOM 元素写入值。而这个接口需要实现三个必选方法: writeValue(obj: any)registerOnChange(fn: any)registerOnTouched(fn: any)

  • writeValue(obj: any):用于向元素中写入值
  • registerOnChange(fn: any):设置一个当控件接受到改变的事件时所要调用的函数。
  • registerOnTouched(fn: any):设置一个当控件接受到 touch 事件时所要调用的函数。

另外的一个 validate(c: FormControl): {[key: string]: any} 是控件的验证器函数。除了这些函数,你应该也注意到,我们注册了两个 provider,一个的 token 是 NG_VALUE_ACCESSOR 这是将控件本身注册到 DI 框架成为一个可以让表单访问其值的控件。但问题来了,如果在元数据中注册了控件本身,而此时控件仍为创建,这怎么破?这就得用到 forwardRef 了,这个函数允许我们引用一个尚未定义的对象。另外一个 NG_VALIDATORS 是让控件注册成为一个可以让表单得到其验证状态的控件
。当然这里还有一个奇怪的东西,就是那个 multi: true,,这是声明这个 token 对应的类很多,分散在各处。

控件的界面

我们这里使用了 @angular/materialinputdatepickerbutton-toggle 控件来分别实现日期输入、年龄输入和年龄单位的选择。注意到我们在里面使用了响应式表单,这感觉好像有点怪,我们本身不是一个表单控件吗?怎么自己的模板还是一个表单?这个其实没啥问题,因为 Angular 中的组件是和外界隔离的,所以组件自身的模板其实想怎么使用都可以。

<div [formGroup]="form" class="age-input"><div><md-input-container><input mdInput [mdDatepicker]="birthPicker" type="text" placeholder="出生日期" formControlName="birthday" ><button mdSuffix [mdDatepickerToggle]="birthPicker" type="button"></button><md-error>日期不正确</md-error></md-input-container><md-datepicker touchUi="true" #birthPicker></md-datepicker></div><ng-container formGroupName="age"><div class="age-num"><md-input-container><input mdInput type="number" placeholder="年龄" formControlName="ageNum"></md-input-container></div><div><md-button-toggle-group formControlName="ageUnit" [(ngModel)]="selectedUnit"><md-button-toggle *ngFor="let unit of ageUnits" [value]="unit.value">{{ unit.label }}</md-button-toggle></md-button-toggle-group></div><md-error class="mat-body-2" *ngIf="form.get('age').hasError('ageInvalid')">年龄或单位不正确</md-error></ng-container>
</div>

上面这个模板中值得注意的一点是,我们把年龄的数值和单位放在了一个 FormGroup 里面,这是由于这两个值组合在一起才有意义,而且后面的表单验证也是这两个值在一起组合后验证。

使用 Rx 的事件流来重新梳理逻辑

私以为 Rx 的两大优点:

  1. 由于在 Rx 世界里,一切都是事件流,所以这『逼迫』开发者将时间维度纳入设计的考量
  2. 提供的各种强大的操作符可以将逻辑非常轻松的组合

那么从 Rx 的角度看的话,这个控件会产生三个事件流:出生日期、年龄数值和年龄单位:

出生日期:-------d----------d---------------d--------------
年龄数值:----------num----------num----------------num----
年龄单位:----unit-------------unit-------------unit-------

写成代码的话就是下面的样子,Angular 的响应式表单为我们提供了非常便利的方法可以得到这些变化的事件流,FormControlvalueChanges 属性就是一个 Observable

// 得到出生日期的值的变化流
const birthday$ = this.form.get('birthday').valueChanges;
// 得到年龄数值的变化流
const ageNum$ = this.form.get('age').get('ageNum').valueChanges;
// 得到年龄单位的变化流
const ageUnit$ = this.form.get('age').get('ageUnit').valueChanges;

由于年龄数值和年龄单位需要合并在一起才有意义,所以这两个流需要做一个合并操作,而且不管是数值变化还是单位变化,我们都要在新的合并流中有一个反映:

年龄数值:----------n1----------------n2------------------n3-------
年龄单位:----u1-------------u2------------------u3----------------
合并后:  ------(n1,u1)--(n1,u2)--(n2,u2)----(n2,u3)---(n3,u3)---

仔细观察一下,你可能会发现这个合并流还有一个特点就是只有在参与合并的两个流都有事件产生后才会有合并的事件发生,在这之后就是任何一个参与合并的流有新的事件,合并流就会产生一个事件,这个合并的值会取刚刚发生的那个事件和另一个参与合并的流中的『最新』事件。这种合并方法在 Rx 中叫做 combineLatest

const age$ = Observable.combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit}));

上面的代码中,我们将年龄数值的事件流(ageNum$)以及年龄单位的事件流(ageUnit$)做了合并,而且通过一个 this.toDate 的工具函数将年龄和单位计算出了一个估算的出生日期。

出生日期:-------d----------d---------------d--------------
年龄合并:---d^----d^----d^---d^--------d^------d^---------
// 年龄合并后产生的出生日期用 d^ 来标识

现在看起来这两个流都产生日期,只不过是不同的控件变化引起的。那么我们应该可以把它们也做一个合并,这个合并就比较简单,可以想象成按照各自流中的位置把两个流做投影。

最终合并:---d^--d--d^----d^--d-d^-------d^--d----d^-------

而这种合并在 Rx 中叫做 merge

const merge$ = Observable.merge(birthday$, age$);

但为了要能区分这个日期是来自于出生日期那个输入框还是来自于年龄和单位的输入变化,我们得标识出这个日期的来源。所以我们需要对 birthday$age$ 做一个变换处理,不在单纯的发射日期,而是将日期和来源组合成一个新的对象 {date: string; from: string} 发射。

const birthday$ = this.form.get('birthday').valueChanges.map(d => ({date: d, from: 'birthday'}));
const age$ = Observable.combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit})).map(d => ({date: d, from: 'age'}));

这样处理之后,我们就可以根据不同情况,根据日期设置年龄和单位,或者反之,由年龄和单位的变化设置出生日期。

this.subBirth = merged$.subscribe(date => {const age = this.toAge(date.date);const ageNum = this.form.get('age').get('ageNum');const ageUnit = this.form.get('age').get('ageUnit');if(date.from === 'birthday') {if(age.age === ageNum.value && age.unit === ageUnit.value) {return;}ageUnit.patchValue(age.unit, {emitEvent: false, emitModelToViewChange: true, emitViewToModelChange: true});ageNum.patchValue(age.age, {emitEvent: false});this.selectedUnit = age.unit;this.propagateChange(date.date);} else {const ageToCompare = this.toAge(this.form.get('birthday').value);// 如果要设置的日期换算成年龄和单位,如果这两个值和现有控件的值是一样的,那就没有必要更新日期的值了if(age.age !== ageToCompare.age || age.unit !== ageToCompare.unit) {this.form.get('birthday').patchValue(date.date, {emitEvent: false});this.propagateChange(date.date);}}
});

大致的逻辑就是这样了,但我们还有几个问题需要解决

  1. 现在的情况是不管你以多快的速度输入日期,或者输错了按 backspace 都会产生新的事件,也因此会有计算。但显然这样做一方面浪费了性能,另一方面会导致一些不合法的值大量出现(比如本来要输入 2000-12-11 , 但事实上现在当你刚刚敲了 2 ,事件就已经产生了,但显然年份 2 不是一个合理的出生年份,我们毕竟不是在做一个考古信息系统)。
  2. 当你和上一次输入相同的值时,现在的系统仍然会发射事件,但这其实是在做无用功。
  3. 我们现在的事件流没有经过一个验证就会把数据发射出来,但一个没有验证成功的值其实对我们来说是没有意义的。
  4. 年龄和单位的合并流只有在年龄和单位都产生变化的时候才开始发射,但一开始的初始状态,这两个控件并没有值,这显然不是我们希望的(比如你可能不想填完年龄,例如 30,然后还得点一下『天』,再点回『岁』来得到合并计算的值)。
const birthday$ = this.form.get('birthday').valueChanges.map(d => ({date: d, from: 'birthday'})).debounceTime(300).distinctUntilChanged().filter(date => this.form.get('birthday').valid);
const ageNum$ = this.form.get('age').get('ageNum').valueChanges.startWith(this.form.get('age').get('ageNum').value).debounceTime(300).distinctUntilChanged();
const ageUnit$ = this.form.get('age').get('ageUnit').valueChanges.startWith(this.form.get('age').get('ageUnit').value).debounceTime(300).distinctUntilChanged();
const age$ = Observable.combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit})).map(d => ({date: d, from: 'age'})).filter(_ => this.form.get('age').valid);
const merged$ = Observable.merge(birthday$, age$).filter(_ => this.form.valid);

上面的代码中,我们使用 debounceTime 过滤掉了短时间内的输入,等待用户略有停顿或输入完成时才发射新的事件。我们还使用了 distinctUntilChanged 来过滤掉和之前一样的输入。而 startWith 其实是在帮事件流拼接一个初始值,使得合并流按我们想像中那样运行。那么 filter 则是屏蔽掉验证未通过的数据。

这样简单的通过几个 Rx 的操作符我们就完成了核心逻辑,而且在核心逻辑不变的前提下对数据验证、事件的『整流』、筛选等进行了调整。

总结和思考

针对复杂的表单,我们通常应该使用『复杂问题简单化』的方法将一个复杂问题拆分成多个简单问题。对于较复杂的表单来讲,自定义表单控件是一个很有用的可以简单化表单逻辑,封装局部逻辑的一种方法。

而使用 Rx 进行逻辑的组装、转换、拼接以及合并是非常容易的事情,而且 Rx 的事件流特点会让你把逻辑梳理的非常清晰,以时间维度把业务逻辑的先后和组装的次序考虑周全。

源码

import {ChangeDetectionStrategy, Component, forwardRef, OnInit, OnDestroy, Input} from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import {subYears,subMonths,subDays,isBefore,differenceInDays,differenceInMonths,differenceInYears,parse
} from 'date-fns';
import {Observable} from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { toDate, isValidDate } from '../../utils/date.util';
export enum AgeUnit {Year = 0,Month,Day
}export interface Age {age: number;unit: AgeUnit;
}@Component({selector: 'app-age-input',template: `<div [formGroup]="form" class="age-input"><div><md-input-container><input mdInput [mdDatepicker]="birthPicker" type="text" placeholder="出生日期" formControlName="birthday" ><button mdSuffix [mdDatepickerToggle]="birthPicker" type="button"></button><md-error>日期不正确</md-error></md-input-container><md-datepicker touchUi="true" #birthPicker></md-datepicker></div><ng-container formGroupName="age"><div class="age-num"><md-input-container><input mdInput type="number" placeholder="年龄" formControlName="ageNum"></md-input-container></div><div><md-button-toggle-group formControlName="ageUnit" [(ngModel)]="selectedUnit"><md-button-toggle *ngFor="let unit of ageUnits" [value]="unit.value">{{ unit.label }}</md-button-toggle></md-button-toggle-group></div><md-error class="mat-body-2" *ngIf="form.get('age').hasError('ageInvalid')">年龄或单位不正确</md-error></ng-container></div>`,styles: [`.age-num{width: 50px;}.age-input{display: flex;flex-wrap: nowrap;flex-direction: row;align-items: baseline;}`],providers: [{provide: NG_VALUE_ACCESSOR,useExisting: forwardRef(() => AgeInputComponent),multi: true,},{provide: NG_VALIDATORS,useExisting: forwardRef(() => AgeInputComponent),multi: true,}],changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgeInputComponent implements ControlValueAccessor, OnInit, OnDestroy {selectedUnit = AgeUnit.Year;form: FormGroup;ageUnits = [{value: AgeUnit.Year, label: '岁'},{value: AgeUnit.Month, label: '月'},{value: AgeUnit.Day, label: '天'}];dateOfBirth;@Input() daysTop = 90;@Input() daysBottom = 0;@Input() monthsTop = 24;@Input() monthsBottom = 1;@Input() yearsBottom = 1;@Input() yearsTop = 150;@Input() debounceTime = 300;private subBirth: Subscription;private propagateChange = (_: any) => {};constructor(private fb: FormBuilder) { }ngOnInit() {const initDate = this.dateOfBirth ? this.dateOfBirth : toDate(subYears(Date.now(), 30));const initAge = this.toAge(initDate);this.form = this.fb.group({birthday: [initDate, this.validateDate],age:  this.fb.group({ageNum: [initAge.age],ageUnit: [initAge.unit]}, {validator: this.validateAge('ageNum', 'ageUnit')})});const birthday = this.form.get('birthday');const ageNum = this.form.get('age').get('ageNum');const ageUnit = this.form.get('age').get('ageUnit');const birthday$ = birthday.valueChanges.map(d => ({date: d, from: 'birthday'})).debounceTime(this.debounceTime).distinctUntilChanged().filter(date => birthday.valid);const ageNum$ = ageNum.valueChanges.startWith(ageNum.value).debounceTime(this.debounceTime).distinctUntilChanged();const ageUnit$ = ageUnit.valueChanges.startWith(ageUnit.value).debounceTime(this.debounceTime).distinctUntilChanged();const age$ = Observable.combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit})).map(d => ({date: d, from: 'age'})).filter(_ => this.form.get('age').valid);const merged$ = Observable.merge(birthday$, age$).filter(_ => this.form.valid).debug('[Age-Input][Merged]:');this.subBirth = merged$.subscribe(date => {const age = this.toAge(date.date);if(date.from === 'birthday') {if(age.age === ageNum.value && age.unit === ageUnit.value) {return;}ageUnit.patchValue(age.unit, {emitEvent: false, emitModelToViewChange: true, emitViewToModelChange: true});ageNum.patchValue(age.age, {emitEvent: false});this.selectedUnit = age.unit;this.propagateChange(date.date);} else {const ageToCompare = this.toAge(this.form.get('birthday').value);if(age.age !== ageToCompare.age || age.unit !== ageToCompare.unit) {this.form.get('birthday').patchValue(date.date, {emitEvent: false});this.propagateChange(date.date);}}});}ngOnDestroy() {if(this.subBirth) {this.subBirth.unsubscribe();}}public writeValue(obj: Date) {if (obj) {const date = toDate(obj);this.form.get('birthday').patchValue(date, {emitEvent: false});}}public registerOnChange(fn: any) {this.propagateChange = fn;}public registerOnTouched() {}validate(c: FormControl): {[key: string]: any} {const val = c.value;if (!val) {return null;}if (isValidDate(val)) {return null;}return {ageInvalid: true};}validateDate(c: FormControl): {[key: string]: any} {const val = c.value;return isValidDate(val) ? null : {birthdayInvalid: true}}validateAge(ageNumKey: string, ageUnitKey:string): {[key: string]: any} {return (group: FormGroup): {[key: string]: any} => {const ageNum = group.controls[ageNumKey];const ageUnit = group.controls[ageUnitKey];let result = false;const ageNumVal = ageNum.value;switch (ageUnit.value) {case AgeUnit.Year: {result = ageNumVal >= this.yearsBottom && ageNumVal <= this.yearsTopbreak;}case AgeUnit.Month: {result = ageNumVal >= this.monthsBottom && ageNumVal <= this.monthsTopbreak;}case AgeUnit.Day: {result = ageNumVal >= this.daysBottom && ageNumVal <= this.daysTopbreak;}default:result = false;}return result ? null : {ageInvalid: true}}}private toAge(dateStr: string): Age {const date = parse(dateStr);const now = new Date();if (isBefore(subDays(now, this.daysTop), date)) {return {age: differenceInDays(now, date),unit: AgeUnit.Day};} else if (isBefore(subMonths(now, this.monthsTop), date)) {return {age: differenceInMonths(now, date),unit: AgeUnit.Month};} else {return {age: differenceInYears(now, date),unit: AgeUnit.Year};}}private toDate(age: Age): string {const now = new Date();switch (age.unit) {case AgeUnit.Year: {return toDate(subYears(now, age.age));}case AgeUnit.Month: {return toDate(subMonths(now, age.age));}case AgeUnit.Day: {return toDate(subDays(now, age.age));}default: {return this.dateOfBirth;}}}
}

细说 Angular 的自定义表单控件 (赞,实用、日期组件)相关推荐

  1. 细说 Angular 的自定义表单控件

    我们在构建企业级应用时,通常会遇到各种各样的定制化功能,因为每个企业都有自己独特的流程.思维方式和行为习惯.有很多时候,软件企业是不太理解这种情况,习惯性的会给出一个诊断,『你这么做不对,按逻辑应该这 ...

  2. Angular: [ControlValueAccessor] 自定义表单控件

    Angular: [ControlValueAccessor] 自定义表单控件 我们在实际开发中,通常会遇到各种各样的定制化功能,会遇到有些组件会与 Angular 的表单进行交互,这时候我们一般会从 ...

  3. Angular 4.x 自定义表单控件

    当我们打算自定义表单控件前,我们应该先考虑一下以下问题: 是否已经有相同语义的 native (本机) 元素?如:<input type="number"> 如果有,我 ...

  4. Angular学习笔记(五) - 自定义表单控件

    本文简单介绍封装使用ngModel实现自定义表单控件的过程. NgModel 相关 NgModel NgModel用于从作用域创建一个FormControl实例,并将它绑定到一个表单控件元素. ngM ...

  5. Angular19 自定义表单控件

    1 需求 当开发者需要一个特定的表单控件时就需要自己开发一个和默认提供的表单控件用法相似的控件来作为表单控件:自定义的表单控件必须考虑模型和视图之间的数据怎么进行交互 2 官方文档 -> 点击前 ...

  6. vb.net form 最大化按钮 代码_【React】利用antd的form自定义表单控件

    由于业务的需求,需要对Form表单进行自定义控件操作 业务需求如下: 首先点击选择按钮---在弹窗中选择产品--将选择好的产品展示在页面上,关于自定义组件的封装网上大牛的方法大多是封装好新的组件,从而 ...

  7. Antd Form 自定义表单控件

    首先我们直接在自定义组件中打印看一下props能得到什么: 可以看到Form.Item向我们的自定义组件内部传递了一个value和一个onChange事件 那么我们可以在自己定义的组件内部维护好一个s ...

  8. antd自定义form表单控件

    用 getFieldDecorator 方法包裹的表单控件会自动添加 value (或由 valuePropName 指定的属性名) 和 onChange (或由 trigger 指定的属性名)属性, ...

  9. 『ExtJS』表单(一)常用表单控件及内置验证

    几点说明 关于ExtJS的表单,我打算分为三个部分来写 常用表单控件及内置验证 -- 这里主要是JS代码 表单行为与Asp.NET页面的消息回复 -- 这里既有JS代码,与有C#代码,我主要是使用As ...

最新文章

  1. EM算法(Expectation Maximization)期望最大化算法
  2. Python网络爬虫与信息提取(三)(正则表达式的基础语法)
  3. 北风设计模式课程---深入理解[代理模式]原理与技术
  4. [ARM] ARM处理器寻址方式
  5. clean build 的区别(转)
  6. nodejs,python,sublime和Eclipse的包管理器 1
  7. 破解ACCESS(2000) .mdb格式文件密码手记
  8. 电气控制技术实训考核装置
  9. 谷歌浏览器插件Adblock Plus、OneTab~
  10. mysql 修改 frm_高性能MySQL:只修改.frm 文件
  11. A/B 测试:数据驱动的产品优化
  12. 正则表达式之密码验证
  13. ctf MISC 简单套娃
  14. dya04 js_02
  15. 【Unity3D】AR应用中,关于东南西北方位的判断。
  16. 一文读懂什么是云原生|推荐收藏
  17. 网站SEO优化的一些知识分享
  18. 软件加密系统Themida应用程序保护指南(十):高级选项
  19. Python高级特性与网络爬虫(二):使用Selenium自动化测试工具爬取一号店商品信息
  20. 【汇正财经】大消费强势,三大指数集体大涨

热门文章

  1. sqlServer 修改默认1433端口
  2. 墙面互动投影实现的原理
  3. 6个小窍门帮你找回创意灵感
  4. 大颗粒积木【电话】教案-课堂演示-说课-少儿积木建构创意评测与展示活动
  5. 5.3.3.tat.gz php_php探针怎么测试服务器isapi版本
  6. 公司萌萌哒言行重大事故经典案例
  7. 姿态篇:一.初识姿态估计
  8. q是p的必要条件的几种描述
  9. camunda嵌入式表单
  10. .net core GBK 编码问题