Angular表单设计

334 阅读21分钟

研究angular表单设计目的、研究目标、未来计划

目的:vue和react的表单设计不好,angular的表单设计更好,学习angular的表单设计为我所用。带着这个目的去调研angular的表单设计,以我自己情况,以前未针对vue和react的表单做过足够多的业务,暂无法从客观的角度来对比两方在表单设计上各自详细的优缺点。能肯定的是,在我们自己的业务实践中表明vue和react在表单上的处理相对angular是有所欠缺的。

目标:以下研究从一个完全独立的知识分支开始探索angular表单部分的设计,从angular的开发开始,希望能从0到1理解和讲述其在表单上设计,使未接触过angular表单,甚至完全未接触过angular的人能达到完全理解angular表单的设计。为了在我们项目中更合理的运用angular的设计思想,也是后期需要进行补充的,需要参与关于vue和react在表单组件的开发。因为实践才能检验真理,需要实际我自己上手体验开发表单组件,横向对比,vue、react、angular的表单组件开发。提高调研效率,需要尽量让业务案例涉及的"特性"面更广而不单纯的多,比如表单验证,业务代码风格,优化原本我们项目表单设计中vue、react没有的特性,做到“为我所用”。

未来计划:对于vue、react本身是否有自己的表单设计,是否需要像angular一样深入研究,这是未知问题,因为时间成本,我认为应该以是否能为fineui或者reactui设计表单功能带来足够价值考虑要不要去做这个事情(研究底层设计),但肯定的是,一定需要从实际业务上去了解vue或者react的表单组件是如何使用的。

正文

1.理解表单设计需要的前置知识(会做讲解)

一、angular组件、模块(NgModule),二、angular对浏览器listener的重写,三、angular变更检测,四、angular双向绑定。

2.整体线路

前置知识->表单分类和关键类->表单初始化、表单-响应式表单->表单-模板驱动表单。

在涉及概念时,确保理解名词是什么,具体做什么之后,才能理解讲解的内容。

3.开始angular之路!

3.1前置知识讲解,如果已知,可跳过。

一、angular组件、模块(NgModule)

模块:angular中自有定义的module,名叫NgModule,与原生js的module不同,但是概念上是一样的,js中一个export出去的文件就是导出一个模块,而在angular中,引入、导出模块也是使用的export、import,只不过这一步不是在文件开头,而是在angular的注解当中,如下NgModule,通过js语法import进来的模块,在NgModule中又进行了import,import了其它自己的NgModule进来,使用其相关功能。declaration声明了整个模块中使用到的组件,这样能在模板中识别组件的html标签。bootstrap定义了这个模块在加载时初始化用的组件,模块在实例化后就会加载这个组件的模板。angular的index.html中你会看到一个的标签,这个bootstrap启动的组件就通过“selector”中定义的字符串与这个标签相互关联起来了。

image.png

image.png

image.png 在angular中开发一个组件,规范情况下会是一个单独的文件夹,文件夹中主要有三个部分,这三个部分组成了angular的一个基本的组件。

image.png

1.template:模板html,采用了html的书写形式,这个模板被称为宿主模板,针对于这个组件的交互开发都是在这个模板上实现的,模板中可以插入其它组件的模板,直接通过html标签的方式引入,这个引入的模板叫嵌入模板,嵌入模板是其它模板的宿主模板。组件如何知道自己能通过标签比如来引入一个嵌入模板,下面的组件class会讲解。

image.png

2.组件的class:处理该组件的所有需要交互的东西,包括函数,服务,数据,配置。

函数:可以在模板中通过事件触发,或是组件本身逻辑执行。

服务:是angular独有的一个模块,通过依赖注入,将服务实例注入到组件中,可以实现多个组件共用相同的函数功能,这是angular对于公共函数的一种实现设计方式,提高了公共函数的通用性,直接在组件内写函数也可以实现”服务“的功能。

数据:提供给模板使用的数据,通过angular的模板语法调用。

配置:组件通过ts的装饰器注解来配置,angular也是通过这个注解来表示一个类是一个”组件“,还是一个”服务“,或者是一个”module“,比如下图的“@Component”,配置中的数据称为元数据,元数据定义了整个angular框架执行到这个地方时做配置需要用到的参数,这里有元数据 selector,templateUrl,styleUrls(是个数组,可以引入多个css)。selector就规定了该组件作为嵌入模板使用的名字,在一个模块中会将模板中同名的标签替换为该组件的宿主模板。

