前端服务化——页面搭建工具的死与生
2020-05-05 7:52:30 UTC
侯振宇

引言

我有个非常犀利的朋友,在得知我要去做可视化的页面搭建工具时问了我一个问题:

“你自己会用这样的工具吗?”

同时带着意味深长的笑。

然而这个问题并没有如他所愿改变我的想法。早在 jquery ui、bootstrap 盛行的时代,就有过无数这样的工具,我没有用过,也不会去用。原因有一万个:

在包括我的很多前端看来,这条路上尸骨累累,甚至有很多连痕迹都没有留下。但是失败者最多的路,并不一定是死路。如果都没有抛开过头脑里的成见,没有进行过独立思考就放弃了,未免太盲目。这篇文章就当做我在求生之路上的记录。也请读者暂且忘掉所有的经验,轻装上阵,这趟旅途不会让你失望。对具体设计不感兴趣的读者可以直接阅读《生门》一章,读完那一章后或许你会迫不及待再从头读起。

起点和方向

在接下来的两章中,我们将从项目背景一直讨论到关键技术的实践。这其中既会包括各种技术也会包括产品和交互的思考。

项目的背景是,公司业务迅速扩张,有大量对内的系统页面需要搭建。而前端人力是瓶颈,所以我们希望能以服务化的方式输出前端能力,让公司内所有非前端出身但有编程能力的人都能使用这种服务快速地开发出较高质量的页面。 从产品角度来说,它的目标已经很明确了:

有了这个目标,我们就可以开始设计产品形态了。

页面分为视图和逻辑两部分,在目前组件化的大背景下,视图基本上可以等同于组件树。首先,什么样的页面编辑方式学习成本最低同时最快速?当然是所见即所得,拖拽或者编辑树型结构的数据这两种方式都可以接受。实际测试中拖拽最容易上手,熟悉了快捷键的情况下则编辑组件树更快。

接着,怎样让用户编写页面逻辑既能学习成本低,又能保障质量?学习成本低意味着概念要少,或者都是用户已知的概念。保障质量这个概念比较大,我们可以从开发的两个阶段来考虑:

为了给读者一个更直观的影响,我们暂且来看一张两张图。

页面编辑:
页面编辑
逻辑编辑:
逻辑编辑

接下来分部分细化形态,梳理关系,来得到一个明确的架构图。目前看来可先拆分成三个部分:

很容易发现这三者的关系并不是平行的。首先,IDE 在这三者中是直接给用户使用的产物,它代表着我们最终想要呈现给用户什么样的东西。对其他部分来说,它算是需求来源。

来看它和页面以及组件的的关系。我们最终希望用户在点击页面上的某个组件或者组件树上的节点时,就能查看、配置这个组件上的属性,逻辑绑定到它触发的事件上。

组建与属性

因此它对组件的需求是:组件必须暴露出自己的所有属性和事件,让外部可读。

再看 IDE 和框架的关系。用户在编写逻辑时,需要理解的概念都是属于框架的, IDE 只是编辑工具。当然 IDE 可以提供很多辅助功能,例如语法校验,例如可视化地展示逻辑与组件的绑定关系。框架为主,IDE 为辅。

最后,框架和组件的关系。这里很有意思,按技术发展的现状来说,一直都是先有组件库,才有上层应用框架。然而,组件规范其实应该是应用框架规范的一部分。举个实际例子,如果应用框架要建立全局数据源(方便做回滚等高级功能),来保存所有状态。那么组件就不再需要内部状态,只要渲染就够了,实现上简单很多。这种上层建筑与基础设施的关系,很像高楼与砖瓦。摩天大楼需要钢筋混凝土,负责烧土砖的工人一开始是想不到的。所以实施中,框架和组件库之间通常还会有适配层。优秀的架构能力就体现在一开始就看到了足够多的上层需求,提前避免了发展中的人力损耗。

理清了所有关系后,来看看整体架构:

整体架构

这其中将 IDE 底层和业务层进行了拆分,IDE 底层提供窗口、快捷键、Tab 等常用功能,IDE 上业务层才用来处理和可视化相关的内容。其中也包括为了提供更好体验,却又不适合放到组件、和应用框架中的胶水代码,例如组件属性的说明,示例等等。IDE 的架构设计将会在另一篇文章中介绍。

