去哪儿低代码平台跨端渲染方案及落地

6,565 阅读14分钟

作者介绍

何欣宇,2021年入职去哪儿旅行,目前担任门票前端开发负责人,擅长iOS、Android以及RN技术,主导了Qunar低代码平台跨端渲染方案的设计开发以及落地工作。

一、低代码平台跨端渲染现状

去哪儿网目前的低代码平台已经搭建了上万个活动页面,包含小程序、touch和 APP 多个平台。去哪儿低代码平台是基于 Shark 框架开发。Shark 是一款有着跨平台(一套代码支持跨端渲染)、按需加载(仅加载页面配置所需代码文件)等特性的类 React 框架。有着缓存、消息中心等多种能力。Shark 和低代码平台的无缝结合,给现在低代码平台带来了跨端、“所见即所得”得等多种特性。而“所见即所得”,就是一种动态加载的功能:我们在低代码平台上配置一个页面所需组件和对应的各种属性,可以及时的在各个端上看到。

随着低代码平台的推广应用,接入了越来越多的业务的核心流程,对于加载性能上的要求越来越高。在当前阶段,低代码平台在 APP 端是利用 H5 的方式来渲染页面。但是这种方式首先需要加载 WebView ,然后才会去绘制页面,导致白屏时间比较久。

去年遇到了一个契机,门票业务在对主流程进行了大改版,当时人力相对比较紧张,而且业务侧希望页面的组件是可配置的,对于这个挑战,结合低代码平台进行了思考,代码平台天然是可视化配置的,也支持多端运行,美中不足的是在端内是以 H5 方式运行的,如果在端内支持 RN 运行,补齐性能的短板,整体来讲将会是一个很好的方案。

二、APP 端替代 HY 方案调研以及可行性分析

说到既满足灵活发版,又能跨平台,还有较高的性能来解决前面的白屏时间久和性能差的问题,要同时满足这三个特点的技术。当前状况去哪儿 APP 是以React Native 为主的;于是我们提出了一个想法:Shark 和 React Native 能否结合一下呢?结合两家之长处,即实现灵活可配,又能保持高性能和跨平台,将扩大我们低代码平台的边界,提供更多可能性,于是我们开始了 Shark 和 RN 的结合探索之旅;

首先,我们开始分析 Shark 组件和 React Native 组件之间的区别;一个 Shark 组件主要是由 JS 文件以及 Scss 文件两个文件组成。那么作为一个类React框架,它和 RN 的代码有多大的区别那?通过下图对比我们可以看到差异点(左图是 Shark 组件代码,右图是 RN 组件代码):

图片

通过上面的对比,我们可以看到 Shark 和普通 RN 代码的区别在

  • 布局名称、方式和 RN 区别较大
  • 语法树标签主要是 View,可以看到交互和文字展示都是 View ,但在 RN中是不同标签
  • 标签属性的不同,在 RN 和 Shark 中点击事件不同等
  • ......

对比完 JS 文件,那布局文件的差异又有多大那?依旧可以通过下图的对比看到差异点

图片

上面是布局Scss文件部分,可以看到区别主要集中在

  • 布局名的嵌套
  • 单位的不同
  • 属性名和RN不同

通过上面的分析观察,我们可以看到 Shark 的代码和 RN 的代码虽然具有一定的区别但是相似度还是很高的。那如果我们先手动将这些差异点修改,能否将这份代码在 APP 上运行起来?下面我们先完成第一步:修改差异点。

图片

我们将手动修改后的代码嵌入在 RN 业务组件中,通过实验得知,这段代码是可以通过编译并正常运行的。

通过这些分析和实验得知,通过修改是可以将 Shark 的代码在 RN 上运行的。在上面的实验中,我们是通过手动修改的方式来达到目的,但是在实际项目中这样做肯定是不切实际的,我们可以通过 Babel 来编写自己的转化器,来达到批量转换的目的。

三、APP端实践

方案简述

通过上面的分析可知,Shark 的核心代码是可以通过 Babel 转换为 RN 的代码并在 APP 上直接运行的。在整个过程中是“代码转化”和“运行时能力提供”两个部分。通过下面的图,我们可以看到整体流程

图片

编译

整个编译时期我们的任务就是将 Shark 的源码转化为 RN 可以直接使用的代码。我们利用 Babel 编写了自己的工具:shark-cli,通过这个工具我们实现代码的适时转换。

- JS文件

JS 文件主要处理包括语法树(标签的替换、布局抹平、标签属性替换等)和JS( document 等的处理)两个部分。针对其中几个主要的问题展开讨论

