查看原文
其他

D2 分享 - ESM Bundleless 在低代码场景的实践

亦逊 云谦和他的朋友们 2022-11-10

编者按:本文由 @亦逊 授权分享,内容为第十六届 D2 前端技术论坛上的分享,作者详细介绍了 ESM Bundless 在蚂蚁集团的实践,这是面向未来的构建方案,不仅适用于本地命令行,同时也适用于搭建系统。Umi 下个版本会支持,但由于依赖内部服务,只会开源 Client 的部分。

封面图:benjaminlehman @ www.unsplash.com 。

主题介绍

今天分享的标题包含了三个关键词:ESM,即 ES Module,当前浏览器标准的模块规范;Bundleless,对应传统的 Bundle,即强调更少的打包;低代码,近两年火热的前端命题,相信大家都听说过、使用过、甚至开发过一个低代码平台,低代码的核心是借助可视化/配置的方式来提升应用的研发效率,这也是我所在的团队「云凤蝶」在努力做的方向。

在蚂蚁集团,我们有超过 36% 的中后台应用使用云凤蝶搭建,应用的平均复杂度在 20 个页面以上。云凤蝶内我们提供了丰富的 UI 资产,自研可视化画布让用户写更少的代码,对接 API 元数据一键生成表单表格,并且提供了官方的存储和模型服务。

一个搭建系统在发展中也会面临各种问题,今天主要结合在云凤蝶的实践经验,聊一聊对于一个企业级的中后台低代码平台,背后的资产加载体系是如何设计的。

接下来我会从背景、机会与挑战、设计思路和未来展望四部分来介绍资产 Bundleless 方案在云凤蝶的实践。首先是介绍云凤蝶「开放」的资产体系的背景。

云凤蝶「开放」的资产体系

在设计一个搭建系统之前,首要的问题就是组件从哪里来?一套组件库的研发成本是很高的,以 Ant Design 为例,单个组件的平均研发+测试成本至少在 2 周。而在体验技术部我们已经拥有了全球最好的一系列组件库,包括 Ant Design、AntV,要是云凤蝶能直接拿过来用我们就能节省非常多的时间和精力。

组件应该怎么拿?

常规的有两种方案,第一种平台集成这些组件库,直接管理组件的版本和打包构建。第二种方案是通过特定的组件规范能消费这些组件库,实现动态加载。

而事实上,无论再标准的中后台应用,业务中往往都会有自己的组件沉淀和能力需求,这些都无法全部内置到平台,所以低代码的资产体系必须要有开放的能力。 「将资产与平台解耦,再通过规范建立连接,这样才能打造出富有生命力的资产体系」。

NPM 导入

作为前端工程师我们都知道,NPM 社区是一个非常有活力的社区,所以我们确定了云凤蝶的资产体系的桥梁——NPM 导入。

接下来花大概不到1分钟的时间我们来演示一下 NPM 导入的过程。

在 NPM 导入面板中输入 NPM 包名和版本号后,云凤蝶就会自动解析出 NPM 包导出的 React 组件并加载,经过简单的配置就可以直接添加到云凤蝶的可视化画布中进行属性编辑。整个过程就像 npm install 一样丝滑。

刚刚到底发生哪些事呢?在 NPM 导入过程中我们会对 NPM 包进行依赖拆解和二次构建,将依赖关系和构建产物上传到 Assets CDN 上。

每次用户在云凤蝶编辑器操作中,通过感知依赖的变化,我们会处理最新的依赖关系并且根据版本进行依赖合并。

到了实际运行阶段,将合并好的依赖关系进行解析,所有模块的 js 产物通过 CDN Combo 的方式一次网络请求加载回来,再拆分给 SystemJS loader 进行模块实例化。

其中核心的部分就是依赖拆解。在依赖拆解和二次构建过程中,先从 npm registry 下载 npm 包,依据 package.json 浅层分析依赖信息,递归下载依赖,并通过 Webpack external 对每个依赖做了二次构建。

最终一个 npm 包就会得到一份依赖关系树和一系列 js 产物。

基于此云凤蝶算是完成了 Bundleless 资产体系1.0 的建设,再总结一下:

1、完全对接 NPM 生态,组件足够丰富,拒绝重复建设;
2、依赖复用,遵循 Semver 规则的版本合并;
3、包级别的按需加载,完全做到用到什么组件加载什么包;
4、实时预览,秒级部署上线,且支持 debug 调试;

下一代方案的设想

这套方案支撑了云凤蝶几百个中后台应用的搭建,但在云凤蝶发展过程中,随着搭建的中后台应用越来越多我们也发现了一些问题:

第一个是业务上的诉求,站点打开慢,尽管做了按需加载,但资产加载还成为搭建页面运行时的性能瓶颈。

