前端服务化——页面搭建工具的死与生
引言
我有个非常犀 利的朋友,在得知我要去做可视化的页面搭建工具时问了我一个问题:
“你自己会用这样的工具吗?”
同时带着意味深长的笑。
然而这个问题并没有如他所愿改变我的想法。早在 jquery ui、bootstrap 盛行的时代,就有过无数这样的工具,我没有用过,也不会去用。原因有一万个:
- 业务需求太灵活,工具都是基于已有的组件库,个性化的东西搭不出来。
- 前端技术发展太快,工具整合没有那么迅速。
- 有学习成本,不如手写快,灵活性没有手写高。
在包括我的很多前端看来,这条路上尸骨累累,甚至有很多连痕迹都没有留下。但是失败者最多的路,并不一定是死路。如果都没有抛开过头脑里的成见,没有进行过独立思考就放弃了,未免太盲目。这篇文章就当做我在求生之路上的记录。也请读者暂且忘掉所有的经验,轻装上阵,这趟旅途不会让你失望。对具体设计不感兴趣的读者可以直接阅读《生门》一章,读完那一章后或许你会迫不及待再从头读起。
起点和方向
在接下来的两章中,我们将从项目背景一直讨论到关键技术的实践。这其中既会包括各种技术也会包括产品和交互的思考。
项目的背景是,公司业务迅速扩张,有大量对内的系统页面需要搭建。而前端人力是瓶颈,所以我们希望能以服务化的方式输出前端能力,让公司内所有非前端出身但有编程能力的人都能使用这种服务快速地开发出较高质量的页面。 从产品角度来说,它的目标已经很明确了:
- 使用人群:非前端的开发者
- 要提供的服务:能以中上等开发速度开发出中上等可维护性页面的集成开发环境(以下简称开发环境)
有了这个目标,我们就可以开始设计产品形态了。
页 面分为视图和逻辑两部分,在目前组件化的大背景下,视图基本上可以等同于组件树。首先,什么样的页面编辑方式学习成本最低同时最快速?当然是所见即所得,拖拽或者编辑树型结构的数据这两种方式都可以接受。实际测试中拖拽最容易上手,熟悉了快捷键的情况下则编辑组件树更快。
接着,怎样让用户编写页面逻辑既能学习成本低,又能保障质量?学习成本低意味着概念要少,或者都是用户已知的概念。保障质量这个概念比较大,我们可以从开发的两个阶段来考虑:
- 一是开发时最好有保障,例如前端开发时的 eslint 加上编辑器提示就能很好地提前避免一些低级错误。
- 二是在开发完之后,代码应该“有迹可循”,无论是排查问题,还是扩展需求,都要让用户在头脑里第一时间就知道应该怎样写逻辑,写在哪里。也就意味着概念要完善,职责分明。同时,工具层面也可以有些辅助功能,例如传统编辑器的变量搜索等。
为了给读者一个更直观的影响,我们暂且来看一张两张图。
页面编辑:
逻辑编辑:
接下来分部分细化形态,梳理关系,来得到一个明确的架构图。目前看来可先拆分成三个部分:
- 一个编辑页面和逻辑的工具,以下暂称 IDE。
- 搭建页面所需的基础组件。
- 运行时框架(以下简称框架),由它将页面的组件树、和页面逻辑结合在一起渲染成最终的页面。
很容易发现这三者的关系并不是平行的。首先,IDE 在这三者中是直接给用户使用的产物,它代表着我们最终想要呈现给用户什么样的东西。对其他部分来说,它算是需求来源。
来看它和页面以及组件的的关系。我们最终希望用户在点击页面上的某个组件或者组件树上的节点时,就能查看、配置这个组件上的属性,逻辑绑定到它触发的事件上。
因此它对组件的需求是:组件必须暴露出自己的所有属性和事件,让外部可读。
再看 IDE 和框架的关系。用户在编写逻辑时,需要理解的概念都是属于框架的, IDE 只是编辑工具。当然 IDE 可以提供很多辅助功能,例如语法校验,例如可视化地展示逻辑与组件的绑定关系。框架为主,IDE 为辅。
最后,框架和组件的关系。这里很有意思,按技术发展的现状来说,一直都是先有组件库,才有上层应用框架。然而,组件规范其实应该是应用框架规范的一部分。举个实际例子,如果应用框架要建立全局数据源(方便做回滚等高级功能),来保存所有状态。那么组件就不再需要内部状态,只要渲染就够了,实现上简单很多。这种上层建筑与基础设施的关系,很像高楼与砖瓦。摩天大楼需要钢筋混凝土,负责烧土砖的工人一开始是想不到的。所以实施中,框架和组件库之间通常还会有适配层。优秀的架构能力就体现在一开始就看到了足够多的上层需求,提前避免了发展中的人力损耗。
理清了所有关系后,来看看整体架构:
这其中将 IDE 底层和业务层进行了拆分,IDE 底层提供窗口、快捷键、Tab 等常用功能,IDE 上业务层才用来处理和可视化相关的内容。其中也包括为了提供更好体验,却又不适合放到组件、和应用框架中的胶水代码,例如组件属性的说明,示例等等。IDE 的架构设计将会在另一篇文章中介绍。
龙骨
整体的架构有了后,接下来就是关键技术——运行时框架的设计了。
在数据驱动的大背景下,应用框架处理的问题实际上只有一个:数据管理。其中“数据”既包括组件数据也包括业务数据,而“管理”既包括如何保存数据,也包括 以何种方式让用户来读写数据。我们仍然从使用场景出发,来分析出数据管理的应用场景,最后再考虑设计实现。在前端领域内,用户对交互的需求是渐进增长的,业务的需求是渐进的,因此应用的复杂度整体看来也是渐进的。所以我们只需要明确出最简单和最复杂的情况,就可以勾勒出框架需要支持的范围了:
- 按业务经验,最简单的情况无非就是纯展示的“详情页” 或 “列表页”。最符合本能的逻辑写法应该是:
- 拼好组件树。如果是静态的数据,直接在每个组件上的属性里设置好即可,流程结束。
- 如果是动态数据,那么使用 ajax 获取到数据。将获取的数据格式化成组件所能接受的格式,然后使用 api set 进去。
在这个场景中用户需要了解两件事情:
- 组件的数据格式,实际上就是组件的属性,在 IDE 中已经是直接暴露出来的。
- 设置数据的 api 。
再接着看最复杂的场景,我所接触过的最复杂的前端应用都是业务关联极强的工具,例如云计算平台的控制台,客服系统的控制台,包括这个 IDE 也算。这类产品的复杂体现在两个方面:
- 有大量的交互细节,例如组件状态要和权限结合(例如 按钮的 disable 状态)、组件要根据需求动态显示或隐藏,表单的校验,异步状态的提示或管理(例如发送请求后,按钮上出现loading)。
- 除了组件数据,还有大量的业务数据要管理,并且是其中有很多联动关系。例如在云计算控制台里面有 ECS、LBS 等概念,ECS 和 LBS 有关联关系,ECS如果改名了。不仅要更新ECS自己的详情显示,还要自动更新关联的LBS的显示等。
有了这两个端点,就找到了要提供的能力的上限和下限,接下来就是框架设计中最有意思也最困难的部分了——如何提供渐进式地开发体验。这几乎也是所有优秀框架的共有的一个品质。渐进式的体验意味着用户只要了解最基本的功能就能马上开始工作,当要处理更高级的需求时才需要再学习高级的功能。更进一步话,最好这些高级功能也是用一种可扩展的机制来实现的,如中间件,学习一次机制,即可解决无限的问题。
在最简场景里可以看到,用户所需的最基本的功能就是一个可读写的,包含所有组件数据的数据源即可(以下简称组件数据源)。为了便于让用户理解,这个数据源的数据格式最好与组件树存在类似的对应关系。举个注册页面的例子,我们的组件树可能长这样:
<div>
<Title>注册</Title>
<Input label="姓名"/>
<Input label="密码" type="password"/>
<Button text="提交"/>
</div>
那么组件数据源可表述为:
{
0: {
text: '注册',
size: 'large'
},
1: {
value: '',
label: '姓名'
type: 'text',
},
2: {
value: '',
label: '密码'
type: 'password',
},
3: {
text: '提交',
type: 'normal'
}
}
用户的读写操作可以设计成这样:
// 借用 redux 中的 store 作为数据源的名字
store.get('1.value') // 读取第一个 Input 的值
store.set('3.type', 'loading') // 将 Button 设为 loading 状态
这个写法可以实现需求,但有两个问题:
- 用组件的位置作为索引不友好,不能适应变化。例如组件的位置调整了一下顺序,代码里就得相应改动。
- 在用户的业务逻辑中,并不是所有组件的数据用户都需要,例如Title。
为何不让用户自己给想要数据的组件取名?这可以一次性解决这两个问题。
<div>
<Title>注册</Title>
<Input bind="name" label="姓名"/>
<Input bind="password" label="密码" type="password"/>
<Button bind="submit" text="提交"/>
</div>
得到的数据源:
{
name: {
value: '',
label: '姓名'
type: 'text',
},
password: {
value: '',
label: '密码'
type: 'password',
},
submit: {
text: '提交',
disabled: false
}
}
再看看用户的提交逻辑如何写(这个逻辑绑定在 Button 的 onClick 事件上):
// 通过注入的方式把数据源管理对象交给用户
function(){
store.set('submit', ) // 为了防止重复提交
ajax({
url : 'xxx',
data: {
name: store.get('name').value,
password: store.get('password').value
}
}).finally(() => {
store.set('submit', )
})
}
稍微好了一点,但是任何开发者都仍然会觉得这段代码太脏,它既处理了业务逻辑又处理了渲染逻辑,项目膨胀之后这样的代码不利于维护。
我们需要一种机制来分离不同类型的处理逻辑,让代码更易维护。这个出发点也正是启发后面设计的关键!
为什么这样说?让我们来看看之前谈到的复杂场景,其中提到了大量的交互状态是复杂场景的特点之一,常见的交互有:
- 异步状态控制,如上面 button 在发请求时要设为 disable 防止重复提交
- 权限控制
- 表单验证状态
如何分离这些交互细节?或者换个更具体的问题,你觉得用户怎样写这些逻辑会最爽?仍然以上面的场景为例子,用户当然希望他代码中的ajax一发送,按钮就自动变成 disable,一结束又自动变回来。这对我们来说不就是 ajax 状态和组件状态之间的自动映射吗?我们能不能提供一种机制让用户给 ajax 命名,同时可以写映射关系,如:
ajax('login', )
映射关系:
function mapAjaxToButton(){ // ajaxStates 由框架提供,保存着所有的ajax 状态
return {
disabled: ajaxStates.login === 'pending'
}
}
这样,刚才处理 ajax 的脏代码就完全分离出来了。我们再看看这个方案中几个概念的关系。
打开这个思路后,你会发现几乎其他所有问题,都可以用这个方案来解了!为专有的问题领域建立专有的数据源,同时建立数据源到组件数据源的映射关系。即能扩展能力,又能分离代码。
我们再看权限控制的例子。如果用户不具有某权限时就把button disable 掉,映射关系我们可以写成:
function mapAuthToButton(){
return {
disabled: !auth.has('xxx')
}
}
非常直观。
再看表单验证状态。建立验证数据结果的数据源,让用户配置哪些组件需要进行校验,校验时机(例如正在输入或者离开焦点时)。例如:
<Input bind='name' onBlur=} />
validator 映射写法的和前面的例子异区同工,用户希望的当然是我只需要告诉你什么情况下是通过,什么不通过即可,同时也可以加上一些必要的message:
function validateRule(state) {
return {
valid: state.value !== 'xxx',
message: state.value !== 'xxx' ? 'success' : 'value must be xxx',
}
}
有了输入源,接下来仍然按之前思路将验证数据源映射到组件数据源上:
function mapValidationToInput() {
const hasFeedback = validation.get('name') !== undefined
return {
status: hasFeedback ? (validation.get('name').valid ? 'valid': 'invalid') : 'normal',
help: hasFeedback ? validation.get('name').message : ''
}
}
到这里,我们已经完全看到用专属的数据源处理专有问题,最后映射到组件数据源上去所产生的效果了。它能很好地将所有将交互细节和业务逻辑划分。
我们进一步注意到,无论异步控制、表单验证还是权限,只要组件遵循某种属性命名规则,那么所有的映射函数就都可以写成固定的!
因此,如果我们为组件制定一个属性接口规范,就可以利用提供更有好的方式自动生成映射代码了。例如,规定带验证功能的表单类的属性接口必须有:
* status: 'normal' | 'valid' | 'invalid'
* help : ''
那么上面例子里面的映射函数,就只需要用户填写 validateRule 就够了,映射函数将 valid/message 字段映射到 组件的 status/help 属性上。
至此,最后剩下的处理复杂场景中的大量业务数据的这一问题也迎刃而解了,同样建立一个业务数据源,声明业务数据与组件数据的映射关系即可。
讲完了逻辑的设计,最后再提一下组件的规范,正如前面所说,所有的组件状态是由应用框架保存的。这和我们现实中常见的经验相悖。现实中的组件通常是数据、行为、渲染逻辑三部分写在一起,使用 class 或者工厂方法来创建。如果是全面由框架接管,则应该打散,全部写成声明式。虽然不符经验,但是声明式的组件定义解决了《理想的应用框架》中提到的组件库的两个终极问题,“覆写和扩展”。具体可参见以开源的组件规范 github.com/sskyy/react-lego,这里不再展开。
生门!
在还没有开始项目之前玉伯就提醒过我,IDE做得再酷炫,组件做得再丰富都不是活路。可视化的集成框架真正的问题在于:虽然对没有前端能力的人来说,它更简单。但相比手写代码它缺少了灵活性,那么在用户前端能力增强后,你拿什么来补偿用户,让他仍然离不开你?这里我可以再清晰的回答一次。
任何一个有一定复杂度、会持续增长的应用最重视的,其实并不是开发速度,而是可维护性和可扩展性。 这也是框架 设计者们摆在首位的事情。可扩展性的好坏取决于框架的扩展机制。在我们的上面的设计中需要扩展的有两部分,组件和功能。组件的扩展可以通过允许用户提交自定义组件来实现。功能的扩展主要由框架开发者完成,但是也可以考虑让用户能仿照异步管理数据源一样建立自己专用的数据源来解决业务专有问题。
可维护性,在数据驱动的前提下,实际上等于”框架能不能很好的回答两个问题“:
-
数据现在是什么样的
-
数据在哪里被修改了,或者更细致地分解为“运行时告诉我数据这次在哪里被修改了”,和“开发时告诉我数据有可能在哪里被修改”。
第一个问题容易解决,建立统一的全局数据源,正如我们所设计的。不仅方便调试,还可以做回滚,做应用快照等功能。
第二个问题,在已知的框架中有两种常见的答案:
一种是利用某种设计模式,让用户将数据的变化集中在一个抽象里。例如 redux 状态机中的 reducer。这种方式的好处在于直接看代码就可以了解数据所有可能发生的变化。但靠代码组织的问题在于它本身受文件系统影响,一但代码拆分不合理还是容易不好找。
另一种方式则更常见,就是运行时记录调用栈。在 《理想的应用框架》中也提到过。以”响应业务事件的声明式代码“作为基础单位,框架来控制调用流程,这样框架即可产出一个和业务事件一致的调用栈,同时因为这种一致性,无论代码拆分得多不合理,都可以展示合理的信息。但调用栈的方式也有个缺点,就是一定要运行,出问题时一定要运行到相应的那一步才能找到问题相应的信息。同时会受到循环、条件语句的影响,这在多步调试或者非幂等操作的场景下非常不好用。它只能回答“数据这 次在哪里被修改了”,不能回答“数据都可能在哪里被修改”。
有没有一种方式,既是静态的,又能产出像调用栈一样的数据结构方便做辅助工具呢?当然有!语法分析就可以,它绝对准确,不受条件语句、异常等影响,甚至能做到提前预知人为错误。Rust 在提前预知人为错误这个方面上达到了一个新高度。它做到了”能编译通过就不会出错“ ,这让工程质量产生了质的提升。举个我们系统中可以理解的例子,在前面的设计中已经提到,组件是声明式的,所以数据格式是已知并且可读的,包括每个字段的类型。在实现中我们的后端使用了 graphQL 作为接口层,因此接口返回的数据结构和字段类型也是已知的,当用户在代码中调用后端接口并尝试把接口返回的数据塞到组件上来展示时,通过语法分析、变量追踪,我们就可以在“运行前”自动检测到用户是否传错了接口参数,是否把不符合组件数据格式的数据塞给了组件等等。这样强度的检测几乎可以帮我们避免日常开发中绝大多数人为失误。除了诊断,语法分析当然还能用来提供全局的依赖视图,例如哪些接口在哪些逻辑里被调用了。哪些数据被哪些逻辑修改了,会引起视图的哪些部分改变等等。可以完美地回答“数据在哪里被修改了” 。
接下来就是如何实现的问题了。稍微想想就会发现,基于手写代码的方式分析成本有点高,而且很有可能实现不了。这里面有两个点比较麻烦:
-
分析程序首先要理解基于文件系统的包管理机制,才能做全局的分析。
-
如果用户在代码中做了二次抽象,分析程序的复杂度会翻倍。试想分析 store.set('xxx', 'yyy') 和 分析 storemethod的复杂度。