龙骨

整体的架构有了后,接下来就是关键技术——运行时框架的设计了。

在数据驱动的大背景下,应用框架处理的问题实际上只有一个:数据管理。其中“数据”既包括组件数据也包括业务数据,而“管理”既包括如何保存数据,也包括以何种方式让用户来读写数据。我们仍然从使用场景出发,来分析出数据管理的应用场景,最后再考虑设计实现。在前端领域内,用户对交互的需求是渐进增长的,业务的需求是渐进的,因此应用的复杂度整体看来也是渐进的。所以我们只需要明确出最简单和最复杂的情况,就可以勾勒出框架需要支持的范围了:

在这个场景中用户需要了解两件事情:

再接着看最复杂的场景,我所接触过的最复杂的前端应用都是业务关联极强的工具,例如云计算平台的控制台,客服系统的控制台,包括这个 IDE 也算。这类产品的复杂体现在两个方面:

有了这两个端点,就找到了要提供的能力的上限和下限,接下来就是框架设计中最有意思也最困难的部分了——如何提供渐进式地开发体验。这几乎也是所有优秀框架的共有的一个品质。渐进式的体验意味着用户只要了解最基本的功能就能马上开始工作,当要处理更高级的需求时才需要再学习高级的功能。更进一步话,最好这些高级功能也是用一种可扩展的机制来实现的,如中间件,学习一次机制,即可解决无限的问题。

在最简场景里可以看到,用户所需的最基本的功能就是一个可读写的,包含所有组件数据的数据源即可(以下简称组件数据源)。为了便于让用户理解,这个数据源的数据格式最好与组件树存在类似的对应关系。举个注册页面的例子,我们的组件树可能长这样:

<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 状态

这个写法可以实现需求,但有两个问题:

为何不让用户自己给想要数据的组件取名?这可以一次性解决这两个问题。

<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}){  store.set('submit', {disabled: true}) // 为了防止重复提交
  ajax({    url : 'xxx',    data: {      name: store.get('name').value,      password: store.get('password').value
    }
  }).finally(() => {      store.set('submit', {disabled: false})
  })
}

稍微好了一点,但是任何开发者都仍然会觉得这段代码太脏,它既处理了业务逻辑又处理了渲染逻辑,项目膨胀之后这样的代码不利于维护。

我们需要一种机制来分离不同类型的处理逻辑,让代码更易维护。这个出发点也正是启发后面设计的关键!

为什么这样说?让我们来看看之前谈到的复杂场景,其中提到了大量的交互状态是复杂场景的特点之一,常见的交互有:

如何分离这些交互细节?或者换个更具体的问题,你觉得用户怎样写这些逻辑会最爽?仍然以上面的场景为例子,用户当然希望他代码中的ajax一发送,按钮就自动变成 disable,一结束又自动变回来。这对我们来说不就是 ajax 状态和组件状态之间的自动映射吗?我们能不能提供一种机制让用户给 ajax 命名,同时可以写映射关系,如:

ajax('login', {name: 'xxx', password: 'xxx'})

映射关系:

function mapAjaxToButton({ajaxStates}){ // ajaxStates 由框架提供,保存着所有的ajax 状态
  return {    disabled: ajaxStates.login === 'pending'
  }
}

这样,刚才处理 ajax 的脏代码就完全分离出来了。我们再看看这个方案中几个概念的关系。

数据源架构1

打开这个思路后,你会发现几乎其他所有问题,都可以用这个方案来解了!为专有的问题领域建立专有的数据源,同时建立数据源到组件数据源的映射关系。即能扩展能力,又能分离代码。

我们再看权限控制的例子。如果用户不具有某权限时就把button disable 掉,映射关系我们可以写成:

function mapAuthToButton({auth}){  return {    disabled: !auth.has('xxx')
  }
}

非常直观。

再看表单验证状态。建立验证数据结果的数据源,让用户配置哪些组件需要进行校验,校验时机(例如正在输入或者离开焦点时)。例如:

<Input bind='name' onBlur={state => {validation.validate(state)}} />

