前端数据建模指南

发布 2022-05-03 阅读 20分钟

什么是前端数据建模?

提到数据建模,大多数人第一时间想到的都是和后端、数据这些方面相关的内容。而前端数据建模,似乎让人感到陌生。

那么我通过回答下面几个问题,来统一一下我们对前端数据建模概念的理解。

什么是数据建模?

数据建模是对业务逻辑所使用的数据以及这些数据之间的关系进行分析和定义的过程。

数据建模有何意义?

  • 为团队成员之间的协作创建一种统一的模式。
  • 通过定义数据需求和使用情况来发现改进业务流程的机会。
  • 节省代码维护成本。
  • 减少错误,改进数据完整性。

前端有必要进行数据建模吗?

在我的理念里,前端应该始终以用户体验为核心。但是事实上,前端无法完全脱离业务逻辑。那既然涉及到业务,那就应该构建模型。

前端数据建模有何优缺点?

研发阶段:需要花费时间和精力进行数据和关系的定义。

维护阶段:节省逻辑变更后的修改时间,让页面保持相对稳定。

综上所述,在业务逻辑相对复杂的系统中,前端数据建模是很有必要的。而在一些临时性的项目中,则没有必要进行数据建模。

页面与模型

数据建模,就是围绕数据建立一个模型。

在大多数前端研发人员眼里,通常都会以页面(Page)为纬度来切割业务内容。而不会以模型(Model)为纬度来切割业务内容。这会导致一个问题,前端难以自己形成一个具有内聚力的系统。

和页面功能相关的内容都会存到该页面中。如果其他页面需要依赖这个页面中的某些数据或者某些功能,很多人会选择复制一份对应的代码来完成功能。因为这种做法趋近于人类的原始本能-「拒绝思考」。稍微有点追求的人,就会把多个页面中会共用的数据和函数提取到某个独立的位置,然后由这两个页面单独引用。这就是所谓的「封装」。但这种封装并没有逻辑性,它只是遵循了「多个页面引用了一个页面中的某个相同的功能,就要把这个功能抽离出去,封装成一个独立的函数」。这种做法并没有任何问题。但是,做的程度还是不够。

举个例子,在开发阶段,页面 Page A、B、C 都依赖了功能 Func X。于是你封装了 Func X。但是后面业务逻辑发生了变化,Page A、B 和 Page C 所需要处理的逻辑不同了。这时你要么在 Page C 中把对 Func X 的依赖全部移除,在 Page C 中写自己的逻辑。或者是在 Func X 中增加一段判断代码,将 Page C 和 Page A、B 进行区分,然后调用不同的逻辑。

无论哪种做法,在代码的维护上都不算简单。因为你的系统是完全不稳定的、是易变的。为什么会这样呢?是因为缺乏一个模型。

以目前的技术角度分析,一个前端页面,可以拆分为几个部分:UI、路由、接口、数据和业务逻辑。其中 UI 是纯前端层面的东西,复用是通过组件化或者 CSS 来实现的。路由是页面的一部分,不可复用。剩下的接口、数据和业务逻辑,都是在一定程度上可以复用的。

而一个模型,主要是由数据和业务逻辑组成的。

数据建模该做什么?

目录结构划分

一个好的目录结构应该是高内聚、低耦合的。

很多人喜欢将文件夹根据技术职责进行区分,如下:

image

这并不是我推荐的做法。我认为一个高内聚的目录结构应该是根据模块区分的,如下:

image

文件夹的层次应该尽量扁平,而不是深层。

定义接口

定义接口的过程,基本上就是定义数据模型的过程。

因为接口是稳定的,而实现是易变的。

实现一个业务功能,随便是一个程序员都可以做到,这并没有什么门槛。真正难的是如何对业务逻辑进行抽象,定义成接口。这存在一定门槛。

拿购物车这个场景举例。

image

购物车的数据分为如下几部分:

  1. 商品排序条件。
  2. 商品过滤条件。
  3. 图片显示状态。
  4. 商品列表加载状态。
  5. 购物车商品列表加载状态。
  6. 商品列表。
  7. 购物车商品列表。
  8. 商品总数。
  9. 商品总价。

