现代化UI开发中,客户端(前端)一般会进行分层设计,实际用户可感知的 UI 作为顶层,称为视图(View),底层中独立于展示方式的数据结构称为模型(Model),而将两者进行关联的中间层部分,根据划分方式则有很多种定义,例如控制器(Controller)、展示器(Presenter)以及视图模型(ViewModel)等。
程序设计中,UI相关的平台API一般以面向对象为基础,通过属性(Property)或者方法(Method)暴露相关状态及行为,供开发人员使用。UI 编程的本质是花式建立双向绑定,例如对于 元素,其 value 属性往往需要同时被程序读取和写入,同时需要满足读取的结果会作用于下次写入,并且写入的结果会作用于下次读取。如果不满足双向绑定的约束关系,要么会产生数据丢失、要么用户操作会被打断。几乎任何 DOM 属性都是双向绑定属性,甚至包括 innerHTML,不过由于其不会因用户交互发生预期的状态变化,因此往往不会进行监听和读取。
响应式概览
UI本质上是对应用状态的体现,因此一旦UI状态失去同步将很容易造成不预期的结果,因此UI响应式编程的重要性不言而喻。响应式编程(Reactive Programming),顾名思义,指通过声明式的设计指定数据依赖关系,并且仅以最小成本应对外界依赖的变化进行相应的数据处理。一个不是十分恰当的简单版本是,非响应式编程是基于拉取(Pull),而响应式编程是基于推送(Push)。
对于视图状态的双向绑定,UI开发框架通常能够在很大程度上进行简化,自动或半自动地将视图状态与某个类实例的属性、某个 State 对象的属性、某个 Store 对象的属性进行同步,保持状态间的关联。但是,只有这层同步关系远远满足不了应用的需求,开发人员的最终目标可能是将状态同步到本地的持久存储空间,甚至跨越互联网同步到服务器。而由于成本限制和用户体验要求,实时同步往往并不可行,只能靠多级缓存的方式达到间接同步,从而让应用的状态管理复杂化。
Angular 概览
Angular是一个 UI 开发框架,采用准 MVVM 的层次划分方式,组件模板对应于视图,而组件类对应于视图模型。Angular 本身很出色地处理了 View 与 ViewModel 之间的绑定关系,只需要修改组件实例的属性,模版中的绑定结果就会自动应用于视图,一个简单组件为:
@Component({
template: `Hello { { name | uppercase }}!`,
})
class HelloComponent {
name = 'World'
}
// Results to "Hello WORLD!"
这里通过模板表达式建立了 DOM(View)与组件类(ViewModel)的绑定,随着 ViewModel 的变化,View 也会自动应用相应的更新。
虽然看上去如此美好,不过真正的开发成本并不在于 View 与 ViewModel 的同步,而是存在于 Model 与 ViewModel 之间同步,即如何根据一系列的外部状态确定 name 属性的值并将其更新。如果处理不妥,可能会导致代码量爆炸并且使得数据流向混乱,类似于:
class HelloComponent {
@Input() name = 'World'
ngOnInit() {
someExternalCallbackFn((name) => {
this.name = name
})
}
onSomeEvent(name: string) {
this.name = name
}
}
随着时间的推移和人员的变动,如果没有优秀的代码组织方案,项目代码往往都会向着混沌的方向发展,造成可读性与可维护性地持续恶化。
Angular 与 RxJS
Reactive Extensions 是微软提出的响应式设计方案,除了自身的 .Net 版本外,目前已有多个不同语言的社区实现。Rx 是
提供方返回 Observable 对象,消费者订阅该对象,即可实现对数据更新的持续可知状态。例如服务能够通过 Observable 暴露持续产生的数据:
@Injectable()
class MyService {
myProperty: Observable
myMethod(foo: boolean): Observable {}
}
为了获得一个 Observable 提供的数据(也可能是不包含数据纯事件通知),需要对其进行订阅,从而在回调中得到数据内容:
class MyComponent {
value: number
constructor(private myService: MyService) {}
ngOnInit() {
myService.myMethod(true).subscribe(value => {
this.value = value
})
}
}
而为了让数据流向更加清晰,能够将多个 Observable 进行组合,对消费者隔离实现细节,但仍然对静态分析友好,使用「Go to definition」即可确定完整的数据流向。类似于:
class MyService {
myMethod(foo: boolean): Observable {
const source3$ = getDynamicSource(foo)
return someComposeFn(
source1$,
source2$,
).pipe(
someOperator(source3$),
)
}
}
其中 source1$、source2$ 与 source3$ 本身也可能是组合的结果,Observable 的可组合性一定程度上保证了分层设计中的隔离性,避免将数据聚合集中到最终消费者的位置。
不过,Observable 的使用本身仍然需要大量样板代码,对开发者而言是一个负担,例如 Angular 中可能出现的场景是:
@Component({
template: `
Value: { { value }}
Count: { { count }}
`,})
class MyComponent {
@Input() clicks: Observable;
value: number = 0
count: number = 1</