为什么在前端很少有人会提到分层架构,比如经典 MVC 架构。这是因为浏览器诞生之初就只是一个后端数据的 GUI 渲染器。也就是说 web 1.0 时代的整个 web 前端工程就是一个 View层,而 Model 和 Controller 就是指后端,所以根本无需在 web 前端工程中去提什么 MVC。
然而 web 生态发展到今天,浏览器越来越强大,随着 PWA、小程序、快应用的推广,WebAPP 已经和传统的“富客户端”没什么两样了。那么在这种趋势下,如果 Web 前端架构还停留在 View 的时代,那么显然是落伍的。
你可能会想,“分层?有必要吗?”
抛开剂量谈毒性是没有意义的,同样,离开了具体的业务复杂度谈分层,也是没有意义的。在极为简单的应用中,我们当然要追求快速高效立马上线,但在一些企业应用中,却需要我们慢条斯理,在长达数年的岁月里慢慢推进一套系统的演进。
我们谈分层,大多是在这类有比较复杂的业务逻辑的系统中去谈,这类系统可能在具体界面的实现上并不复杂,甚至没有什么交互上的难度。但是,这类系统中的前端开发者们,常常还是很抓狂,可能被一个逻辑折腾死,最后一定会思考,我们如何才能合理的区分哪些代码是业务的,哪些代码是交互的?应该如何组织代码才能高效的解决自己遇到的烦恼?
一种通用的分层思想,是将应用分为“数据层”“逻辑层”“表现层”,在每层内,我们又可以细分。
我们前端在开发一个业务的时候,总是习惯先从界面出发,看着设计稿想我这里要怎么做怎么做,等把界面交互大致写出来之后,再把产品文档里面的业务逻辑作为一些判断条件加入到写好的交互代码中,最终交付。
View 是最直观的,前端开发的大部分工作也就是编写一个又一个的组件。不过,正是因为这种以 View 为核心的开发思维,导致我们只为解决表层问题而架构,从而忽略了思考顶层设计。这也是很多前端项目工程经过几轮迭代和人员转手后,很难继续维护下去的根本原因。
用一段伪代码来探索这种工作流有什么问题:
<template>
<div>
<form @submit="handleSubmit">
<input v-model="price" type="number" placeholder="单价" />
<input v-model="count" type="number" placeholder="数量" />
<input type="number" :value="total" disabled />
<span v-if="save">折扣10%</span>
<span>
<input v-model="code" type="text" placeholder="优惠码" @change="handleChangeCode" />
<button type="button">查询</button>
<span v-if="codeChecked">优惠码有效</span>
</span>
<button>提交</button>
</form>
</div>
</template>
import { ref, computed } from 'vue'
import axios from 'axios' // 假设使用axios进行ajax请求
const price = ref(0)
const count = ref(0)
const code = ref('')
const codeChecked = ref(false)
const total = computed(() => {
return price.value * count.value * (1 - save.value) * (codeChecked.value ? 0.9 : 1)
})
const save = computed(() => {
return price.value * count.value > 100 ? 0.1 : 0
})
const handleCheckCode = async () => {
try {
const res = await axios.post('...', code.value)
codeChecked.value = !!res.data
} catch (error) {
console.error('Error checking the code:', error)
}
}
const handleChangeCode = () => {
codeChecked.value = false
}
const handleSubmit = () => {
// 一大堆校验逻辑
const data = { price: price.value, count: count.value }
if (codeChecked.value) {
data.code = code.value
}
// 提交数据
// ...
}
你看,也就简简单单的几个字段,就让代码开始有点点混乱了,要搞清楚每一个字段与其他字段之间的关联,你需要通读整个组件的代码,而随着业务的越来越积累,这个看似简单的组件,会慢慢撑开,字段从这几个慢慢增长到 10 多个,甚至 20、30 多个,字段与字段之间的关联性,以及每一个字段和它的提示语在什么情况下才展示出来,等等,越来越复杂。当这个业务持续增长超过 1 年后,你发现这个组件已经满目全非,根本不敢改一行代码,因为你怕一改就影响整个业务。
到底是因为什么?是什么东西冥冥之中就让我们的代码走向不可维护了呢?
一个重要原因就在于:我们的代码同时承载了业务的逻辑和界面的交互逻辑。
比如上面的 codeChecked,对于整个业务而言,是非必需的,但是对于交互而言是必需的,你必须用一个状态去控制提示语是不是要展示出来。在上面这段代码中,用于完成业务目标的 price, count, code,和用于完成交互任务的 codeChecked 被放在了一起管理。
而且更糟糕的是,其中在 handleSubmit 中,用于交互的 codeChecked 却成为了控制 code 字段是否提交的开关,这直接让业务逻辑和交互逻辑耦合在一起。
正因为这种线性的开发思维,让我们写的组件随着业务的扩展,越来越难以高效的维护,直到最后不敢修改一行。
另外,因为 Vue、React 这些优秀的 UI 渲染框架很强大,可以干很多分内和分外的事情,更助长了开发者的惰性,将所有逻辑都直接以最原始的方式写在 View 中,不管是 Mode 还是 Controller,不管是业务逻辑、还是渲染逻辑、还是存储、通信、路由逻辑... 所有各类逻辑叠加各种 UI 生命周期,全部都耦合在一个 Component 中。
事物往往会自发地从无序到混乱的程度发展。我们需要对代码重新进行管理,让原本线性的逻辑表达,按照一定的结构重新梳理,并把这些结构用合理的文件结构进行框定,从而做到不打结。
解决代码逻辑打结的第一个杀手锏是领域建模。
领域建模是指,我们先抛开软件的界面、实现逻辑、运行环境等应用层面的东西,转换自己的角色,把自己当作一个业务人员,思考自己要用这套系统要完成什么业务目的,梳理出业务流程,指明不同角色在业务流程中的责任,画出业务的示意图,并最终用代码把它表达出来。
在开始编码之前,建立领域模型。实际上,领域模型包含两个部分:
那接下来,怎么实现与图纸等价的建模代码呢?
我们要清楚在这个过程中,其实主要包含 3 类对象:
我们进行领域建模,主要针对第一类和第二类。面向对象是 DDD 的核心方法,我们在具体编程时,通过创建和关联各种 class 完成模型。我认为在前端语境下,模型一定是充血的,因为前端建模要为交互留足空间。
实体采用充血模型,即实体相关的所有业务逻辑都在实体类的方法中实现,跨多个实体的领域逻辑则在领域服务中实现。
以前文的例子为例,我们可以建立这样的模型
clas Order {
price = 0
count = 0
code = ''
total = 0
}
这种就是所谓的贫血模型,它只能告诉你有什么,但是具体的业务你需要另外封装出来,这显然不可能在前端领域成为合理的建模方式。怎么做呢?我们要对每一个字段进行业务说明,可以这样:
import { Model, meta, state, Int, Validator } from 'tyshemo'
class Order extends Model {
@meta({
type: Number,
label: '单价',
required: true,
validators: [
Validator.required('单价必填'),
],
})
price = 0
@meta({
type: Int,
label: '数量',
required: true,
validators: [
Validator.required('数量必填'),
],
})
count = 0
@meta({
type: String,
label: '优惠码',
checked: false,
checking: false
watch() {
const view = this.use('code')
view.checked = false
view.checking = true
ajax.post('...', this.code).then(res => {
view.checked = !!res
}).finally(() => {
view.checking = false
})
},
drop() {
return this.use('code').checked
},
validators: [
determine(code) {
return !!code && !this.use('code').checking = false
},
validate() {
return this.use('code').checked
},
message: '优惠码无效',
],
})
code = ''
@meta({
type: Number,
label: '总额',
compute() {
const { save } = this.use('total')
const { checked } = this.use('code')
return this.price * this.count * (1 - save) * (checked ? 0.9 : 1)
},
save() {
return this.price * this.count > 100 ? 10 : 0
},
saveMessage() {
return this.save ? '折扣10%' : ''
},
disabled: true,
drop: true, // 由后台计算,这个字段仅前端展示,不提交
})
total = 0
}
我们上面写的这个模型,它是充血的,它完整的描述了对应业务实体的所有字段,以及每个字的的具体业务阐释。而且更重要的是,基于这一模型设计,我们可以从 meta 信息中,阅读每一个字段关于自己的全部逻辑。这种设计的思路很清晰,就是字段本身的逻辑应该放在字段的旁边,集合在一起,阅读关于字段本身的业务逻辑,只需要关注这一处代码,而不需要跨多个上下文去理解。要了解一个字段的全部逻辑,基本上可以在对应的meta中获得全部信息(必要的时候,需要阅读整个模型的相关方法,找出多个字段有关联逻辑的业务)。阅读这段代码,你不仅能理解代码本身的意思,而且还能掌握业务的知识。
你可能会想,这些字段要怎么用。不要着急,到目前为止,我们只关心业务,不关心界面和交互。
领域模型帮我们描绘了有关这个业务的核心对象的各种逻辑,但是,同一个业务实体可能会面对很多场景,不同场景下,可能存在一些特定的转化逻辑,这就需要我们在领域模型的基础上,提供对应场景的服务。
简单讲,你可以把领域服务想象成领域模型实例的处理工厂,在这些处理中,我们是为了描述特定场景下的业务需求,所以,领域服务仍然是业务描述,和 UI 无关。
一般而言,我们在不需要跨领域的时候,就不需要领域服务。
怎么讲?在领域模型的分类中,除了实体、值,还有一类叫“聚合”的模型,大部分情况下,在聚合中我们就可以调动子模型完成各种处理,因此,如果通过聚合就可以完成不同场景的业务处理需求,我们就不需要领域服务。但是,假如实在没有办法,我们就应该考虑用领域服务完成业务描述。
还是以上面的例子为例,同样是订单,可能会有创建和编辑两种业务场景。编辑的时候,和新建稍有不同,需要从服务端接口拉取数据,并填充,而创建时则不需要。这也就意味着,相同的领域模型,具有多态性。如何解决呢,我们可在领域模型之上,提供领域服务,用以在不同场景下进行调用。
此处的处理方式有两种,一种是直接对类进行扩展,编辑的时候,使用扩展的类,比如:
class OrderService {
static toEdit(Order) {
return class OrderEdit extends Order {
constructor() {
super()
ajax.get('...').then(data = this.fromJSON(data))
}
}
}
}
// 使用
OrderService.toEdit(Order)
另一种方式是直接在服务内对实例进行数据填塞。例如:
class OrderService {
static recoverOrder(order, order_id) {
ajax.get(`.../${id}`).then(data => order.fromJSON(data))
}
}
总而言之,领域模型是相对比较普遍的业务描述,而领域服务是相对比较特殊的业务描述。
另外,一般来讲,服务需要遵循无状态的原则,状态一般会放在领域模型中。
至此为止,我们的编码还没有涉及 UI 或交互。这其实有悖以往的编程经验,“怎么界面都还没有开始写就已经有一大堆代码了?”是的,这是我们实现目标“把业务逻辑从交互代码中解救出来“的必经之路。我们要有一层领域层专门去完成业务逻辑。领域层是静态的,描述性质的,因此,可以承载业务知识体系。
有了核心的业务逻辑了,接下来,我们就要考虑在应用中完成界面和交互,这和后端完全不同,后端实施 DDD,没有这一层,业务到 DO 就结束了,而前端则还要继续,完成人机交互的真实效果。所以从某些角度讲,前端 DDD 比后端在某些方面更复杂。
而且,在我们的产品文档中,经常会这样描述:
当用户点击“提交”按钮的时候,该订单被发送给检验员进行核对。
很明显,PM 在写这句话时,是在描述一个业务过程。“点击提交按钮”这个动作是交互层面的,它无法由后端完成,后端只能完成这个动作之后的跟随动作,也就是“订单被发送给检验员”。那么,“点击提交按钮”才能触发“订单被发送给检验员”这个业务逻辑,你能说不是业务逻辑吗?
这种事情往往有屁股坐哪里哪里就是真理的意味,后端人员不管理任何交互行为,因此,他们斩钉截铁的说“这不是业务逻辑”,其实,他们想要表达的是“这不是我们后端的业务逻辑“。这就有点变味了,产品文档中的一句话,只有一半是业务逻辑,你觉得说得通吗?所以,交互有两种,一种是界面交互,一种是业务交互。在这个例子中,“点击提交按钮”就是业务交互。“业务交互”是可以独立于界面存在的。
以上面这个“点击提交按钮”为例。你知道这个“点击”动作是一个 click 事件,但是我想问的是,你现在知道这个按钮是以什么样的界面展示的么?是红色的按钮,还是灰色的?是方角的还是圆角的?是短的,还是长条的?是不是都不清楚?在描述业务本身的过程中,这里是没有界面的,它只是一句抽象描述,对于编码而言,就是一个抽象的表达,因此,这里要建立交互模型。
什么是交互模型?
就是在没有界面的情况下,对产品文档中的业务交互进行的建模。一般情况下,交互模型会引用领域模型和领域服务,同时,它还会被用到视图层中,交给视图层使用。 说白了,站在视图层编程的角度讲,可以把交互模型和我们平时讲的“状态管理器”划一个约等于号,交互模型的实例向视图层提供状态属性和方法,属性用于视图层进行渲染,而方法用于事件回调。
在上面的例子中,我们创建这样的交互模型:
import { Controller } from 'nautil'
class OrderEditController extends Controller {
// Order 是一个关于订单的领域模型
// 当 OrderEditController 进行实例化时,会自动实例化 Order,你可以通过 this.model 读取该模型实例
// 当模型数据发生变化时,Controller 会自动更新自己所管理的视图
static model = Order
// 需要在视图层赋值
onError = null
recover(order_id) {
OrderService.toEdit(this.model, order_id)
}
async submit() {
const errors = this.model.validate()
if (errors.length) {
this.onError?.(errors.message)
return
}
const data = this.model.toData()
const res = await ajax.post('xxx', data) // 这个接口可能就是我们上面说的发送给检验员
return res
}
}
一个 Controller 自身其实并不完成各种逻辑的决定,它更多的是调用自己控制着的模型、服务等其他实体的方法,完成数据和视图的调配。
这样,我们就创建好了一个交互模型。你看它的表达是否很清晰呢?到现在为止,你还没有写任何的视图层面的代码。到目前为止,我们已经把需求文档中,有关业务的部分完全表达出来了,用领域模型和领域服务表达了业务实体及对应的处理逻辑,用交互模型表达了某些业务交互。是不是很神奇,在没有开始写界面的时候,我们就已经完成了大部分逻辑的编写。
假如你的业务系统有 PC 端和 APP 端,其中 PC 端是基于 react 的,APP 端是基于 react native 的,到目前为止,由于我们上述代码中没有任何视图层的编码,所以,我们上述的代码全部都是可以在两端复用的,但是由于 react 和 react native 视图层编程方式不同,设计稿也不一样,所以,视图层的代码,我们必须一定肯定是会有两份的。现在,业务交互逻辑都已经完成了,两端虽然需要写自己的视图层代码,但是,这些与业务相关的逻辑,却不需要再重新编写了,可以拿过来就用。你可以把两端的代码放在一个 git 仓库中,这样,就可以直接共用一份业务代码。
有人讲前端就应该是胖 UI。对于这一点我不置可否,不过在我看来,胖 UI 的前提是在剖离业务逻辑,纯界面交互的情况下讲胖 UI 才是准确的。以 react 为例,我们的一个 react 应用中有组件,有状态管理,有路由管理,这些都是应该的,但问题在于,是因为基于 react 的视图层处理导致我们的代码臃肿了,还是因为我们一边写界面交互一边处理业务逻辑把代码撑肥了呢?
回到我们文首的例子中,在我们有了建模成果后,我们可以写界面了:
import { Component } from 'nautil'
import { Form, FormItem } from '...some form library...'
import { Toast } from '...some toast library...'
class OrderForm extends Component {
constructor(props) {
this.controller = new OrderController()
this.controller.onError = Toast.error
}
onInit() {
const { id } = this.props
this.controller.recover(id)
}
async handleSubmit = (e) => {
e.preventDefault()
const res = await this.controller.submit()
const { ... } = res
// ... 做一些跳转之类的
}
render() {
return (
<Form model={this.controller.model} onSubmit={}>
<FormItem name="price" component={['input', { type: 'number' }]} />
<FormItem name="count" component={['input', { type: 'number' }]} />
<FormItem name="code" component="input" />
<FormItem name="total" render={({ value, onChange, saveMessage }) => {
return (
<span>
<input value={value} onChange={onChange} />
{saveMessage ? <span>{svaeMessage}</span> : null}
</span>
)
}} />
<button>提交</button>
</Form>
)
}
}
现在,我们在视图层,主要是对已经写好的 controller 进行操作和使用,在视图层的所有代码,基本上都是和界面与界面交互相关的,而几乎没有看到任何业务的影子。
我们基于类似的思路,可以把写好的领域模型、交互模型再次用到 react native,甚至跨一个框架,用到 vue 中去,因为它们本身和框架无关,所以你在任何框架中都可以使用它们。
然而,这里会有一个问题,不同的框架要使用这些代码,还存在一个和框架进行结合的东西,比如vue3 的响应式系统是基于 Proxy 的,react 是基于内部的 fiber 的,angualr 是基于脏检查的,这就导致不同的框架里面,你想要使用同一套代码的话,你就需要有一个把建模代码和框架的响应式系统连接起来的东西,比如 Mobx,利用 Mobx 来写 controller,将非常有利于在 vue 或 react 中使用相同一个 class,因为它提供了覆盖全框架的连接工具。
在前端这样去思考和实践,是和我们以往的一些习惯不符的,这需要我们慢慢体会。现在你并不需要立即接受这种开发思维,但是你可以先了解它,直到有一天,你突然发现,你的业务系统开始在庞大的组件网络中变得难以维护时,可以再找出这篇文章,阅读一下,获得一些思路,然后重新梳理你的代码组织。
这样的代码组织还面临一个问题,就是:模型、控制器、视图,应该放在不同的目录中,还是放在同一个目录中?这个问题还是需要根据实际的情况来看。就我个人而言,更倾向于将一个模块的模型、控制器、视图放在一个模块目录中,这个模块从某些意义上,可以从这个项目拖到另外一个需要这个模块的项目中去,你只需要在顶层的应用上,组织和使用这个业务模块。但是,在一些情况下,比如你有多端同时一起开发,那么就要好好考虑,在实践中摸索,到底应该怎么组织代码目录。