聊聊中后台前端应用:上下文的那些事儿

欧雷 发表于

0 条评论

经过《聊聊中后台前端应用:模块相关的一些事》和《聊聊中后台前端应用:业务中的组件体系》这两篇文章的铺垫,终于可以单独写一篇文章来专门讲讲「上下文」相关的事情了——

概念明晰

在进入正题之前,先试图厘清与主题关系密切的几个概念:状态、状态管理和上下文。

状态

有时会听到两拨人在打嘴仗——有一拨人说:「前端都是状态,没有数据,是状态驱动视图而不是数据驱动视图」。另一拨人反驳说:「状态难道不是数据吗?不是数据是啥?」——这两拨人的说法都没有错,只不过是站在了不同的角度。

一般来说,「数据」是指存放在数据库、文件系统中的持久数据,是「静态」的、「持久」的,常被当作「数据源」的略称来用;「状态」则是保持在内存当中的瞬时数据,是「动态」的、「临时」的,来源于通过 HTTP 请求或本地存储读取的数据以及终端用户在界面上的操作——然而它们都是数据。

也就是说,可以认为在经典的三层架构中数据层往上的分层中的数据都是「状态」:

「表现-领域-数据」分层架构
「表现-领域-数据」分层架构

对于前端来说,数据层通信的对象就是服务端和本地存储。

状态管理

「状态管理」是什么?顾名思义,就是对「状态」的「管理」。虽然在前端圈儿内随着 Redux 等的流行让「状态管理」成了热词,但它并不是什么新鲜货,

世上的任何事物都需要被管理,只不过当它还没那么复杂的时候,不需要作为一门学问或者说一套方法论拿出来供人们单独讨论。

由于前后端分离和单页面应用的出现,使得前端的状态复杂化,如何去有效地进行管理成为了问题,因此形成了现如今很多人去关注并讨论「状态管理」的局面。

我了解到的前端项目中,它们的状态管理方案可以说是「两极分化」——要么分散在各个 UI 组件中,要么集中到一个所谓的「全局 store」中——这两种我都不太认可。

上下文

在《聊聊中后台前端应用:模块相关的一些事》和《聊聊中后台前端应用:业务中的组件体系》这两篇文章中都对「上下文」有所描述,简单来说,它对于程序的作用就相当于帮助人去理解事物并做出相应反应的「语境」,毕竟它们的英文都是「context」。

在实际应用时,「上下文」很可能是一个带有很多属性及对其进行读、写操作的方法的对象。那些已经被暴露出来或没有被暴露出来的变量,就是上文所说的「状态」,而暴露出来的方法或函数就是对「状态」进行管理用的——它们共同构成了「上下文」。

一个上下文可以用类的方式去实现:

class ValueContext {
  private value;

  constructor({ initialValue }) {
    this.value = initialValue;
  }

  public getValue() {
    return this.value;
  }

  public setValue(value) {
    return this.value = value;
  }
}

const context = new ValueContext({ initialValue: 'Hello, Ourai!' });

也可以用函数的方式:

function createValueContext({ initialValue }) {
  let value = initialValue;

  return {
    getValue: () => value,
    setValue: newValue => (value = newValue),
  };
}

const context = createValueContext({ initialValue: 'Hello, Ourai!' });

无论用哪种方式实现,无论实现的具体逻辑是什么,对于上下文的消费者来说它就是一个 API——「上下文」是串起各部分逻辑,具有一定程度泛化的业务语义的接口。

组件与状态

交互与状态如影随形,除了那些纯展示用的 UI 组件,一般来说 UI 组件都会有与其关联的状态,只是维护的地方不同。

根据 UI 组件自身内部是否维护了状态,可分为「无状态组件」与「有状态组件」。

那些纯展示用的 UI 组件毋庸置疑都是「无状态组件」,而有交互的 UI 组件若是将状态维护外置,那就是「无状态组件」,否则是「有状态组件」——

<template>
  <input :value="value" @input="handleInput" />
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';

@Component
export default class StatefulInput extends Vue {
  private value: string = '';

  private handleInput(evt): void {
    this.value = evt.target.value;
  }
}
</script>

同样是自定义的输入框组件,上面的示例是有状态组件,而下面的则是无状态组件:

<template>
  <input :value="value" @input="handleInput" />
</template>

<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator';

@Component
export default class StatelessInput extends Vue {
  @Prop({ type: String, default: '' })
  private readonly value!: string;

  private handleInput(evt): void {
    this.$emit('input', evt.target.value);
  }
}
</script>

相较之下,有状态组件在保证功能正常的情况下可以暴露更少的属性和事件,但可复用性就降低了,无状态组件正好相反。

控件通常会被设计为无状态组件,尤其是交互简单的和纯展示的;为了保证基本功能可用,交互复杂的控件可能会被设计为有状态组件。

在封装部件时,大多数人的思路和封装控件一样,就会——

在相对大粒度的部件中,如果主要依赖属性和事件进行通信的话,它们的数量很容易变得失控,并且内部的结构和逻辑也会被改得面目全非,维护起来十分困难和难受——这就变成了一坨翔💩!