validator 映射写法的和前面的例子异区同工,用户希望的当然是我只需要告诉你什么情况下是通过,什么不通过即可,同时也可以加上一些必要的message:

function validateRule(state) {  return {  	valid: state.value !== 'xxx',  	message: state.value !== 'xxx' ? 'success' : 'value must be xxx',
  }
}

有了输入源,接下来仍然按之前思路将验证数据源映射到组件数据源上:

function mapValidationToInput({validation}) {  const hasFeedback = validation.get('name') !== undefined
  return {    status: hasFeedback ? (validation.get('name').valid ? 'valid': 'invalid') : 'normal', 
    help: hasFeedback ? validation.get('name').message : ''
  }
}

到这里,我们已经完全看到用专属的数据源处理专有问题,最后映射到组件数据源上去所产生的效果了。它能很好地将所有将交互细节和业务逻辑划分。

我们进一步注意到,无论异步控制、表单验证还是权限,只要组件遵循某种属性命名规则,那么所有的映射函数就都可以写成固定的!

因此,如果我们为组件制定一个属性接口规范,就可以利用提供更有好的方式自动生成映射代码了。例如,规定带验证功能的表单类的属性接口必须有:

那么上面例子里面的映射函数,就只需要用户填写 validateRule 就够了,映射函数将 valid/message 字段映射到 组件的 status/help 属性上。

至此,最后剩下的处理复杂场景中的大量业务数据的这一问题也迎刃而解了,同样建立一个业务数据源,声明业务数据与组件数据的映射关系即可。

数据源架构2

讲完了逻辑的设计,最后再提一下组件的规范,正如前面所说,所有的组件状态是由应用框架保存的。这和我们现实中常见的经验相悖。现实中的组件通常是数据、行为、渲染逻辑三部分写在一起,使用 class 或者工厂方法来创建。如果是全面由框架接管,则应该打散,全部写成声明式。虽然不符经验,但是声明式的组件定义解决了《理想的应用框架》中提到的组件库的两个终极问题,“覆写和扩展”。具体可参见以开源的组件规范 github.com/sskyy/react-lego,这里不再展开。

生门!

在还没有开始项目之前玉伯就提醒过我,IDE做得再酷炫,组件做得再丰富都不是活路。可视化的集成框架真正的问题在于:虽然对没有前端能力的人来说,它更简单。但相比手写代码它缺少了灵活性,那么在用户前端能力增强后,你拿什么来补偿用户,让他仍然离不开你?这里我可以再清晰的回答一次。

任何一个有一定复杂度、会持续增长的应用最重视的,其实并不是开发速度,而是可维护性和可扩展性。 这也是框架设计者们摆在首位的事情。可扩展性的好坏取决于框架的扩展机制。在我们的上面的设计中需要扩展的有两部分,组件和功能。组件的扩展可以通过允许用户提交自定义组件来实现。功能的扩展主要由框架开发者完成,但是也可以考虑让用户能仿照异步管理数据源一样建立自己专用的数据源来解决业务专有问题。

可维护性,在数据驱动的前提下,实际上等于”框架能不能很好的回答两个问题“:

第一个问题容易解决,建立统一的全局数据源,正如我们所设计的。不仅方便调试,还可以做回滚,做应用快照等功能。

第二个问题,在已知的框架中有两种常见的答案:

一种是利用某种设计模式,让用户将数据的变化集中在一个抽象里。例如 redux 状态机中的 reducer。这种方式的好处在于直接看代码就可以了解数据所有可能发生的变化。但靠代码组织的问题在于它本身受文件系统影响,一但代码拆分不合理还是容易不好找。

另一种方式则更常见,就是运行时记录调用栈。在 《理想的应用框架》中也提到过。以”响应业务事件的声明式代码“作为基础单位,框架来控制调用流程,这样框架即可产出一个和业务事件一致的调用栈,同时因为这种一致性,无论代码拆分得多不合理,都可以展示合理的信息。但调用栈的方式也有个缺点,就是一定要运行,出问题时一定要运行到相应的那一步才能找到问题相应的信息。同时会受到循环、条件语句的影响,这在多步调试或者非幂等操作的场景下非常不好用。它只能回答“数据这次在哪里被修改了”,不能回答“数据都可能在哪里被修改”。

