英文原文: Client-Side Templating
在浏览器中使用模板是一个日渐热门的趋势。将服务端的逻辑应用到客户端上,还有越来越多的类MVC模式(模型-视图-控制器:model-view-controller)的使用都使得在浏览器中“模板”的角色越来越重要。在过去,“模板”从来都是服务端的事情,但事实上在客户端开发中,模板的作用是非常强大又具有表现力的。
为什么要使用模板?
大体上来说,借助模板是一种能很好地将视图(views)中标记和逻辑分开的方法,还能将代码的重用性和可维护性最大化。如果使用的是语法与最终所得结果很相近的语言(比如HTML),你就能又快又好地把任务完成了。虽然模板可以用来输出任何形式的文本,但由于我们想要讨论的客户端开发是有关于HTML的,所以在这篇文章里,我们还是以HTML作为例子。
现在的动态应用中,客户端常常需要频繁地刷新界面。这个效果可以通过服务端将HTML片段插入到客户端的文档中。这样做的话,服务器要能支持传送HTML的片段(与之相对:传送完整的页面)。还有就是,作为一个要处理这些标记片段的客户端的开发者,你应该会想能完全控制你的模板。而模板引擎(Smarty)、流量(Velocity)还有ASP这些服务器端的内容你都不用了解,也不用管那些“面条式代码”(spaghetti code):例如在HTML文档里是不是出现的臭名昭著的<?或者<%。
那么现在来看看客户端模板吧。
第一印象
对初学者而言,理解“模板”的含义很重要,foldoc(免费在线计算机词典)中的解释是:模板是一种文档,不过文档中有形参,再通过模板处理系统的特定语法用实参代替形参。
让我们来看看最基本的模板长什么样子:
<h1>{{title}}</h1> <ul> {{#names}} <li>{{name}}</li> {{/names}} </ul>
如果你写过HTML,那么你一定很熟悉上面的代码。上文的HTML中有一些占位符。这些占位符将会被真实的数据取代。例如这个对象:
var data = { "title": "Story","names": [ {"name": "Tarzan"},{"name": "Jane"} ] }
把数据和模板结合起来,就会得到下面的HTML代码:
<h1>Story</h1> <ul> <li>Tarzan</li> <li>Jane</ul> </ul>
将模板和数据分离开来对于维护HTML来说是一件好事。比如说,如果想要更改标签或者添加类(class)就只需要更改模板就可以了。另外,对于需要迭代出现的元素(比如<li>),程序员只需要写一次就好了。
模板引擎
模板的语法是根据你需要的模板引擎来决定的(例如:占位符{{title}})。引擎是负责分析模板,用提供的数据替换占位符(变量、函数、循环等等)。
有些模板引擎看起来没有什么逻辑性。这指的不是在模板中只能插入简单的占位符,而是说智能标签(intelligent tags)方面的特性很少(比如数组迭代器,条件渲染等等)。有些引擎就有很多特性和很好的可扩展性。关于这一点就不在这展开讲了,你需要问问自己,在模板中你是否需要、需要多少逻辑。
每个模板引擎都有自己的API,不过通常你都能找到像render()和compile()这样的方法。渲染的过程就是将真正的数据放入模板然后呈现出来。也就是说,渲染就是用真正的数据替代了占位符。如果在此期间木板上有什么逻辑,就会被执行。编译模板指的是解析模板,然后将它转换成一个JavaScript函数。模板中的逻辑都会被解释为纯JS(plain JavaScript),给定的数据会被传入这些JS函数中,这么做可以最大程度地优化HTML。
Mustache实例
上文中的例子可以借助模板引擎实现,例如使用了Mustache模板语法的mustache.js。关于这种语法更多信息,我会在后面告诉你的。现在先来看看下面的JS代码能得到什么效果:
var template = '<h1>{{title}}</h1><ul>{{#names}}<li>{{name}}</li>{{/names}}</ul>'; var data = {"title": "Story","names": [{"name": "Tarzan"},{"name": "Jane"}]}; var result = Mustache.render(template,data);
document.body.innerHTML = result;
第一个客户端模板就完成了!在代码文件中加入下面这句,你就可以试一试上面的例子了,或者看下在线演示
<script src="https://raw.github.com/janl/mustache.js/master/mustache.js"></script>
组织模板
如果你和我一样,不喜欢HTML文档里出现很长的内容,既造成了阅读的困难还增加了维护的负担。理想情况下,我们可以把模板分开维护,既能享受模板的语法高亮的便利,又能保证HTML的可读性。
但事情总不会十全十美的。如果一个项目中要使用非常多的模板,出于避免过多Ajax请求而影响性能的原因,我们不希望这么多文件被分开加载下来。
场景1:脚本标签
常见的解决方案就是把所有的模板直接放在<scrpit>标签中,<script>标签的可选类型要稍作更改,比如改成type=”type/template”(浏览器在渲染或解析时会将这个属性忽略)。
<script id="myTemplate" type="text/x-handlebars-template"> <h1>{{title}}</h1> <ul> {{#names}} <li>{{name}}</li> {{/names}} </ul> </script>
这样的做,你就可以把所有的模板都放在HTML文档中,避免了额外的Ajax请求。
script标签中的内容会后面被JavaScript当做模板来使用。请看下面的代码,这次我们用的是Handlebars模板引擎再结合一些jQuery,模板就用刚刚的里的。也可以直接看在线演示
var template = $('#myTemplate').html(); var compiledTemplate = Handlebars.compile(template); var result = compiledTemplate(data);
最终效果和上文的Mustache例子是一样的。Handlebars也可以使用Mustache格式的模板,所以在这里我们就用一样的模板了。不过要注意,它们之间还是有一个很重要的区别:Handlebars是先得到一个中间结果,再通过这个中间值得到HTML的。它先是将模板编译成一个JS函数(称之为compiledTemplate),然后数据再被传入这个函数中执行,再返回最终结果。
场景2:预编译模板
虽然说将渲染模板包装在一个方法里看起来要方便多了,但是将编译和渲染分开也有显而易见的优点。最重要的是,分开以后,可以把编译放在服务器端完成。我们可以在服务器上执行JS代码(比如使用Node),有些模板引擎支持这样的预编译。
我们可以用一个JS文档(叫它comiled.js吧)将多个预编译好的文件放在一起。这个文件的内容看起来可能是这样的:
var myTemplates = { templateA: function() { ….},templateB: function() { ….}; templateC: function() { ….}; };
然后在应用中,我们只需要将数据传入这些预编译好的模板中:
var result = myTemplates.templateB(data);
这个方法远比上文中讨论过的将所有的模板放在<script type=”text/javascript”>中要好,客户端会忽略编译过程。取决于你的应用套件(application stack),这个解决方式并不一定很难实现,我们会在下文看到它具体的实现。
Node.js示例
任何模板预编译脚本至少要满足下面的要求:
下文中的Node.js脚本就实现了上面说的那3点(使用Hogan.js模板引擎):
var fs = require('fs'),hogan = require('hogan.js'); var templateDir = './templates/',template,templateKey,result = 'var myTemplates = {};'; fs.readdirSync(templateDir).forEach(function(templateFile) { template = fs.readFileSync(templateDir + templateFile,'utf8'); templateKey = templateFile.substr(0,templateFile.lastIndexOf('.')); result += 'myTemplates["'+templateKey+'"] = '; result += 'new Hogan.Template(' + hogan.compile(template,{asString: true}) + ');' }); fs.writeFile('compiled.js',result,'utf8');
这段代码先是读取了在templates目录下所有的文件,再编译了这些模板,最后将它们写入compiled.js。
注意!现在得到的结果是完全没有优化过的代码,也没有做任何错误处理。不过它还是完成我们想要它做的事,也不需要很长的代码来预编译模板。
场景3:AMD和RequireJS
随着异步牵引模块(通常我们都称之为AMD)越来越多地被使用,为了更好地组织你的APP,建议将模块解耦。RequireJS是现在主流的模块加载器之一,在模块定义中,你可以特定某些依赖,在实际的模块里你就可以使用它们了(工厂模式)。
在使用模块时,RequireJS有一个text插件用于规定基于文本的依赖。默认是将AMD的依赖当做JavaScript来处理,不过模板并不是JS而是文本(比如HTML格式的模板),所以我们需要用上这个插件:
define(['handlebars','text!templates/myTemplate.html'],function(Handlebars,template) { var myModule = { render: function() { var data = {"title": "Story",{"name": "Jane"}]}; var compiledTemplate = Handlebars.compile(template); return compiledTemplate(data); } }; return myModule; });
这样,就能在单独的文件中管理各个模板了,虽然这么做是挺好的,但无疑增加了很多额外的Ajax请求,而且仍然需要在客户端编译模板。但是,可以用RequireJS中的r.js来优化这些额外的请求。这个决定了依赖,将模板或者依赖植入模块定义中,大大减小了请求数。
你会发现我们还没有说到预处理,事实上有两个方法可以完成预处理。可以写一个r.js的插件或者别的程序来预处理模板。这么做的话就会改动了模块定义:我们需要在优化之前先使用一个模板*字符串*,然后再使用一个模板*方法*。不过这些问题也不是很难处理,你可以去检测它的变量类型或者将逻辑抽象出来(写在插件中或者直接写在应用中)。
监听模板
在场景2和场景3中,如果将模板当做未编译的资源我们还能将应用构建地更好。就像你在写CoffeeScript、Less或者SCSS,在开发时,可以监听模板文件的变化,一旦发现文件出现变化,就立刻自动重新编译,就像从CoffeeScript编译到JavaScript一样。这样我们在代码中处理的模板都是已经预编译过了的,还方便了在开发过程汇中将预编译模板做相关的内联优化。
define(['templates/myTemplate.js'],function(compiledTemplate) { var myModule = { render: function() { var data = {"title": "Story",{"name": "Jane"}]}; return compiledTemplate(data); }; }; return myModule; }
性能问题
用客户端模板完成UI更新时的渲染是常见的方法。还是那句话,想要达到性能最优,那就要在第一次请求页面时尽可能少的请求额外的资源。这样浏览器在渲染HTML页面时不会因为要去加载JS资源或者别的数据而中断渲染。这听起来挺难的,特别是在又要动态加载内容又要尽可能减少加载时间的页面上。理想情况下,模板是既可以在客户端也可以在服务端使用的,这样可以提供最优的性能还能保持它的可维护性。
有两个问题还需要考虑一下:
- 我的应用中哪里是有最多动态加载的呢?又是哪部分需要最短的加载时间的呢?
- 处理种种问题的程序是要放在客户端还是服务端呢?
实际问题实际分析。确实使用预处理过的模板,客户端可以比较轻易地快速渲染出效果。但是如果你需要重用模板,你会偏爱逻辑较少的模板一些。
结论
我们已经看到了客户端模板的种种好处,比如:
- 服务器和API最好只负责提供数据(比如JSON);客户端模板就能直接把数据套上了。
- 客户端方向的开发者可以自如地使用HTML和JS。
- 使用模板的话,你就必须把逻辑和表现分离开。
- 模板可以预编译好然后缓存起来,这样服务器每次都只要发送数据就可以了。
- 不在服务器端渲染而在客户端渲染,多少会影响性能。