image.png

3.css,就是组件会用到的style文件,在组件的class中引入。在模板中引入方法和原生html方式相同。

二、angular对浏览器listener的重写

浏览器默认的事件绑定方法是addEventListener,angular对该方法做了重新包装,在zone.js这个文件中,通过:EventTarget.prototype['addEventListner'] = xxx,重写了原生的监听方法,这样做的原因目前已知需要知道的:

1.在表单中,dom触发的事件会被引导到自定义的类中去处理,如valueAccessor,这个类是表单中用来向原生dom发出命令的类,比如当用户在<input / >中输入触发"input"事件时,事件会直接导向valueAccessor的_handleInput方法中处理。

2.触发angular的变更检测,详见三内容。

三、angular变更检测

假设你vue和react都没写过,怎么理解变更检测呢。当与模板相关联的 组件类 中的 某个数据发生变化时,angular需要及时将这些变化更改到真实的页面上,何时触发这些变化就是变更检测要做的事情。变更检测会刷新整个组件涉及的宿主视图,宿主视图是一颗视图树,刷新会从宿主的根开始刷新整个视图(补充:大致了解即可,angular采用依赖注入方式配置组件,使用了ts的装饰器功能,相当于在函数外包装了一个函数,可以传入参数。而在angular中,可以在组件的装饰器中,配置"元数据",元数据就是组件初始化时用到的参数,组件和模板就是通过该配置生效相互链接的,可以配置“ChangeDetection”为ChangeDetectionStrategy.OnPush来阻止默认修改组件数据的变更检测,它会根据数据的引用变化来判断是否刷新,但其不会阻止dom事件触发变更检测。)。根据官网的描述,以下几种情况会触发变更检测。

1.mouseMove、mouseEnter、input、onClick...等鼠标键盘事件

2.http请求、ajax请求回调时

3.setTimeout、setInterval

4.组件类中所写方法在元素上绑定的事件,(这里涉及以下双向绑定中的事件绑定)。

四、angular双向绑定

angular的绑定分为属性绑定和事件绑定,双向绑定是数据绑定的结合实现,在代码中数据绑定的模板语法是[],而事件绑定的模板语法是(),双向绑定的写法则是[()]这里也能形象看出其设计思想。

1.属性绑定

这里的属性指Property,与Attribute是不同的概念。举个例子(假设有这么一个元素)某个元素的Attribute value值是”flower“,那么这个元素初始时显示的值就是”flower“,如果元素的Property value,在初始时被赋值为了“flower”,那么这个flower则是个变量,现在我在editor上可以操作把"flower"变量的值改为了”bird“,那现在这个元素显示的就是”bird“。即Property是动态的,而Attribute是静态的。\

在angular中,元素有一些默认的属性,比如,在组件模板中就可以写<img [src]="imgUrl">,它意思为img的src属性为在组件class中声明的imgUrl数据值,在变更检测时会用这个imgUrl刷新img的src属性。

image.png

属性绑定中,有一个用于父子组件属性传递的内容,与之后的表单非常相关,下面做描述。实例参考官网例子:angular.cn/generated/l… 下载该例子:angular.cn/generated/z…

在嵌入模板中,可以通过以下模板语法传递一个变量属性(Property)给组件”app-item-detail“的childItem赋值,在子组件中,需要通过注解@input表示这个值是一个输入变量。子组件的宿主模板中,可以用这个变量显示在模板中。

确认一下,该模板渲染出来后显示的即是父组件中parentItem的值”lamp“

父组件:

image.png

子组件:

image.png

2.事件绑定

angular的事件绑定语法为<button (click)="func(e)">

语法的意思是click事件绑定到组件的func函数中。组件类中定义了一个函数function func()来处理这个事件。在事件绑定之上,angular对原生的addEventListener做了替换,因为angular刷新dom是基于变更检测的,需要在事件触发时,触发变更检测刷新dom。相关概述在二、三、中有详细描述。了解这些已经足够理解接下来的事情了,如果需要更详细探究其实现和原理,可以学习angular的zone.js。

