写Open UI5源代码解析这个系列,断断续续已经到第 100 篇了。这次想聊一个很多人不太在意、甚至在 UI5 官方文档里都一笔带过的视图实现:TemplateView.js。我之所以专门挑它出来写,是因为它在老项目里的出镜率并不低,很多从模板字符串渲染页面的代码,最终都会追溯到这个文件。如果你正在维护一个有些历史的 UI5 应用,或者碰巧在代码库里看到类似new TemplateView({...})的写法,这篇文章就是给你准备的。我会从源码模块定义出发,把它的初始化逻辑、模板渲染流水线、与 XMLView/JSView 的差异,以及实际使用时踩过的坑,一层层说清楚。
1. 为什么框架里会留着 TemplateView 这个"老家伙"
1.1 一个被多数人忽略的视图类型
UI5 的 MVC 体系里,视图类型的数量比大多数人以为的要多。日常开发最常见的是 XMLView,其次是 JSView。JSONView 偶尔出现在配置化页面里。而 TemplateView 几乎是透明人,官方文档里往往是简单几句"不推荐使用",然后就没了下文。但这不代表源代码里没有值得看的东西。
我第一次注意到 TemplateView 是在一个历史报表项目里,页面结构很简单:顶部几个筛选框,下面一张大表。当时代码没有用 XMLView,而是把一个很长的表格模板放在后端返回的 HTML 文件里,前端拿到之后直接塞进视图容器。维护的人说,这种写法是项目早期的规范,因为那时候 XMLView 的定义能力还不完善,很多页面都是后端拼 HTML,前端只负责把模板和数据粘在一起。这个把模板字符串变成视图对象的类,就是 TemplateView。
其实很多从那个年代走来的 UI5 项目,都有类似的"混血"结构:视图层用字符串模板做整体骨架,控制器负责取数,然后通过模板占位符把数据填进去。TemplateView 就是这套结构的承载者。读懂它,不仅能看懂老代码,也能理解 UI5 的视图抽象到底包含了哪几层能力。
1.2 它一度存在的理由
TemplateView 存在的核心理由,是解决"把一段预生成的 HTML 模板快速挂到 MVC 视图生命周期里"的问题。它不像 XMLView 那样需要经过 XML 解析器编译,也不像 JSView 那样要求你用 JavaScript API 逐个创建控件。它接受一个字符串模板,配合当前视图的数据模型,在渲染阶段输出对应 HTML。这在早期企业应用中非常实用,尤其是后端技术比较强势、前端只做展示层的项目。服务端渲染出来的模板片段,经过 TemplateView 包装,就能拥有普通 View 的 controller、生命周期、数据模型,让现有代码统一走 MVC 流程。
当然,它也有明显的边界:模板内部不能像 XMLView 那样声明控件 id 和事件绑定,所有的交互都要等渲染完成后再手动处理。数据绑定方面,它只能做一次性注入,模型变化后不会自动重建 HTML。这也是后来被 XMLView 全面替代的原因。可理解这些边界,反而能解释很多历史代码里的"怪写法":为什么模板渲染完后还要单独跑一段onAfterRendering?为什么修改数据后要在控制器里手动刷新整个视图?答案都在 TemplateView 的设计取舍里。
1.3 模板驱动与控件驱动的根本分歧
想真正理解 TemplateView,必须抓住"模板驱动"和"控件驱动"的分歧。XMLView 的思路是:你声明一棵控件树,框架负责把控件树生成 HTML,并管理控件状态。TemplateView 的思路是:你给一段 HTML 字符串,框架只负责把字符串插入输出流,它不知道也不关心字符串里有什么。前者像是给你一套乐高积木,你搭出房子,积木本身有拼接规范;后者像是给你一张装修效果图,你照着贴到墙面上,效果图里的一切都只是像素。
这个分歧直接决定了后续所有行为差异。控件驱动的视图可以精确到某个控件的状态自动更新;模板驱动的视图做不到,因为 HTML 字符串一旦生成就是死的,后续只能整体替换或者靠外部代码去修补。源码里 TemplateView 的方法比 XMLView 少得多,原因不是偷懒,而是它的能力边界本就如此。
2. 源码主干:模块定义、metadata 与 init 里的初始化逻辑
2.1 从 sap.ui.define 到 View.extend 的骨架
打开 TemplateView.js,第一眼看到的通常是标准的 UI5 模块定义。我用一个简化的核心骨架来演示它的结构:
sap.ui.define([ "sap/ui/core/mvc/View" ], function(View) { "use strict"; return View.extend("sap.ui.core.mvc.TemplateView", { metadata: { library: "sap.ui.core", properties: { template: { type: "string", defaultValue: "" }, templateId: { type: "string", defaultValue: "" } } }, init: function() { if (View.prototype.init) { View.prototype.init.apply(this, arguments); } this._sRenderedTemplate = ""; } }); });这里有几件事值得注意。第一,它继承了sap.ui.core.mvc.View,框架里所有视图都继承自这个基类。因此 TemplateView 天然具备getController、getModel、getViewData等 View 的能力。我们读的时候,不能只盯着 TemplateView 自己写了什么,还要知道它从父类继承了哪些。第二,metadata里定义了两个属性template和templateId,这是模板驱动视图特有的配置项,分别负责存放模板字符串和模板标识。第三,init方法里先调用父类 init,再初始化实例变量_sRenderedTemplate。这么做的目的,是确保基类在建立事件委托、初始化绑定上下文之前,自己的内部状态字段已经准备好。
2.2 metadata 属性背后的运行机制
UI5 的 metadata 不是摆设,定义在 properties 里的每一个属性都会自动生成 getter/setter。以template为例,源码如果继续往下写,就会出现这样的逻辑:
getTemplate: function() { return this.getProperty("template"); }, setTemplate: function(sTemplate) { this.setProperty("template", sTemplate); this._sRenderedTemplate = ""; return this; }getProperty和setProperty是 View 基类从sap.ui.base.ManagedObject继承来的属性访问机制。每次setTemplate除了更新属性值,还会把内部的_sRenderedTemplate清空。为什么要清空?因为模板变了,之前缓存的渲染结果就不再有意义。忽略这个细节,很容易在动态修改模板后看到旧内容闪烁,本质是缓存没刷新。
读源码时,我建议先把 metadata 过一遍,不要急着看下面的函数。metadata 能告诉你这个类的配置面长什么样,后面所有方法都是在消费这些配置。templateId的存在也暗示了一个事实:模板不一定只由字符串配置传入,也可能指向页面里的某个script标签,或者一段由外部模板引擎产出的内容。
2.3 init 里很可能还藏了一段模板读取逻辑
UI5 源码中 init 方法还容易做一件事:在初始化阶段读取默认模板信息。有的版本会访问mConfig.props.template,有的会从 DOM 中的 script 标签读取。模板字符串如果是以<script type="text/x-template">方式存放在 HTML 页面里的,init 阶段就会尝试从 document 中查找对应 id。这解释了为什么有的工程里,TemplateView 的模板块没有被渲染,却已经存在于页面源码中。查找的逻辑通常是:先找实例配置里的 template,找不到再找 templateId 对应的 script 标签,把标签内容作为模板。这一步完成后,_sRenderedTemplate才可能有值,渲染阶段才有的玩。
如果模板是通过异步请求拿回来的,init 阶段其实拿不到内容,那通常的做法是先用一个空模板完成首次渲染,等请求结束后调用setTemplate触发重绘。这里最容易出的问题就是:请求回来时视图已经被销毁,set 一个模板泄到已经销毁的 DOM 上,控制台只报一个不动声色的小错误,但页面已经完全不是你想的样子。
2.4 父类 init 与子类字段初始化的顺序问题
很多初读源码的人会忽略init中调用父类 init 的顺序。为什么不在自己字段初始化之后再调父类?因为父类 init 里可能触发了某个同步事件,比如创建绑定上下文、解析 view 数据,如果那时子类字段还是空的,而父类逻辑又恰好访问了子类扩展的方法,就可能拿到 undefined。
TemplateView 的处理思路是标准的"先长辈后自己"。先让父类把通用机制搭好,再初始化自己特有的缓存字段。这个顺序看起来简单,但在你自定义继承自 View 的类时非常有参考价值。我自己写扩展视图时就踩过一次:把子类的字段初始化放在父类 init 之后,结果父类初始化里调用了一个访问这些字段的方法,直接报错。后来翻到 TemplateView 的 init 写法,才意识到顺序问题不是偶然。
3. 模板字符串如何变成真实 DOM:渲染链路的三个关键节点
3.1 onBeforeRendering 里的缓存更新
View 的生命周期方法onBeforeRendering在每次渲染前触发。TemplateView 的渲染链路,第一步就是把当前模板解析成普通 HTML 字符串。在源码层级,这一步可能被抽成了_updateRenderedTemplate之类的私有方法,做的无非是调用getTemplate(),如果没有缓存,就走到_parseTemplate。
我们可以自己写一个类似的私有方法来模拟:
_updateRenderedTemplate: function() { if (!this._sRenderedTemplate) { var sTemplate = this.getTemplate(); this._sRenderedTemplate = this._parseTemplate(sTemplate); } return this._sRenderedTemplate; }注意:这里用空字符串作为缓存是否失效的判断,是一个常见坑。如果模板真的是空字符串,解析结果也是空字符串,那每次渲染都会重新解析一遍,浪费几个毫秒,问题不大;但如果模板里面有异步加载的数据,缓存逻辑会让你拿不到新数据。
3.2 模板解析里的占位符约定
_parseTemplate是理解 TemplateView 最容易绕晕的地方。不同历史版本对占位符的写法不太一致。有的版本支持{/user/name}这种路径,有的版本使用{#user/name}。它的原理并不复杂:用正则表达式匹配所有占位符,提取出绑定路径,再从当前 view 的 model 上取对应值,替换回字符串。
代码逻辑大致是:
_parseTemplate: function(sTemplate) { var that = this, oModel = this.getModel(), sResult = sTemplate.replace(/\{#([^}]+)\}/g, function(sMatch, sPath) { var vValue = oModel ? oModel.getProperty(sPath) : undefined; if (vValue === undefined) { vValue = ""; } return that._escapeHTML ? that._escapeHTML(vValue) : vValue; }); return sResult; }这个简略版里有两个关键点。第一,占位符里的路径是相对于模型根节点的绝对路径,很多时候源码还会在此基础上支持相对路径拼接,具体要看当前 view 的 binding context。第二,返回值是否做 HTML 转义,直接关系到 XSS 安全。如果模板内容是用户输入后在后端渲染的,并且没有转义,那就是给攻击者留了门。读这一小段源码时,强烈建议停一下:看看它是在什么时候转义、什么时候不转义。
3.3 渲染输出:RenderManager 写 DOM 与 onAfterRendering 接管事件
模板字符串解析完成后,会通过RenderManager写入 DOM。基类 View 的渲染通常会把子类生成的 HTML 塞到根节点里。TemplateView 的特点是,它写出的 HTML 可能包含动态结构,但不像控件系统那样会自动管理每个 DOM 的事件处理。
因此onAfterRendering就成了接管交互的关键位置。源码中这一段通常会遍历模板里带有>onAfterRendering: function() { View.prototype.onAfterRendering.apply(this, arguments); var oDomRef = this.getDomRef(); if (oDomRef) { this._attachEvents(oDomRef); } }
换句话说,模板里的onclick="..."这种内联事件,在 Content Security Policy 严格的页面里会被拦截;正确做法是像这里一样,渲染结束后用addEventListener执行绑定。源码里这一段的注释往往很少,因为作者默认这件事太直白了。但对使用者来说,这正是"为什么我模板里写的点击事件没反应"的答案。
3.4 绑定上下文与相对路径的暗坑
解析占位符时,还有一个容易被忽略的细节:绑定上下文。如果 TemplateView 本身放在一个列表控件里面,列表项已经有一个 binding context,那模板里的相对路径解析应该基于这个 context,而不是从模型根节点开始。
有些旧版本源码并没有自动拼接这一步,而是把getBindingContext()的结果直接作为默认上下文,在replace回调里判断路径是否以/开头:如果是,表示绝对路径,走模型根;如果不是,表示相对路径,要从 context 的路径前缀拼出来。这个逻辑不到二十行,但直接影响模板在聚合控件里能不能正确显示数据。我见过不少人把 TemplateView 放在一个列表模板里,出现所有项显示同一个值,根因就是相对路径全部解析到了模型根。
4. TemplateView 与 XMLView、JSView、JSONView 的取舍
4.1 一份直接的视图类型对比
为了不在选型时拍脑袋,我把 UI5 里常见的几种视图放在同一张表里对比:
| 视图类型 | 声明方式 | 数据绑定 | 控件事件 | 维护性 | 典型场景 |
|---|---|---|---|---|---|
| TemplateView | 模板字符串 | 一次性注入为主 | 手动绑定 | 低 | 老项目、打印模板 |
| XMLView | XML 标签 | 双向/声明式绑定 | 声明式 | 高 | 标准业务页面 |
| JSView | JavaScript API | 一般 | 手动/声明式 | 中 | 动态控件生成 |
| JSONView | JSON 结构 | 一般 | 声明式 | 中 | 配置化页面 |
表格是抽象的,实际边界会有模糊。比如 JSView 也能实现比较强的绑定能力,但代码量明显上去。XMLView 用mvc:View作为根节点,内部声明控件,绑定模型路径,框架自动监听属性变化,这是 TemplateView 做不到的。JSONView 更像一种结构化配置,适合没有复杂业务逻辑的纯展示场景。
4.2 选型判断:什么时候还能碰 TemplateView
我自己的标准很简单:新项目一律不碰,老项目能换就换。非要保留 TemplateView 的场景有三种。第一种是页面结构完全由服务端模板决定,前端只是套一层 MVC 壳,来不及或没必要重写成 XMLView。第二种是纯展示型报表,数据是一次性计算好的,不需要和用户交互频繁刷新。第三种是邮件模板或者打印模板,这些内容并不进入业务主 UI,用 TemplateView 能快速塞字符串。
反过来,如果页面需要根据用户操作局部刷新、大量表单校验、复杂表格编辑,TemplateView 会造成灾难。原因很简单,它没有控件级的内部事件管理,你需要在渲染后手动绑定、手动解绑,稍不留神就重复绑定或内存泄漏。千万不要被"视图只是模板"这句话骗了,UI5 的视图核心价值在于生命周期和模型联动,不在于省那几分钟的 XML 编写时间。
4.3 从继承源码看差异,而不只是看用法
从继承源码角度看,TemplateView 和 XMLView 都继承自 View,但它们的差异不在类层级,而在"谁来生产内容"。XMLView 的渲染管线里,XML 模板会被解析成控件树,然后由控件树渲染器输出 HTML;TemplateView 的渲染管线里,模板只是字符串,框架不对它的语法负责。这个差别带来一个使用体验:XMLView 写错了会有解析器报错,TemplateView 写错了只会输出空白或者混乱 DOM,而且很难定位。
这也是我为什么强调,读 TemplateView.js 时要看懂它的渲染上下文,而不是死记 API。源码会告诉你:这个类的职责是"尽量少做",而不是"尽量多做"。当框架源码的方法数量变少,通常意味着你需要把更多的活揽到自己手上。
4.4 性能对比:为什么 XMLView 更重却更值得
有一种误解是 TemplateView 比 XMLView 快,因为它少了解析 XML 这一步。从单次渲染看确实可能快一点,但放到真实业务里,这个优势很快被抵消。XMLView 的控件树能增量更新,用户输入一个值只更新对应控件;TemplateView 的字符串一旦生成,要么整块重刷,要么用 jQuery 去改 DOM,后者写多了就是面条代码。
而且 XMLView 有 CSS 类名作用域、控件 id 自动管理、事件委托等成熟机制,TemplateView 全都没有。性能不是单看首屏渲染时间,还要看交互响应和内存占用。用一个几分钟能写完的 XMLView 替代一个不断在onAfterRendering里补丁的 TemplateView,长远看是省钱省力的。
5. 一段可复用的 TemplateView 实战代码与踩坑记录
5.1 一个最小可运行示例
假设我们要做一个简单的员工信息卡,数据已经放到模型里:
sap.ui.define([ "sap/ui/core/mvc/TemplateView", "sap/ui/model/json/JSONModel" ], function(TemplateView, JSONModel) { "use strict"; var oModel = new JSONModel({ employee: { name: "员工A", dept: "研发部" } }); var sTemplate = "" + '<div class="employee-card">' + " <h2>{#/employee/name}</h2>" + " <p>{#/employee/dept}</p>" + "</div>"; var oView = new TemplateView({ template: sTemplate }); oView.setModel(oModel); oView.placeAt("content"); });这段代码在概念上还原了 TemplateView 的用法。真实 API 可能要求使用create()或经过sap.ui.core.mvc.View.create工厂,但核心不变:实例化、给模型、放到 DOM。这么做的好处是,你可以很快看到模板被数据填充,在页面里出现一张卡片。如果有问题,优先检查占位符语法是否和源码解析规则匹配,别急着怀疑模型。
5.2 踩坑一:模板更新后事件丢失
我实际遇到的第一个坑是:模板里有按钮,第一次渲染后点击有效,第二次更新模板后再点击就无效了。排查下来,问题出在onAfterRendering里只做了一次事件绑定,绑定的是旧的domRef里的元素。模板更新后 DOM 被替换,旧元素上的监听器自然没了。解决办法是在每次onAfterRendering时先解绑,再重新绑定;或者干脆用事件委托,把监听器挂在根节点上,通过冒泡处理子元素事件。
这个教训也适用于所有用字符串模板做视图的技术。只要 DOM 结构被整体替换,就必须考虑监听器的生命周期。我发现不少同事在这个坑里反复转圈,原因是他们习惯了 XMLView 里声明式事件绑定的自动管理,下意识认为模板也会自动处理。
5.3 踩坑二:HTML 转义导致展示内容不一致
第二个坑是转义。模板里显示用户提交的备注,源码如果没有做转义,会导致页面 HTML 被篡改;如果做了转义,那么备注里的<b>标签会被当作纯文本显示出来,业务人员会觉得"为什么加粗没了"。本质上这是安全性和展示灵活性的博弈。我在那一次处理中,采取的方案是把模板占位符做了区分:普通文本占位符默认转义,富文本占位符走专门的{!#...}语法,只允许白名单内的标签通过。
读源码的时候,可以留意一下自己手头版本支持哪些占位符,不支持的话,就只能在传数据前自己预处理,不要把逻辑写在模板里。模板里做复杂数据处理,看起来方便,后面维护起来非常痛苦。
5.4 踩坑三:模型刷新不会自动重绘模板
第三个坑和官网文档里强调的"单向绑定"直接相关。模型数据变了,XMLView 里绑定的文本会自动更新,TemplateView 不会。它没有注册模型属性的监听器,页面上显示的内容还是第一次渲染时的旧值。要让它刷新,最粗暴的方式是手动调用oView.rerender(),或者调用一个控件的invalidate()触发重绘。
但这样做的代价是整个视图全部重新渲染,如果页面里有输入框,用户刚填的内容会被清空。我的建议是,在 TemplateView 内部需要动态更新的区域,尽量预留独立的容器,更新数据时只操作这个容器,不要触发整视图重绘。这也是老代码里常见的一个优化点,值得在接手时看一眼。
5.5 调试 TemplateView 的顺序建议
如果你接手了一个用 TemplateView 写的页面,调试顺序建议是这样:先看template属性在实例化时有没有值;再看_parseTemplate解析后的字符串是什么;然后在onAfterRendering里打点,确认 DOM 挂载后长什么样。这三个点覆盖了输入、转换、输出三个环节,能快速锁定问题是出在模板字符串、模型数据还是事件绑定。
为了避免每次都去翻源码,可以在控制台里执行Object.getOwnPropertyNames(Object.getPrototypeOf(that)),把 view 原型上的所有方法打印出来,看有没有自定义的私有方法干扰了流程。这个方法比猜要快得多。
6. 源码解析的两个小技巧:别被压缩文件吓住
6.1 从模块名和继承关系找入口
读 UI5 源码时,很多文件是压缩后的,或者名字带-dbg的调试版本。我的习惯是先看文件头部sap.ui.define的依赖数组。这个数组揭示了类的继承和外部依赖,比函数体更值得先读。比如 TemplateView 继承自 View,那么 View 的生命周期方法就会被 TemplateView 覆盖或扩展。如果只盯着 TemplateView.js 里的几十行,很容易忽略它真正的行为来源。
用带-dbg后缀的版本调试是非常有效的,里面保留了完整的注释和变量名,读起来效率比压缩版高很多。网上常见源码包同时提供多个版本,优先选那条带-dbg的路径,能少走很多弯路。我自己读模板渲染逻辑时,就是把调试版源码单独拉出来,配合断点一步步走,比看文档快得多。
6.2 用控制台反向观察运行时结构
除了静态读源码,还可以在运行时反向验证。创建一个 TemplateView 实例,在控制台里打印:
var oView = document.querySelector(".sapUiView").__sap_inst; Object.keys(oView.mMetadata.properties);这样能看到框架给实例注入了哪些 property。对源码理解不够时,动态观察比反复读文档要直观得多。也可以用oView.getClassInfo().renderer查看渲染器引用的函数名,顺着渲染管线的引子往下追。这个技巧不只在 TemplateView 上有效,整个 Open UI5 框架都适用。可以说,读懂 TemplateView.js 的过程,就是学会"从运行时反推源码定义"的过程。
6.3 老代码库迁移的几条经验
最后分享几条我在迁移 TemplateView 页面时积累的经验。第一,不要一次性整体替换,先把模板里的数据绑定改成模型绑定,让 XMLView 逐步接管;第二,保留模板的渲染快照,用于对比新旧页面输出;第三,如果页面里有大量表单输入,不要用 TemplateView,尽快移走。
迁移时的顺序也很重要。我一般先从纯展示区域开始,把一段 TemplateView 模板替换成 XMLView 片段,再逐步扩大范围。这样做的好处是每一步都有可见结果,出了问题也能定位到最近一次改动。还有一点是记得查看模板里有没有依赖旧的事件绑定方式,迁移到 XMLView 后,声明式事件绑定语法可能和原来内联事件逻辑不一致,需要一并调整。这些经验不复杂,但在老代码里每条都意味着若干个不眠夜。