对此我们也进行了分析,以官方资产 Ant Design 为例,官方提供了 62 个默认好用的组件,但实际的中后台应用中平均使用个数大约是 25 个,使用比例 40%;

而我们选取了使用频率最高的 25 个组件进行了 bundle 测试,是 640 Kb,而 antd 整体的 bundleSize 是 1.2 M(UNZIPPED),也就是说差不多有一半的冗余体积。这还只是官方资产的情况,像图表、业务线资产包等冗余体积只多不少。

所以在搭建场景中,包级别的按需加载还远远不够,细粒度到组件级别的按需加载才更适合搭建的场景。此外方案中的 SystemJS 的性能也会影响模块实例化的耗时,我们需要寻找更快的模块加载方案。

第二个是技术上的追求,我们回顾过去20年的前端发展时间线:

视图相关从 jQuery 发展出了 React、Vue 等一系列优秀的框架。构建工具也从 Webpack、Rollup 到这两年火热的 Vite、esbuild。这一切的背后还伴随着网络能力和浏览器标准规范的升级,从 HTTP/1.1 到 HTTP/2.0,从 2G、3G 到如今的 5G,从 CommonJS、AMD、UMD 等模块规范到标准的 ES Module。

而云凤蝶的资产体系的技术却依旧停留在差不多 5-6 年前,这都马上 2022 年了,显然不够 modern。面向未来的方向,我们需要向前再迈一步。

基于此,对于下一代的资产加载方案我们期望的是能达到体积上的下降和性能上的提升,核心是设计一套支持组件级别的按需加载,兼顾当前生产同时面向未来的高性能模块加载方案。最终我们将目光看向了 ES Module。

机会与挑战

于是我们开始对 ES Module 进行了调研分析,看看它的机会和挑战有哪些?

作为企业级中后台应用的搭建平台,首先我们要掌握的就是 ES Module 能够上生产环境了么?浏览器兼容性如何?加载性能相较于原先 SystemJS 怎么样?

从 CanIUse 的数据上来看,除了 IE11,Chrome、Firefox、Safari 的支持度都比较友好,满足了中后台应用浏览器的兼容性要求。同时在单模块的加载性能上对比 SystemJS,也有 1.3x -1.5x 的提升。

有了兼容性和性能上的保障后我们再来看看 ES Module 究竟是如何工作的。

浏览器从 ES Module 入口文件开始解析,找到导入语句的模块说明符,然后下载文件,继续解析,全部解析之后才会通过深度优先的后序遍历进行模块实例化

显然,在这个过程中递归的模块解析和下载会带来请求瀑布的问题,并且由于 import/export 语句的顺序要求,相比于传统的 SystemJS / UMD 等方案,无法直接做 CDN Combo。如果依赖关系层级非常深,那么不管 ES Module 单模块解析有多快,这个速度都不太理想。

面对请求瀑布的问题,我们开始调研有哪些解决方案,首先是 HTTP/2.0。

我们知道,在 HTTP/1.1 协议中浏览器在同一时间,针对同一域名下的请求有一定数量限制,超过限制数目的请求会被阻塞。以 Chrome 为例,最大并发限制是 6 个。而 HTTP/2 多路复用则允许同时通过单个连接发起多重的请求响应消息。HTTP/2 把通信的基本单位缩小为一个一个的帧,并行地在同一个 TCP 连接上进行双向交换。

下面有两个简短的视频可以演示一下 HTTP/1.1 对比 HTTP/2.0 的直观差异。看得出来,HTTP/2.0 的对并行加载的提升确实非常明显,但 js 模块依赖还是非常复杂的,大家都知道的 node_modules 黑洞,所以我们认为 HTTP/2.0 或许只能说是 ES Module 上生产必要条件但并不是标准答案。它可以解决依赖树广度的问题,但我们依然面临依赖树深度的问题。

H1 vs. H2 Performance: https://evertpot.com/h2-parallelism/

看起来 ES Module 上生产确实还是有一些阻力,回过头来我们还是找到了官方的说明,这是 V8 在 2018 年对使用 ES Module 的性能建议。

大概的意思是可以在以下两个场景中直接使用 ES Module,而不借助 Webpack、Rollup 等打包工具进行 Bundle。

1、本地研发,大家都比较好理解,像近两年火热的 Vite、Snowpack 等本地开发工具,通过对依赖的提前预构建 ESM 和本地单文件 ESM,启动和热更新都非常快,极大的提升了开发者体验;
2、生产环境上推荐小型 Web 应用,V8 在这里也对小型 Web 应用进行了解释:模块数量 < 100,依赖树层级 < 5。比如说偏展示型的静态站点,CodeSandBox 等在线 Demo 编辑场景。

那么距离 2018 年过去 3 年了,中后台低代码搭建或者说是云凤蝶应用可以么?

