我来聊聊前端应用表现层抽象

欧雷 发表于

0 条评论

我们处于变化很快的时代,无论是商业还是科技。一家公司看上去商业很成功,也许前脚刚上市,后脚就因为什么而退市,甚至倒闭;一项看似高大上的技术横空出世,各类媒体争先恐后地撰文介绍,热度炒得老高,没准没多久就出现了竞争者、替代者。

在这样的大环境下,传统的「web 前端开发」演变成了「泛客户端开发」,前端开发者从「配置工程师」被「逼」成了「软件工程师」。开发变得更复杂了,要处理的问题更多了,从业难度不知提升了多少倍——前端早就不再简单。

在众多必须要处理的问题中的一个,就是表现层运行环境的兼容问题,像跨浏览器和跨端、平台、技术栈。注意,这里说的是「表现层」而不是「视图层」。

「表现层」与「视图层」

「表现层」的英文是「presentation tier」或「presentation layer」,具体是哪个取决于是物理上还是逻辑上划分;而「视图层」的英文是「view」。「表现层」是「视图层」的超集,根据前端应用的架构设计,它们既可以不等又可以相等。

表现层

「表现层」这个词出自经典的三层架构(或多层架构),是其中一个分层。三层架构包括数据层、逻辑层和表现层,一般用在 C/S 架构中。

三层架构
三层架构

为什么会在这篇讲前端开发的文章中提到它?这是因为,虽然在一些前端应用中用不到,尤其是快餐式应用,但在企业级复杂前端应用中就十分需要一个前端的「三层架构」。

视图层

「视图层」则来自表现层常用的「model-view-whatever」模式中的「view」,即「视图」。至于说的时候在「视图」后面加个「层」字合不合适,就不在这里讨论了,文中皆使用「视图层」这个词。

运行环境兼容

跨浏览器

由于各浏览器厂商对标准实现的不一致以及浏览器的版本等原因,会导致特性支持不同、界面显示 bug 等问题的出现。但庆幸的是,他们基本是按照标准来的,所以在开发时源码的语法几乎没什么不同。

所谓的「跨浏览器」实际上就是利用浏览器额外的私有特性和技术或辅以 JS 对浏览器的 bug 进行「修正」与功能支持。

跨端、平台、技术栈

现在,绝大部分的前端开发者是在做泛客户端开发——开发 web 应用、客户端应用和各类小程序。

在做 web 应用时需要考虑 PC 端和移动端是分开还是适配?技术选型是用 React、Vue?还是用 Web Components?或是用其他的?做客户端应用、各类小程序时这些也会面临技术选型的问题。

如果公司某个业务的功能覆盖了上述所有场景,该如何去支撑?与跨浏览器不同的是,不同端、平台、技术栈的源码语法不一样,要满足业务需求就得各开发一遍。然而,这显然成本过高,并且风险也有些大。

那么,要怎么解决这个问题呢?从源头出发。根本的源头是业务场景,然后是产品设计,但这些都不是开发人员可掌控的,几乎无法改变。能够完全被开发人员所左右的基本只有开发阶段的事情,那就从这个阶段的源头入手——源码编写。

若是与业务相关的代码只需编写一次就能运行在不同的端、平台、技术栈上,那真是太棒了!这将会大大地降低成本并减少风险!

表现层的抽象

为了达到跨端、平台、技术栈的目的,需要将表现层再划分为抽象层、运行层和适配层。其中,抽象层是为了统一源码的编写方式,可以是 DSL、配置等,它是一种协议或约定;运行层就是需要被「跨」的端、平台、技术栈;适配层则是将抽象层的产物转换为运行层正常运行所需要的形式。

表现层中可以被抽象的大概有视图结构、组件外观、组件行为等。

视图结构

在 web 前端开发中,HTML 就是一种视图结构的抽象,描述了界面中都有什么,以及它们之间的层级关系。最终的显示需要浏览器解析 HTML 后调用操作系统的 GUI 工具库。

