精通 Pug 樣版語言(一)語法基礎篇

前言

我在很久以前就想寫一篇關於 Pug的教學文,分享自己在實戰中累積下來的經驗,並推廣給所有前端開發者。

Pug 能與現代前端框架(ReactVue之類的)的樣版系統相輔相成,經過整合能讓攥寫樣版事半功倍,除了開發速度更快、維護性也更好。像白花殿的前端樣版就有一半以上是由其搭建而成。

於是就有今天的主題啦!ヽ(✿゚▽゚)ノ

礙於篇幅,本文會將「基礎、整合、實戰範例」拆成兩~三篇文章,本篇是關於「基礎」的部分。

在閱讀這篇文章前,你會需要:

關於 Pug

Pug最初是設計作為一款 Node.js的 HTML 樣版引擎(像 Blade 之於 Laravel、ERB 之於 Rails), 結合 HamlEJS的優點,提供給開發者更現代、更優雅的語法,讓 HTML、JSX 不再難以維護,成為入門前端開發必學的技能之一。

其特徵有:

  • 基於縮排,無需 closing-tag。
  • 可編程(變數、條件與迴圈)。
  • 透過 Mixin 建立可覆用結構。
  • 能透過轉譯器、插件來自定義輸出。
    (例如:Pug -> React JSX、Pug -> Vue SFC Template)
  • 繼承、引用其他樣板檔案;自訂結構化樣版。
    (雖然引入前端框架後就再也用不到了)
  • 可透過插件擴充預處理器來轉換其他語言。
    (雖然自從有 Webpack 就再也沒用過了)

雖然大部分樣版引擎可能都具備這些特徵,但要比語法簡潔,應該無人能出其右。

故事

Pug 初亮相是在 2010 中旬,當時以「Jade」面世,後來因註冊商標問題,最終在 2016 中旬正式更名為「Pug」。現今仍有許多文章在介紹 Pug 時會註記 (a.k.a Jade)。

如何運作?

Pug 本身既是樣版引擎也是標記語言,Pug 透過 Pug 語言來驅動 Pug 樣版引擎。透過對引擎輸入文字、.pug 文件、參數,來動態產生能被瀏覽器讀取的 HTML 文件,也可以選擇在轉換後保存為靜態 HTML 文件。

Pug 的語法概念類似於 Emmet(ZenCoding),格式相當於「無需展開的 Emmet 表達式」。

Pug 透過輪詢正規表達式來判別樣版語法與區塊,解析成 token sequence,再將 tokens 轉換成 AST 丟進 Pug 的獨立 runtime 進行 evaluation 來取得所有變數在渲染時的實際值,最後再根據 AST 輸出對應的 HTML。(細節會因整合形式而有差異)

進入下一節之前,先看一下 Pug 長什麼樣子:

index.pug
doctype html
html( lang="zh-TW" )
  head
    meta( charset="UTF-8" )
    meta( name="viewport" content="width=device-width, initial-scale=1.0" )
    title Hello, Pug!
  body
    #app-root
    script( src="app.js" async )

上方的 Pug code 能透過引擎轉換為:(經過手動縮排)

index.html
<!DOCTYPE html>
<html lang="zh-TW">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Hello, Pug!</title>
  </head>
  <body>
    <div id="app-root"></div>
    <script src="app.js" async></script>
  </body>
</html>