事件绑定可以自定义事件进行绑定,这里对理解angular的双向绑定有关键意义

如下,在父组件的宿主模板中内嵌一个嵌入模板,这个嵌入模板中写入一个事件绑定(deleteRequest),这个deleteRequest就是从子组件中emit出的事件,而方法deleteItem存在在父组件类中定义了如何处理这个事件抛出的value。在子组件中调用某个方法时需要将事件emit出去,这一步的操作是,angular定义了一个注解@output,说明这个属性是一个会向外抛事件的属性,会用new EventEmitter初始化它,而在组件内触发函数时,调用这个属性的emit方法将值emit出去。

父组件

image.png

子组件

image.png

以上是官方提供的关于事件绑定的例子,可以下载实验:angular.cn/generated/z…

3.双向绑定

双向绑定是angular组合了事件绑定和属性绑定,加上@input、@output(父子组件相互传递信息,见1.、2.中相关描述)实现的,如果理解了两个绑定和输入输出,就能理解以下写法,用一个input举例,简单来说它的双向绑定就是这样实现的<input [value]="variable" (click)="variable=event.target.value"/>variable是组件定义的一个数据,event.target.value" />。variable是组件定义的一个数据,event是angular默认的模板语法,事件抛出的参数都是$event。

下面看下实际的双向绑定是如何实现的,<element [(value)]="variable">  angular中定义了双向绑定的简写模板语法[(value)],括号中的属性存在于子组件中定义,因为事件绑定中()中需要有一个子组件定义eventEmitter,所以angular做了一个规定,使用[()]模板语法定义数据双向绑定时,必须将事件命名为valueChange。如果我的value叫size,那么事件必须叫sizeChange。由于这样的定义,因此angular的双向绑定又有了第二种写法<element [value]="variable" (valueChange)="variable=$event">,这和上面的语法效果是相同的。下图中会看到有一个ngModel,这个是angular对表单类元素交互提供的一个双向绑定的实现,需要引入FormsModule模块才能支持,之后在响应式表单内容中会提及。

以下示例可以查看在线版:angular.cn/generated/l… 下载:angular.cn/generated/z…

父组件

image.png

子组件

image.png

看以下图解,”app-sizer“实际就是 "视图 - 模型" 中的"视图",它展示实际的"视图"(展示size),并且可以发出视图相关的事件(sizeChange),而父组件中的fontSizePx就是“模型”,与“视图”的“size”做了双向绑定

image.png 以上是对前置知识的全部说明。

3.2表单分类和关键类。

angular的表单分为"响应式表单"和"模板驱动表单",响应式表单是表单设计的核心,模板驱动表单在初始化"表单对象"处做了简化处理,所以理解模板驱动表单需要以响应式表单为基础,因此会先从响应式表单讲起,结合一个实际的表单案例贯穿源码解析表单设计。

在表单中,需要知道三个关键的类的作用。

1.FormControl,表单对象,以往写表单,每个表单项是直接关联到数据的,比如表单上的name,对应的数据就是一个字符串name。而angular中,每个表单是一个对象每个表单项就对应了一个FromControl对象,这个FromControl对象中保存了具体的表单项的值value。对象实例还有一些特定的方法,这些方法的作用分为两类,1.修改值,然后触发回调(触发ValueAccessor修改dom)。2.验证表单(同步和异步)。以往的双向绑定只绑定了原始类型的数据,而之后会具体展开angular是如何实现表单对象和视图之间的双向绑定的。另外的FormGroup和FormArray实际上是在FormControl基础上的扩展,其核心仍是FormControl,所以我们关键学习angular对FormControl是如何操作的。

2.ValueAccessor,值访问器,angular中定义了值访问器来操作dom元素,这个类里面只是一些简单的修改dom的方法和修改后要触发的回调方法。

3.Directive,指令类,在表单中,官网对Directive的解释是“FORM CONTROL DIRECTIVE”将表单和视图联系了起来,我认为这个描述抽象了太多细节。通过查看源码,angular的Directive类主要做了两个方面的事情,1.在初始化表单时,将“修改视图的方法”注册到FormControl的实例中去,将修改FromControl的方法设置到ValueAccessor中去。在后面会更详细的讲述这套过程如何实现的。