对于业务支撑来说,无论是 HTML 还是其他什么拼凑界面的方式,相对来说比较低级(是「low level」而不是「low」),视图单元的划分粒度比较细,在开发界面时就会花费更多的时间。

我们需要一种能够屏蔽一些不必关注的细节的视图结构抽象,在这个抽象中,每个视图单元都有着其在业务上的意义,而不是有没有都可以的角色。具体做法请看下文。

组件外观

大部分已存在的组件的视觉呈现是固定的,即某个组件的尺寸、形状、颜色、字体等无法被定制。如果同样的交互只是因为视觉上有所差异就要重新写组件,或者在组件外部重新写份样式进行覆盖,那未免也太痛苦了……

我们可以将那些希望能够被定制的视觉呈现抽象成「主题」的一部分,这部分可以被叫做「皮肤」。在进行定制时,分为线下和线上两种方式。

「线下」是指在应用部署前的开发阶段进行处理。在前端构建工具丰富的现在,写页面样式时已经不会去直接写 CSS,而是像 Sass 这种可编程式的预处理器。这样就可以抽取出一些控制视觉呈现的 Sass 变量,需要定制时通过在外部对变量赋值进行覆盖,而不需要费劲重写组件或样式。

「线上」则是部署后根据运行时数据动态改变。在皮肤定制即时预览和低代码平台等场景,是基本没机会去修改 Sass 变量并走一遍构建流程的,即使技术上能够办到。借助 CSS 自定义属性(CSS 变量)的力量可以较为方便地做到视觉呈现的运行时变更。

组件行为

组件除了外观,其行为也应当是可以定制的。看到「行为」这个词,第一反应就是跟用户操作相关的事情,然而这里还包括与组件内部结构相关的。

对于组件的外部来说,组件内部就是个黑盒子,其自身结构的组成部分有的可以被上文所说的视图结构所控制,有的则无能为力:

搜索组件
搜索组件

上图是一个比较复杂的搜索组件,虽然外观和布局看起来有所不同,但「它们」确实是同一个组件。外观不同的解决方案上面已经大体说明,这类视图结构无法控制的布局问题,需要枚举场景后在组件内进行支持,然后作为「主题」的一部分存在。

跟用户操作相关的行为有组件自身的交互规则及与业务逻辑的结合这两类。

交互规则又有两种:一种是像表单是在字段值发生改变时就校验还是在点击按钮时校验这样;另一种是像字段值是在输入框的值改变(input 事件)时更新还是失焦(change 事件)时更新这样,或是像下拉菜单的弹出层是在悬停(hover 事件)时出现还是点击(click 事件)时出现这样。

前者的解决方式与上面说的视图结构无法控制的布局问题差不多,后者则是需要组件支持事件映射,即外部可以指定组件某些交互的触发事件。当然,这两者同样也可以作为「主题」的一部分。

我们在写组件时有件事是需要极力避免却往往难以避免——组件中耦合业务逻辑。组件决定的应该只是外貌与交互形态,里面只有交互逻辑及控制展现的状态,不应该牵扯到任何具体业务相关的逻辑。只要长得一样、操作一样,那么就应该是同一个组件,具体业务相关的逻辑注入进去。

这段十分「个性化」的业务逻辑,说白了就是响应用户操作的变化以及业务数据的变化去更改组件内部的状态:

{
  // 组件事件
  events: {
    // 组件的一个点击事件
    'click-a': function() {},
    // 组件的另一个点击事件
    'click-b': function() {},
    // 组件的一个改变事件
    'change-c': function() {},
  },
  // 业务数据变化的回调
  watch: function( contextValue ) {},
}

运行时会注入一个上下文给上述对象方法的 this,组件还可以添加工具方法给上下文。该上下文的内置属性与方法有:

interface IDomainSpecificComponentContext {
  getState(key: string): any;
  setState(key: string, value: any): void;
  setState(stateMap: { [key: string]: any }): void;
}