有没有一种方式,既是静态的,又能产出像调用栈一样的数据结构方便做辅助工具呢?当然有!语法分析就可以,它绝对准确,不受条件语句、异常等影响,甚至能做到提前预知人为错误。Rust 在提前预知人为错误这个方面上达到了一个新高度。它做到了”能编译通过就不会出错“ ,这让工程质量产生了质的提升。举个我们系统中可以理解的例子,在前面的设计中已经提到,组件是声明式的,所以数据格式是已知并且可读的,包括每个字段的类型。在实现中我们的后端使用了 graphQL 作为接口层,因此接口返回的数据结构和字段类型也是已知的,当用户在代码中调用后端接口并尝试把接口返回的数据塞到组件上来展示时,通过语法分析、变量追踪,我们就可以在“运行前”自动检测到用户是否传错了接口参数,是否把不符合组件数据格式的数据塞给了组件等等。这样强度的检测几乎可以帮我们避免日常开发中绝大多数人为失误。除了诊断,语法分析当然还能用来提供全局的依赖视图,例如哪些接口在哪些逻辑里被调用了。哪些数据被哪些逻辑修改了,会引起视图的哪些部分改变等等。可以完美地回答“数据在哪里被修改了” 。

接下来就是如何实现的问题了。稍微想想就会发现,基于手写代码的方式分析成本有点高,而且很有可能实现不了。这里面有两个点比较麻烦:

但是,我们刚刚设计的系统不是放弃了灵活性吗?用户在使用 IDE 时不需要文件系统的概念,只需要如填空一般在函数中写逻辑,所有依赖的变量也不需要自己关系,都是框架通过函数参数注入的。在这个背景下,用户逻辑的目的提前知道了,所有的入参出参的用途也提前知道了,那么要实现上述的“数据在哪里被修改了”等功能,是不是只需要追踪用户代码里的变量就够了?!上面说的难点在我们这里不存在了。

到这里,死门竟然变成了生门!“开发环境通过对逻辑使用的限制,实现了对整个应用的控制达到了 100% 的状态“!具体可以从两个方面来进一步理解:

运行时分析示例:
运行时分析示例

静态依赖分析示例:
静态依赖分析示例

想到了这里,才算真正找到了活路。文章的前半部分,我强调过从头思考,原因很简单,任何时候经验都是可能成为束缚的。就像从框架开发者的角度来说,放弃了灵活性,把自己局限在一定范围内简直是逆行倒施,但正是这样的局限才有可能在开发速度上和可维护性上带来质的飞升。

在这两年做框架开发的同时我也在做全栈教学的工作。这个过程中也发现对公司来说”授人以鱼”和“授人以渔“同样重要。因为无论教学做得多么成功,最后的产出物的质量仍然会受到受到学生的自身素质、工作内容等影响。特别是团队人员变化快时,教学的收益会特别低。而将能力服务化再提供给受众,可以抵御这种风险,因为服务自身可以不断沉淀、升级。后来在学习FBP时,与作者 J.P.Morrison 通信了解到 IBM 时代的 FBP 可视化工具的应用场景和这个项目非常像,而 FBP 当时在 IBM 内部取得了成功,他们甚至成功把全部可视化编辑的系统卖给了一家银行。这些信息也让我进一步意识到团队越大,构建上层建筑越有意义。在很多大公司里,光内部系统就有上百个,有大量复杂度在一定范围内的页面要开发,前端服务化的意义远大于我们站在自己固有的经验中所看到的程度。

到这里这一篇可以先告一段路了,之后组件库的碰到的常见问题和设计还有基于 web 的 IDE 通用架构会有另外的文章来说明。相比这些具体的技术实现,我更希望后面这些关于质变,以及如何形成质变的思考能带给读者更多收益。感谢阅读。

最后放出几张用户制作的页面:


凉风有信
全栈构建 · 文档至上 · 规范狂魔 · 代码洁癖 · 爱重构 · 懒癌 · 科幻迷 · 摩托粉

风向标 © 2020

Powered by Django