搭建系统并不是一个稀奇的概念,从 Dreamweaver 开始,有大量的产品试图用装配式的开发解决应用生产的效率问题。它们面向特定场景做抽象、沉淀出最佳实践,再通过产品封装来加速整个制作过程。
比如 Dreamweaver 认为 HTML 树是不重要的,应该通过所见即所得的自由拖拽生成;比如 SwiftUI 认为移动场景下,数据的流向与更新应该被高度简化,于是提供简洁的交互来快速建立组件与模型间响应式的数据流。
前端沉重的上手门槛导致招聘培养非常困难,前端资源逐渐成为项目瓶颈,无法跟上爆发式的业务增长。现场我做了一个调查,写过前端页面的人中只有 1/3 是专职前端,剩下的大家都是被逼的没办法:服务早就写好了,但没前端资源,只能撸起袖子自己把页面做了。
via How it feels to learn JavaScript in 2016
作为高精尖产品 iPhone,通过标准化手机的制作程序,将大量生产外包。苹果有 200 多个零部件供应商,400 多道组装工序,终端的富士康郑州工厂每天可以生产 50 万部 iPhone,零部件拆分和流水线的组装大大提升产能,苹果通过这种方式书写了普通工人批量制造高精尖产品的神话。
iPhone 的供应链,红框内是富士康 via 云凤蝶中台研发提效实践
汽车行业将整车拆分为零部件,外包给专业的零部件生产商,再通过流水线的组装完成最后一步。这些组装汽车的人,可能对汽车一窍不通,但只需要在流水线,按部就班的重复再重复就行了。
传统研发 vs 搭建研发 via 云凤蝶中台研发提效实践
在如今的 Web 领域,无论是大公司还是小公司,都很少会从 span、div 标签写起,大家都会有自己的一套组件库,对于 Web 研发我们似乎也可以使用这样的思路来解决问题。
在蚂蚁集团,有 36% 的中后台应用以这种方式搭建,应用的平均复杂度在 20 个页面,上百个组件的量级。我们通过对中后台应用的抽象,结合设计规范,希望能重新定义应用的制作方式,解决三个问题:
自然搭建系统在发展中也会面临各种问题,本文主要结合在云凤蝶的实践经验,聊一聊对于一个搭建系统来说,如何通过组件化的思路应对搭建系统发展上面临的各种挑战。
每个人应该都写过最朴素的搭建系统,它的形式类似一个营销搭建系统。在业务开发中,我们会把页面中经常变化的内容抽象为模版的配置项,并在模版外进行配置,每当配置变化,模版无需重新构建和验证即可快速迭代,此时一个应用由一个巨大的模版组成,如下图 1。
随着需求复杂度的提升,搭建系统需要做更细粒度的拆分。比如双 11 有多个活动页面,页面间有一些共享模块,比如签到和抽奖。从复用和维护性的角度,我们会面向功能封装出一些业务组件,此时搭建系统也相应要进化为楼层式搭建,对业务组件做二次编排和配置。
原本的一个配置项变为配置项数组,按照顺序描述每个组件暴露的接口,此时的搭建系统已经能够承载一定对复用的需求,有了组件化的雏形,如下图 2。
再往后发展,页面出现了更自由的组合关系:导航栏、父子嵌套、弹窗、表单表格,这是典型的中后台应用。我们必须进一步拆分,并且多数组件已经无法针对具体应用重新开发,必须沉淀更细粒度的通用型组件,把原本大量内置到模版的逻辑,拆出来表达为组件间关系,才能支撑业务的持续增长,如上图 3。
从最简单的营销搭建到中后台搭建,整个组件体系在技术上有很多挑战:
NPM 是社区非常有生命力的组件中心,复用 NPM 的基础设施是一个理想的选择:
而组件的生产必须与平台解耦,再通过组件规范建立连接,这样才能快速建立组件生态,完成面向组件的拓展。
1、对组件进行抽象和建模
前面我们提到,对应用的配置可以分解对多个组件的配置。一个典型的组件编辑的场景如下,我们要能找到组件向外暴露的接口,并通过图形化的方式编辑他们。
UI 就是组件最终的渲染效果,f 是组件的实现,而 props 就是组件向外暴露的接口,以 React 的代码为例,上面的可视化编辑和下面的代码相对应:
<Button type="primary" loading={false} children="我是一个按钮" />
2、从类型出发找到组件
根据 NPM 的规范,我们可以从 package.json.typings
类型文件出发,通过 AST 解析找到所有的模块导出,并提取出其中符合 UI = F(props)
抽象的组件和属性定义。
比如在 React 技术栈下,React.Component
React.FC
React.PureComponent
都是符合要求的。
3、识别/提取组件的元信息
找到组件后,我们可以深入挖掘他的元信息,比如如下的类型声明,我们很容易推测几件事情:
/**
* @title 尺寸
*/
size: "small" | "middle" | "large";
/**
* @format icon
*/
beforeIcon: string;
children?: React.ReactNode;
将这些对元信息的推测规则沉淀下来,我们可以得到一个渲染引擎驱动的属性面板。在组件无需任何额外定义的情况下,尽可能的提升组件编辑效率。
4、一些复杂的编辑意图推断
除了上面对基础类型的简单推断,我们还可以做一些更深入的分析推断。
比如 nullable
通常用来表达可关闭的编辑意图。表格的分页属性可以配置为 Object
类型数据表达分页详细信息,也可以将值设为 false
来关闭整个分页功能,我们可以使用下面这种交互:
Table.pagination: false | Pagination
比如 unionType
通常用来表达分支情况。组件的提示信息有三种类型,每种类型都会一些特定配置项,在不同分支切换时,需要删除前一个分支的值,并为新的分支设置默认值。
tips: Text | Card | Popconfirm
在写代码的模式里,我们把依赖安装到本地,再通过 Webpack 类似的工具对文件进行打包,每当代码修改/依赖发生变化,应用会重新构建,最终发布时,应用会把所有依赖的代码打包到一起。
但对于搭建系统来说,改一行文字等 5s 显然是不能接受的,我们要提供实时预览的方案。
常见的玩法是,每个组件提前独立打 umd 包,应用只构建自己的代码,再通过 loader 远程加载所有外部组件依赖,形成一个 distMap,最终做组件渲染,这里 Map 的值就是上面我们提到的 UI = f(props)
里的 f。
{
Button: eval(request(buttonDist)),
Card: eval(request(CardDist)),
}
React.createElement(map['Button'], ...);
但把所有依赖都打包进去的方法会导致 A 被重复打包多次,如上图,如果 A、B、C 分别打一个 umd 包,应用会有三个 A 的打包体积,并且对于 React.Context
等场景还会带来不同实例数据不通的问题。
我们需要有更细粒度的模块打包方式,能够支持按照版本规则对 A 进行复用。
1、Bundless 技术方案
组件在第一次进入到系统时,会按照依赖树递归的做 NPM 级打包,并将结果存储到 Assets CDN 上。
当前端应用的依赖发生变化时,通过请求 Assets CDN,按照版本合并 A & C 的所有依赖,并通过一次网络请求加载回来,再拆分给 loader 装载到 distMap 上。
2、TreeShaking
上面的场景中,提前打包的粒度是 NPM 包级,这会导致一个问题,应用只使用了 lodash.get
但却加载了整个 lodash
,体积还是很大。如下图,使用了 antd
的 Button
Menu
Table
,最后加载了整个 antd
。
我们还需要做一些动态的处理,在应用发布时,根据应用对组件的实际使用情况,自动 TreeShaking 掉未使用的模块,创建一个虚拟的 antd
来降低体积。
这里有一些衍生的问题,如何保证依赖计算的速度、为什么 treeShaking 是安全的,以及为什么不做文件级的 Bundless?后续会有文章专门介绍。
通过一键导入 NPM 可以帮我们快速补充组件内容,但 NPM 上是相对松散的组件,距离在搭建系统上好用还有很大距离,我们需要对他们做一些封装,并且建立能持续迭代和治理的方案。
1、弹窗类组件难以使用
弹窗类组件通常有一个受控属性来控制显示隐藏,如果设为可见,会挡住其他所有组件的编辑;如果设为不可见则无法编辑弹窗本身。
我们尝试抽象弹窗组件的特性,结合编辑态大纲树选中状态这一交互做一些封装:
-
大纲树上选中 visible: true
-
取消选择 visible: false
2、dataEntry 类组件双向绑定成本过高
输入框等组件的值也是受控模式,在输入框输入后需要手动在 onChange
方法里把新的值同步回 value
上,这使得表单类组件在搭建系统下使用效率很低。
我们同样去抽象这类组件的特征,在组件接入时,让组件回答几个问题:
-
是否为表单类组件
-
表达值的属性名是什么(比如 value)
这样我们可以建立一个虚拟的 store,在 onChange
事件触发时,自动完成事件参数到 value
的数据同步。在产品上的体现就是双向绑定,用户只需要为表单类绑定一个变量,数据同步就自动完成了。
3、面向特征做能力切面的拓展
类似上面两种的封装方式还有很多,这种面向组件特征而不是具体的组件做抽象,有几个非常明显的好处:
举个例子,一个普通的头像组件,经过大量通用能力的描述可以变为带徽标的头像数组。
除了用导入 NPM 的方式生产组件,我们还可以在搭建系统沉淀一些组件吗?
1、画布组件
如果一个应用中有多个地方都使用了相似的布局,我们可以把这部分内容提取为画布组件,并像 React 那样,向外暴露一些属性,实现一处维护、多处使用的目的。
如下,我们把用户信息的展示封装为一个组件,而用户信息作为外部参数传入,这种局部复用的思路和 NPM 是一致的,只是生产组件的方式不仅局限于写代码,还可以通过搭建。
2、JSXBox 最后一公里
除了使用已有的组件进行搭建外,有些场景可能更适合用代码,比如根据数据动态嵌套渲染,或是绘制一个复杂的图表。我们可以在页面上挖一个洞,让用户写代码的方式,完成这部分定制化的需求。
它的形态有些类似 CodeSandbox,写一段 React 代码,最终和其他组件混合跑在一个页面上。
3、资产包
在持续发展中,每条业务线都会沉淀自己适用的业务资产,可能是一系列 NPM 包,也可能是我们可以提到的搭建系统内的资产。在产品形态上,我们可以引入资产大包的概念,业务线的开发可以聚合比如 UI 组件、工具函数、服务等,整合到资产包内,再发布给其他应用使用,通过这种方式完成业务资产沉淀和定向的二次效率提升。
并且无论是画布组件、代码组件、JSXBox 都是遵循同样一套组件规范,我们可以直接将搭建系统内沉淀的资产包发布到 NPM。
适合搭建的走搭建、适合代码的写代码。用户可以通过 NPM 完成各自的互相融合研发。
antd 的版本碎片分布 & antd4 changeLog
当搭建系统封装组件给应用使用时,必然会出现版本,也就必然面临版本碎片问题。版本碎片无论对组件的开发者还是使用者都是巨大的成本,对于搭建系统来说,如何解决这个问题?
一次 API 不兼容变更
我们来看一个具体的例子,Menu 早期用 Boolean 表达水平/垂直布局,但新的设计规范引入第三种布局,Boolean 无法承载,需要升级为 String 枚举,从 API 上来看,确实是一次不兼容升级。
但组件的 API 发生变化,能力并未发生变化,也就是 Menu2.0 仍然支持水平/垂直的布局能力,只是旧的 Boolean 数据不再适用新的组件实现,需要更新。
而搭建平台下,组件的使用是严格受控的,是一份结构化的数据,我们完全可以通过精准的 Codemod 来将所有旧版本的数据升级为新版本,即:
delete $props.vertical
create $props.layout: $before.vertical ? 'vertical' : 'horizontal'
通过这种方式,我们可以将大部分组件不兼容的版本升级上来,组件始终可以保持向前兼容,0 版本碎片。
分享一个数据,去年 antd 从 3 到 4 的升级,使用云凤蝶搭建的几百个应用全程无感知,只需要点一下升级等上一会就行了。
通过上面各种手段,我们实现了一个无序的 NPM 组件到搭建系统的资产的过度,在这个过程:
-
一键导入:让原来的组件都能用
-
横向封装:让原来的组件在搭建系统下更好用
同时,面向组件规范、能力切面的抽象,使得在支撑更复杂业务的同时,搭建系统本身的复杂度可以得到收敛。通过底层的各种功能模块支撑,建立起一个有生命力的资产体系。
就算搭建系统再好用,资产再有生命力,用户仍然还是要一个表单项一个表单项配置表单、表格、高级搜索,处理双向绑定、字段映射等重复工作,面对有大量规律可循的增删改查中后台应用,我们能不能抽象一些套路,把程序员从千篇一律的工作中解脱出来呢?
实际上我们观察 API 文档可以发现,接口格式和最终的表单、表格有非常大的关联关系:
这些推断来源于对 API 元信息的理解、按照能力切面为组件做的封装,以及中后台应用在设计上的最佳实践。
有了智能向导的帮助,用户只需要选择接口、勾选他们想要的字段,再做一些简单的映射配置,即可生成带有完整逻辑功能的表单、表格页面,在云凤蝶应用中,有 53% 的等效代码由机器自动生成。
The dignity of movement of an ice-berg is due to only one-eighth of it being above water.
最后以一张经典冰山图作为结尾,实际上我们看到的很多酷炫功能,为了保障他的正常运行,在看得到的冰山下,有大量看不见的工具链路、渲染引擎、设计规则在悄悄运转。
作者:绯一 ,就职于蚂蚁集团体验技术部(AFX),专注于工程领域。曾参与组件规范、构建工具、应用框架、研发平台等基础设施的研发工作。目前主要负责低代码搭建平台的资产体系设计,致力于打造下一代 Web 研发平台「云凤蝶」。
文章信息来源:阿里巴巴终端技术,如有侵权,请联系删除。
– END –
大佬观点