视图结构描述

上面说了我们需要一种比 HTML 之类的更进一步的视图结构抽象,下面就来说说这部分的大体思路。

技术选型

在做视图结构抽象时最常用到的技术就是 XML-based 或 XML-like 以及 JSON-based 的某种技术。XML-base 和 XML-like 的技术都是符合 XML 语法的,唯一的区别是前者要完全符合 XML 的标准规范,像 Angular 和 Vue 的模板就是后者;同样的,JSON-based 的技术是完全符合 JSON 的标准规范的技术,像 JSON Schema。

自从 React 问世以来,其带来的 XML-like 的 JSX 也会被用于视图结构抽象,但基本仅限于编辑时(edit time)。一段 JSX 代码并不是纯声明式的,作为视图结构描述来说可读性较低,解析难度较高,并且通用性很低。

JSON-based 的技术对前端运行时最为友好,解析成本几乎为零;相反的,其可读性很低,JSON 结构是纵向增长的,指定区域内的表达力十分受限,无法很直观地看出层级关系与视图单元的属性:

{
  "tag": "view",
  "attrs": {
    "widget": "form"
  },
  "children": [{
    "tag": "group",
    "attrs": {
      "title": "基本信息",
      "widget": "fieldset"
    },
    "children": [{
      "tag": "field",
      "attrs": {
        "name": "name",
        "label": "姓名",
        "widget": "input"
      }
    }, {
      "tag": "field",
      "attrs": {
        "name": "gender",
        "label": "性别",
        "widget": "radio"
      }
    }, {
      "tag": "field",
      "attrs": {
        "name": "age",
        "label": "年龄",
        "widget": "number"
      }
    }, {
      "tag": "field",
      "attrs": {
        "name": "birthday",
        "label": "生日",
        "widget": "date-picker"
      }
    }]
  }, {
    "tag": "group",
    "attrs": {
      "title": "宠物",
      "widget": "fieldset"
    },
    "children": [{
      "tag": "field",
      "attrs": {
        "name": "dogs",
        "label": "🐶",
        "widget": "select"
      }
    }, {
      "tag": "field",
      "attrs": {
        "name": "cats",
        "label": "🐱",
        "widget": "select"
      }
    }]
  }]
}

如果一个应用的设计是不需要人工写视图结构描述的话,可以考虑使用 JSON-based 的技术。

像 Angular 和 Vue 的模板那种 XML-like 的技术是相对来说最适合做视图结构描述的——纯声明式,结构是向水平与垂直两个方向增长,无论是可读性还是表达力都更强,解析难度适中,并且具备通用性。

下面的模板代码所描述的内容与上面那段 JSON 代码一模一样,深呼吸,好好感受一下两者之间的差异:

<view widget="form">
  <group title="基本信息" widget="fieldset">
    <field name="name" label="姓名" widget="input" />
    <field name="gender" label="性别" widget="radio" />
    <field name="age" label="年龄" widget="number" />
    <field name="birthday" label="生日" widget="date-picker" />
  </group>
  <group title="宠物" widget="fieldset">
    <field name="dogs" label="🐶" widget="select" />
    <field name="cats" label="🐱" widget="select" />
  </group>
</view>

至此,视图结构描述最终该选用哪种技术,想必无须多言。

鸡哥(小鸡)
鸡哥(小鸡)

设计思路

毋庸置疑,模板的语法要符合 XML 语法是前提,再在此基础上根据需求进行定制、扩展。首先要定义标签集。所谓的「标签集」就是一个元素库,其中的每个元素都要具备一定语义,使其在业务上有存在意义。然后是制定描述元素的 schema 并实现其对应的解析、校验等逻辑。

元素 schema 大概是长这样:

// 属性值类型
type PropType = 'boolean' | 'number' | 'string' | 'regexp' | 'json';

// 属性描述符
type PropDescriptor = {
  type: PropType | PropType[];
  required: boolean; // 是否必需
};