如果下面的結果符合你的預期,你已經懂超過一半了 ヾ(*´ω`*)ノ

文章方向

這篇文章的主要目的是推廣,內容基本上只是把官方文件用中文整理一遍。

學習 Pug 最有效率的方式是多看範例;本文將透過大量比較「轉換前—轉換後」的方式,配合附註來快速介紹 Pug 語言。

本文不含搭建環境教學;如在實際使用時遇到不知如何查詢的困難,歡迎來白花殿或社群發問 ٩(。・ω・。)


基礎語法

注意!所有轉換結果皆經過手動調整,與實際上的輸出會有排版上的差異。

首先要掌握如何透過 Pug 來改寫出等價的 HTML;熟悉基礎語法,基本上就能完全使用 Pug 來產生靜態 HTML。

本章將介紹所有靜態語法,透過前後比較能更容易明白。

標籤 Tags

div
<div></div>
巢狀標籤
section
  header
  main
  footer
<section>
  <header></header>
  <main></main>
  <footer></footer>
</section>
巢狀標籤縮寫
div: p
  span
    a
<div>
  <p>
    <span><a></a></span>
  </p>
</div>
Self-Closing 標籤
img
input
Button1
Button2/
<img />
<input />
<Button1></Button1>
<Button2 />

*在標籤後方加上 / 能讓自定義標籤強制 Self-Closing。預設的 Self-Closing 標籤見 void-elements

HTML 字面標籤
<html>
<body>
div: p
</body>
</html>
<html>
  <body>
    <div><p></p></div>
  </body>
</html>

內文 Plain Text

單行內文
h1 直接尾隨在標籤後面
<h1>直接尾隨在標籤後面</h1>
多行內文 |
p
  | 透過「| (pipe)」符號能串連多行文字,
  | 但要留意純文字之間會有不期望的額外空白,
  | 之後會教大家如何避免。
<p>
  透過「| (pipe)」符號能串連多行文字,
  但要留意純文字之間會有不期望的額外空白,
  之後會教大家如何避免。
</p>
多行內文 .
p.
  透過「. (dot)」符號能使下一個縮排等級的
  元素全部視為內文字串,但會保留所有換行字符,
  適合搭配 pre 使用。
p
  .
    寫在下一個巢狀階層也適用,
    能和其他子元素相互搭配。
<p>
  透過「. (dot)」符號能使下一個縮排等級的
  元素全部視為內文字串,但會保留所有換行字符,
  適合搭配 pre 使用。
</p>
<p>
  寫在下一個巢狀階層也適用,
  能和其他子元素相互搭配。
</p>
內文穿插標籤
多行穿插標籤
p
  | 常常你會需要在標籤中
  br
  | 穿插文字與標籤。
<p>
  常常你會需要在標籤中
  <br>
  穿插文字與標籤。
</p>
行內穿插標籤 #[tag]
p 單行文字#[br]穿插標籤。
p
  | 行內穿插標籤的格式#[span 比較嚴格],
  | 中括號中不允許換行,
  | 但允許#[span: b 巢狀嵌套]。
<p>
  單行文字<br>穿插標籤。
</p>
<p>
  行內穿插標籤的格式<span>比較嚴格</span>,
  中括號中不允許換行,
  但允許<span><b>巢狀嵌套</b></span></p>

標籤屬性 Attributes

單行屬性 tag(...)
input( type="text" )
input( type='text' )
input( type=`text` )
<input type="text">
<input type="text">
<input type="text">

*用雙引號 "、單引號 '、Backtick ` 都行,只要它在 JavaScript 能表示合法字串。

input(type="text" value="")
input( type="text" value="" )
input( type="text", value="" )
<input type="text" value="">
<input type="text" value="">
<input type="text" value="">

*括號內屬性間的空格與逗號不影響結果。

多行屬性 tag(...)
input(
  type="text"
  value="常規縮排" )
input(
    type="text"
    value="多一級縮排" )
input(
type="text"
value="少一級縮排" )
div
  input(
type="text"
  value="隨便亂縮"
)
<input type="text" value="常規縮排">
<input type="text" value="多一級縮排">
<input type="text" value="少一級縮排">
<div>
  <input type="text" value="隨便亂縮">
</div>

*只要在括號內,縮排不影響結果。

id 屬性 #id
#id_1
input#id_2
input( id="id_3" )
<div id="id_1"></div>
<input id="id_2">
<input id="id_3">

*Id 能透過 # 來縮寫;使用縮寫時若省略標籤將預設使用 <div>

class 屬性 .class
.c-1
input.c-2
input.c-3.c-4
input( class="c-5" )
input.c-6( class="c-7" )
<div class="c-1"></div>
<input class="c-2">
<input class="c-3 c-4">
<input class="c-5">
<input class="c-6 c-7">

*Class 能透過 . 來縮寫;使用縮寫時若省略標籤將預設使用 <div>

布林屬性
input(
  value="Disabled"
  disabled )
<input value="Disabled" disabled>

*布林屬性可忽略屬性值,輸出的結果會依據 Doctype 而有所不同。

含特殊字元的自定義屬性
button(
    "(click)"="onClick()" )
  | Button
<button (click)="onClick()">
  Button
</button>

*若自定義屬性包含 ()[] 字元,則必須將屬性用引號("'`) 包住。

跳脫屬性 !=
input(
  value="\"><script>alert(1)</script>" )
input(
  value!="\"><script>alert(1)</script>" )
<input value=""><script>alert(1)</script>" />
<input value="">
<script>
  alert(1)
</script>">

*所有的屬性預設將被跳脫,透過將 = 替換為 != 能夠避免跳脫。

合併屬性 &attributes

合併屬性較常和 Mixins 搭配使用,等熟悉後續章節會更容易理解。

- let isDisabled = { class: 'is-disabled', disabled: true }
- let isWarning = { class: 'is-warning' }
input&attributes(isDisabled)
input&attributes(isDisabled)&attributes(isWarning)
<input class="is-disabled" disabled>
<input class="is-disabled is-warning" disabled>

*合併屬性會將 &attributes 內物件的 key-value 映射到標籤對象中。目前只有 class 屬性會被轉為字串後串接。source

註解 Comments

HTML 註解 //
// 註解a
//
  註解b
  註解c
<!-- 註解a -->
<!-- 註解b
  註解c -->
註解 (無輸出) //-
//- 註解a
//-
  註解b
  註解c

*這表示你只需要註解其中一行,底下所有的子元素全部都會被註解。

Doctype

宣告 HTML5
doctype html
<!DOCTYPE html>

*其他 Doctype 請見 Doctype 縮寫清單


表達式

除了靜態轉換,Pug 還能藉由自身的 runtime 透過表達式條件性地改變輸出結果;倒不如說,這才是 Pug 真正重頭戲的部分。

根據整合方式,Pug 能透過預定義和輸入參數來取得能在 Pug 文件中使用的變數,也能在 Pug 文件內宣告與定義。

插入代碼 Code

無輸出代碼 (Unbuffered Code) 與輸出代碼 (Buffered Code) 總是相互搭配使用,其中緩衝代碼的表達式使用更為頻繁。(這邊翻譯輸出、無輸出其實並不準確,官方用詞緩衝和無緩衝實際上指的是表達式運算完成後的返回值是否入棧。)

實際使用時,變數經常以參數的形式輸入,會需要直接宣告變數的場合比較少,通常作為輸入後轉換輸出格式使用。

基礎語法
無輸出代碼 -
- let value = 'Lamy'
- console.log(value)
-
  value = value + value
  console.log(value)
// 由 console 印出
Lamy
LamyLamy
輸出代碼 =
- let value = 'Lamy'
div= value
div
  = `Hello, ${value}!`
div
  = document.location.href
  br
  - let date = new Date()
  = date.toString()
<div>Lamy</div>
<div>
  Hello, Lamy!
</div>
<div>
  http://localhost:45678/blog/articles/2020-mastery-pug-template-engine
  <br>
  Mon Mar 18 2024 15:15:34 GMT+0800 (Taipei Standard Time)
</div>

*輸出代碼表達式目前無法使用巢狀結構,見 pug#2371

跳脫輸出代碼 !=
-
  let html = ''
    + '<script>alert(1)</script>'
div
  = html
br
!= html
<div>
  &lt;script&gt;alert(1)&lt;/script&gt;
</div>
<br>
<script>alert(1)</script>

*所有的輸出代碼預設將被跳脫,透過將 = 替換為 != 能夠避免跳脫。

代碼插值
插值屬性與內文 =
-
  let link = {
    url: 'https://shirohana.me/',
    label: '回首頁'
  }
a( href=link.url )
  = link.label
<a href="https://shirohana.me/">
  回首頁
</a>
原生結構代碼

不建議實際使用這種寫法,Pug 在結構表達式有自己的實作。

原生 if-else
- let type = 'div'
- if (type === 'div')
  span if-branch
- else
  span else-branch
<span>if-branch</span>
原生 for-loop
ul
  - for (let i = 0; i < 3; ++i)
    li
      = i + ': '
      = i % 2 ? 'odd' : 'even'
<ul>
  <li>0: even</li>
  <li>1: odd</li>
  <li>2: even</li>
</ul>
原生 for-in/for-of
-
  let colors = [
    'red', 'green', 'blue'
  ]
- for (const index in colors)
  div= index + ': ' + colors[index]
- for (const color of colors)
  div= color
<div>0: red</div>
<div>1: green</div>
<div>2: blue</div>
<div>red</div>
<div>green</div>
<div>blue</div>
原生 while-loop
- let i = 3
- while (i > 0)
  div= i--
- do {
  div= ++i
- } while (i < 3)
<div>3</div>
<div>2</div>
<div>1</div>
<div>1</div>
<div>2</div>
<div>3</div>

插值 Interpolation

變數除了能透過 = 輸出,也能透過插值的方式與字串 |. 合併輸出。

字串插值 #{...}
- let name = 'Lamy'
h1 Hello, #{name}!
h2
  = `Hello, ${name}!`
h3
  | Hello, #{name}!
h4.
  Hello, #{name}!
h5.
  Hello, \#{name}!
<h1>Hello, Lamy!</h1>
<h2>Hello, Lamy!</h2>
<h3>Hello, Lamy!</h3>
<h4>Hello, Lamy!</h4>
<h5>Hello, #{name}!</h5>

*使用反斜線 \#{ 能輸出字面字串 #{

跳脫字串插值 !{...}
-
  let script = '' +
    '<script>alert(1)</script>'
h1.
  #{script}
h2.
  !{script}
h3.
  \!{script}
<h1>
  &lt;script&gt;alert(1)&lt;/script&gt;
</h1>
<h2>
  <script>alert(1)</script>
</h2>
<h3>
  !{script}
</h3>

*使用反斜線 \!{ 能輸出字面字串 !{。但現在不會正確渲染,見 Pug#3299

標籤字串插值 #[#{...}]
- let bold = 'b'
- let italic = 'i'
#{bold} 粗體
#{bold}
  #{italic} 粗體+斜體
div
  | #[#{bold} 粗體]和#[#{italic} 斜體]
<b>粗體</b>
<b>
  <i>粗體+斜體</i>
</b>
<div>
  <b>粗體</b><i>斜體</i>
</div>

*這種使用方式很少見,只要稍微有點印象就好。

條件式 Conditionals

Pug 能在轉換前輸入參數並使用於 Pug code 之中,藉此渲染出不同結果。

雖現今多由其他先進的前端框架來處理渲染邏輯,但主流前端框架(ReactVue之類的)仍能透過 Pug 組織邏輯來簡化渲染樣版,畢竟幾乎每個組件都會有條件地進行渲染。

if-else if, else if, else
- let hours = 12
if hours < 11
  span 早安。
else if hours < 16
  span 午安。
else
  span 晚安。
<span>午安。</span>
unless unless
- let books = []
unless books.length > 0
  span 一本書也沒有
<span>一本書也沒有</span>

*unless 也能接續 elseelse-if,只是 unless-else 解讀起來不太直覺,還是建議避免。

迭代 Iteration

each 迴圈
each-in Array each, in
-
  let colors = [
    'red', 'green', 'blue'
  ]
each color in colors
  div= color
each color, index in colors
  div= index + ': ' + color
<div>red</div>
<div>green</div>
<div>blue</div>
<div>0:red</div>
<div>1:green</div>
<div>2:blue</div>
each-in Object each, in
-
  let node = {
    type: 'input',
    props: {}
  }
each value in node
  div= value
each value, key in node
  div= key + ': ' + value
<div>input</div>
<div>[object Object]</div>
<div>type: input</div>
<div>props: [object Object]</div>

*雖然不是很實用,但 each-in 能直接 iterate Object,語法類似 Array.forEach()

each-in-else each, in, else
- let colors = []
each color in colors
  span= color
else
  span colors.length < 1

- let node = {}
each value in node
  span= value
else
  span Object.keys(node).length < 1
<span>colors.length === 0</span>
<span>Object.keys(node).length === 0</span>

如果傳進的陣列或物件為 nullable,each-in 將拋出例外,記得檢查輸入:

- let colors = null
if colors
  each color in colors
    span= color
//- or
each color in colors || []
  span= color
while 迴圈 while
- let i = 0
while i < 5
  span= 'item-' + i
  - i += 2
<span>item-0</span>
<span>item-2</span>
<span>item-4</span>

Switch-Case

case-when (巢狀) case, when, default
- let type = 'text/plain'
- let data = 'Hello, world!'
case type
  when 'text/plain'
  when 'text/html'
    p
      = type
      = '(' + data.length + ')'
    - break
  when 'image/png'
    p
      = type + '<Blob>'
<p>
  text/plain(13)
</p>

*值得留意的是,Pug 的 switch-case 在 when 後端沒有接冒號時,Case 有 Fall-Through 行為。

case-when (單行) case, when:, default:
- let amount = 1
case amount
  when 0: p empty
  when 1: p only one
  default: p more than 2
<p>only one</p>

*巢狀與單行能視情形混用;標題寫單行實際上是指「單一子元素」,只要有一級容器在上層就都能寫成單行形式,也就無需額外 break 了。


宣告與引用

Pug 能透過宣告結構來建立能重複使用的樣版區塊。然而實際使用時往往會整合其他的轉譯工具,這讓在 Pug 內宣告結構變得不必要,或是根本不適用。

只要記得避免在不預期的情況下使用保留字就好。

Mixins

mixin 允許你宣告能重複使用的樣版組件,並透過組合多個 mixin 來創造更為高階的可覆用結構。

宣告 Mixin mixin
mixin IconPlus
  svg.icon-plus

button
  +IconPlus
  | Add
<button>
  <svg class="icon-plus"></svg>
  Add
</button>
包含參數 mixin()
mixin Link (label, href = '#')
  a( href=href )
    = label

+Link
+Link('Anchor')
+Link('Home', '/home')
<a href="#"></a>
<a href="#">Anchor</a>
<a href="/home">Home</a>

*宣告 mixin 時如不使用參數,可以省略後方的 ()

*即使 mixin 宣告成需要參數,在不帶參數呼叫時仍能省略 ()

使用 Mixin Block mixin, block
mixin Link (href)
  a( href=href )
    if block
      block
    else
      | Go to "#{href}"

+Link
+Link
  svg.icon-plus
  | Add
+Link('/home')
+Link('/home')
  svg.icon-home
  | Home
<a>
  Go to ""
</a>
<a>
  <svg class="icon-plus"></svg>
  Add
</a>
<a href="/home">
  Go to "/home"
</a>
<a href="/home">
  <svg class="icon-home"></svg>
  Home
</a>

*block 關鍵字會在 mixin 包含子元素時有值,未包含子元素時則為 undefined

其他參數
...rest
mixin Dump (...rest)
  != JSON.stringify(rest)
+Dump
+Dump('v1')
+Dump('v1', 'v2')
[]
["v1"]
["v1","v2"]
arguments
mixin Dump ()
  != JSON.stringify(arguments)
+Dump
+Dump('v1')
+Dump('v1', 'v2')
+Dump('v1', 'v2', 'v3')
{}
{"0":"v1"}
{"0":"v1","1":"v2"}
{"0":"v1","1":"v2","2":"v3"}

*注意 arguments 不是陣列,是 key 為整數字串的 Object;其實就是 JavaScript function arguments 參數。

attributes
mixin Dump ()
  != JSON.stringify(attributes)
+Dump
+Dump()
+Dump()( type="text" )
+Dump()( type="text", required, min=20 )
{}
{}
{"type":"text"}
{"type":"text","required":true,"min":20}

未翻譯

剩下的章節在整合前端或後端框架後幾乎、應該說根本用不到,這邊只留下連結。

Includes
Inheritance: Extends and Block
Filters

接下來

在熟悉 Pug 語法後,接下來是實際與既有的開發環境整合。由於本文主軸是推廣與基礎教學,整合環境的教學要麻煩各位自行 Google 了。如在實際使用時遇到不知如何查詢的困難,也歡迎來白花殿或社群發問 ٩(。・ω・。)


結語

先有基礎,才能寫後續的第二、第三篇實戰範例;Pug 簡單易學卻受用無窮,使用者卻十分小眾…Pug 已有十年歷史,會長還在職場打滾的時候,前端部門幾乎沒有人實際使用過,那時做的實戰講稿也有一部份挪進了這篇教學。

Pug 除了語言易學、應用場景多,它的實作面也有很多值得參考的設計模式;且由於 Pug 完全使用 JavaScript 開發而成,這讓 Pug 能輕鬆地在網頁上轉譯,開發第三方插件也相當輕鬆。

有機會再和大家解析內部實作,但先把時間留給其他更想分享的主題吧 (ノ∀`)

對了,這篇文章會長努力設計成「從上到下捲過一遍就懂」的形式,希望能讓第一次接觸的朋友讀完一次就會。如果讀完一次感覺有地方不懂,希望能回饋給本殿,我們會努力做到最好 (´・ω・`)

謝謝大家讀到這裡,如果有幫上忙就太好了 ヾ(*´ω`*)ノ

後記

……選錯主題了啊。明明幾乎照搬官方文件,卻還是花了那麼長時間寫稿…

下次選主題就該水一點,選什麼喔喔喔 CSS 小撇步啊、什麼不教你搭環境就來 ES6 基礎到不能再基礎的語法教學啊、什麼三分鐘記住一個單字啊什麼的…

這次真的花了超出預期的時間,讀官方文件發現缺東缺西又要爬原始碼,爬一爬發現 bug 又回報又發 PR 的,到最後校稿兩個禮拜都過去了,明明會長還期待自己一週能產一篇技術文章或翻新部分網站功能的…

短時間不想寫 Pug 了…開頭說的「整合、實戰範例」就排隊排後面,會長先把白花殿缺少的功能架構起來 (´-ωก`)

另外,有機會再跟大家分享有趣的職場小故事 (*´꒳`*)