改变购物车数据的途径有如下几个操作:

  1. 初始化购物车。
  2. 添加商品到购物车。
  3. 在购物车删除商品。
  4. 增加商品。
  5. 减少商品。
  6. 提交下单。

根据上述的文字定义,我们可以利用 TypeScript 来定义出一个接口。

interface IGoodscartModel {
  filter: Filter;
  order: string;
  imageVisible: boolean;
  goodsList: GoodsList;
  goodscartList: GoodscartList;
  total: () => number;
  quantity: () => number;

  initialGoodsList: () => Promise<void>;
  initialGoodscart:() => Promise<void>;
  addToCart: (goodsId: string) => void;
  plusGoods: (goodsId: string) => void;
  minusGoods: (goodsId: string) => void;
  removeGoods: (goodsId: string) => void;
  submitOrder: () => Promise<void>;
}

一旦定义出这个接口,实现也就无关紧要了。因为业务逻辑的细节,随便一个思维逻辑能力正常的人都可以写得出来。

对逻辑而言,接口就像是一张设计图纸。有了这张图纸,即使是最廉价的苦力一样可以建造出琼楼玉宇。

当然,其中有一些细节。

比如购物车商品总量和购物车商品总价,是通过计算而得到的,属于计算属性,所以在模型的定义中,将其定义为函数,而非基础类型。

比如初始化商品列表和购物车商品列表,是一种异步操作,所以定义为返回 Promise 的函数。

你可能会发现,这不就是面向对象编程中的知识吗?没错,这就是面向对象中的封装、继承、多态和组合。

区分数据的性质

虽然在上面的模型中定义了很多个数据字段,但其实并不是所有的数据都一定属于模型。有一些临时性的数据,是应该存储在页面中的。

区分数据性质的方法有很多,但我认为,对前端而言并不需要过度复杂,只需要根据一个条件来区分即可-数据从何而来?

因此,可以区分为系统外部数据和系统内部数据。

由于 Web 应用的特殊性,基本上所有的 Web 应用都依赖系统外部数据。而这些数据通常都依赖接口提供。这种系统外部数据相对稳定和纯净。

而系统内部数据,又可以根据业务的重要程度来区分为多种类型。比如存储在内存中的值,可以称之为临时值。页面刷新时,这些值就会被重置。而重置的值,通常来自于两个位置,一个是存储在本地存储(WebStorage)中的值,可以称之为缓存值。另一个是写在代码中的值,可以称之为默认值。

这几类数据之间是存在流转关系的,也就是意味着不同性质的值,在系统运行的不同阶段会相互转换。

比如页面加载时,先调用接口,获取系统外部数据,获取成功后,系统外部数据转化为临时值,用户对临时值进行操作变更后,再对数据进行某种提交操作,将临时值同步到系统外部。

用户的某些操作也可以将临时值存储到本地缓存,变成缓存值。

用户离开网站,再次回归时,会读取缓存值替换为临时值。

总结而言,数据的类型分为如下几类:

  1. 系统外部数据(通常来自于接口)
  2. 系统内部数据
  3. 默认值(通常硬编码在代码中)
  4. 临时值(通常存储在内存中)
  5. 缓存值(通常存储在本地缓存中)

这几类数据之间又可以相互同步与转换。

根据上面的归类,我们可以发现,排序、过滤与图片显示隐藏状态应该归属于页面,而不是模块。

因为这些数据的功能是和页面强绑定的,或者说,这些功能本身就属于页面,不应该再割裂到模块中。

与框架结合实现数据响应式

数据模型的定义和框架并没有关系。但我们通常需要配合数据响应式来实现操作数据自动更新 UI 的功能。否则就需要在模型中处理 UI 的变化,会导致模型依赖 UI,这不是我们想要的。

目前最主流的 UI 框架,都具有数据的响应式。但我更推荐和一些状态管理库进行结合。

因为前面有提到,只有相对有规模的项目才会用到数据建模。而在具备相对规模的项目中,使用状态管理库可以让我们的开发事半功倍。

下面是使用 pinia 的代码示例:

import { defineStore } from "pinia";
import { GoodscartModel } from "./model";

const goodscart = new GoodscartModel();
const {
  goodsList,
  goodscartList,
  goodsListLoading,
  total,
  quantity,
  initialGoodsList,
  plusGoods,
  minusGoods,
  addToCart,
  removeGoods,
  order,
} = goodscart;

