[toc]
最近項目中涉及到模板引擎,參考了一些博客文章進行了一些學習,並在此進行記錄
1. 模板引擎是什麼
首先我們來了解什麼是模板,模板就我個人理解而言其產生的目的是為了解決展示與數據的耦合,簡單來説模板還是一段字符,只不過其中有一些片段跟數據相關,實際開發中根據數據模型與模板來動態生成最終的HTML(或者其他類型片段,本文都以HTML
為例子)
而模板引擎就是可以簡化該拼接過程,通過一些聲明與語法或格式的工具,儘可能讓最終HTML的生成簡單且直觀
搬一下網上的概念:模板引擎(這裏特指用於Web開發的模板引擎)是為了使用户界面與業務數據(內容)分離而產生的,它可以生成特定格式的文檔,用於網站的模板引擎就會生成一個標準的文檔。
模板引擎的核心原理就是兩個字:替換。將預先定義的標籤字符替換為指定的業務數據,或者根據某種定義好的流程進行輸出。
2. 不使用模板引擎的示例
這裏我們通過一個例子來更加直白的瞭解模板引擎。
首先我們需要實現這樣的一個界面:
有如下要求:
- 數據必須來源一個指定的數組
- 具有動態性,不能寫死數據
如果不使用模板引擎,希望最終HTML頁面跟數據綁定的話常見的實現有兩種。
字符串拼接
直接上相關代碼,其實就是將HTML作為字符串一個個拼出來:
var songs =[
{name:'剛剛好', singer:'薛之謙', url:'http://music.163.com/xxx'},
{name:'最佳歌手', singer:'許嵩', url:'http://music.163.com/xxx'},
{name:'初學者', singer:'薛之謙', url:'http://music.163.com/xxx'},
{name:'紳士', singer:'薛之謙', url:'http://music.163.com/xxx'},
{name:'我們', singer:'陳偉霆', url:'http://music.163.com/xxx'},
{name:'畫風', singer:'後弦', url:'http://music.163.com/xxx'},
{name:'We Are One', singer:'鬱可唯', url:'http://music.163.com/xxx'}
]
//拼接字符串,有一定惡意腳本注入風險 遍歷
var html = '';
html +='<div class="song-list">'
html +=' <h1>熱歌榜</h1>'
html +=' <ol>'
for(var i=0;i<songs.length;i++){
html += '<li>'+songs[i].name+' - '+songs[i].singer+'</li>'
}
html +=' </ol>'
html +='</div>'
document.body.innerHTML =html;
構造DOM對象
藉助DOM對象和數據源來操作
// 構造DOM對象 遍歷 缺點複雜;
var elDiv = document.createElement('div')
elDiv.className = 'song-list';
var elH1 =document.createElement('h1')
elH1.appendChild(document.createTextNode('熱歌榜'))
var elList = document.createElement('ol')
for(var i = 0; i<songs.length;i++){
var li = document.createElement('li')
li.textContent = songs[i].name +' - ' + songs[i].singer
elList.appendChild(li)
}
elDiv.appendChild(elH1);
elDiv.appendChild(elList);
document.body.appendChild(elDiv);
可以看到上述兩種方式雖然可以達成需求,但是尤其繁瑣且缺乏規範,很容易出錯。
我們這樣思考,其實這些數據替換的地方都是固定的也有一定的邏輯,那能不能將這個替換邏輯抽離出來形成規範,來統一進行處理呢?
3. 使用模板引擎的方式
置換型模板引擎
這種模板引擎原理比較直觀,實現也相對簡單,我們先來看一下:
var template = '<p>Hello,my name is <%name%>. I am <%age%> years old.</p>';
var data ={
name:'zyn',
age:31
}
var TemplateEngine = function (tpl,data){
var regex = /<%([^%>]+)?%>/g;
while(match = regex.exec(tpl)){
tpl = tpl.replace(match[0],data[match[1]])
}
return tpl
}
var string = TemplateEngine(template,data)
console.log(string);
這裏其實就是把模板中需要替換的字符串做了個標記,這裏是以<%...%>作為標記,然後替換時基於正則捕捉該標記並進行數據源的替換(通過同一個key進行)
模板文件: var template = '<p>Hello,my name is <%name%>. I am <%age%> years old.</p>';
數據: var data ={
name:'zyn',
age:31
}
模板引擎: var TemplateEngine = function (tpl,data){
var regex = /<%([^%>]+)?%>/g;
while(match = regex.exec(tpl)){
tpl = tpl.replace(match[0],data[match[1]])
}
return tpl
}
HTML文件:
var string=TemplateEngine(template,data)
document.body.innerHTML= string
JS代碼函數型模板語法
上述方式存在一個問題,就是基本上以data["property"]方式來使用簡單對象傳遞數據,但是如果對象是嵌套對象就有點難辦:
var data ={
name:'zyn',
profile:{age:31}
}
在模板中使用<%profile.age%>的話,代碼會被替換成data[‘profile.age’],結果是undefined,因為括號型沒辦法認識.符號,當然我們可以改進Template函數來分解複雜對象轉換為[][]的形式。但是這裏我們換一個方式。
這裏我們思考是否一定要在標記中寫key或者常規字符,能不能寫一段有邏輯的JS代碼進去,類似這樣:
var template = '<p>Hello, my name is <%this.name%>. I\'m <%this.profile.age%> years old.</p>'
這裏為了之後的示範,我們補充一下關於new Function的知識,這個函數的構造函數可以根據傳入參數來動態生成一個函數,包括函數入參,函數體等:
var fn = new Function("num", "console.log(num + 1);");
fn(2); //3
等同於:
var fn = function(num) {
console.log(num + 1);
}
fn(2); // 3
這裏我們思路基本明確了,就是希望構建一個函數體字符串,然後利用JS代碼執行過程幫我們把數據綁定到模板上面。
這裏我們把所有字符串統一放到一個數組中,在程序最後將其拼接起來,然後藉助new Function幫助我們處理JS邏輯:
var Arr=[];
Arr.push("<p>Hello,my name is");
Arr.push(this.name);
Arr.push("i am");
Arr.push(this.proflie.age)
Arr.push("years old</p>")
return Arr.join('')
接下來需要做的還是去尋找模板中的標記位,即<%...%>片段,然後遍歷所有的匹配項將其push到字符串數組中去,最後藉助new Function完成。
我們來看下初步的代碼:
var TemplateEngine = function(tpl, data) {
// 正則全局匹配
// code用於保存函數體字符串
// cursor是遊標,用於記錄tpl處理的位置
var re = /<%([^%>]+)?%>/g,
code = 'var Arr=[];\n',
cursor = 0;
// 函數add負責將解析的代碼行添加到code函數體中
// 後面的replace是將code包含的雙引號進行轉義
var add = function(line) {
code += 'Arr.push("' + line.replace(/"/g, '\\"') + '");\n';
}
// 循環處理模板,每當存在匹配項就進入循環體
while(match = re.exec(tpl)) {
add(tpl.slice(cursor, match.index));
add(match[1]);
cursor = match.index + match[0].length;
}
add(tpl.substr(cursor, tpl.length - cursor));
code += 'return Arr.join("");'; // <-- return the result
console.log(code);
return tpl;
}
var template = '<p>Hello, my name is <%this.name%>. I\'m <%this.profile.age%> years old.</p>';
var data = {
name: "zyn",
profile: { age: 29 }
}
console.log(TemplateEngine(template, data));
循環過程:
第一次循環:match=[
0:<%this.name%>",
1:"this.name",
index:21,
input:"<p>Hello, my name is<%this.name%>.I'm<%this.profile.age%>years old.</p>",
length:2
]
tpl.slice(cursor, match.index) = "<p>Hello, my name is "
執行函數add("<p>Hello, my name is ")
code=
"
var Arr=[];
Arr.push("<p>Hello, my name is ");
"
在執行add(match[1]);match[1]="this.name"
code =
"
var Arr=[];
Arr.push("<p>Hello, my name is ");
Arr.push("this.name");
"
cursor = match.index + match[0].length;
cursor = 21+13=34;//就是<%this.name%>最後一位的位置;
第二次循環跟第一次一樣繼續把模板文件添加到code上;兩次循環完成後code =
"
var Arr[];
Arr.push("<p>Hello, my name is ");
Arr.push("this.name");
Arr.push(". I'm ");
Arr.push("this.profile.age")
"
cursor =60 ;
然後執行: add(tpl.substr(cursor, tpl.length - cursor));
cursor =60 ; tpl.length=75
tpl.substr(cursor, tpl.length - cursor)
截取最後一段模板文件 years old.</p>
code += 'return Arr.join("");'
code =
"
var Arr[];
Arr.push("<p>Hello, my name is ");
Arr.push("this.name");
Arr.push(". I'm ");
Arr.push("this.profile.age")
Arr.push("years old </p>")
return Arr.join("")
"
如果還不明白可以複製代碼在代碼上打幾個斷點看下執行的過程,很快就能明白;
最後我們會在控制枱裏面看見如下的內容:
var Arr[];
Arr.push("<p>Hello, my name is ");
Arr.push("this.name");
Arr.push(". I'm ");
Arr.push("this.profile.age")
Arr.push("years old </p>")
return Arr.join("")
<p>Hello, my name is <%this.name%>. I'm <%this.profile.age%> years old.</p>
這裏還存在一些問題:
- this.name和this.profile.age不應該存在引號
- 還沒有創建函數
- 是否可以支持更多複雜的語句
最後完善之後的如下:
var TemplateEngine = function(html, options) {
var re = /<%([^%>]+)?%>/g,
reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g,
code = 'var Arr=[];\n',
cursor = 0;
var add = function(line, js) {
js? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n') :
(code += line != '' ? 'r.push("' + line.replace(/"/g, '\\"') + '");\n' : '');
return add;
}
while(match = re.exec(html)) {
add(html.slice(cursor, match.index))(match[1], true);
cursor = match.index + match[0].length;
}
add(html.substr(cursor, html.length - cursor));
code += 'return r.join("");';
return new Function(code.replace(/[\r\t\n]/g, ''));
}
這裏感興趣的可以基於上面的示例自己嘗試去實現一下上一小節的例子~
dom-based模板引擎
dom-based模板引擎基本用於HTML相關領域,輸出模板直接是dom了(當然,很多dom-based模板引擎也可以很方便的掛載string輸出端,從而在服務端也能輸出)
而輸入是沒有具體規定的,你可以基於也有的DOM樹,也可以是字符串(例如Angular)。還可以是自己定義的語言,只要你的模板引擎認識就行了(例如React的JSX,JSX可以説是AST-based的,因為其不依賴DOM,這裏就不區分那麼細了)。
前者是需要模板引擎把字符串解析為AST,而後者就是定義了一套語法,給你語法糖讓你自己去寫AST了。得到AST後再解析得到模板語法,例如變量bind,循環,條件判斷等。
dom-based模板引擎基本上不考慮輸出HTML/XML以外的東西
目前前端MVVM框架基本都內置了相關的模板引擎用於快速且最小化完成DOM更新操作
4. 前端與後端的模板引擎渲染髮展變化
上面介紹的基本圍繞模板引擎的實現原理和概念,下面主要分析一下目前模板引擎的應用和發展階段,以及區別
發展階段
後端模板引擎渲染
最初模板引擎是放在後端的,那個時候靜態網頁居多,基本返回的都是後端拼接好的HTML,前端拿來直接渲染,然後再用JS進行一些交互處理就行。
該方式存在一些不足:
- 前後端是在一個工程,不方便開發調試,與自動化測試
- 前端沒辦法使用自己的生態
- 前後端職責混淆
但是該方式也擁有頁面渲染快,SEO友好,當下不少純展示性網頁仍然使用該方式進行處理
客户端渲染
隨着後續前端工程化以及前後端職責分離概念明確後,一系列前端MVVM框架也出現了,客户端進行模板渲染漸漸成為主流。
此時後端只負責Model層處理,不再關心任何渲染相關內容。
前後端解耦,數據通過ajax方式進行交互
優勢顯而易見:
- 前端獨立出來,可以充分使用各個生態與工具
- 更好管理
- 職責明確
仍有不足:
- 首屏加載緩慢,因為要等JS加載完畢之後才能處理模板,渲染最終頁面
- SEO能力弱,因為html中基本都是模板信息,沒有啥實際內容
node中間層
為了解決上述不足,便出現了node中間層概念。
整個流程變為:瀏覽器 -> node -> 後端服務器 -> node -> 瀏覽器
一個典型的 node 中間層應用就是後端提供數據、node 層渲染模板、前端動態渲染。
這個過程中,node 層由前端開發人員掌控,頁面中哪些頁面在服務器上就渲染好,哪些頁面在客户端渲染,由前端開發人員決定。
這樣做,達到了以下的目的:
- 保留後端模板渲染、首屏快速響應、SEO 友好
- 保留前端後分離、客户端渲染的功能(首屏服務器端渲染、其他客户端渲染)
但這種方式也有一些不足:
- 增加了一箇中間層,應用性能有所降低
- 增加了架構的複雜度、不穩定性,降低應用的安全性
對開發人員要求高了很多
服務器端渲染(SSR)
大部分情況下,服務器端渲染(SSR)與 node 中間層是同一個概念。只不過是在上文的基礎上加上前端組件化技術,優化服務器端的渲染,例如針對react或vue
react、vue、angular 等框架的出現,讓前端組件化技術深入人心,但在一些需要首屏快速加載與 SEO 友好的頁面就陷入了兩難的境地了。
因為前端組件化技術天生就是給客户端渲染用的,而在服務器端需要被渲染成 html 文本,這確實不是一件很容易的事,所以服務器端渲染(ssr)就是為了解決這個問題。
好在社區一直在不斷的探索中,讓前端組件化能夠在服務器端渲染,比如 next.js、nuxt.js、razzle、react-server、beidou 等。
一般這些框架都會有一些目錄結構、書寫方式、組件集成、項目構建的要求,自定義屬性可能不是很強。
以 next.js 為例,整個應用中是沒有 html 文件的,所有的響應 html 都是 node 動態渲染的,包括裏面的元信息、css, js 路徑等。渲染過程中,next.js 會根據路由,將首頁所有的組件渲染成 html,餘下的頁面保留原生組件的格式,在客户端渲染。
使用建議
把模板引擎渲染的過程放在前端(客户端)還是後端是要看具體應用場景的。
如果你的網頁只是傳統展示型網頁,且需要SEO優化,很少需要實時刷新,交互少,那麼傳統的後端渲染模式還是可以使用的,再配合緩存,那麼前端直接請求可以拿到最終頁面了。
另一方面,如果你不需要首屏快速加載,也不需要SEO優化,那麼可以選擇全客户端渲染,開發方式最直觀
又或者你可以嘗試在需要首屏快速渲染與SEO的地方不適用react、vue等框架技術,而在其他頁面使用這些框架進行純客户端渲染。
最終如果你的技術團隊出色且支持,而且又需要快速渲染和SEO優化,且用了react,vue等技術,那你可以嘗試搭建SSR渲染架構
5. 開源的模板引擎
這裏簡單推薦幾個較優秀的模板引擎,感興趣的可以自己看一下源碼繼續深入學習~
- Art-template
- Jinja
- pug
6. 參考博客
- 五分鐘瞭解模板引擎原理,閲讀後做出自己的模板引擎
- 細説後端模板渲染、客户端渲染、node 中間層、服務器端渲染(ssr)