深入学习 SAP UI5 框架代码系列之三:HTML 原生事件 VS UI5 Semantic 事件

本系列的前一篇文章 深入学习 SAP UI5 框架代码系列之二:UI5 控件的渲染器,我们了解了 SAP UI5 引入控件渲染器的原因,以及渲染器运行时的表现行为分析。
本文将讨论 SAP UI5 控件的事件处理,全文会围绕下图表现出的差异来阐述。

首先用一个简单的例子来回顾 HTML 原生事件处理原理。
有这样一个简单的 HTML 页面,里面使用了一个 HTML 原生 button 标签,通过 onclick="copyText"注册了一个名为 copyText 的事件处理函数。

点击按钮之后,响应函数 copyText 将 Field1 的值拷贝到 Field2 去。这个通过 onclick 注册的事件处理函数,在 Chrome 开发者工具里可以直接查看。

除了 onclick 之外,调用浏览器原生的 addEventListener 方法也能给 DOM 元素注册事件。

在企业级 web 应用里,DOM 树的结构通常都不简单。例如仅仅包含一个简单 button 控件的 SAP UI5 应用,页面渲染出来后也会自动生成 5 个 div 标签:

如果每个需要响应事件的控件,都使用 onclick 或者 addEventListener 给 DOM 元素注册一个事件处理函数,随着 DOM 事件处理函数数量的增加,web 应用的性能会降低。因此 SAP UI5 引入了另一种所谓 Semantic(语义)事件的概念,来完成 UI5 控件的事件注册和响应工作。
使用 Jerry 文章 一个用于SAP UI5学习的脚手架应用,没有任何后台API的依赖 提到的脚手架,开发一个只包含 sap.ui.commons.button 的 UI5 应用:

上图的 Elements 标签页里,显示的是 SAP UI5 应用渲染完毕后,生成的 HTML 原生代码。里包含的 button 标签的生成逻辑,我们已经在前一篇文章 深入学习 SAP UI5 框架代码系列之二:UI5 控件的渲染器 里介绍过了。
我们采用与前一个原生 HTML button 例子同样的操作方式,在 Chrome 开发者工具里检查 UI5 应用里该 button 的 Event Listeners,却什么也没发现。
选中"Ancestors"前面的勾之后,一下子显示了很多条目出来:

展开条目中的 click,发现 SAP UI5 把 click 事件注册在 button 标签的父节点,即 id 为 content 的 div 标签上了,如下图所示。

再看看 UI5 应用里 sap.ui.commons.Button 的事件注册代码:

这里并没有出现 HTML 原生事件 click 的身影,而将一个包含了属性名称 press,值为 JavaScript 函数的 JavaScript 对象,作为输入参数,传入了 UI5 Button 的构造函数里:

用户点击这个按钮时,触发的应该是名称为 click 的事件,和我们在这里为 press 事件注册的处理函数有什么关系?
在 UI5 button 的实现源代码里能找到答案。切换到 Chrome 开发者工具的 Sources 标签页,快捷键 Ctrl + O,输入 button,选择第一个结果 Button-dbg.js:

这里能看到,press 作为 button 支持的事件,定义在 Button-dbg.js 里:

下面这段代码的含义是,当 UI5 button 有 click 事件发生时,如果其本身处于 enabled 并且是 visible 状态,则 fire 一个 Press 事件(this.firePress()):

因此,正是 Button 实现里的这个 onclick 函数,实现了从事件 click 映射到事件 press 的任务。

上图调试器里 168 行的 this.firePress 调用,最终如何成功地调用到 UI5 程序里针对 press 事件注册的处理函数的呢?
还记得这个系列的前一篇文章 深入学习SAP UI5框架代码系列之一:UI5 Module的懒加载机制 里介绍的一个知识点吗?
SAP UI5 运行时为所有的 Module 维护了一个注册表,以键值对的数据结构存储了这些 Module 的信息,键的数据类型为 string,值类型即 window.eval()将加载好的 JavaScript 文件内容作为输入参数,执行后返回的 JavaScript 对象。
类似的原理,SAP UI5 里每个控件都维护了一个键值对结构的事件注册表 mEventRegistry, 键的数据类型 string,存储事件名称,值类型为数组,里面存放了针对该事件,应用程序实现的响应函数。
下图展示的是我脚手架应用里的 button 控件的事件注册表,只包含一条记录,键为 press,值为一个数组,里面唯一的元素即我在脚手架应用里实现的包含了 alert 调用的事件响应函数。

下图展示的逻辑是:
(1) SAP UI5 框架从第 237 行的控件事件注册表里,根据事件名称 press,取出存放其事件处理函数的数组;
(2) 遍历该数组,在 for 循环里用 JavaScript function 原型提供的 call 方法,对这些响应函数进行调用,完成事件响应:

至此又引出了一个新的问题:button 控件的事件注册表 mEventRegistry 里的那唯一的条目,是何时填充进去的?
再回忆本系列第一篇文章里介绍的 SAP UI5 控件的原型链:
Button->Control->Element->ManagedObject->EventProvider->BaseObject.
UI5 应用里这一行语句:
new sap.ui.commons.Button()
会依次执行控件原型链上每一个节点对应的构造函数。控件事件注册表 mEventRegistry 的填充操作,就发生在 EventProvider 这个节点的构造函数里:

上图的变量 oValue,就是我 new 一个 button 实例时传入的 press 事件的处理函数。在第 1192 行代码里,调用 attachPress 将 oValue 指向的函数进行注册。函数 attachPress 最终调用 EventProvider 的 attachEvent 方法,将键值对写入 mEventRegistry:

至此有最后一个问题还未解答:本文开头部分展示的 Chrome 开发者工具里,SAP UI5 页面渲染后生成的 button 标签,在 Event Listeners 一栏里观察不到任何响应函数。而在其父节点,id 为 content 的 div 标签里,在 click 事件下却能观察到响应函数。
Button 父节点的 div 标签上的 click 方法,和本文讨论了这么长时间的 button 事件注册表里的 press 事件,到底有何关系?
按钮被点击时,查看调试器里显示的调用栈最外一层,发现 SAP UI5 的 jquery-dbg.js, 响应的是 HTML 原生的 click 事件,且触发该事件的对象的的确确是 id 为 content 的 div 标签,而不是 button 标签,这一点可以从 event.currentTarget 的值来确认。

以上图调用栈中绿色的线为分隔,绿线下方的代码,处理的是 HTML 原生的点击事件 click,同时完成了将 click 事件,经 div 投递给其子节点,button 标签的任务。
绿线上方的 Button.onclick, 前文我们已经阐述过,通过 this.firePress 将 click 事件映射成 press 事件,后续 SAP UI5 的所有事件处理,均围绕这个 press 事件进行。
按照 SAP UI5 开发团队大佬 Andreas Kunz 的介绍,button 这种 press 事件称为 Semantic 事件。同 HTML 原生的 click 事件直接通过 onclick 或 addEventListener 注册在 HTML DOM 元素上不同,Semantic event 的注册和调用都是通过 SAP UI5 框架的 JavaScript 代码施加在 SAP UI5 自行实现的控件上,比 HTML 原生的 DOM 事件处理和响应轻量得多,能避免随着 DOM 树复杂度的增加而造成的应用性能下降。

引入 Semantic 事件后,UI5 控件不直接响应 HTML 原生事件,而是通过一个叫做 UIArea 的实体,来接收用户触发的 HTML 原生事件,并将其 dispatch 给 UI5 控件,后者再将其映射成一一对应的 Semantic 事件,并调用应用程序里实现的响应函数。这里的 UIArea 可以类比成设计模式里的 Facade(外观)模式,对 SAP UI5 的应用开发人员屏蔽了底层事件映射的复杂度。
上图的 UIArea 的详细描述,在 SAP UI5 官方文档里有记载。
下图高亮的一段对 UIArea 的阐述,展开来讲就是 Jerry 本文的内容,大家感兴趣的可以移步这个链接继续阅读。

如果把本文提到的 Semantic 事件换个叫法,比如称其为虚拟事件,那么很容易联想到 Angular,Vue 和 React 里引入的 Virtual DOM(虚拟 DOM)概念。从本质上说,这些前端框架都采取增加框架实现复杂度的代价,引入一个中间抽象层,来减少直接在 JavaScript 层操作 DOM 层造成的性能开销。
顺便说一句,AngularJS 里的控件注册实现,同 SAP UI5 思路一致:同样未采取将事件处理函数直接注册到 HTML DOM 元素上的机制。
下图是一个 Angularjs 应用,第 22 行的 ng-click 指令,告诉 Angularjs 框架,超链接被点击后,根据模型字段 name,进行排序。

Angularjs 框架如何解析这个 ng-click 指令,并完成事件注册的?
在 Angularjs 应用 bootstrap 阶段,框架会遍历 HTML DOM tree,递归调用 compileNodes 方法,逐一解析每一个包含了 ng 指令的元素:

当解析到包含了 ng-click = "sortField = 'name'"的 a 标签时,调用 Angular 元素 element 的 on 方法,进行事件注册:

查看 on 方法的实现代码可知:Angularjs 也并未将事件响应函数注册到 DOM 元素上,而是同 SAP UI5 一样,在框架内维护了一个控件事件注册表,this.$$listeners(SAP UI5 的名称叫做 mEventRegistry),采用键值对的数据结构,来存储事件名称和其对应的事件响应函数。

Angularjs 应用里,事件响应函数被调用时的调用栈截图:

关于 SAP UI5 和 Angularjs 的事件处理机制比较的更多细节,可以参考我的 SAP 社区博客:
Compare Event handling mechanism: SAPUI5 and Angular
本系列下一篇文章,将会给大家介绍 SAP UI5 控件元数据的实现细节。
版权声明: 本文为 InfoQ 作者【Jerry Wang】的原创文章。
原文链接:【http://xie.infoq.cn/article/84be5ac443d72e7e8fa4556a9】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论