export const useGoodscartStore = defineStore("goodscart", {
  state() {
    return {
      goodscartList,
      goodsList,
      goodsListLoading,
    };
  },
  actions: {
    initialGoodsList,
    plusGoods,
    minusGoods,
    addToCart,
    removeGoods,
    order,
  },
  getters: {
    total,
    quantity,
  },
});

增加接口的防腐层

定义模型,对前端而言还有另一层意义。就是可以起到隔离外部和内部的关联、提高系统稳定性的作用。

我们把前端想像成一个系统,系统中存在很多外部的资源,图片、视频、字体、图标、样式、脚本等等,以及非常重要的接口。

正常情况下,在开发过程中,接口中的字段名称、字段类型以及数据结构都是非常易变的,特别是遇到不专业或者对代码追求较低的后端人员时,对接接口将会是一件非常令人感到烦恼和痛苦的事情。

从根本上看,造成这个现象的原因就是接口的变动不在我们的掌控之中,它是系统外部的不可控的事物。

那么该如何减少和降低接口变化导致的前端变动呢?

答案很简单,增加防腐层。

防腐层 Anti Corruption Layer 是领域驱动设计中的一个概念,用于隔离两个系统,允许两个系统之间在不知道对方领域知识的情况下进行集成。

主要进行的是两个系统之间的模型(model)或者协议(protocol)的转换,并且最终目的是为了系统使用者的方便而不是系统提供者的方便,进一步的解释就是把系统提供者的模型转换为系统使用者的模型。

这个概念在前端落地到代码中非常简单,可能就是一个关系映射函数而已。

function dataMap(rawData) {
	// ...logic
  return formatData;
}

现在,按照我们上述理论构建的系统中,是存在三个层级的,自下而上分别是防腐层、模型层和 UI 层,如图所示:

image

采用这种方式构建的系统,无论接口以何种方式返回数据、以何种数据格式返回数据,都不会直接对我们的模型层代码进行破坏,更不可能将这种破坏性穿透到 UI 层代码中。

当然,如果是整条业务流程都改变了,那这种破坏性会影响到所有层级,这是不可避免的。

总结

前端数据建模是一种研发中大型规模项目和产品的可实践方法论,它通过设计一种模型来帮助我们降低系统的复杂程度,这看上去很吸引人。

但我们不能否认的是,从前后端分离这种架构在业界真正流行开始(以 2016 年计算),距今已经有六七年的时间了,前端数据建模从未被统一或者被作为某种开发模式广泛应用。

那我们不禁要思考了,前端数据建模到底是不是一件正确的事?

根据我的经历来看,有几个原因:

  1. 宏观业态:
  2. 前端程序员跳槽频率高。根据个人招聘经历和国内几个招聘大平台的数据来看,前端程序员的平均跳槽频率不足 1 年。既然大家都是短工,那么为什么要思考那么长远的事情呢?代码被搞成了「屎山」,我跳槽便是,为什么要死磕呢?又不是找不到工作。
  3. 大多数公司不是特别重视前端质量。因为无论是大公司还是小公司,无论是传统行业还是互联网行业,都会更看重数据的重要性。因为网站只是展示数据、操作数据的一种手段。数据才能决定流程和金钱。甚至很多传统行业的公司,连 UI 都没有。只有中大型的互联网企业,才会有用户体验部门。既然公司都不重视,为什么要做呢?
  4. 微观业态:
  5. 从业人员水平低。前端的门槛相对较低,很多人还处于实现某个 UI 效果而抓耳挠腮的状态,哪还有什么精力投入到业务上来?流程跑不通?找后端哇。连问题都意识不到的人,怎么可能会去解决问题呢?
  6. 高手大多不屑于碰业务。编译构建、框架、组件化、数据可视化、WebGL、CI/CD、LowCode/NoCode、工具链......前端可以深挖的方向太多太多了,但谈论到谁技术好,基本上都是上面这些,很少有人会说,某人业务代码写得好,技术好牛啊。因为业务在大多数人眼里只是基本操作而已。再者,我前面也有说,前端的未来应该是交互和体验,而非业务。

但无论何种原因,都不能否认前端数据建模对业务的意义和价值。

时刻记住,我们要成为工程师,而不是码农。