严格意义上来说任何中后台应用都算不上小型 Web 应用,但如果我们单看云凤蝶的资产加载,通过对云凤蝶资产的历史数据分析,我们认为通过特定的优化策略收敛复杂度,达到 V8 的建议,把资产加载体系搬到 ES Module 上是具备可行性的。

方案设计

接下来介绍我们在 ESM Bundleless 方案上是如何克服上述这些上生产环境的困难的。

Bundleless 粒度

首先是 Bundleless 的粒度,Bundleless 不是 Bundle 也不是 UnBundle,拆分的粒度决定了模块的数量级、依赖树复杂度和依赖复用程度。

拆的粒度越细,模块数量越多,但复用程度越高,整体体积会越小。

拆的粒度越粗,模块数量越少,但复用程度就越低,整体体积也会越大。

所以说,拆分粒度的选择是一个体积和数量的权衡,要么大要么多。经过我们的测试,相较于文件级别粒度的海量请求,包级别的粒度会更适合生产环境。

导出级别的依赖分析

有了包维度的拆分依据,就要分析出依赖关系,按照之前我们的做法是也同样按包维度做了分析依赖,但这次我们进一步做到了导出级别,因为之前的经验告诉我们搭建系统需要组件级别的按需加载,也就是 Treeshaking。

同时 TreeShaking 在 Bundleless 里带来的收益不仅是减少了整体 Bundle 的体积,还因为摇掉了一些不必要的依赖从而显著降低依赖关系的复杂度。

通过 AST 分析 npm 导出后,对每个导出进行独立的依赖分析,我们得到了导出级别的依赖关系结构。在这个分析过程中还会遇到复杂的一些问题,包括:CommonJS 导出、babel-plugin-import 转回 namedImport 等。

以左侧的代码为例,就可以分析得到右侧结构化的依赖关系。

依赖合并

有了所有 npm 包的导出级别的依赖关系,我们就可以根据实际的组件消费情况进行依赖关系的合并,得到一颗 TreeShaking 后的依赖关系树。可以看到,经过 TreeShaking 过后,依赖关系的复杂度已经显著降低。

当然在实际过程中还会遇到更多复杂的依赖结构,所以我们还需要设计一套可扩展的依赖合并优化策略。这里举2个例子:

1、当一个依赖节点的子节点嵌套非常深,并且所有的子孙节点都没有参与过整颗依赖树的依赖合并时,这个依赖节点就被定义为「孤立节点」,可以不拆分子依赖直接 bundle 在一起;
2、对于一个依赖的所有导出,如果都依赖了2个以上的公共子依赖,那这些公共依赖也可以 bundle 在一起;

解决了模块之间复杂的依赖合并,经过 TreeShaking 和特定策略的适当 Bundle,我们可以做到将依赖数量收敛到100,层级收敛到 5 级以内。

importmap 最后一公里

最后是构建后的模块引用路径的问题,浏览器识别 ESM 引用的时候是只支持相对路径,而不支持裸导入说明符的,也就是我们平时常写的 import React from 'react'import { Button } from 'antd'

有两种方案,一种是将带有构建 hash 的文件路径直接替换到 ES Module 的引入文件中,就像 Vite 的本地做法一样,但带来的问题是无法精准的更新有变更的依赖。如果 antd 更新了,那所有依赖 antd 的 js file 都需要更新。

第二种方案就是浏览器的标准:importmap,通过提前的声明,浏览器就能正确识别出 react、antd,一旦模块有版本更新只需要更新 importmap 就行了。

预加载优化

额外的,我们还借助 modulepreload 提前预加载最深层的子依赖,从而大幅改善加载时间,大约是 1.3x - 1.5x 的提升。

最终我们通过 npm 导出级别的依赖计算、ES Module 的 Treeshaking 、特定的依赖合并策略以及运行时的预加载优化和 importmap 真正将云凤蝶资产加载体系搬到了 ES Module 的生产环境。

未来设想

畅想未来的模块方案和前端构建方式。由于 ES Module 作为标准和 Treeshaking 的优势,未来社区上的 npm 包都会优先使用 ES Module,同时 Bundleless 甚至 NoBundle 的构建方式会有更多的场景,我们可以更好的去享受网络、浏览器标准升级带来优势。LowCode 和 ProCode 之前由于平台的天然隔离,我们只能借助 DSL 互转和微前端的方式去做混合研发,但无论这两种方式,对于依赖复用还是没有一个通用的方案,之后借助统一的 ESM 构建方式我们可以天然地解决依赖复用的问题。

同时这套方案接下去也会在 Umi next 接入,大家拭目以待。

以上就是我今天分享的所有内容,如果你也对文中提到的这些感兴趣,欢迎加入我们,一起打造更好的云凤蝶。

点击“在看”,与好友共享!


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存