作者 | 郑嘉涛(羣青)

来源|尔达 Erda 公众号

前言

上篇我们讲了故事发生的背景,也简单阐述了组件及协议的设想:

一、丰富的通用组件库。

二、组件渲染能力,将业务组件渲染成通用组件。

三、协议渲染能力,以处理复杂交互。

以及这种开发模式带来的好处:

这样的设计初衷旨在大量减少前端工作,尤其是前后端对接方面,甚至可以认为对接是“反转”的,体现在两个层面:接口定义的反转和开发时序的变化。

如果你对我们的设计思路还不够了解,可以先阅读上篇:《疯了吧!这帮人居然用 Go 写“前端”?(一)》

本篇我将更细致地介绍组件渲染和协议渲染,以及如何通过这两种渲染做到前端彻底不关注业务。

当然最后你会发现是否 REST 并非重要,重要的是合理的切分关注点,而框架只是运用切分的帮助手段。

组件渲染

具体而言,针对一个通用组件,如何完成业务逻辑?

比如说下面同样的一个卡片组件(Card),它由通用的元素构成和呈现:

cardComp:
props:
titleIcon: bug-icon
title: Title
subContent: Sub Content
description: Description

但是,通过不同的 props,可以渲染出不同的场景。

场景 1:需求卡片

kanbanCardComp:
props:
titleIcon: requirement-icon
title: 一个简单的需求
subContent: 完成容器扩容不抖动
description: 需要存储记录用户的扩容改动,通过调用内部封装的 k8s 接口以实现。

场景 2:打包任务卡片

taskCardComp:
props:
titleIcon: flow-task-icon
title: buildpack (java)
subContent: success
description: time 02:09, begin at 10:21 am ...

对于后端来说,只需要遵循通用组件的数据定义,根据组件渲染器的规则,实现渲染方法即可(需要强调的是,后端不需要知道 UI 的长相,后端面对的始终是数据)。

func Render(ctx Context, c *Comp) error {
// 1. query db or internal service
// 2. construct comp
return nil
}

在交互方面,我们也需要通用组件定义所有的操作,操作(operation)可以认为是交互的影响或者说结果。举个例子,其实查询渲染就是最基础的一种操作;而对于需求卡片来说,点击查看详情,右上角的删除、编辑等都是操作:

不过在通用组件层面,无需感知业务,定义的都是通用的 click, menu-list 等操作,由业务组件实现具体的业务。

前端在呈现层表述的交互(比如悬浮、点击、释放等),最终都会对应到通用组件定义的操作,而操作即是一次标准的组件渲染请求。可以这么思考:假设页面已经呈现在用户面前了,用户通过鼠标(也可能是触摸板)触发的浏览器交互事件,都由前端“呈现器”翻译成组件操作(operation),比如说删除操作,一旦执行操作组件便会触发重新渲染。

下面的伪代码表述了操作在渲染中的体现:

// 伪代码,精简了数据结构和条件判断
func Render(ctx Context, c *Comp, ops string) error {
if ops != "view" {
doOps()
}
// continue render (aka re-render)
return nil
}

是不是缺了点什么?没错,后端也无法凭空变出一个卡片。组件渲染必须要有输入的部分,可能是用户直接或者间接的输入。比如用户说:“我想要看 id=42 的需求卡片”,这就是直接的输入,一般会在 url 上体现。另一种情况则是间接的输入:“我想要看 status = DONE 的所有需求卡片“,那么针对某一张需求卡片而言,它所需的 id,是从另一个组件 - 需求列表中获得的。

具体这个数据怎么在组件间绑定,我们会在后续章节(协议渲染)中详细阐述。现在只需要知道,对于单个组件的渲染(也就是业务组件)而言,我们规范了开发者只需要定义组件渲染必要的输入。这是一个很有吸引力的做法,通过参数屏蔽外界逻辑,能够有效地做到高内聚和低耦合。

当然有输入就有输出(要知道数据绑定肯定是把一个组件的输出绑定在另一个组件的输入)。当然交互其有状态的特性(在协议渲染中会详细阐述),我们最终让输入输出合并在一个 state 中体现,仍然是需求卡片的例子:

kanbanCardComp:
props:
titleIcon: requirement-icon
title: 一个简单的需求
subContent: 完成容器扩容不抖动
description: 需要存储记录用户的扩容改动,通过调用内部封装的 k8s 接口以实现。
state:
ticketId: 42

最后一张大图来总结一下组件的渲染过程:

协议渲染

这里我们需要引申一个实际的问题,以 web ui 为例:当用户访问一个页面时,这个页面并非只有一个组件,比如事项看板页面,就有诸如过滤器、看板甬道、事项卡片、类型切换器等多个组件。