三者之间的关系:

1.ValueAccessor和FromControl不会以游离的状态存在,如果要找某个ValueAccessor或者FromControl都会通过Directive对象实例找到。

2.所有修改表单的操作追溯根源都是通过Directive这个对象做到的,Directive中可以包含其它Directive,想象一个表单有一组值,修改某个表单项后需要更新整组的状态,那么每个表单项都需要有一个和整个组联系起来的渠道。

3.3表单初始化、表单-响应式表单。

下图是官网对响应式表单的描述,响应式表单直接操控表单对象(FromControl),通过FORM CONTROL DIRECTIVE这个"桥梁"将视图和表单对象连接起来。对视图的修改会直接反映到FromControl中(无法通过图片体会,后面根据代码说明),而修改视图则直接调用FormControl的setValue方法(如右图体会以下),和模板驱动表单的区别就在这个地方,稍后在说明模板驱动表单时会做详诉。

image.png

下面会从数据流的路程理解分析响应式表单的实现,先来看看官网对响应式表单数据流的图解,实际看源码时,发现官网原文的描述和实际流向有一些不符,虽然大致思路没错,但为了保证理解的正确性,我重新梳理了数据流的流程。

1.视图->模型的数据流

image.png

2.模型→视图的数据流

image.png

理解大致这个过程很容易,但如果我们想自己设计出一套想angular思想的表单,需要去了解最关键的问题:angular在什么时候怎么将ValueAccessor和FromControl关联起来的?它们之间如何关联的?设计的具体流程是什么?

下面是angular表单初始化做的事情

例子在线版:angular.cn/generated/l…,下载:angular.cn/generated/z…

1.一个简单的使用FormControl的例子,angular表单组件中,模板中通过属性绑定[formControl]说明该html元素对应了一个FormControl对象,而在组件类中这个"name"用一个FromControl实例化。angular通过这种方式告诉自己,在初始化模板时,将”name“这个数据识别成一个FormControl

image.png

再看看FormGroup和FormArray是如何写的。

说明:模板中的"*ngFor"是angular的模板语法,实现的动态扩展模板的功能,其后面内容和js语法和功能类似,应该很容易推断其作用。

this.fb是一个创建group和array表单对象的工具类,实际作用和new FormGroup()里面再new FromControl 、new FromArray作用一样,就像第三张图一样。

formControlName指明了[formGroup]元素下的哪个标签是对应哪个FormControl。

image.png

image.png 以上,模板和组件类中声明的表单对象关联起来了,下一步正式开始响应式表单的讲解。

2.初始化这个模板,会先实例化ValueAccessor,FormControl,最后实例化Directive。

(1)根据模板提供的信息,实例化了ValueAccessor,以1.中name为例,但此时这个ValueAccessor中只知道自己控制的是元素,它有一个onChange成员属性,之后会给这个属性赋值一个函数。

(2)实例化表单对象name,此时这个FormControl只有一个初始值"",它有一个成员属性this._onChange,这是一个数组,之后会push一个函数进来,每次setValue都会依次调用this._onChange中的函数。

以上两个类都是简单的类,只有一些更改数据和方法调用的功能,他们本身没有任何复杂的业务逻辑。

(3)实例化Directive,这一步是最关键步骤,这里这个表单对应的Directive是FormControlDirective,如下图,初始化时,给其设置了valueAccessor,valueAccessor与"name"的dom元素关联。而此时,input元素还没有在网页上渲染出来,且此时FormControlDirective上不存在FormControl对象。

image.png

当这个dom在网页上渲染出来时,会触发一次angular的变更检测,这个变更检测会引起这个FormControlDirective的ngOnChanges事件,在这之前会先经过angular的依赖注入,将一个FormControl对象注入到FormControlDirective实例中,以使Directive和FormControl关联起来,暂时不理解这里用依赖注入而不在初始化时设置的原因,此后,FormControlDirective就多了一个form成员属性。这个成员属性就是FormControl

image.png

接下来来到FormControlDirective实例的ngOnChanges事件中。这里出现了一个关键方法:setUpControl

image.png

