如何用不到30行程式碼寫一個模板引擎?模板引擎從內部來看真的很簡單
注意:本文以模板庫 mote 為基礎,其簡潔性給了我啟發,對於沒有了解過模板引擎內部機制的人來說,它是很好的研究材料。
前言:什麼是模板?
模板引擎是從模板生成文字(字串)並且幫助分離表示層和業務邏輯的工具。
除非你已經被遺留軟體的程式碼纏住了(或者沒有開發過有 UI 的軟體),否則你可能已經用過一個以上的模板引擎了。
但它們究竟是怎麼工作的?你怎麼建立一個?快速瀏覽一些主要的模板庫,會發現它們的程式碼沒有幾千行也有幾百行。即使是名副其實的“slim”也不是如此“苗條”。
所以你可能認為模板引擎是個難題,但我想要把問題一步步分解,並且給你展示你也可以通過幾行程式碼來打造自己的模板引擎。
好了,讓我們深入下去…
定義特性
在這篇文章中,模板引擎將會有兩條規則:
1.以%開頭的程式碼行都認為是ruby程式碼。
2.在任意一行的{{ … }}符號中插入ruby程式碼。我們可以將其用於像{{article.title}}這樣的語句。
就這樣?就這兩條規則?沒錯–記住第一條規則讓我們可以使用ruby的所有功能。這意味著你的通常的模板特性(迴圈,呼叫高階函式,嵌入區域性模板)都是可用的。它們甚至還帶來福利:你不需要學習一門新的模板語言或者某領域的特定語言,因為你已經知道ruby了。
你可以像這樣呼叫另一個模板:
1 |
% render("path/to/template.template", {}) |
可以新增註釋:
1 |
% # this is a comment |
可以執行語句塊:
1 2 3 4 |
% 3.times do |i| {{i}} % end |
基於以上特性,這是一個示例模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
<html> <body> % if access == 0 <div> no access :( </div> % else <ul> % data.each do |i| <li>{{i}}</li> % end </ul> % end % # comments are just normal ruby comments </body> </html> |
從現在起我把這稱為index.template。
現在我們只需要弄明白如何寫一個方法解析這段模板並且給出正確的輸出字串。為了弄明白它是如何工作的,讓我們思考第一個中間步驟,如何在純ruby中渲染輸出html。
一個就像index.template的渲染函式
在一個不存在模板引擎的世界裡,通過如下的純ruby程式碼,你可以實現我們希望通過index.template來實現的同樣的邏輯。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
def render_index(access, data) output = "" #a new string to hold our output in output << "<html>" output << "<body>" if access == 0 output << "<div> no access :( </div>" else output << "<ul>" data.each do |i| output << "<li>#{ i }</li>" end output << "</ul>" end output << "</body>" output << "</html>" return output end |
你可以將這些貼上到IRB(注:Interactive Ruby Shell)並得到如下結果:
1 2 3 4 |
>> render_index(0,["foo", "bar"]) => "<html><body><div> no access :( </div></body></html>" >> render_index(1,["foo", "bar"]) => "<html><body><ul><li>foo</li><li>bar</li></ul></body></html>" |
如果你的應用程式只是很小的規模,那麼也許你完全不需要一個模板引擎,這樣就夠了!你可以僅在需要時實現render_index(), render_header(), render_footer()這些方法。PHP本身就是個模板引擎,並且展示了為什麼在PHP領域的人們常常這麼做。
但研究render_index()是為了瞭解我們能否通過某種方法將index.template翻譯成render_index(),並且將其廣泛應用於任何模板,這樣就得到我們的模板引擎了。
但我們既不希望在任何地方實際去寫像render_index(), render_header(), render_footer()這樣的方法,也不想讓程式碼生成器做這些。
我們想要的是無論什麼時候,當我們需要時就能動態生成一個表現得像render_index()的方法,而不是給render_index()寫實際的程式碼。
讓我們來看看如何通過另一箇中間步驟實現它:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
def define_render_index() func = "" # new empty string to store the string we're using to build our function in func << "def render_index(access, data) \n" func << "output = \"\" \n" func << "output << \"<html>\" \n" func << "output << \"<body>\" \n" func << "if access == 0\n" func << " output << \"<div> no access :( </div>\" \n" func << "else\n" func << " output << \"<ul>\" \n" func << " data.each do |i|\n" func << " output << \"<li> \#{ i } </li>\" \n" func << " end \n" func << " output << \"</ul>\" \n" func << "end\n" func << " output << \"</body>\" \n" func << " output << \"</html>\" \n" func << " return output \n" func << "end\n" eval(func) end |
你可以把這個貼上到IRB並呼叫:
1 2 3 4 |
>> define_render_index() => nil >> render_index(1, ["foo", "bar"]) => "<html><body><ul><li>foo</li><li>bar</li></ul></body></html>" |
那麼現在我們有了一個更完整的圖景:一系列的字串能被建立來逐行表示原始模板,這些字串經過了修改以使它們能在ruby中執行。這個方法被呼叫時將展示從模板而來的預期行為。
這樣逐行翻譯的過程將是我們的解析函式的根基,現在我們來看看這個過程。
解析函式
1.使用Proc
不像define_render_index(),我們不以一個命名函式開始我們的func字串。–取而代之的我們將使用Proc,然後將其存入一個變數並且在需要的時候呼叫它。
2.給Proc設定變數
define_render_index()也硬編碼了它的變數:access和data。但我們需要給解析函式傳遞這些變數的名字,以便它構建合適的Proc定義字串。
在這裡我們將變數名作為字串傳遞給解析函式,就像
1 |
parse(template, "access, data") |
3.把模板逐行翻譯為函式字串
研究上面的define_render_index(),告訴我們為了建立一個可以求值的ruby方法所需要對模板逐行應用的所有規則。它們是這樣的:
任何情況下雙引號必須轉義,每行內容被雙引號包圍並且每行結尾需要加上“n”行結束符。
如果行首字元(不是空格符)為%,那麼刪除%。
1 2 3 4 |
before: % if data.empty? after: "if data.empty?\n" |
其餘行前面加上 “output <<“。
1 2 3 4 |
before: <html> after: "output << \"<html>\" \n" |
4. 將{{ … }}轉換為#{ … }
我們將以正規表示式來做這個
1 2 3 4 |
before: <li></li> after: "output << \"<li> \#{ i } </li>\" \n" |
為了執行上述規則,我們用.split(“n”) 將原始模板檔案分割為陣列,每個陣列元素都是用字串表示的模板中的一行。
然後遍歷得到的陣列,構建我們將求值的字串func。
將這些放在一起就是一個解析函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
def parse(template,vars = "") lines = File.read(template).split("\n") func = "Proc.new do |#{vars}| \n output = \"\" \n " lines.each do |line| if line =~ /^\s*(%)(.*?)$/ func << " #{line.gsub(/^\s*%(.*?)$/, '\1') } \n" else func << " output << \" #{line.gsub(/\{\{([^\r\n\{]*)\}\}/, '#{\1}') }\" \n " end end func << " output; end \n " eval(func) end |
你可以在IRB中自己試驗這個:
1 2 3 4 |
>> index = parse("index.template", "access, data") => nil >> index.call(1,["Foo"]) => " <html>\n <body>\n<ul>\n<li>foo</li> \n</ul>\n </body>\n </html>\n" |
這就是它了!僅用了幾行程式碼你就得到了一個非常不錯的模板引擎。
沒有追求過多額外的特性,我們還得到一個很大程度上降低了複雜性,並且有了顯性增強的模板語言。
降低複雜性使得應用程式更易推導,不易出錯,能更快地為之開發特性。
它是可規模的嗎?
我的程式碼不是!但是,mote,這篇文章所受到啟發的那個庫當然可以。
它配備了一些幫助和快取,並且我們成功地將其用於所有型別的大型網路應用程式的生產開發中。更不用提mote非常快。
我還要強調一個重要的問題關於簡潔–雖然mote非常小,但對於它被設計來解決的問題,它是集中且完整的解決方案。
我希望這篇文章能給那些從沒看過模板引擎底層或是考慮去構建一個的人增長些知識。如果你想要評論或者反饋,請告訴我。