标签以及属性的转换:

Shark 中绝大部分标签都是 View ,但是在 RN 中不同,RN 中不同的标签会承担不同的功能。比如在 Shark 里 View 还可以接收点击事件,但是在 RN 中只能是 TouchableOpacity 等少数组件。针对这一情况我们根据一些属性,当发现是一些特殊组合的时候就会在代码中替换组件。通过下面的映射表,我们将不同标签和属性的组合映射到 RN 中对应的标签和属性。

shark组件RN组件
ViewView、TouchableOpacity(当有onClick时转换为此组件)
InputTextInput
TextText
ImageImageImage + TouchableOpacity (当有onClick时转换)
ScrollViewScrollView

import的处理:

和 Shark 不同,RN 需要将使用到的标签、组件显示的引入并指明它在哪个库当中,比如我们经常遇到的下面的代码。

图片

为此,我们在代码转换时,准备了一个映射表,里面针对 react-native 的组件,可以直接 import 。但是这样并不能很好的支持,因为三方库和标签并不一样并不能枚举,为了解决这个问题我们提供了另一个能力,支持在标签上新增了两个属性,指定标签名称和来源来达到这一目的

图片

嵌套布局的抹平:

整个嵌套算是 Shark 和 RN 上分歧最大的地方,布局上要将Shark多种写法统一成 RN 的写法,其次就是要将 Shark 嵌套的布局在 RN 上抹平。过程如下图所示。

图片

对于不同的 class 或者 style 写法,在 babel 中都是不同的节点要单独处理,对于不同的节点我们应用不同的规则。我们收集到统一的格式之后,就可以运用一个规则去处理抹平。在 scss 文件处理的过程中,嵌套文件拿到的最后的属性名都是多层拼接完成的,比如 styles. 层级1_层级2_层级3,但是在 js 文件中处理完的都是 styles. 属性名,这就引出了嵌套布局抹平的问题。我们维护了一个当前布局层级的栈,我们在每一层 View 进入的时候入栈,记录一次布局名称,每一层 View 结束的时候作为出栈。在前面处理 scss 文件时,我们拿到了所有布局的嵌套关系,根据这个栈和我们拥有的嵌套关系去遍历,去匹配是否有布局嵌套,如果有就替换如果没有则进行下一次匹配。通过这种方式我们来解决嵌套布局的问题。

- Scss文件

图片

scss 文件和 RN 使用的布局属性,其实差异不大。我们最重要的是处理类型名的嵌套,整个的转换我们分为两步,每一步去处理不同的问题。

第一步:将 scss 文件转换为 css 文件。在转换的同时,我们将单位 rem 删除、嵌套的类名抹平,这时得到了我们想要的中间文件 .css 文件。

第二部:将 css 文件转换为 RN 的布局文件即 style.js 。在这一过程中,我们要记录所有嵌套布局的路径给将来index.js去处理嵌套布局。同时为了解决属性上的问题,我们通过配置文件将不支持的属性删除,并替换不同属性值的问题。通过这个方式我们获取到 RN 可使用的一个 object 对象并保存为一个 style.js 的文件。

整体 scss 文件的转换思路如下图所示

图片

Babel详解

编写自己的Babel插件

AST

整个工作流程可以描述为 AST → visitor 修改 AST→ 获取目标代码。在这其中,理解清楚 AST 十分重要,我们之所以需要将代码转换为 AST 也是为了让计算机能够更好地进行理解。我们可以来看看下面这段代码被解析成 AST 后对应的结构图:

以这一行代码为例子,它的语法树如下

图片

所有的 AST 根节点都是 Program 节点,从上图中我们可以看到解析生成的 AST 的结构的各个 Node 节点都很细微,github.com/babel/babyl… (不过这个文档并没有说明具体输出的样式,有时同一个节点,输入不同参数输出的代码可以差距非常大,尤其是在格式化时这点就非常重要)这个文档对每个节点类型都做了详细的说明,你可以对照各个节点类型在这查找到所需要的信息。通过astexplorer.net/ 可以有效的观察代码对应的节点,以及节点的各种属性关系。熟悉了 AST 之后,就可以通过 Visitor 来遍历节点,更改我们想要的代码。

Visitor

image.png

在 visitor 中引入了 path 的概念,它中包含了节点的信息以及节点和所在的位置,以供对特定节点进行操作。不仅包含了当前节点的信息,也有当前节点的父节点的信息,同时也包含了添加、更新、移动和删除节点有关的其他很多方法。具体地,Path 对象包含的属性和方法主要如下:

image.png