并且,有个头疼的问题:组件之间显然是有联动的。比如过滤器的过滤条件控制了看板甬道的列表结果。

传统的 web 开发,这些联动肯定是由前端代码来实现的。但如果前端来实现这些联动关系,显然就需要深度理解和参与业务了,这与我们整个设计思路是违背的。

这里需要我们有个清晰的认知:在实际的场景中,绝不是标准化单个组件的结构后,前后端就能彻底分离的。换言之,仅将结构的定义由后端转移到前端,只达成了一半:在静态层面解耦了前后端。

而另一半,需要我们将组件间联动、对组件的操作、操作导致重新渲染等,也能由渲染器进行合适处理,也就是在动态层面解耦前后端。

在讲组件渲染的时候我们刻意留了一个悬念:为了保持组件的高内聚低耦合,我们将组件需要的所有输入都参数化,并将输入和输出参数合称为“状态”(state)。那如何将参数、状态串联起来,完成整个页面的逻辑呢?

想想其实也很简单,我们需要有一个协议去规范定义这些依赖关系和传递方式,详见如下形式。

protocol.yaml:

// 组件初始值
component:
kanbanCardComp:
state:
// ticketId: ??
operations:
click:
reload: true
ticketDetailDrawerComp:
state:
visible: false
// ticketId: ??
operations:
close:
reload: true
// 渲染过程
rendering:
__Trigger__:
kanbanCardComp:
operations:
click: set ticketDetailDrawerComp.state.visible = true
ticketDetailDrawerComp:
operations:
close: set ticketDetailDrawerComp.state.visible = false
__Default__:
kanbanCardComp:
state:
ticketId: {{ url.path.2 }}
ticketDetailDrawerComp:
state:
ticketId: {{ kanbanCardComp.state.ticketId }}

在进行协议渲染时,首先执行 __Trigger__ 部分,操作类型的渲染会临时性地修改部分组件的状态;其次执行 __Default__ 部分,进行组件之间的数据绑定;最后会进行单个业务组件的渲染,这部分在第一篇文章中已经详细阐述。

不过最终需要将这个协议渲染之后给到前端,因为 rendering 不过只是过程数据,最终需要转化成平凡的值。以这个例子而言,(假设用户进行了卡片的 click 操作)协议最终渲染成:

component:
kanbanCardComp:
props:
// 后端组件基于 ticketId=42 渲染出的具体数据
titleIcon: requirement-icon
title: 一个简单的需求
subContent: 完成容器扩容不抖动
description: 需要存储记录用户的扩容改动,通过调用内部封装的 k8s 接口以实现。
state:
ticketId: 42
operations:
click:
reload: true
ticketDetailDrawerComp:
props:
// 后端组件基于 ticketId=42 渲染出的具体数据
...
state:
visible: true
ticketId: 42
operations:
close:
reload: true

值得强调的一点是,前端不需要知道组件之间的联动。所有的联动,都通过重新渲染来实现。这意味着,每次操作,会导致重新渲染这个协议。而从内部来说,则是先进行操作的落实(比如删除、更新),即调用确定的接口执行操作,然后进行场景的重新渲染。

简单的说就是前端每次发生操作,只要告诉后端我操作了什么(operation),后端执行操作之后立刻刷新页面,当然实际的流程会稍微复杂。

从上图中我们可以看到,每次的操作是非常“短视”的,尤其是前端可以说只需要“告诉”后端做了什么操作,别的一概无需知晓。那么就会有人问了:如果某次操作需要传递数据怎么办?比如传统的对接方式,如果要删除一个资源,前端就必须传入后端资源的 ID。那就需要讲到协议必须要有的一个特性:状态。

RESTful API 是无状态的,但是业务逻辑需要有先后顺序,势必就需要存在状态。传统的做法是由前端维系这个状态,尤其是 SPA 更是将所有的状态都维系在内存。

举个例子,比如一个编辑表单,首先打开表单之后,前端需要调用后端接口传入资源 ID 取得数据,并将数据 copy 进表单进行渲染;当保存按钮 click 触发时,需要取得表单中当前值,并调用后端 save 接口进行保存。

我们知道,当前端不关心业务时,状态的维系也随之破碎。这个状态必须要下沉到和渲染同一个位置,准确的说是协议渲染这一层(因为组件单体我们刻意设计成内聚和无状态)。



如何做到状态的下移呢?其实也非常简单,我们知道一个事实,那就是操作之前必定渲染(也就是只有访问了页面才能在页面上点击)。我们只需要在渲染的时候提前预判之后操作所需要的全部数据,提前内置在协议中;而前端在执行操作时,将协议以及操作的对象等信息悉数上报即可。当组件渲染器接收到这个协议的时候,是可以拿到所有需要的参数的(因为本来就是我自己为自己准备的),此时执行完操作后,就开启下一个预判,并重新渲染协议给予前端进行界面呈现。