// 元素 schema
type ElementSchema = {
  name: string; // 元素名
  tag?: string; // 标签名,不指定时取元素名
  props?: {
    [key: string]: PropDescriptor;
  };
  attrs?: {
    resolve: (key: string, val: any) => any;
  };
  // 节点行为,是作为父节点的子节点还是属性存在
  behavior?: {
    type: 'append' | 'attach';
    // 以下都用于 `type` 是 `'attach'` 时
    host?: string; // 宿主(属性名)
    keyed?: boolean; // 是否为键值对集合,值为 `true` 且 `merge` 为 `false` 时以节点 ID 为键
    merge?: boolean; // 当值为 `true` 时将 `reduce` 的返回值与 `host` 指定的属性的值进行合并后重新赋值给 `host`
    reduce?: (node: ITemplateNode) => any; // 转换节点信息
    restore?: (reduced: any, node?: ITemplateNode) => ITemplateNode | Partial<ITemplateNode>;
  };
};

可以看到 schema 中有 propsattrs,它们共同组成了模板元素的属性(XML attributes),区别是:模板解析后的属性如果是在 props 中定义的并且满足属性描述符的 typerequired 所指定的限制条件,会成为模板节点的 props 属性;剩余没在 props 中定义的则成为模板节点的 attrs 属性,通过 resolve 方法能够对属性根据自己的规则进行值的转换。

虽然在模板中元素总是以嵌套的形式展示出层级关系,但一个元素并不一定就是其父级的结构,还可能是配置。因此,元素 schema 中的 behavior 用于设置当前元素在模板解析后是作为一个节点的子节点存在还是作为某个属性存在。

上述的模板设计是纯视图结构描述的,并且只对元素这种「块」进行处理,我认为这样够用了。根据情况,可以扩展为像 Angular 和 Vue 的模板那样支持文本、插值和指令等。

如果懒癌发作并且没什么特殊需求,模板解析的工作可以交给魔改后的 Vue 2.6 编译器,再适配为模板节点树。

每个模板节点的结构大致为:

interface ITemplateNode {
  id: string;
  name: string;
  tag: string;
  props: {
    [key: string]: any;
  };
  attrs: {
    [key: string]: any;
  };
  parent: ITemplateNode | null;
  children: ITemplateNode[];
}

最后,通过适配层将模板节点树转为运行层的组件树,并把渲染的控制权也转交给了最终的运行环境。

总结

在一个复杂的前端应用中,如果不对其进行分层,那它的扩展性和可维护性等真的会不忍直视……通常是采用经典的三层架构,从下到上分别为数据层、逻辑层和表现层。本文以表现层为例,将其再次划分出抽象层、运行层和适配层这三层,实际上数据层和逻辑层也可以套用这种模式——就像在生日蛋糕上切上四刀——我称其为「九宫格」模型。

「九宫格」模型
「九宫格」模型

在表现层的各种抽象中,本文着重阐述了视图结构描述的技术选型与设计思路,可以看出 XML-like 的模板从编写到解析再到渲染这一整条流程,与 Angular 和 Vue 的模板及 HTML 大体上一致;其他抽象只是稍微提了提,以后有机会再展开来说。

之前也写过几篇与模板相关的文章:从提效角度与「面向组件」做对比的《我来聊聊面向模板的前端开发》;从可定制性角度讲的《我来聊聊配置驱动的视图开发》;从低代码平台的核心理念「模型驱动」出发的《我来聊聊模型驱动的前端开发》。可以说,本文的内容是它们有关表现层描述的「根基」。

无论一家公司是不是做低代码平台的,或者内部有没有低代码平台,都应该从表现层抽象出视图结构描述,至少要有如此意识。

创作不易,若本文给你提供了价值,还请不吝欧雷充电

左为微信,右为支付宝;充电累计 ¥88 以上可在付款时备注或邮件告知昵称和需要被链接的网址,会列在「赞助」页。其他方式与具体规则请见「资助」。