我来聊聊模型驱动的前端开发

Posted Coding as Hobby

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了我来聊聊模型驱动的前端开发相关的知识,希望对你有一定的参考价值。

如果把「客户端」想成是楼,把「数据」想成是水——「Model」就是这幢楼的蓄水池,提供充足的水源;「ViewModel」是将蓄水池里的水进行净化等加工的地方,然后输送给挨家挨户;「View」部分的每个 UI 组件就是「挨家挨户」,对水进行消费的地方。


一切皆为模型


模型是人们根据事物特征将它们分类并抽象后的结果,建模是人们认知世界的一种方式。


模型驱动


数字世界这种虚拟空间,里面本无一物,是个需要被人开垦的空虚的世界。那么人该如何打造数字世界呢?


就像《圣经》里描述的——上帝按照自己的样子创造了亚当这个世上第一个人类,又从他身上取下一根肋骨创造了夏娃这个世界上第二个人类。在这里,上帝将自己作为参照提取特征抽象出祂所认为的「人」的模型,并根据这个模型创造出「亚当」和「夏娃」。


人在打造数字世界时必然会参照自己所存在的并且是自己所认知的世界,因为人不可能想像出自己无法认知的事物。人们所抽象的现实世界的事物的模型,就成了建设数字世界的基础,而数据则为构造数字世界的基本单元,数字世界成了现实世界的映射。


模型是数字世界万物的概念,程序是将概念具像化的工具,打造数字世界需从建模开始。


领域驱动


上面说了在打造数字世界时首先要建立模型,然后以模型为中心开始建造。那么要怎样进行建模呢?


至今为止,软件工程发展这么多年,产生了很多方法论,其中「领域驱动设计」在构建大型软件时是被广泛采纳的实践方法。它的核心就是针对问题域分析并建立领域模型,理出模型间的关系及业务逻辑。


领域驱动设计最常用在商业层面的模型上,如:包含名称、编号、规格、出厂日期等信息的商品模型;同时也可以用在技术层面的模型上,如:包含名称、编码、字段、关系、约束等用来描述模型的信息的模型。前者称之为「业务模型」,后者则是「元模型」。业务模型可以被元模型描述。


如果把模型映射为数据库表,那么元模型所对应的表中的每条记录都是元数据,业务模型所对应的表中的每条记录都是业务数据。


MVVM 架构


标准的 MVVM 架构是 Model-View-ViewModel 三部分:



而这里所说的如下图所示:


我来聊聊模型驱动的前端开发


从图中可以看到,多了个「Action」,所以实际上应该是 Model-View-ViewModel-Action 四部分。它们之间彼此分离,以组合的方式协同工作。


为了讲究对称美,将这种架构简称为「MVAVM」。


模型


模型的主要职责是前、后端协议处理,以及对数据进行读写操作。


前、后端协议的处理包括元数据适配和 HTTP 请求构造。与后端对接的工作都控制在这一层,其他层的运作都基于这层适配后的结果。


在这层中进行读写的数据,既有业务数据又有元数据。元数据只加载一次,将适配后的结果进行缓存;业务数据只暂时缓存尚未持久化的处于草稿状态的记录,持久化之后会将其删除。


ViewModel


VM 的职责很单纯,就是处理业务数据流转相关的逻辑,即数据的分发、汇总与联动。理论上,在这层不直接进行任何与请求服务、执行动作相关的处理。


正如文章开头所说——在一个应用中,数据是像水一样不断流动的,在此过程中,VM 应该起到铺设输送管线与在特定节点对数据进行处理的作用。根据这一特点,可以考虑采用管道和过滤器模式:


我来聊聊模型驱动的前端开发


实例与数据的关系


每个 VM 实例都来源于数据,是数据的变形,是具备能力的数据。


根据数据源的形态,VM 实例大致分为列表、对象和值三种。如果值是布尔、数字、字符串等简单类型,那就即刻终止;若值为对象、列表等复杂类型,则要递归下去,直到末端为简单类型。