下面的例子中,可以看到当用户进入第一页(currentPageNo = 1)时,我们早已料到用户会进行下一页(next)操作,就已经把这个操作所需要的参数(pageNo = 2)置于协议之中了;随后用户针对组件 paginationBar 进行了一次操作 next,操作处理时便能拿到所需数据。

components:
paginationBar:
state:
currentPageNo: 1
operations:
next:
reload: true
meta:
pageNo: 2

所谓的“早已想到”并非难事,因为各个业务组件中会定义此业务组件实现了通用组件的那些操作,我们要求在定义这些操作的时候,必须要定义这些操作所必须要的外界传入参数(之所以说外界,是因为有些业务参数在组件内部就可以自行处理,而无需依赖外部组件,比如 state 或者 props 的数据信息已经充足)。

最后针对呈现而言,还需要补充组件之间的层级关系,最终形成一个树形的关系,为了布局也需要填充一些“无意义”的组件像 Container、LRContainer 等:



不过这些都是静态的数据,可以直接放入协议,也无需渲染:

hierarchy:
root: ticketManage
structure:
ticketManage:
- head
- ticketKanban
head:
left: ticketFilter
right: ticketViewGroup components:
ticketManage:
type: Container
head:
type: LRContainer
...

暂时告一段落

我们通过组件渲染、协议渲染以及一个通用组件库完成了彻底的前后端分离。不过我们在实践中发现,很多时候彻底的前后端分离会带来一定的困难,这也是我们将认为协议承载的是场景而非页面。

如果是彻底的前后端分离,那势必整个页面甚至整个网站就应该是一个协议,因为只要跳出协议或者说页面间切换,就会有业务含义。但真实情况是,如果一个协议中有太多的组件需要编排,这个复杂编排对于开发者而言是非常繁琐的,并且这个复杂性带来的损失完全淹没彻底前后端分离带来的优势。

从务实角度出发,我们更应该实践“关注点分离”而非是彻底的“前后端分离”。在设计组件以及协议时,我们总是问自己:

  • 前端关注什么?
  • 后端关注什么?
  • 框架/协议应该关注什么?

最终我们框架选择和传统对接方式共存的形式,并且能够友好地互相操作。

比如前端在呈现一个组件的时候,可以选择“偷偷”调用一些 RESTful API 来完成特定的事情,也可以在一个页面中“拼凑“多个协议进行联动等等。

我们也发现,当大量业务逻辑能够从前端下沉到后端时,前端呈现层的逻辑将变得非常简单(数量有限的组件)。我们意外获得了多端支持能力,比如可以实现 CLI 的呈现层,也可以实现 IDE 插件的呈现层等等。

当然我们现在并没有实现这些,不过相信如果是聪明的你,实现这个不难吧~

目前 Erda 的所有代码均已开源,真挚的希望你也能够参与进来!

最新文章

  1. CentOS7安装图形界面和修改运行级别
  2. eclipse 条件断点的使用
  3. 如何在腾讯云快速构建一个Wordpress个人站点
  4. HTML相对路径 当前目录、上级目录、根目录、下级目录表示法
  5. 加入ScrollView后OnGestureListener无效的解决办法
  6. 252. Meeting Rooms
  7. 使用IE滤镜实现css3中rgba让背景色透明的效果
  8. VC维
  9. Linux下搭建Hadoop具体步骤
  10. uploadify在火狐下上传不了的解决方案,java版(Spring+SpringMVC+MyBatis)详细解决方案
  11. 2018 ACM-ICPC World Finals B.Comma Sprinkler
  12. Raft协议实战之Redis Sentinel的选举Leader源码解析
  13. 【asp.net】asp.net遍历Request的信息
  14. 【原创】大叔问题定位分享(30)mesos agent启动失败:Failed to perform recovery: Incompatible agent info detected
  15. python之asyncio三种应用方法
  16. 0003-20180422-自动化第三章-python基础学习笔记
  17. P3379 【模板】最近公共祖先(LCA)(树链剖分)版
  18. (4.21)sql server中复制查询结果集
  19. 批量下载,多文件压缩打包zip下载
  20. hdu 1163 Eddy's digital Roots 【九余数定理】

热门文章

  1. Linux上Qt旋转显示
  2. PWN环境搭建
  3. js和jq文档操作
  4. Linux 文本三剑客之 sed
  5. ansible模块及语法
  6. loadrunner奇怪问题解决:TPS中有Action_Transaction 和 vuser_init_Transaction
  7. PTA 银行排队问题之单队列多窗口服务 (25分)
  8. 什么是齐博x1标签
  9. Go iota 原理和源码剖析
  10. C++和Java中的i+++i++