按照封装控件的思路去封装部件,很容易会让属性和事件的数量变得失控,或者内部各种逻辑膨胀,使部件变得十分臃肿——无论是哪种,都会加大维护成本。

私以为,部件内部尽量不要有交互逻辑和业务逻辑,也尽可能不去维护任何状态——业务的状态与逻辑上升至上下文中,交互逻辑下沉到控件中,展现状态由业务状态计算出来——部件中理论上只有业务与交互/展现间的转换逻辑。

理想状况下,部件中的各种依赖也是通过上下文获取的,而不是自己从哪个位置 import 进来的。

说白了,在前端应用这个「有机体」中,控件和部件是具体产生功能的「组织」和「器官」,而上下文则是为它们传递信息、输送养分的「神经」和「血管」。

上下文概要

构成上下文的基本元素除了上文说过的要维护的状态及对其进行读、写操作的方法/函数之外,大多还会有让上下文内外数据保持一致的基本是基于观察者模式实现的同步机制。

在本系列文章所阐述的体系中,上下文大概分为三类:应用上下文、模块上下文和数据上下文。

「应用上下文」是作用于整个应用的上下文,如果运行时只有一个应用,那么可以把它视为是「全局」的上下文;倘若运行时中存在多个应用,那就是每个应用对应一个应用上下文。

应用上下文中维护的是单一应用范围内共享的状态,如路由、主题、国际化等配置信息,和用户的基本信息与权限等。

「模块上下文」主要用来维护以「模块」为中心的状态,像指定模块所依赖的其他模块的资源和它提供给其他模块的可用资源这类依赖信息,以及该模块的模型、视图、服务端动作(通过 HTTP 请求与服务端通信的函数)等元数据。

「数据上下文」则是前端应用中使数据流动起来的主力,稍后展开说。

数据上下文

在继续往下说「数据上下文」之前,首先要理解在《聊聊中后台前端应用:业务中的组件体系》中提到的「从数据的视角看前端」。

「数据上下文」又细分为「视图上下文」和「搜索上下文」,根据整个体系的复杂程度,它们可分别再往下划分出「字段上下文」和「过滤器上下文」。实际上,可以认为「搜索上下文」是为了收集列表数据过滤条件而特化了的「(对象)视图上下文」。

「值」的抽象

各种「数据上下文」的共同特点是对「值」的操作,因此可以围绕着「值」进行一些抽象——

根据用途,有三种「值」的状态——一直处于活动状态的「当前值」,用 value 来表示;在初始化与重置时用来赋值的「初始值」和「默认值」,分别用 initialValuedefaultValue 来表示。

在不同的具体数据上下文中「当前值」的含义会有所差别。比如,在对象视图上下文中它是指随着用户操作而变化的字段的键值集合,而在列表视图上下文中则是已选中的记录。

在实际应用中,「初始值」与「默认值」的主要区别在于优先级不同,「初始值」大于「默认值」,即在没有「初始值」时才会用「默认值」。

与「值」相关的操作基本只有 4 个,分别是对「当前值」进行读与写的 getValue()setValue(),将「当前值」向外/上传递的 submit() 以及恢复「当前值」到「初始值」或「默认值」的 reset()

相应地,有 4 个「事件」供外界在不同时机进行数据同步用——代表数据已经准备好了的 ready 事件;「当前值」的每次变更都会触发 change 事件;调用 submit()reset() 时会触发对应的 submitreset 事件。

关于 ready 事件,有一个使用场景是:网页加载完成后,列表数据要等过滤条件收集好了再发请求获取,但过滤条件又得先从 URL 的查询参数中恢复——这就需要列表视图上下文去监听搜索上下文的 ready 事件,进而去发请求获取数据。

「值」的校验

为了保障数据的安全及纯净,在处理数据时先进行合法性或者说有效性校验是基本操作,因此在调用 setValue() 时其内部会先校验一波。

对「值」的校验实际上就是按照优先级执行一下各个约束条件。这里隐含了一个信息——约束是可以显示定义并且可扩展的。

「值」的约束可分为来自数据类型和数据结构的自然性约束,以及源于模型关系与业务规则等的非自然性约束。这里的「自然」与否是单纯从数据的特性层面来说。

总结

提起「应用」这个词,很多人的第一反应是:「这个东西好重、好庞大啊!」它在他们脑中的形象就像压在孙悟空身上的五指山一样的巨石。

而一个更好的视角是,把「应用」看作是「接口」与「实现」的组合。更准确地说,可能是「流水线」与「物料」——将不易变的、关系与规则相对固定的东西泛化并接口化,它们之间相互连接形成「流水线」;把易变的部分作为在「流水线」上流转的「物料」存在。

就像在《聊聊中后台前端应用:业务中的组件体系》的最后,我说——

理想情况下,最终会发现——除了业务逻辑,好像其余部分几乎都是接口(interface)——具体实现可以任意移除,随意替换!

如果不作进一步说明,上面的描述也许会有些让人摸不着头脑——

在一个中后台前端应用中,最易变的是业务逻辑和 UI 设计,最不易变的是「值」的自然性约束、「视图」与「字段」间的内在关系、控件的属性和事件等。

构建一个体系,将易变的部分弄成作为「物料」存在的元数据或配置,互相连通的上下文就成为了「流水线」。