setUpControl的几个setUp方法核心是将向ValueAccessor和FormControl中设置回调,以在发生数据变化时相互响应,其中最关键的两个方法:setUpViewChangePipeline、setUpModelChangePipeline,源码也将这两个相关方法写到了一起。

image.png

image.png

image.png

(1)setUpViewChangePipeline,向valueAccessor中注册了一个回调函数,DefaultValueAccessor就是这里操控input dom的ValueAccessor,如果我们在这个input上触发了事件,就会触发onChange事件(下左图)。如果FormControl的updateOn(响应变化的参数)满足条件,就会触发FormControl的setValue方法(下右图),由于emitModelToView === false,因此不会再触发FormControl的回调(这个回调用来触发视图更新),之后,会调用FormControl的updateValueAndValidity函数,先验证值的有效性,再经由valueChanges和statusChanges两个可观察对象向外emit值变化事件,通知所有订阅者(下第三张图)然后调用parent的同名方法。最后,updateControl还会执行FormControlDirective的viewToModelUpdate方法,向外发出一个ngModelChange事件(如上图),这个事件会在模板驱动表单中提到。

image.png

image.png

image.png

(2)setUpModelChangePipeline,见(1)中第一张图,该方法会向FormControl中注册一个onChange事件,这个事件通过ValueAccessor更新dom的值,同时根据第二个emitModelEvent判断是否调用emitModelToViewChange,这是和上文中触发"ngModelChange"的同一个方法。如果调用FormControl的setValue方法,就会触发这个onChange事件。

现在回看脑图,整理一下整体思路,再结合1. 2.的图,就可以很清楚的明白,在整个响应式表单中,angular是如何设计的了。

image.png

视图->模型image.png 模型->视图image.png 整个响应式表单的设计思路完全解析完成。

3.4表单-模板驱动表单。

下图是官网对模板驱动表单的图解,对比响应式表单,模板驱动表单不再直接通过FORM CONTROL DIRECTIVE连接,而是用了一个NGMODEL DIRECTIVE来操作模型数据和视图之间的关系。对此,在官网解释中有一句“数据是事实之源”,如何理解这句话?对比响应式表单,数据是用了一个new FormControl()初始化的,而模板驱动表单的数据就是原始数据类型,如下图,声明数据用了原始的对象,字符串类型,而没有使用FormControl类,模板中关联数据,使用了模板语法"ngModel",标签"name"来定位一个元素所对应的FormControl,name对应了FromControl的名字,[(ngModel)]后的变量对应组件类中的数据名。如果你明白了angular的双向绑定,这里的[(ngModel)]=“hero.name”也可以替换成[ngModel]="hero.name" (ngModelChange)="hero.name=$event"。所以angular的意思是,对于写代码的程序员来说,使用了模板驱动表单,就不用自己去写new FormControl,xxx.setValue等操作,直接操作数据即可,模板上通过ngModel模板语法关联模型和视图,后面会谈及底层的思想,实际上底层的表单项仍然是FormControl类,只不过创建类的操作交给了NGMODEL DIRECTIVE来自动实现了。

以上可以知道,模板驱动表单是响应式表单的提升,底层原理几乎一样,我们要造自己的表单,核心是去挖掘响应式表单的实现,为了理解透彻,仍旧讲一下模板驱动表单的实现。

以下例子在线版:angular.cn/generated/l…,下载:angular.cn/generated/z…

image.png

image.png

先看看模板驱动表单的数据流

视图→模型  把一个input中的”RED“ 改成 ”BLUE“  

image.png

image.png (为了方便,贴上响应式表单视图→模型的数据流)

模型→视图 把一个数据中的”BLUE“ 改成 ”RED“ 

image.png

image.png 响应式表单中提到了三个关键的类,ValueAccessor,FormControl,Directive,其中说道FormControl是在input元素在页面生成时,触发了ngOnChanges,通过依赖注入,将FromControl实例注入到Directive中的,而在模板驱动表单中,只有些许的不同。

1.ValueAccessor的生成方式和响应式表单一样。