整个 visitor 的过程,可以简述为通过修改 path 来改变 AST 语法树的过程。

image.png

如上所示,我们在修改的过程中针对 path 对替换或者修改,生成新的节点,就可以达到我们的目标。以我们这次的代码为例子,当发现 onClick 属性时,我们要将 View 标签替换为 TouchableOpacity 标签,onClick 替换为 onPress 。

image.png

通过语法树分析,onClick 在语法树中的层级是 JSXOpeningElement → attributes → JSXAttribute → JSXIdentifier → name。这时才找到了 name = "onClick"。

image.png

此时我们在 visitor 中找到 JSXIdentifier 并通过 path 找到 name 

image.png

我们找到了对应的节点后,问题就是要替换成什么。通过上一步分析语法树的方式我们可以知道,onClick 转换为 onPress ,语法树上只是对应的 name 有变化。此时我们需要生成一个新的 JSXIdentifier 类型的节点。对应到我们的插件里如下图所示:

image.png

这样,我们就达到了转换的目的。通过总结不同的节点的替换和修改,就能达到修改语法树,转换代码的目的。

运行时

简述

拿到了通过编译态转换好的 RN 代码后,就是运行时要做的工作了。运行时的主要工作可以分为和低代码平台通讯获取需要渲染哪些组件、获取这些组件的实例进行组装,最后进行渲染。整体工作流程如下图所示。

image.png

可以把整个容器可以理解为一个 RN 业务组件。这个容器包含了缓存、动态加载、页面的绘制、提供 Shark 能力等几个部分。

提供Shark能力

整个 Shark 的能力包括几个部分:ability、core 以及 Qunar 特性,三个部分。其中 ability 是 shark 和 core 是 Shark 的核心能力。他们包括了如下能力:

  • message
  • request
  • jump
  • logger
  • ……

在运行时,我们要提供这些能力,让转换后的代码能够在 RN 上无缝运行。

缓存

如果每次进去页面都需要实时获取页面配置信息,这样很影响用户体验。针对这个问题,我们设计了一套缓存策略,让用户可以无感知的更新配置。我们缓存了不同页面的配置信息,在用户进入页面时可以通过直接读取缓存,省下了等待接口的时间。在用户进入页面的同时,去获取最新的配置信息来更新缓存内容。缓存包括内置配置文件和 cache 的二级缓存系统,当进入页面时会实时更新 cache ,如果有 cache 则优先读 cache ,否则就读取内置配置文件。整体流程如下

image.png

动态加载

有了上面两点能力,我们就可以实现在文章开头提到的动态加载的能力。由于 RN 只能渲染已经加载完的代码,为了达到这个目标,在容器端获取页面的配置信息,通过这个配置文件,我们能够获取到页面的组件信息,包括需要哪些组件、每个组件的属性,由于 RN 框架限制做不到去动态的加载一个新的组件,只能加载已经打包的组件,否则还是要去更新版本。通过一个专门的组装器,注册组件、解析组件属性并赋值。通过这个组装器,我们来选择对应的页面的对应的组件,来动态加载组件或更新组件信息。我们又借助上面提到的缓存的能力,来减少了用户的感知时间。

image.png

在获取到了页面配置信息之后,我们将配置信息交给容器。容器可以通过组件名称来找到需要渲染的组件。容器可以通过这个配置文件实现组件的选择、props的传递,来达到低代码平台组件所见即所得的功能。

成果展示

编译态代码转换结果。左图是低代码平台组件的源码,右图是经过编译态转换后的结果。

image.png

最后实际效果: 下方图 1 可以看到我们在低代码平台上配置的信息,图 2 是对应 APP 内页面的截图。可以看到左侧在平台上配置各个组件,最后可以做到在平台上配置组件属性和组件修改,在 APP 内部可以即使生效以及组件的灵活上下线。

image.png

(图1)

image.png

(图2)

针对我们开始提到的性能问题,根据TTI监控指标,我们可以看到,P90 和 P50 的平均时间都在 2 秒之内的,这样看整体的方案是达到了我们最初的目标。

image.png

四、未来规划

经过在实际业务线两个页面的尝试,整个低代码平台的跨平台能力已经经受了实际项目的验证。但是在开发人员实际使用的过程中还是有可以优化的地方,来提升开发人员的转换效率。将来考虑能将一些属性或者能力做成配置开放给开发人员,这样能做到更加个性化。

现在只有一个业务线尝试过这个功能,但是整个低代码平台其实是很大的一个平台且使用业务线众多,后面也要考虑针对业务线的推广,来扩大使用人群。