需要注意的是,VM 实例与数据一一对应,其实质就是数据本身,而不是数据的容器。也就是说,VM 实例不是装水的瓶子,不能把已经装的水倒掉换些水进来,而是一起丢弃。


生命周期


任何对象的生命周期都可粗略地分为初始化、活动中与销毁三个阶段。


在初始化时根据策略获取自身数据源,与上级 VM 实例创建的流进行对接形成数据管道,然后创建向外推送自身变化的流。


活动期间就是不断地与外界进行数据交换:

  1. 视图输入变化时,通过对应的 VM 实例提交自身的数据变更

  2. 在处理被提交的输入数据时会对其进行保留,并发出有数据提交的信号

    1. 自身的数据变化会通过数据管道流向下级 VM 实例

    2. 外部(主要是上级)接收到信号后会做些后续处理


销毁时做些清理、善后的工作,如:移除子 VM 引用,取消订阅等。


数据流转


在活动期间,数据在各层 VM 实例所连通的数据管道中流转时会发生变化,为了方便在不同场景下对数据进行处理,需要在初始化 VM 实例时将数据源进行备份,并生成几个拷贝:初始值(initial value)、默认值(default value)、原始值(data source)和当前值(current value)。


其中,初始值是获取到数据源那一刻的值,默认值在没有指定的情况下与初始值相同,它们都是一经初始化就不会改变的;当前值是自身一段时间内的数据变更,是最新的但不确定的值,可以理解为是一种草稿状态的值;原始值只有在上级当前值变动,接收到下级提交的数据或强制更新时才会更新,它是阶段性的确定值,可以看作是可靠的数据。


「原始值」中的「原始」也许会容易让人误解。在这里,它的含义是相对于「当前值」来说,它是「原始」的,可以拿来作为参考的,而不是「最初的值」。表达「最初的值」的含义的是「初始值」。


原始值与当前值的区别与特点是:

  • 原始值是确定的,当前值是不确定的;

  • 原始值是纯的,当前值是脏的;

  • 通过「提交(commit)」操作对各级的原始值、当前值进行同步;

  • 当前值的「版本」始终不落后于原始值;

  • 有些场景下原始值与当前值始终相同。


数据在流转时遵循以下几个原则:

  • 自身的原始值变动会引起自身的当前值以及子孙级的原始值和当前值变动,子孙可以定义抛弃变动的规则;

  • 自身的当前值变动在没提交时不会影响自身的原始值,会引起子孙级的原始值和当前值变动,子孙可以定义抛弃变动的规则;

  • 将自身的当前值提交到上级后,不会引起回流,兄弟 VM 也不会发生变化。


总的来说,只有在上级引发数据变动的情况下,才会发生上到下的数据流动。


各层级 VM 实例之间数据的传递过程大致如下:



过滤器


在数据通过上下级 VM 实例之间所连通的数据管道,即数据的分发与汇总时,会经过一系列相对独立的逻辑的处理,如:数据的裁剪、变形、校验等。每一段处理逻辑就是一个「过滤器」,每个过滤器都可以抛出异常终止后续的操作。


与视图的交互


每个 VM 实例都会提供一些供视图进行状态同步、数据联动等的接口:


interface IViewModel<ValueType> { // 获取原始值 getDataSource(): ValueType; // 设置原始值 setDataSource(value: ValueType): void;  // 获取当前值 getCurrentValue(): ValueType; // 设置当前值 setCurrentValue(value: ValueType): void;  // 监听当前值变化 watch(handler: Function): Subscription;  // 监听提交等事件 on(handlers: {[key: string]: Function}): void;  // 在分发数据的过滤器队列头部添加一个过滤器 prependDispatchFilter(filter: Function): void; // 在分发数据的过滤器队列尾部添加一个过滤器 appendDispatchFilter(filter: Function): void;  // 在提交数据的过滤器队列头部添加一个过滤器 prependCommitFilter(filter: Function): void; // 在提交数据的过滤器队列尾部添加一个过滤器 appendCommitFilter(filter: Function): void;  // 获取上级 VM 实例 getParent(): IViewModel; // 获取下级 VM 实例 getChildren(): IViewModel[];  // 获取模型,返回值包含发请求的 API getModel(): IModel;  // 执行动作,不指定 VM 实例的话使用当前 VM 实例 call(action: IAction, vm?: IViewModel): Promise<void>;}


动作


关于「动作」是什么,在之前的文章《》中已经提及——


「动作」是一段完整逻辑的抽象,与函数相当,用来描述且只描述「做什么事」,不描述「长什么样」。一个可复用的动作应该是原子化的。


根据逻辑的定义、执行所在位置,可以分为客户端动作(广义)与服务端动作:客户端动作(广义)是定义并且执行在前端;服务端动作是定义并且执行在后端。


客户端动作(广义)根据具体场景的用途及特性,又可分为以下几种动作:

  • 路由动作

  • CRUD 动作

  • 客户端动作(狭义)

  • 组合动作


其中,路由动作的作用是进行页面跳转;CRUD 动作是对数据进行操作;客户端动作(狭义)是单纯的一段逻辑,可以简单理解为是一个 JS 函数;组合动作用于将其他类型的动作「打包」处理,就像一个调用了其他函数的函数。


服务端动作可以简单粗暴地理解为是非常规 CRUD 的后端接口。


除了客户端动作(狭义)需要自己写逻辑之外,其他的都是完全根据元数据执行。


路由动作是进行页面跳转的动作,这里的「页面」是广义的,根据情景,可以理解为是浏览器窗口中的整个页面,也可以理解为是某个视图所在的宿主。在这个体系里,将视图跳转的动作称为「视图动作」,跳转到当前应用之外的页面的叫做「页面动作」。


既然组合动作是将其他类型的动作「打包」处理的动作,那么它就得具备调整被「打包」的动作的执行顺序及如果某个动作执行失败要终止后续处理等的控制能力。实现方式可以参考 continuation 在 JS 中的实践应用。


视图


解析视图描述信息,并根据注入的 VM 实例所携带的数据进行渲染。


视图中可以自己发请求,但理论上只能发获取数据的请求,不能发修改数据的,修改数据需要通过 VM 实例或动作去处理。


视图这部分又细分为描述层、包装层和渲染层:



「描述层」即「DSL 层」,通过内部定义的 XML 标签集去描述一个界面中的 UI 元素、数据等信息,是一种相较于 JSON 来说更符合直觉,更容易理解的界面配置。


包装层的作用是将描述层的标签转换为实际渲染的部件,渲染层则是具体的运行时环境。不像描述层那样相对独立,包装层和描述层可以说是不能分离的,包装层在将描述层的标签转换为实际渲染的部件时需要渲染层的支撑。


包装层的包装器与描述层的标签集里的标签可以说是一一对应的,标签通过包装器转换为部件集里的部件,但部件却不一定与包装器一一对应,很可能一个包装器对应多个同类别的部件。


描述层


在 web 前端开发中,html 是一种 DSL,CSS 也是一种 DSL。在这个模型驱动的体系里,内部定义的用来描述一个界面中的 UI 元素、数据等信息的 XML 标签集就是 DSL。


描述层是运行时无关的,能够在任何平台及运行时库中运行。


日常工作交流中常会说到「模板」,这个词在不同语境中代表着不同的东西。在这个体系中,当在开发的语境里时,如果没带任何修饰词,应该就是指「一段描述界面配置的标签」,如:


<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="

以上是关于我来聊聊模型驱动的前端开发的主要内容,如果未能解决你的问题,请参考以下文章

前端开发的领域驱动设计语雀精选

前端开发常用代码片段(中篇)

前端开发常用js代码片段

分享前端开发常用代码片段

前端开发工具vscode如何快速生成代码片段

前端开发工具vscode如何快速生成代码片段