2.FormControl不是在外部单独实例化,通过依赖注入,而是直接在Directive初始化时,new了一个FormControl作为一个对应元素的数据模型。下面看看其源码,和响应式表单如出一辙,这个NgModel就对应了响应式表单中提到的FormControlDirective,它们都继承了NgControl类。初始化多了一个步骤,this.control=new FormControl(),这个就是"数据之源"对应的表单对象。它同样有ngOnchanges方法,里面有_setUpControl()方法,仔细看,最终流向了和响应式表单同样的方法,setupControl(this.control, this),(standalone:如果为 true,则此 ngModel 不会把自己注册进它的父表单中,其行为就像没在表单中一样。默认为 false。),所以这里初始化的历程就足够清楚了。

image.png

image.png

回顾一下响应式表单的部分,最后根据input的值发出了一个"ngModelChange"事件,emit出最后的value,模板驱动表单的开始又提到,双向绑定可以写成 [(ngModel)]=“[hero.name]”也可以替换成[ngModel]="[hero.name]" (ngModelChange)="[hero.name]=$event"的形式,因此,这里改了两个“模型数据”:1.将ngModel中的FormControl的value改成了input的值,2.将组件类中的数据也改变成了input的值。

这里有一个问题没有串联上,现在只绑定了视图到模型的改变,也就是改变视图后,FormControl的值会跟着变化了,但模板驱动表单中,数据不是调用的FormControl的setValue方法,是直接改变的数据,那么模型→视图的变化是如何实现的呢?

前文提到,在操作数据时,angular会触发变更检测,变更检测会检查数据前后是否发生变化来更新整个视图,这是一个深比较(详细可以在zone.js了解更多angular的变更检测的相关设计),所以如果修改了模板中的数据,比如这样,(这个change()方法是我自己在例子上添加的,原本的例子没有)。那么它会触发变更检测,经过一系列的调用到ngModel的"ngOnChanges"方法中,如上左图,"ngOnChanges"会比较Property是否更新,NgModel的viewModel值其实和FormControl中的value是保持一致的,如果Property发生了变化,就会触发_updateValue。方法内容如下,会异步执行一个修改FormControl的方法,这也就是“数据流”图片中,有一句“updates on next tick”的由来。所以可以知道,响应式表单修改数据是同步的,而模板驱动表单修改数据是异步的。setValue过后要做的事,就和响应式一模一样了,是否向外emit onModelChange事件,验证表单值,发出valueChanges statusChanges事件。

image.png

现在再看看脑图,结合数据流图,整理一下关于模板驱动表单的整体设计思路。

image.png

视图→模型

image.png 模型->视图

image.png 以上就是关于angular的模板驱动表单的整体设计思路。

4.最后(附思维导图)

完成了关于angular表单的设计的解读,回看一下整个脑图,对整个知识体系最后回顾一遍,我们设计自己表单时会很大程度参考其设计,但也有很多需要剥离的东西,比如变更检测整个angular独有的东西,大概率我们是不需要的,那么这块需要怎么处理就是设计要考虑的问题。可能剖析得有些太细枝末节以至于看起来有些啰嗦,但我保证没有记流水帐的成分,因为对于真正设计表单,这些我认为是非常必要解构清楚,才能设计得更合理,也可能还有没足够清晰而未涉及到的内容。我想在设计我们自己的表单时,完全照搬思路肯定会过度设计,所以真正设计时,会基于它们的功能做大的过滤,只留已知且最必要的部分提取。

angular表单设计.png

补充:对于表单验证的环节,调研中涉及了表单验证函数的入口和出口,我的意思是整个流程中什么时候注册、什么时候调用,且脑图中提到了表单验证的两个关键点(同步验证异步验证),没有去具体讨论研究angular实现的细节。关于这部分我认为它是一个被组装的功能,在掌握主体架构和表单验证的接口位置的安排后,是可以先着手表单核心框架的设计的,这块很容易想到一种基本实现:在关键的对象中添加一个属性表示自己的状态,存在三个值:VALID\INVALID\PENDING,PENDING状态的设置可以实现异步验证的功能,在异步验证完成后,将PENDING状态转换,验证函数只负责修改这个关键状态。所以我认为这部分是可以独立出去开发的,最最核心的是整个表单对象的设计,在双向绑定设计中的抽离。如果之后在表单验证模块开发中实际遇到了问题,可以考虑再学习angular在表单验证上的实现。