最近笔者在工作中,修复了一些我团队负责的 Angular 应用里关于控件 id 的 issue,正好我在从事 Angular 开发之前,使用 UI5 这个前端开发框架也工作了很多年。虽然二者都是优秀的企业级前端应用的开发框架,但二者无论是从设计理念还是开发思路上来说都有着很大的差异。
所谓『管中窥豹,可见一斑』。本文从 UI 控件元素 ID 的生成逻辑这个切入点出发,向大家分享我对这两个前端框架设计理念差异的一些理解。
我们先用 UI5 创建一个简单的 button
控件:
UI5 控件拥有对应的渲染器,比如 Button 的渲染器叫做 ButtonRenderer,负责渲染出如下图高亮的 HTML 代码,其中控件 ID 为 __button0.
对于 UI5 的开发场景来说,一个常用的需求,就是如何使用原生的 JavaScript 代码,触发 UI5 按钮控件的点击事件处理函数?
对于这个需求来说,三个 UI5 Button 控件的 ID,在渲染出的 HTML 代码里分别为 __button0, __button1 和 __button2.
很容易看出 UI5 控件 ID 的格式为:控件对应的名称前缀,再加上一个计数器。 其中控件前缀名称,例如 Button 控件 ID 的前缀为 button, 该前缀是 UI5 控件元数据的一部分:
而 UI5 控件的全局计数器,维护在字典数据结构 mUIDCounts 里,其中 key 为不同的 UI5 控件元数据里存储的前缀,value 为该类型的 UI5 控件当前的计数器值。
迄今为止,我们讨论的都是开发人员在创建 UI5 控件实例时,没有显式指定 ID 的情形。 如果开发人员通过构造函数 ID 参数,显式传入一个 ID:
则最后渲染出的 HTML 源代码里,ID 值不再包含前缀:
这个逻辑在 UI5 控件对应的原型链节点 ManagedObject 的构造函数里可以清楚地看到:
- 如果开发人员显式指定了控件 ID,则使用该 ID 渲染 HTML
- 如果开发人员没有指定控件 ID,则使用控件元数据里包含的前缀,加上全局计数器自动生成 ID
UI5 控件提供了一个工具方法,.ui.getCore().byId,能够根据控件 ID,返回对应的控件实例。 下面的代码,assert 断言语句能够成功执行:
有的朋友可能会认为, byId 方法最终会交由原生的 DOM API document.getElementById 来执行,事实并非如此。
每个创建好的 UI5 控件实例,都会被添加到 UI5 全局注册表 mElements 中。随后开发人员调用 .ui.getCore().byId 时,该方法直接从注册表 mElements 中查询并返回对应的控件实例即可,其效率高于原生 DOM API document.getElementById.
UI5 的控件实例注册过程,实现在 Core.prototype.registerElement 方法里。下图高亮的第 40705 行代码抛出的错误消息,也解释了为什么 UI5 不允许两个控件拥有相同的 ID. 方法 this.oConfiguration.getNoDuplicateIds 检测到重复 ID 后,会执行相应的处理逻辑。
Angular 虽然和 UI5 一样,也是单页面应用,并且二者都允许并重度依赖自定义控件 (Angular 里称 Component),但二者在视图设计上一个较大的差异就是,Angular Component 的视图实现于原生的 HTML 文件 (或者于内联的 HTML 字符串) 里,而非像 UI5 那样,使用 XML 或者 JavaScript 视图来实现自己的页面布局。
因此,一个前端开发人员,仅凭静态浏览 Angular Component 的 HTML 视图源代码,大致就能判断出最后渲染而成的 HTML 页面源代码:二者相差不大,Angular 没有 UI5 控件渲染器的概念。
例如,下图是 Commerce Cloud 组织架构明细页面 (Organization Unit Detail) 的 HTML 视图源代码:
这个例子使用的完整源代码如下:
html
<cx-org-card
*ngIf="model$ | async as model"
i18nRoot="orgUnit.details"
[cxFocus]="{ refreshFocus: model }"
[showHint]="true"
>
<a
actions
class="link edit"
[class.disabled]="!model.active || (isInEditMode$ | async)"
[routerLink]="{ cxRoute: 'orgUnitEdit', params: model } | cxUrl"
>
{{ 'organization.edit' | cxTranslate }}
</a>
<cx-org-toggle-status
actions
key="uid"
i18nRoot="orgUnit"
></cx-org-toggle-status>
<cx-org-disable-info
info
i18nRoot="orgUnit"
[displayInfoConfig]="{ disabledDisable: true }"
>
</cx-org-disable-info>
<section main class="details" cxOrgItemExists>
<div class="property">
<label>{{ 'orgUnit.name' | cxTranslate }}</label>
<span class="value">
{{ model.name }}
</span>
</div>
<div class="property">
<label>{{ 'orgUnit.uid' | cxTranslate }}</label>
<span class="value">
{{ model.uid }}
</span>
</div>
<div class="property">
<label>{{ 'orgUnit.active' | cxTranslate }}</label>
<span class="value" [class.is-active]="model.active">
{{
(model.active ? 'organization.enabled' : 'organization.disabled')
| cxTranslate
}}
</span>
</div>
<div class="property" *ngIf="model.approvalProcess?.name">
<label>{{ 'orgUnit.approvalProcess' | cxTranslate }}</label>
<span class="value">
{{ model.approvalProcess?.name }}
</span>
</div>
<div class="property" *ngIf="model.parentOrgUnit">
<label>{{ 'orgUnit.parentUnit' | cxTranslate }}</label>
<a
class="value"
[routerLink]="
{
cxRoute: 'orgUnitDetails',
params: model.parentOrgUnit
} | cxUrl
"
>
{{ model.parentOrgUnit?.name }}
</a>
</div>
</section>
<section main class="link-list">
<ng-container *ngIf="model.uid">
<a
class="link"
[routerLink]="{ cxRoute: 'orgUnitChildren', params: model } | cxUrl"
routerLinkActive="is-current"
>
{{ 'orgUnit.links.units' | cxTranslate }}
</a>
<a
class="link"
[routerLink]="{ cxRoute: 'orgUnitUserList', params: model } | cxUrl"
routerLinkActive="is-current"
>
{{ 'orgUnit.links.users' | cxTranslate }}
</a>
<a
class="link"
[routerLink]="{ cxRoute: 'orgUnitApprovers', params: model } | cxUrl"
routerLinkActive="is-current"
>
{{ 'orgUnit.links.approvers' | cxTranslate }}
</a>
<a
class="link"
[routerLink]="{ cxRoute: 'orgUnitAddressList', params: model } | cxUrl"
routerLinkActive="is-current"
>
{{ 'orgUnit.links.shippingAddresses' | cxTranslate }}
</a>
<a
class="link"
[routerLink]="{ cxRoute: 'orgUnitCostCenters', params: model } | cxUrl"
routerLinkActive="is-current"
>
{{ 'orgUnit.links.costCenters' | cxTranslate }}
</a>
</ng-container>
</section>
</cx-org-card>
下图是最终渲染出的在浏览器里观测到的 HTML 源代码,同上图相比差异不大。
而 UI5 XML 视图,特别是引入 Fiori Elements 之后,XML 视图的代码同最后渲染出的 HTML 源代码相比,差异巨大。因为渲染过程中,Fiori Elements 根据 OData 上的 Annotation,进行了非常多复杂的处理,后续 Jerry 的公众号会详细介绍。
比如一个 Fiori Elements 应用,只用了 7 行代码,定义了一个 Smart List:
Angular UI 不像 UI5 那样,倾向于为每一个 HTML 元素分配一个不重复的 ID. 下图是 UI5 的 HTML 源代码,能观察到不少 HTML 元素都有一个 Unique ID,而这种情形不会在 Angular 应用的 HTML 源代码里发生。
然而 Angular 有一个结构化指令 ng-template, 也具有通过 # 符号标注的 ID 属性,配合另一个指令 NgIf,能实现页面布局的条件渲染。下图是一个具体例子:
UI5 也有类似 Angular 这种 Template 设计,在 UI5 里称为 ViewFragment. 在 Fiori Elements 的框架实现里,更是重度依赖了 ViewFragment,它能作为容器,将若干逻辑上相关且具有重用可能性的 UI5 控件包裹在一起,方便多个 XML 视图重用。
以上就是笔者工作多年使用 Angular 和 UI5 后的一些感悟,希望对使用这两个前端框架工作的同行们有所帮助。