CSS Grid 深入淺出

CSS Flexbox 深入淺出

上一篇的 Flexbox 徹底改變了我們在網頁上排版的方式,但它其實只是整個佈局系統 (Layout) 的一部分。它還有個大哥就是所謂的網格佈局模組 (Grid Layout) 。這兩個佈局系統加起來,提供了一套非常完整又強大的佈局工具。

CSS 網格允許你定義出列與行,做出「二維佈局」,然後再把項目放進這個網格裡。有些項目可能只佔一個格子,或是跨好幾列或好幾行,我們可以很精確地設定每個格子的大小,也可以讓它們根據內容自動調整。項目可以精確地放在某個格子裡,也可以讓它們自己流動去填補空位。透過網格,我們可以打造出非常複雜的版面設計,就像下圖。

CSS Grid Layout

基礎:Basic Grid

我們先從一個很基礎的範例開始:把六個框排成三列。如下圖:

CSS Grid Layout

1
2
3
4
5
6
7
8
<div class="grid">    
<div class="a">a</div>
<div class="b">b</div>
<div class="c">c</div>
<div class="d">d</div>
<div class="e">e</div>
<div class="f">f</div>
</div>

就像 Flexbox 一樣,Grid 也是作用在 DOM 的父子層級。當你把某個元素設為 display: grid,它就變成了「網格容器」 (Grid Container) ,而它底下的子元素就會變成「網格項目」 (Grid Items)

1
2
3
4
5
6
7
8
9
10
11
12
.grid {
display: grid; /* 使元素變成網格容器 */
grid-template-columns: 1fr 1fr 1fr; /* 定義三個等寬的列 */
grid-template-rows: 1fr 1fr; /* 定義兩個等高的行 */
gap: 0.5em; /* 設定間距 */
}
.grid > * {
background-color: darkgray;
color: white;
padding: 2em;
border-radius: 0.5em;
}

我們用了 display: grid 把元素變成了「網格容器」。這個容器的行為就像一般的 block 區塊元素一樣,會自動填滿整個可用寬度。

雖然這裡沒寫到,不過你也可以用 inline-grid,這會讓元素像 inline 行內元素那樣跟文字一起流動,而且寬度只會剛好包住它的子元素。不過實際上,大多數情況還是會用 display: grid,用 inline-grid 的機會比較少。

接下來是兩個新的屬性:grid-template-columnsgrid-template-rows,用來定義每一列和每一行的大小。

這裡我們用了一個新的單位 fr,它的意思是「比例單位」,有點像 Flexbox 裡的 flex-grow。例如,grid-template-columns: 1fr 1fr 1fr 就是建立三個等寬的欄位,每個欄都佔一等份。不一定每一列或每一行都得用 fr,也可以用其他單位像是 pxem%,甚至可以混著用。像這樣:grid-template-columns: 300px 1fr,意思是第一欄寬度固定 300px,第二欄則佔據剩下所有可用空間。如果你寫 2fr,那它的寬度就是 1fr 的兩倍。

還有一個很實用的屬性是 gap,它是用來設定網格之間的間距,就跟 Flexbox 裡的 gap 一樣用法。你也可以一次設定兩個值,像是 gap: 0.5em 1em,這樣就能分別指定垂直和水平的間距。

在剛開始定案網格規範的時候,gap 這個屬性原本叫做 grid-gap,所以可能會在一些早期的範例中看到這個名稱。其實它的功能跟現在的 gap 是一樣的。後來 CSS 規範更新了,改為比較通用的 gap,而且連 Flexbox 也一起加入支援,變得更一致好用。

剖析:Grid

想要真正掌握網格系統,就要先搞懂它的各個術語。接下來,在網格系統裡,還有四個術語是一定要知道的:

  • 網格線 (Grid Lines) :網格線構成整個網格的結構,可以是垂直的或水平的,分別出現在列或行的兩邊。如果你有設 gap 的話,網格線就會在間距的兩側,或是說間距會出現在網格線的之間。
  • 網格軌道 (Grid Tracks) :是指兩條相鄰網格線之間的區域。軌道可以是橫向的 (行) 或直向的 (列) 。
  • 網格單元格 (Grid Cells) :網格裡的每一格,簡單來說,就是橫向和直向的軌道交錯出來的空間。
  • 網格區域 (Grid Areas) :由一個或多個單元格組成的長方形區塊,範圍是由兩條垂直網格線和兩條水平網格線包起來的。

CSS Grid Layout

建立網格佈局的過程中,會不斷參考網格裡的這些基本結構。舉個例子來說,當你寫下 grid-template-columns: 1fr 1fr 1fr,其實就是定義了三個等寬的垂直「網格軌道」。這樣一來,也就產生了四條垂直的「網格線」:一條在網格線的最左邊、兩條在每個欄位之間,還有一條在最右邊。

Note:你的設計不一定要把網格的每個格子都填滿!如果你有區域想要留空白,只要讓那個格子保持空著就可以。記得要放進網格的每個元素,都必須是網格容器的直接子元素。

另外我們介紹一個新的語法:repeat() 函數。這是一種簡寫方式,可以幫你快速建立多個重複的網格軌道。像這樣寫:grid-template-rows: repeat(4, auto),就等於是你寫了 auto auto auto auto,意思是總共四行,並且每個軌道高度會依照內容自動調整大小。

你也可以用 repeat() 來定義一種重複模式。舉例來說,repeat(3, 2fr 1fr) 會重複 2fr 1fr 這個組合三次,結果就會是六個欄位:2fr 1fr 2fr 1fr 2fr 1fr

CSS Grid Repeat

我們可以將先前的例子改寫成這樣:

1
2
3
4
5
6
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr); /* repeat function */
grid-template-rows: repeat(2, 1fr);
gap: 0.5em;
}

定位:用數字編號定位網格線 Numbering grid lines

定義網格軌道後,我們將每個網格項目放置到特定位置上。瀏覽器為網格中的每條網格線指派編號,如下圖。 CSS 使用這些數字來指派每個項目應放置的位置

CSS Grid Repeat

用「網格編號」來指定每個項目要放在網格的哪個位置,這時候會用到 grid-columngrid-row 這兩個屬性。

比如說,如果你想讓某個項目跨越從第 1 條到第 3 條的垂直網格線,那就可以寫成:grid-column: 1 / 3。這樣它就會橫跨兩個欄位。同樣的道理,如果你想讓一個項目從第 3 條到第 5 條的水平網格線之間延伸,就可以寫成:grid-row: 3 / 5

這兩個屬性加起來,就能準確地指定一個項目要佔據的網格區域。

1
2
3
4
.card {
grid-column: 1 / 3;
grid-row: 3 / 5;
}

grid-columngrid-row 其實都是簡寫屬性。像 grid-column 其實是 grid-column-startgrid-column-end 的合併寫法,grid-row 也是一樣。只有在用簡寫的時候,才需要用斜線 / 來分隔起始和結束的位置。而那個斜線前後要不要加空格都可以的,純粹看你排版習慣。

Flexbox vs Grid

很多開發者常常會問:Flexbox 不是跟 Grid 很相似嗎?

其實不是,它們不是競爭對手,而是互補的工具,而且它們幾乎是一起被設計出來的。雖然有些功能會重疊,但在不同的情境下,它們各有強項。到底什麼時候該用 Flexbox、什麼時候該用 Grid?可以從這兩個核心差異來判斷:

  • Flexbox 是一維的,只能在「橫的」或「直的」方向上排列;
  • Grid 是二維的,可以同時處理橫向和縱向的排列。

還有一個觀念可以幫助理解:Flexbox 是由內而外,從內容出發,它會根據項目的大小來決定怎麼排版;而 Grid 則是由外到內,先規劃好版型,再把內容填進去,比較像你先畫好格子,再把東西放進去。

如果只是要排列一組類似的東西,比如導覽列、按鈕列,那用 Flexbox 會比較直覺;但如果你要建立一個整體版型,比如整個網頁的區塊結構,Grid 就會更適合,因為它能讓你精準對齊不同區塊。

Flexbox 是從內容向外排版,而 Grid 是從排版結構向內安排內容

Flexbox 很適合用來排列一整行或一整列的元素,而且你不需要特別設定它們的寬度或高度,因為每個項目的大小會依照它的內容自動調整。簡單來說,就是內容有多少,空間就給多少。

Grid 的做法就不太一樣了。你是先定好整個版面怎麼切格子 (幾欄、幾列) ,再把內容塞進去。當然,內容還是會影響格子的大小,但這個變動會影響整個列或整欄,也就是說,一個格子的內容變多,會影響跟它同一列或同一欄的其他項目。

但像是導航選單這種比較不需要精確對齊的項目,其實可以讓內容來決定寬度。比方說文字多的連結寬一點、文字少的就窄一點,這就是典型的「一維水平佈局」。在這種情況下,用 Flexbox 會比 Grid 更合適。

如果你的設計需要在橫向和縱向兩個方向上都安排項目,那就選 Grid;但如果你只需要在單一方向上排列項目,像是一列或一行,那就用 Flexbox 比較合適。

實際上,在大多數情況下 (雖然不一定總是這樣) ,Grid 比較適合用來處理整體版型的架構,例如整個頁面的區塊配置;而Flexbox 則很適合用來處理這些區塊裡面的內容排列,例如按鈕列、選單、圖片組合等等。

注意:無論是 Grid 還是 Flexbox,都可以防止子項目之間的邊界重疊 (Margin Collapsing),這點跟平常使用的區塊排版不太一樣。不過有時候,瀏覽器預設樣式會讓區塊之間看起來空隙太大,可以重新設定瀏覽器預設樣式 margin: 0,來避免間距過多的問題。

好用語法

到這邊為止,我們已經把 Grid 的基本概念都跑過一輪了。接下來要介紹另外兩種寫法:命名網格線 (Naming grid lines)命名網格區域 (Naming grid areas)

這兩種方式其實沒有誰比較好,主要看你的習慣。根據設計的情況,有時候用其中一種會比較直觀、比較容易看懂。

命名網格線 (Naming grid lines)

網格會有很多欄、很多行,單靠數字來追蹤每一條網格線,會讓人眼花撩亂。為了讓這件事變簡單一點,我們可以幫網格線取名,這樣就不需在記數字,換成語意清楚的名稱來表示位置。做法很簡單:在你定義網格行或列的時候,把名稱放在中括號裡。

1
grid-template-columns: [start] 2fr [center] 1fr [end];

也就是建立一個有兩欄的網格,中間會有三條垂直的網格線,分別叫做 startcenter、和 end。之後在放項目的時候,就可以這樣寫:

1
grid-column: start / center;

這樣就代表這個項目從 start 線開始,到 center 線結束,比起寫 1 / 2,更容易理解。

另外也可以給同一條網格線設定多個名稱

1
2
3
4
grid-template-columns: 
[left-start] 2fr
[left-end right-start] 1fr
[right-end];

這樣的設定第 2 個網格線,可以同時有兩個名字:left-endright-start。你在放項目的時候,想用哪個名稱都可以。

更厲害的是,如果你使用 -start-end 這種命名方式,瀏覽器會自動把這兩個之間的區域理解成一個區塊。也就是說,當你定義了 left-startleft-end,就等於創造了一個叫做 left 的區域。之後就可以這樣寫:

1
grid-column: left;

那元素就會從 left-start 跨到 left-end,非常直覺!我把完整範例放在下方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.container {
display: grid;
grid-template-columns:
[left-start] 2fr
[left-end right-start] 1fr
[right-end];
grid-template-rows: repeat(4, [row] auto);
}
.item1 {
grid-column: left-start / right-end;
grid-row: span 1;
}
.item2 {
grid-column: left; /* 直接指定區域 */
grid-row: row 3 / span 2; /* `row 3` 代表第三個 row 的網格線 */
}

這個範例是用命名網格線來把每個項目放進對應的網格中。而且這個寫法還蠻有趣的:它在 repeat() 函數裡面也加上了水平網格線名稱。

這樣每一條水平網格線 (除了最後一條) 都會被命名。雖然一開始看起來有點怪怪的,但其實同一個名字可以重複使用,這在語法上是完全沒問題的。

row 3 代表從第 3 條同樣命名為 row 的水平網格線開始,然後 span 2 代表與下方軌道合併。

命名網格線的用法其實非常靈活,可以根據自己的排版需求來決定怎麼用。每個網格的結構不同,用法當然也會有所變化。

CSS Grid Naming

這個範例是一種重複的兩欄網格模式,重點在於在每對欄位前面命名網格線。

1
grid-template-columns: repeat(3, [col] 1fr 1fr);

這表示會重複三次這個組合:在一條叫 col 的網格線後面放兩欄。也就是說,你會有三組欄位,每組前面都有一條 col 的命名網格線。接下來,可以這樣放置元素:

1
grid-column: col 2 / span 2;

這行的意思是:從第二個名為 col 的網格線開始,然後橫跨兩欄。

命名網格區域 (Naming grid areas)

命名網格區域是另一種放置網格項目的方式,這種寫法更直觀,你不用去計算格子的編號,也不用命名一堆網格線,而是直接用區塊的名字來安排版面。做法很簡單:

  • 在網格容器上使用 grid-template-areas,來劃分出每個區域的位置。
  • 然後在每個網格項目上加上 grid-area,指派它要對應到哪個區域。

這種寫法算是一種語意化的替代語法,有時候會讓整體結構更容易看懂,特別是對大型版型來說。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
.container {
display: grid;
grid-template-areas:
"title title"
"nav nav"
"main aside1"
"main aside2";
grid-template-columns: 2fr 1fr;
grid-template-rows: repeat(4, auto);
}
.title {
grid-area: title;
}
.nav {
grid-area: nav;
}
.main {
grid-area: main;
}
.aside1 {
grid-area: aside1;
}
.aside2 {
grid-area: aside2;
}

grid-template-areas 這個屬性可以在 CSS 裡用一種像是「ASCII」的方式排版,把整個網格畫出來。

我們將字串加上雙引號,每字串就代表網格裡的一行,然後用空格分隔每一個區域位置。看起來就像你在 CSS 裡畫出一塊區域。第一行可能整排都寫成 title title,代表整行都是標題;第二行寫成 nav nav 放導覽選單;接下來兩行,左邊的格子用 main,右邊分別用 aside1aside2,就能準確表示每個區塊位置。然後你只要在對應的項目加上:

1
grid-area: main;

這樣元素就會自動被放到 main 那一格去。不用再比對數字和線名,這種方式適合固定、清楚分布的版型設計,用來快速看懂整體排版很方便。

注意:每個命名網格區域都必須是個矩形。你不能用它來畫出像 L 形、U 形這種不規則的區塊,這是不被允許的。

如果你想要在某些地方留空白區域,也很簡單,只要在 grid-template-areas 的字串裡用句點 . 當作空白格子就可以。舉個例子,像下方這樣寫法:

1
2
3
4
grid-template-areas:
"top top right"
"left . right"
"left bottom bottom";

這段程式碼定義了四個區塊 (toprightleftbottom) ,中間那個格子留空,是用句點表示的。這樣的視覺結構看起來也很清楚,一眼就知道每個區域的位置。

總結一下:當你在做 Grid 佈局時,可以選擇三種方式來安排元素位置:

  • 編號網格線 Numbering Grid Lines
  • 命名網格線 Naming Grid Lines
  • 命名網格區域 Naming Grid Areas

這三種方式都沒有對錯,選你最順手的就好。但個人比較喜歡使用命名區域,因為比較直觀清楚。

顯式和隱式網格 Explicit and implicit grid

有時候你不一定知道每個項目應該放在網格的哪個位置,像是,你可能有一大堆項目要處理,一個一個手動指定位置很麻煩,又或者這些項目是從資料庫來的,數量根本不確定。在這種情況下,與其指定每個位置,不如大致定義網格結構,然後讓瀏覽器自動幫你擺好,這時候我們就會用到所謂的「隱式網格」 (implicit grid)

你平常用 grid-template-columnsgrid-template-rows 來定義網格時,其實是在建立一個「顯式網格」 (explicit grid),就是你指定哪一欄、哪一列。

但如果有項目超出你定義的欄或列,瀏覽器就會自動幫你新增額外的軌道,也就是所謂的隱式軌道,這樣整個網格就會自動擴展來容納那些額外的項目。

如果某個項目被放到沒有事先定義的格子外面,瀏覽器就會自動幫你新增隱式網格,讓這個項目有地方可以放。它會一直加軌道,直到能夠完整容納這個項目為止。

CSS Grid Layout

上圖的網格只有明確定義一個軌道,但當有項目跑到「第二行」的位置時,瀏覽器就自動新增一條行軌道,來裝下多出來的元素。

在預設情況下,隱式網格軌道的大小是 auto,意思是它們會自動根據內容大小來調整。

不過,如果你想要更精確地控制這些隱式軌道尺寸,可以在網格容器上加上 grid-auto-columnsgrid-auto-rows,像這樣:

1
grid-auto-columns: 1fr;

這樣一來,所有自動新增的欄位都會套用這個大小。

隱式網格軌道不會影響負數網格線的行為。就算有隱式軌道,你用負數 -1 來對齊,它還是會從顯式網格的最底部或最右邊開始算,不會變。

攝影作品集

接下來,我們要實作一個新的頁面,來看看隱式網格在實務上有多好用!這次的範例是一個攝影作品集頁面,最後會像這樣:

CSS Grid Portfolio

我們只定義欄位 (columns) ,每一行都讓瀏覽器自動產生 (也就是隱式網格)。這樣好處是:你不需要知道有幾張圖,不管是 3 張、10 張還是 100 張,網格都會自動幫你排好。每當照片需要換行,就自動新增一行,完全不用手動調整。首先建立我們的 HTML,圖片是找線上隨機產生的圖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<div class="portfolio">
<figure class="featured">
<img src="https://picsum.photos/seed/a/400/400" alt="random image" />
</figure>
<figure>
<img src="https://picsum.photos/seed/b/200/200" alt="random image" />
</figure>
<figure class="featured">
<img src="https://picsum.photos/seed/e/400/400" alt="random image" />
</figure>
<figure>
<img src="https://picsum.photos/seed/c/200/200" alt="random image" />
</figure>
<figure>
<img src="https://picsum.photos/seed/d/200/200" alt="random image" />
</figure>
<figure>
<img src="https://picsum.photos/seed/f/200/200" alt="random image" />
</figure>
</div>

我們用 .portfolio 的元素來當作網格容器,裡面放了一系列 <figure>,每個 <figure> 都有一張圖片 <img>,這些 <figure> 就是網格項目。其中幾項上加上了 .featured 的 class,我們會讓它們在版面上看起來比其他的圖大一點,做出視覺重點。

我們分幾個步驟來完成作品集的排版:一開始,先定義好網格軌道,讓所有圖片整齊排列;接著,再把 .featured 放大;最後做一些調整和收尾,讓整體看起來更完整。

我們使用 grid-auto-rows,把所有隱式網格行高都設成 1fr,也就是每一行的高度都一樣,平均分配。這段 CSS 也引入了兩個新概念:auto-fillminmax() 函數,我等等再詳細解釋它們的用法。現在,先把清單裡的 CSS 加進去,我們再來看看這些新語法是怎麼用的。

CSS Grid Portfolio

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
body {
background-color: #f1dbba;
font-family: Helvetica, Arial, sans-serif;
}
.portfolio {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-auto-rows: 1fr;
grid-gap: 1em;
}
.portfolio > figure {
margin: 0;
}
.portfolio img {
max-inline-size: 100%;
}

有時候你不想幫網格軌道設定固定寬度,而是希望它在某個最小值跟最大值之間變動,這時候就可以用 minmax() 函數。這個函數的寫法是 minmax(最小值, 最大值),它會告訴瀏覽器:「這個軌道的大小不能小於某個值,也不能大於某個值」。舉例來說,如果你寫: minmax(200px, 1fr)。那瀏覽器就會保證每個欄位至少有 200px 寬,如果還有空間,就讓它們彈性變寬,最多佔 1fr 的比例空間。

另外還有一個關鍵字叫 auto-fill,是用來搭配 repeat() 函數的特別值。它的意思是:「根據你設定的寬度限制,自動塞滿整個容器能放下的欄數」。也就是說,如果你這樣寫:grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))。瀏覽器就會自動放入盡可能多的欄位,而且每一欄最少寬 200px,最多寬不超過 1fr,這樣每個欄位就會平均分配空間,而且不會太小。

這種搭配很好用,尤其在做 RWD (響應式網頁) 時,可以讓你的格子根據螢幕自動調整數量與大小,不需要寫一堆 @media query

如果螢幕寬度剛好能放下 4 欄、每欄 200px 寬的網格,瀏覽器就自動幫你建立了 4 個欄位。如果螢幕更寬,能放更多,瀏覽器就會多加幾欄;反過來,如果螢幕比較窄,那欄位數也會自動變少。

但要注意:使用 auto-fill 的時候,有可能會出現空的網格欄位。比方說,你的畫面可以放 6 欄,但實際上只有 4 個項目,那剩下的 2 欄就會變成空的格子,還是照樣被排出來。

CSS Grid Auto Fill vs Auto Fit

如果你不想出現這種空的格子,那可以改用 auto-fit 來取代 auto-fillauto-fit 的意思是:「把有內容的欄位拉寬來填滿整個容器」,這樣就不會留下空的欄位,看起來更緊湊。

  • auto-fill:你會得到預期數量的欄位大小,不管有沒有東西填進去;
  • auto-fit:會讓有內容的欄位自動撐開,讓整個容器看起來是滿的,不會有多餘空格。

用哪一個,取決你是想要固定欄數,還是想要讓畫面填滿得漂亮。

增加變化:將 .feature 放大

接下來,我們可以視覺效果更吸引人一點:把 .feature 圖片放大。目前每個網格項目都只佔 1x1 的格子,也就是一欄一列。現在我們要讓 .featured 的項目放大到佔兩欄、兩列,也就是 2x2 的區塊。用 .featured 來鎖定這些項目,然後讓它們在水平和垂直方向上各跨兩個軌道,就能達成放大的效果。

CSS Grid Porfolio

不過這樣做會遇到一個小問題:當你讓某些項目變大時,根據項目的順序,有可能會在網格中產生空隙 (如右上角)。因為有些項目原本預期只佔一格,突然變大後可能會擠到別的項目,結果就出現一些空白。

在這個圖裡,因為網格裡的第 3 個項目放大了,要佔兩格寬和高,所以沒辦法塞進右上角的空間裡,結果它就被擠到下一行。為什麼會這樣?因為當你沒特別指定每個項目的位置時,瀏覽器會使用內建的「網格項目自動放置演算法 (Grid item placement algorithm)」來自動幫你排列。

這個演算法的預設行為是:按照 HTML 裡項目的順序,從左到右、從上往下放。但如果某個項目太大,放不下當前這一行,它就會被移到下一行,去找能放得下的空間。所以在這個例子中,第三個項目就被移到下方,因為那邊的位置才夠。

不過好消息是:CSS Grid 有提供一個屬性叫做 grid-auto-flow,可以用來控制這個排版邏輯。預設值是 row,就是我們剛剛說的「先按列再換行」;如果你設定成 column,那它就會變成「先按行再換列」;更進一步,你可以加上 dense,像這樣:

1
grid-auto-flow: row dense;

這樣做的效果是:演算法會盡量把小的項目「補進」那些因為大項目被跳過的位置。就算會讓項目呈現的順序和原本 HTML 的順序不完全一樣,它也會盡可能把版面填滿,不留空白。

現在,我們來更新一下樣式表:

1
2
3
4
5
6
7
8
9
10
11
.portfolio {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-auto-rows: 1fr;
gap: 1em;
grid-auto-flow: dense;
}
.portfolio .featured {
grid-row: span 2;
grid-column: span 2;
}

這段樣式使用了 grid-auto-flow: dense,其實等同於寫成 grid-auto-flow: row dense,因為 row 是預設值,就算省略也一樣有效。接著,鎖定 .featured 項目讓它在水平方向和垂直方向上各跨兩格,也就是:

CSS Grid Porfolio

注意這個範例只用 span 關鍵字來設定大小,並沒有明確指定項目要放在哪一格。這樣的寫法可以讓瀏覽器的「網格項目自動放置演算法自行決定這些項目要排在哪裡,不用特別指定位置,也避免出現錯位或衝突。

根據你螢幕寬度,你看到的畫面可能會跟上圖完全不一樣。這是因為我們用了 auto-fill 來決定垂直網格軌道的數量。如果你的螢幕比較寬,就能容納更多欄;如果螢幕比較窄,就只會顯示比較少的欄位。

我自己是用大約 1000px 左右的視窗來截圖,所以畫面上剛好出現了 4 欄。如果調整瀏覽器的寬度,網格就會自動適應空間變化,這就是 Grid 的厲害之處!

當你使用 grid-auto-flow: dense 時,畫面上項目的排列順序,可能跟 HTML 原始碼的順序不一樣。對大多數使用者沒差,但對鍵盤 (像是按 Tab 鍵) 或用螢幕閱讀器的使用者來說可能會有點混亂,因為這些輔助工具是根據原始碼的順序來瀏覽,不是照畫面上看到的順序。所以如果你很在意無障礙體驗 (accessibility) ,在使用 dense 這個選項時要特別小心,確保不會造成使用上的混淆。

微調

現在我們已經完成一個複雜的網格佈局,而且還不需要指定每個項目位置,瀏覽器自動幫你排好,非常省力。不過最後還有一個小問題要解決:你會發現有些圖片放大 (.featured項目) 並沒有完全填滿它所在的網格格子,導致下面或右邊多出一點小空隙。理想情況下,同一個網格軌道裡的所有項目,上緣和下緣都應該要對齊,這樣版面才整齊。目前我們的圖片上緣對齊沒問題,但下緣就有點歪。

CSS Grid Porfolio

我們來把空隙修正一下,還記得每個網格項目其實是一個 <figure> 元素,裡面有一張圖片:

1
2
3
<figure class="featured">
<img src="..." alt="monkey" />
</figure>

預設情況下,每個 <figure> (也就是網格項目) 會自動撐滿它被分配到的整個格子,但網格項目裡的子元素 (像圖片) 並不會自動撐滿整個高度。結果就是:<figure> 佔滿了格子,但 <img> 還是保留原來的大小,所以下面就出現一段空白。

要修正這種情況,我們可以用 Flexbox 來解決圖片下方出現空隙的問題。我們可以把每個 <figure> 設成一個彈性容器,接著,對圖片設定 flex-grow,這樣它就會撐滿剩下的空間,不會是原本圖片的高度,下面也就不會有空白了。

但這樣會遇到另一個問題:圖片被強行拉伸的時候,寬高比例會跑掉,導致變形。CSS 有一個專門處理圖片拉伸的屬性叫做 object-fit。預設情況下,<img>object-fitfill,也就是整張圖片會撐滿 <img> 這個框框,不管比例對不對。如果你想讓圖片保持原來的比例,那可以改用這兩個設定:

  • cover:讓圖片放大到填滿整個容器,即使有一部分被裁掉也沒關係。
  • contain:讓整張圖片完整顯示在容器裡,但容器裡可能會留一些空白。

下圖展示這些效果的差別,選擇哪一種取決於你希望圖片被裁切掉,還是要完整顯示。

CSS Grid Image Object Fit

這裡有一個很重要的觀念要釐清:<img> 有兩個層次的尺寸要分開看:

  • 一個是外框大小,也就是 <img> 元素本身的 widthheight
  • 另一個是圖片內容大小,透過 object-fit 設定圖片在框裡面怎麼呈現。

預設情況下,這兩者大小是相同的。但透過 object-fit 可以控制圖片在框裡的呈現方式,而不會因此改變框的大小。

我們剛才用 flex-grow 讓圖片伸展去填滿整個網格格子,然後再加上 object-fit: cover,讓圖片避免拉扯變形。cover 會讓圖片保持比例,並撐滿整個外框,雖然會裁掉圖片的邊角,但這是我們在這裡所做的取捨。

1
2
3
4
5
6
7
.portfolio > figure {
display: flex;
}
.portfolio img {
flex: 1;
object-fit: cover;
}

CSS Grid Porfolio

Subgrid

前面範例中,我們的網格結構都只侷限在 DOM 的兩個層級內:一個網格容器 (parent) 加上它的直接網格項目子元素 (children) 。不過在某些設計中,你可能會遇到更複雜的需求,像是:你需要對齊不在同一個父層底下的元素;或者你希望兩個元素,雖然不在同一層,卻能有一樣的大小或對齊方式。舉個例子:你可能想要讓兩個相同祖先的內容區塊,都能垂直對齊或寬度一致,無論內容多少。

CSS Grid Subgrid

在這個設計裡,每張卡片本身就是網格的一部分,所以它們會整齊地排成一列,並且高度一致。但不只這樣,卡片裡面的內容也會彼此對齊。像有些卡片的標題比較短,這時就會在上方加一些空間,讓它跟標題比較長的卡片對齊;每段內文的開頭也都對齊在同一高度,最底下的「Read More」連結也都整齊排在同一條水平線上。要做到內層與外層內容同步對齊的效果,最理想的方法是用新的 CSS 功能:子網格 (subgrid)

子網格的好處是:可以在一個網格容器裡再建立一個內層網格,並且讓這個內層網格的項目直接對齊外層網格的線條。這樣就可以讓整體看起來更一致。

在這個頁面中,每張卡片會跨三行,而卡片裡的標題、段落、按鈕也剛好對應到那三行的位置。不管是卡片本身還是卡片裡的內容,都是依照同一組網格線排列。

CSS Grid Subgrid

子網格 Subgrid 是一個比較新的 CSS 功能,已經支援大多數瀏覽器。你可以到這個網站查看最新的支援狀況:https://caniuse.com/css-subgrid

我們先建立一個最基本的卡片和樣式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<div class="author-bios">     
<div class="card">
<h3>Long Long Long Long Long Long Long Long Long Long Long Long Long Long Long Long Header</h3>
<div>
<p>
Lorem Ipsum
</p>
</div>
<div class="read-more"><a href="#">Read more</a></div>
</div>
<div class="card">
<h3>Header</h3>
<div>
<p>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum
</p>
</div>
<div class="read-more"><a href="#">Read more</a></div>
</div>
<div class="card">
<h3>Header</h3>
<div>
<p>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.
</p>
</div>
<div class="read-more"><a href="#">Read more</a></div>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
body {
background-color: #eee;
font-family: Helvetica, Arial, sans-serif;
}
.author-bios {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1em;
max-inline-size: 800px;
margin-inline: auto;
}
.card {
padding: 1rem;
border-radius: 0.5rem;
background-color: #fff;
}
.card > h3 {
margin-block: 0;
padding-block-end: 0.5rem;
border-block-end: 1px solid #eee;
}
.read-more {
margin-block-start: 1em;
text-align: right;
}

CSS Grid Subgrid

現在樣式加上去後,已經接近你想要的設計。不過,還差最後一步:我們來用子網格,讓卡片裡的內容也能跟整個網格對齊一致。

要在頁面上套用子網格,需要做兩件事:先在每個網格項目 (也就是卡片) 上加上 display: grid,這樣就能在每張卡片裡建立一個內層網格;然後,在內層網格的 grid-template-rowsgrid-template-columns 上設定 subgrid,這表示:這個內層網格要使用父層網格的網格來對齊

這樣一來,卡片裡的內容 (標題、文字、按鈕) 就會直接對齊其他卡片內容,達到完全一致的視覺效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.card {
display: grid;
gap: 0;
grid-row: span 3;
grid-template-rows: subgrid;
padding: 1rem;
border-radius: 0.5rem;
background-color: #fff;
}
.card > h3 {
align-self: end;
margin-block: 0;
padding-block-end: 0.5rem;
border-block-end: 1px solid #eee;
}

因為子網格是比較新的功能,有些瀏覽器可能還不支援。如果你想要更保險一點,也可以用 CSS 的功能查詢 (feature query) 來判斷瀏覽器是否支援 subgrid,像這樣寫:

1
2
3
@supports (grid-template-rows: subgrid) {
/* 只有支援 subgrid 的瀏覽器才會套用這段樣式 */
}

另外也可以用 grid-template-columns: subgrid 來對齊列 (columns),但前提是父層有定義網格。你也可以列和欄一起使用 subgrid,讓內部區塊完整對齊父容器。

更進一步,你還可以把這個結構一層一層往下套,也就是在一個子網格裡面再建立另一個子網格,讓整個 DOM 結構中的區塊都能保持一致的網格對齊,非常適合用在大型或模組化的設計裡。

subgrid 中,有一個很棒的特性是:你可以繼承父網格的所有線號 (line numbers) 、線名稱 (line names) 和區域名稱 (grid area names) ,這代表你可以直接用這些資訊來擺放子網格中的元素。舉個例子,假設你想要把卡片中的標題全部放在第二行,你可以這樣寫:

1
2
3
.card > h3 {
grid-row: 2;
}

除了可以繼承父網格的線名稱,你也可以在 subgrid 裡定義自己的線名稱,即使父網格沒有那些名稱:

1
2
3
.card {
grid-template-rows: subgrid [line1] [line2] [line3] [line4];
}

這樣就能使用自訂的線名稱來放置元素。grid-row: line3 / line4; 這表示你要把項目放在第 3 條和第 4 條橫向網格線之間的位置。

Masonry

補充小知識,未來 CSS 可能會加入新功能叫做「masonry layout (瀑布流排版) 」,這種排版方式在圖片集 (像 Pinterest) 裡很常見,但目前要靠 JavaScript 才能實現。

CSS Grid Masonry

Masonry 的特色是:它會把項目放在等寬的欄位中,但每個項目的高度可以不一樣,排版就不會要求每一列都整齊對齊,能更靈活地呈現不同大小的內容。

你可以到這個網站查看最新的支援狀況 https://caniuse.com/?search=masonry

對齊屬性

在 CSS Grid 模組中,我們會用到一些跟 Flexbox 很像的對齊屬性,另外還多了幾個新的。我們其實已經在 Flexbox 介紹過大部分,但有一些屬性是只有在 Grid 裡才有作用的。當你需要更細緻地控制網格排版時,這些對齊屬性就會派上用場。

首先是三個 justify 開頭的屬性,這些是用來 控制水平方向 (橫向) 的排列。接著是三個 align 開頭的屬性,用來控制垂直方向 (直向) 的排列

CSS Grid Alignment Property

最後補充一點:CSS 還提供了簡寫屬性,可以同時設定 align-*justify-* 的值:

  • place-content:同時設定 align-contentjustify-content
  • place-items:同時設定 align-itemsjustify-items
  • place-self:同時設定 align-selfjustify-self

Reference

CSS Flexbox 深入淺出

Flexbox 正式的名稱又稱為 Flexible Box Layout (彈性框排列),是一種在頁面上排列元素 (element) 的方法,它主要用於將元素排列成行或列,也為歷史上困擾已久的垂直居中和等高列的問題提供了一個簡單的解決方案。要說 flexbox 的缺點,那就是它提供的選項數量太多了。

其實不需要把所有新的屬性都學會才能使用 flexbox,因為通常會使用的就那幾個,其他的屬性主要是用來調整對齊方式和元素之間的間距。

這是我們的 Outline:

  • Flexbox 原理
  • Flex item 調整彈性項目
  • Flex Direction 排列方向
  • Flex 參數資料整理

Flexbox 原理

從我們熟悉的 display 屬性開始的。當你在一個元素上設置 display: flex 時,它就會變成一個「彈性容器」 (flexbox container),而它的直接子元素則會變成「彈性項目」 (flexbox item)。預設情況下,這些彈性項目會橫向排列,從左到右排成一行;彈性容器會像 block 區塊元素撐滿可用的寬度,不過裡面的彈性項目不一定會填滿整個容器的寬度,另外它們的高度通常一樣,主要由內容的大小決定。

CSS Flexbox Principle

Note: 你也可以使用 display: inline-flex,這會讓元素變成一個彈性容器,不過它的行為更像是 inline-block,而不是一般的 block 區塊元素。它會跟其他內聯 (inline) 在一行上,不會像區塊元素那樣自動拉到 100% 寬度。至於裡面的彈性項目,表現基本上和 display: flex 是差不多的。實際上不太常會用到這個設定。

這些彈性項目會沿著一條叫「主軸」 (Main Axis) 的方向排列,這條軸是從主軸起點 (Main Start) 延伸到主軸終點 (Main End),方向從左到右。主軸垂直的方向叫做「交叉軸」(Cross Axis),是從交叉軸起點 (Cross Start)到交叉軸終點 (Cross End),方向由上到下。Flexbox 屬性一致使用 start 和 end 術語。

導覽列範例 Menu Bar

我們想要建立一個的選單,大部分的項目會靠左排列,只有其中一個項目在右邊:

CSS Flexbox Image

1
2
3
4
5
6
7
<ul class="site-nav">
<li><a href="/">Home</a></li>
<li><a href="/features">Features</a></li>
<li><a href="/pricing">Pricing</a></li>
<li><a href="/support">Support</a></li>
<li class="nav-right"><a href="/about">About</a></li>
</ul>

在建立這個選單時,你需要先想清楚哪個元素要設為彈性容器。注意它的子元素會自動變成彈性項目。我們下方的範例,應該把 <ul> 設為彈性容器,而它裡面的 <li> 就是彈性項目。把瀏覽器預設的清單樣式取消掉,並加上一些顏色設定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.site-nav {
display: flex; /* 設定 flexbox */
padding: 0.5rem;
list-style-type: none;
background-color: #eee;
}

.site-nav > li > a {
display: block;
padding: 0.5em 1em;
background-color: #bbb;
color: black;
text-decoration: none;
}

在這個範例裡,選單項目的內邊距 padding 是加在裡面的 <a> 標籤上,而不是直接加在 <li> 上。這是因為在點擊時,選單連結的區域都能像連結一樣運作,如果 padding 加在 <li> 上,則 <li> 看起來有一個按鈕,但只有裡面那一小塊 <a> 是能點的。

我們把連結設為 block 元素。這是因為如果還是維持 inline 狀態,那 <a> 對父元素高度的影響只會來自行高 line-height,而不會包含 padding 的高度。但在這裡,我們希望能用 padding 來調整連結區域的大小。

我們要在選單之間加點間距。雖然可以用 margin 辦到,不過 Flexbox 有個更方便的屬性叫 gap,專門用來處理這種情況,比如設定 gap: 1rem,就能在每個彈性項目之間加入 1rem 的間距。此外,Flexbox 也支援用 margin: auto 來填滿項目之間所有剩下的空間,這個技巧也可以用來把最後一個選單項目推到最右邊。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.site-nav {
display: flex;
gap: 1.5rem;
padding: 0.5rem;
list-style-type: none;
background-color: #eee;
}

.site-nav > .nav-right {
margin-inline-start: auto;
}

.site-nav > li > a {
display: block;
padding: 0.5em 1em;
background-color: #bbb;
color: black;
text-decoration: none;
}

margin-inline-startinline 代表的是文字書寫方向,這裡指的是水平的方向,也就是寬度。start 代表的是書寫方向的起點,以我們的例子來說就是左邊。

調整彈性項目 Flex item

在調整 Flexbox 元素大小時,雖然你還是可以用我們熟悉的 widthheight 屬性,不過 flexbox 提供的調整方式比單靠這兩個屬性還要靈活得多。接下來我們要看看其中一個非常實用的屬性:flex (MDN Flex)。這個屬性能控制彈性項目在主軸方向上的大小(通常是寬度)。

1
2
3
4
<div class="flexed">
<div class="column-main">Header</div>
<div class="column-sidebar">Sidebar</div>
</div>

我們將內容分為兩列:左側是主欄,右側是側欄。如果還沒做任何事情來指定兩列的寬度,因此它們會根據其內容自然地調整大小。彈性項目的 flex 屬性為您提供了多種選擇,我們先來看最基本的範例熟悉它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.flexed {
display: flex;
gap: 1.5rem;
}
.column-main {
flex: 2;
}
.column-sidebar {
flex: 1;
}

/* add some color */
.flexed > div {
background-color: #eee;
height: 100px;
text-align: center;
}

CSS Flexbox Image

使用 .column-main.column-sidebar 來定位列,分別套用 flex 2/3 和 1/3 的寬度。這兩個欄位會自動延伸來填滿整個區域,主欄的寬度會是側欄的兩倍。這就是 Flexbox 的厲害之處,它能幫你處理這些計算的細節。

flex 這個屬性,其實是三個屬性的縮寫:flex-growflex-shrinkflex-basis。在這個例子裡,我們只設定了 flex-grow,其他兩個就會採用預設值(分別是 1 和 0%)。所以像 flex: 2 這樣的寫法,其實等同於 flex: 2 1 0%。雖然這種縮寫形式很常用,但如果需要更細的控制,你也可以分別設定這三個屬性。

1
2
3
flex-grow: 2;
flex-shrink: 1;
flex-basis: 0%;

我們分別介紹這些屬性,先從第三個 flex-basis 開始,因為另外兩個會基於 flex-basis 的結果來做調整。

Flex basis: 設定項目大小 (主軸方向)

flex-basis (MDN Flex Basis) 是用來設定元素沿主軸方向的大小。你可以用任何寬度的單位來設定 flex-basis,像是 px、em 或百分比等。

flex-basis初始值 initialauto,如果是 auto 的話瀏覽器會先看當前元素有沒有設定寬度 width。如果有,就使用 width 的值;如果沒有,就依照內容自動決定大小。

也就是說,當你給一個元素設定了非 autoflex-basis 值,原本的寬度 width 設定就會被忽略。

CSS Flexbox Basis

Flex grow: 設定項目放大權重

當每個彈性項目有了 flex-basis 之後,它們有時還需要放大或縮小,以便適應或填滿彈性容器在主軸上的空間。這時候,就輪到 flex-grow (MDN Flex Glow) 和 flex-shrink 發揮作用了。

當每個彈性項目的 flex-basis 被計算出來後,這些項目加上它們之間的 marginpadding,總寬度會達到一個固定值。但這個總寬度不一定剛好填滿整個彈性容器,通常會剩下一些空間。這些多出來的空間,會依照每個彈性項目的 flex-grow 值來分配。這些值都是非負整數。如果某個項目的 flex-grow 是 0,那它就不會比它原本的 flex-basis 更大;但如果某些項目的 flex-grow 是非零的,那它們就會開始擴展,直到把所有剩下的空間都填滿為止。這樣一來,彈性項目就能完全撐滿容器的寬度。

CSS Flexbox Grow

設定較高的 flex-grow 值,等於是賦予這個元素更多的「權重」,它就會拿走剩餘空間中更大的一部分。比如,一個 flex-grow: 2 的項目,會比一個 flex-grow: 1 的項目多擴展一倍的寬度。

CSS Flexbox Grow

這個設定正是我們先前的範例。簡寫寫法 flex: 2flex: 1 表示 flex-basis0%,也就是說整個容器寬度的 100% 都算作剩餘空間(扣掉兩欄之間的 1.5rem 間隙)。然後這些剩下的空間會被分配給兩個欄位:第一欄拿到三分之二,第二欄則拿到剩下的三分之一。

flex 跟大多數簡寫屬性 (shorthand property) 不同的是,這裡如果你省略了一些值,它們不會被還原成初始值,而是會自動套用一些實用的預設值:也就是 flex-grow: 1flex-shrink: 1flex-basis: 0%。這些預設通常就是你需要的設定。這種情況就會建議你使用 flex 的簡寫屬性,而不是只單獨設定 flex-grow

Flex shrink: 設定項目縮小權重

flex-shrink (MDN Flex Shrink) 屬性的作用和 flex-grow 類似。當彈性項目的 flex-basis 確定後,如果這些項目的總寬度超出了彈性容器的可用空間,就可能會出現溢位 (overflow)。

CSS Flexbox Shrink

這時候,每個項目的 flex-shrink 值就決定它是否應該縮小來避免溢出。如果某個項目的 flex-shrink 設為 0,那它就不會縮小。而如果設為大於 0 的數值,它就會縮小,直到不再超出容器。值越高的項目會比值低的項目縮小得更多,縮小的比例是根據 flex-shrink 的值來分配的。同時也會考慮原本的大小,所以當權重相同時,大元素通常會比小元素縮得更多。

在頁面佈局中,這提供了另一種調整欄位寬度的方法。你可以設定每欄的 flex-basis 為所需的百分比(例如 66.67% 和 33.33%),這樣它們加上中間的 1.5rem 間隙總寬度就會多出 1.5rem。然後將兩欄的 flex-shrink 都設為 1,這樣它們就會各自縮小一點來適應容器的寬度。

1
2
3
4
5
6
.column-main {
flex: 66.67%; /* 等同於 flex: 1 1 66.67% */
}
.column-sidebar {
flex: 33.33%; /* 等同於 flex: 1 1 33.33% */
}

這是一種不同的做法,但一樣能有效地達到和之前相同的效果。(跟使用 flex-grow 的方式相比,會有一些微小的差別,使用 flex-shrink 的例子左邊欄位會大一點點)

Flex 範例與各種變化

你可以用很多種方式來運用 flex 屬性。就像我們在頁面上做的那樣,可以透過 flex-growflex-basis 來設定欄位的比例。你也可以設定某些欄是固定寬度,而其他欄則根據視窗大小變化,形成流動欄位 (fluid column)。

CSS Flexbox Example

第三個例子展示的是以前常被稱作「聖杯佈局」版型 (Holy Grail Layout),這在早期的 CSS 裡是非常難實現的一種排版方式。在這個佈局中,左右兩側的欄位寬度是固定的,而中間那欄是「流動的」,也就是說它會自動擴展來填滿剩下的空間。最特別的是,這三個欄位的高度會自動一致,依內容而定。

Flex Direction

Flexbox 裡另一個很重要的功能,就是可以改變排列項目的軸向。你可以透過設定彈性容器的 flex-direction 屬性來控制這一點。它的預設值是 row,讓項目沿著橫向排列,也就是沿著內聯方向排列。如果你改成 flex-direction: column,那項目就會垂直堆疊,也就是沿著區塊方向排列。Flexbox 同時還支援 row-reverse,可以讓項目從右往左排,或是 column-reverse,讓項目從下往上排。

CSS Flexbox Direction

內聯方向 (inline direction)是書寫時的方向。英文是由左到右書寫,因此內聯方向代表著寬度 width。但中文可能由上到下書寫,則內聯方向代表高度 height。區塊方向 (block) 則是跟書寫垂直的方向。

對齊、間距、其他參數

Flex 提供了各式各樣的設定。通常來說,這些步驟可以幫你把元素大致排到想要的位置:

  • 先找出要當作容器的元素,然後在它上面加上 display: flex,讓它變成彈性容器
  • 如果需要的話,可以在容器上設定 gap 或是改變排列方向flex-direction
  • 接著在需要控制大小的彈性項目上設定 flex 屬性

之後如果需要再微調,你可以再加上其他屬性。

彈性容器參數表格:

CSS Flexbox Cheat Sheet

彈性項目參數表格:

CSS Flexbox Cheat Sheet

彈性容器參數

Flex Wrap

flex-wrap 屬性可以讓彈性項目換行,也可以讓它們排成多行。這個屬性有三個值可以設:nowrap(預設值)、wrapwrap-reverse。如果開啟了換行,項目就不會再根據它們的 flex-shrink 值來縮小,而是會直接換到新的一行,避免擠不下。
如果彈性方向是 columncolumn-reverse,那開啟 flex-wrap 會讓項目換到新的一列。不過,只有當容器的高度有設定上限時才會換列,否則容器會自己變高,以容納所有的彈性項目。

Flex Flow

flex-flowflex-directionflex-wrap 的簡寫。例如,flex-flow: column wrap 就表示彈性項目會從上往下排列,必要的時候會換行,排到新的那一列上。

Justify Content

當彈性項目沒有填滿整個容器時,justify-content 屬性可以用來控制它們在主軸上的排列方式。它支援幾個常用的值:flex-startflex-endcenterspace-betweenspace-aroundspace-evenly

預設的 flex-start 會把項目排在主軸的起點,也就是從左邊開始排,而且彼此之間不會有間距,除非你有加 gap 或設定 marginflex-end 則是把項目排在主軸的尾端,看起來會集中在右邊。startend 是邏輯值,會根據書寫模式來決定項目對齊左邊還是右邊,不管主軸方向為何;leftright 是絕對的,永遠左對齊或右對齊,跟書寫模式和主軸方向無關。

如果你想讓項目之間平均分配空間,有三種方式可選。space-between 是把第一個項目貼齊主軸起點,最後一個貼齊主軸終點,其他項目平均分布在中間;space-around 會在第一個和最後一個項目外側加上間距,不過這些間距只有中間間距的一半;而 space-evenly 則是所有的間距都相等,兩端外側間距與中間的間距大小一樣。

要注意的是,這些間距是在算完 marginflex-grow 值之後才會套用。所以如果有項目的 flex-grow 不為 0,或是用了主軸方向的 auto margin,那 justify-content 可能不會發揮作用。

Align Content

如果有開啟換行(也就是用了 flex-wrap),這個屬性可以用來控制每一行在容器裡橫向的間距。它支援的值包括:flex-startflex-endstartendcenterstretch(預設值)、space-betweenspace-aroundspace-evenly
這些值的排列方式,其實跟前面提到的 justify-content 很像,只是是用在每一行之間的距離上。

另外還有一個叫 place-content 的屬性,其實就是 align-contentjustify-content 的簡寫。

Align Items

justify-content 是用來控制項目在『主軸』上的對齊方式,而 align-items 則是用來調整項目在『交叉軸』上的對齊方式。
它的預設值是 stretch,也就是讓所有項目自動撐滿容器的高度(橫向排列時)或寬度(直向排列時),這樣可以讓每一列的高度都一樣,看起來更整齊。

不過如果你不想讓項目撐滿,也可以用其他的對齊方式,這些方式其實有點像 CSS 裡的垂直對齊。像是:

  • flex-startflex-end:讓項目靠交叉軸的起點或終點對齊(通常是一行的頂部或底部)。
  • startend:這是根據容器的書寫方向來決定的邏輯對齊方式。
  • self-startself-end:這會根據每個彈性項目的書寫方向來對齊,如果彈性項目跟彈性容器的書寫方向不同,就會跟 startend 會有差異。
  • center:把項目垂直置中對齊。
  • baseline:讓每個項目中文字的第一行基線對齊(基線就是字的底部那條線)。這在你有些項目字體比較大、有標題,而其他項目是一般小字時特別有用。

另外,你也可以指定對齊的是「第一行」或「最後一行」的基線,比如用 align-items: last baseline,就是讓每個項目中最後一行的文字基線對齊。

justify-contentalign-items 這兩個屬名很容易搞混。可以把它們想像成 Word 裡的文字對齊功能。像是 justify-content 有點像設定文字是靠左、靠右還是左右分散一樣,是用來控制水平方向的排列。
align-items 就像是在做垂直對齊一樣,用來調整內部項目在上下方向上的對齊方式。

place-items 屬性是 align-itemsjustify-items 的簡寫。但 justify-items 只有在 grid 才有用,對於 flexbox 是沒效果的。

彈性項目參數

前面我們已經介紹過 flex-growflex-shrinkflex-basis,還有它們的縮寫 flex。接下來要看的兩個,是跟彈性項目有關的額外屬性:align-selforder

Align Self

align-self 是用來控制單個彈性項目在容器交叉軸上的對齊方式。它的作用跟容器用的 align-items 很像,不同的是,align-self 可以讓你針對每個項目個別設定對齊方式。

預設值是 auto,也就是跟著容器的 align-items 設定。但如果你給它設定別的值,那就會覆蓋容器的對齊設定。

align-self 的值和 align-items 一樣,包括:flex-startflex-endstartendself-startself-endcenterstretch,還有 baseline(基線對齊)。

另外 place-self 屬性,它是 align-selfjustify-self 的簡寫。注意 justify-self 是用在 grid 裡的,在 flexbox 不會有作用。

Order

一般來說,彈性項目的排列順序會跟它們在 HTML 原始碼裡出現的順序一樣,從主軸的起點開始一個接一個排下去。不過,你可以用 order 屬性來改變這個排列順序。

你可以設定任何整數,正的、負的都可以。如果好幾個項目的 order 值一樣,它們之間還是會照 HTML 的順序來排列。預設情況下,所有彈性項目的 order 值都是 0。舉個例子,設成 -1 的項目會被排到最前面,而設成 1 的就會排到後面。你可以自由地給每個項目設定不同的值來調整它們的順序,而且這些值不需要連續的。

注意:雖然 order 很方便,使用上會改變了畫面上的視覺排列順序,但原始碼順序沒改,可能會影響到網站的無障礙體驗。像是用鍵盤(Tab 鍵)切換時,瀏覽器通常還是會按照 HTML 原始碼順序來移動焦點;螢幕閱讀器在讀取時也一樣,大多還是會依照原本的 HTML 順序,這樣可能會讓使用者感到混亂。

Reference

比較 Svelte 渲染元件的三個方法:mount vs render vs hydrate

Svelte Version: 5.25.7

Outline

  • render():在 Server 端上,將從 Component 產生 HTML。
  • mount():在 Client 端上,從空的 DOM 節點建立 Component 的 DOM 附加到 HTML。
  • hydrate():混合 render()mount()。功能上類似 mount(),但不是從空的 DOM 節點開始,而是從 Server 端 render() 過的資料開始建立。

Svelte Render VS Render VS Hydrate

使用時機

如果需要在 server 端渲染 e.g. SEO 的需求,可以使用 render(),Server 端會將完整的 HTML 輸出至 Client 端。而 mount() 完全相反,僅在 Client 端時建立 DOM 節點。而 hydrate() 同時兼顧了 Server 端 SEO 的需求與 Client 端與使用者互動的需求,

render:Server 端渲染

當您在 Server 端上使用 render() 功能時,Component 將被編譯為 HTML 字串,可以將其傳送到 Client 端:

1
2
3
4
5
6
7
8
9
10
11
// Server-side code (e.g., in a Node server)
import { render } from 'svelte/server';
import App from './App.svelte';

const result = render(App, {
props: { some: 'property' }
});

// Then send `result` as the response to the client.
result.body; // HTML <body> tag
result.head; // HTML <head> tag

App 為 Svelte 的 Componenet,props 則是傳入 App 的屬性參數。

mount:Client 端渲染

如果你有一個空 DOM 節點或是即沒有預先渲染的 HTML e.g. <div id="app"></div> 並且希望 Svelte 在 Client 端上從頭建立 DOM,可以使用 mount()

1
2
3
4
5
6
7
import { mount } from 'svelte';
import App from './App.svelte';

const app = mount(App, {
target: document.querySelector('#app'),
props: { some: 'property' }
});

hydrate:升級伺服器渲染的 HTML

假設以下程式碼已在 Server 端產生的 HTML 並傳送到 Client 端:

1
<div id="app">something</div>

我們可以在 Client 端上使用 hydrate() 來讓 Server 端渲染的物件具有互動性 (interactive)。

1
2
3
4
5
6
7
import { hydrate } from 'svelte';
import App from './App.svelte';

const app = hydrate(App, {
target: document.querySelector('#app'),
props: { some: 'property' }
});

可以想像成 Server 端已經渲染好 HTML,用 hydrate() 附加額外邏輯和事件處理來「提升」此靜態 HTML 的功能:

Reference

React vs Svelte 開發體驗:子元件暫時更新父層資料

當程式規模慢慢變大時,為了管理與維護會將大元件拆分成許多小元件,在父子元件之間傳遞資料是開發時很常見的。但在傳遞資料上,React 跟 Svelte 沒什麼區別,同樣都是將資料傳入元件的 attribute 中,子元件透過 props 來獲得父層傳入的資料。所以我們這章舉一個比較複雜一點的例子:讓子元件可以暫時更新資料,但如果父元件的資料更新時,子元件也要更新為父元件元件的值。我們將透過範例比較兩者在開發體驗上的差異。

開發體驗

開發者在使用工具、框架、語言、API、系統或流程時,整體的感受、效率、流暢度與滿意度。

使用情境

當你在開發表單、步驟流程或是可編輯區塊時,子元件常會需要「暫時修改」父元件提供的資料。例如使用者輸入值,但尚未儲存前,這些變更應該是本地的。這時候就需要讓子元件在不直接修改父層狀態的前提下,保有一定程度的控制權。

React:需要手動管理狀態同步

React 採用單向資料流設計,而且我們無法直接修改 props。在子元件想要暫時更新資料時,必須透過 useState 加上 useEffect 來同步 props 的變化 (demo)。

ParentComponent.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, { useState } from 'react';
import ChildComponent from './ChildComponent';

function ParentComponent() {
const [count, setCount] = useState(0);

return (
<div>
<button onClick={() => setCount(count + 1)}>
clicks (parent): {count}
</button>
<ChildComponent count={count} />
</div>
);
}

export default ParentComponent;

ChildComponent.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React, { useState, useEffect } from 'react';

function ChildComponent({ count }) {
const [localCount, setLocalCount] = useState(count); // create a local count for temporary use

useEffect(() => {
setLocalCount(count);
}, [count]);

const increment = () => {
setLocalCount(localCount + 1);
};

return (
<button onClick={increment}>
clicks (child): {localCount}
</button>
);
}

export default ChildComponent;

在這個範例中:

  • 子元件透過 useState 創建一個本地的 localCount,用來「暫時更新」畫面上的計數值。
  • 當父元件的 count 改變時,useEffect 會同步更新 localCount
  • 子元件的操作不會影響父元件的實際資料。

Svelte:直覺操作、語法簡潔

Svelte 允許我們在子元件中直接修改 props,而不需要額外建立本地狀態。這種語法大幅簡化了開發流程,對開發者非常友善 (demo)。

Parent.svelte

1
2
3
4
5
6
7
8
9
10
11
<script>
import Child from './Child.svelte';

let count = 0;
</script>

<button onclick={() => (count += 1)}>
clicks (parent): {count}
</button>

<Child {count} />

Child.svelte

1
2
3
4
5
6
7
<script>
let { count } = $props();
</script>

<button onclick={() => (count += 1)}>
clicks (child): {count}
</button>
  • 無需 useEffect 或本地狀態。
  • 可直接操作傳入的 count,操作簡單明瞭。
  • 預設變更僅限於子元件本地,不會同步到父層(除非你明確綁定 bind: 或使用事件)。

以上 React 與 Svelte 兩個例子功能相同,但 Svelte 的語法設計,讓開發者可以更快速專注在邏輯本身,而非額外的狀態管理機制。

延伸閱讀

React vs Svelte 開發體驗:雙向資料綁定

資料綁定 (Data Binding) 是連結應用程式資料 (Application) 與使用者介面 (UI) 的方式或機制。React 是使用單向資料綁定 (one-way data flow),需手動呼叫 setState 來更新狀態;而 Svelte 使用 bind: 指令來達成雙向資料綁定。我們將透過範例來了解兩者在開發體驗上的差異。

開發體驗

開發者在使用工具、框架、語言、API、系統或流程時,整體的感受、效率、流暢度與滿意度。

使用情境

當你在開發表單、表單輸入與使用者互動或即時資料顯示,需要反向更新狀態變數,並即時顯示給使用者 (e.g. 動態價格計算、表單即時驗證、即時預覽)。

React:單向資料綁定

React 採用單向資料流 (one-way data flow),透過手動呼叫 setstate 來更新狀態。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useState } from 'react';

function InputComponent() {
const [text, setText] = useState('');

return (
<>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<p>{text}</p>
</>
);
}
  • useState 宣告狀態變數 text,初始值為空字串。
  • value={text} 綁定輸入框顯示值。
  • onChange 在輸入變動時更新 text

Svelte:直覺式雙向綁定

Svelte 提供了 bind: 指令,簡化雙向資料綁定,讓變數與元件保持同步。

1
2
3
4
5
6
<script>
let text = '';
</script>

<input type="text" bind:value={text} />
<p>{text}</p>
  • 使用 let 宣告響應式 (reactivity) 變數 text
  • bind:value={text} 自動雙向綁定輸入值與變數。
  • 使用者輸入會更新 text,變數更新也會同步回輸入框。

哲學

React 與 Svelte 在資料綁定上的差異,反映了各自的框架哲學:

  • React 注重控制與可預測性,適合處理複雜應用邏輯與資料流。
  • Svelte 注重簡潔與開發效率,讓使用者開發時更貼近原生思維。

延伸閱讀

React vs Svelte 開發體驗:狀態管理

在管理資料狀態上,React 採用的是 useState Hook,而 Svelte 則是直接使用變數,使用起來更直觀。我們將透過範例來了解兩者在開發體驗上的差異。

開發體驗

開發者在使用工具、框架、語言、API、系統或流程時,整體的感受、效率、流暢度與滿意度。

React 狀態管理:使用 useState Hook

React 讓元件可以擁有內部狀態的方式是透過 useState Hook 來完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useState } from 'react';

function Counter() {
const [count, setCount] = useState(0);

return (
<button onClick={() => setCount(count + 1)}>
Counter:{count}
</button>
);
}

export default Counter;
  • useState(0):初始化 count 為 0。
  • setCount:更新 count 的函式,每次按鈕點擊讓值加 1。
  • 透過 React 的 Virtual DOM,變化的值會觸發元件重新渲染。

非同步更新注意事項

React 的狀態更新是非同步的,因此如果在同一函式中連續呼叫多次 setCount(count + 1),可能無法即時反映最新的狀態。建議改用以下方式來避免此問題:

1
setCount(prevCount => prevCount + 1);

這樣可以保證邏輯永遠是基於最新狀態更新。

Svelte 狀態管理:響應式變數

Svelte 採用一種更直覺的狀態處理方式。它不需要額外的 Hook,直接使用變數即可自動觸發畫面更新。

1
2
3
4
5
6
7
<script>
let count = 0;
</script>

<button on:click={() => count++}>
Counter:{count}
</button>
  • 使用 let count = 0 宣告變數。
  • 直接對 count 進行操作,例如 count++,Svelte 就能自動感知變化並更新 DOM。
  • 這是透過 Svelte 的 編譯時響應式系統 (reactivity system) 實現的,完全不需要 Virtual DOM。

Note: React.js 使用 Virtual DOM 來追蹤是否元件需要被更新;而 Svelte 沒有使用 Virtual DOM 而是直些更新在 DOM 上。

React vs Svelte 狀態更新差異

功能面向 React Svelte
語法複雜度 使用 Hook,語法稍多 直覺操作變數,語法簡潔
DOM 更新機制 Virtual DOM 比對再更新 編譯階段生成 DOM 操作
狀態更新方式 必須使用 setState / setCount 直接修改變數
更新非同步處理 是,需注意資料一致性 否,變數改變即更新
可讀性與維護性 中等

如果追求極簡語法與優化效能,Svelte 提供了非常流暢的開發體驗。而 React 則擁有強大的生態系與社群支持。

延伸閱讀

使用 Node.js 監控資料夾變更,並以 Event Emitter 封裝 File Watcher

Node.js 監控資料夾

在 Node.js 中,我們可以透過 fs.watch 來監控資料夾是否有異動,不需要寫太多程式碼。以下是一個基本範例:

1
2
3
4
5
6
7
8
9
10
11
const fs = require('fs');
const path = require('path');
const directoryToWatch = './watched';

fs.watch(directoryToWatch, (eventType, filename) => {
if (filename) {
console.log(`File ${filename} has changed with event type: ${eventType}`);
} else {
console.log('filename not provided');
}
});

這段程式會持續監控 ./watched 資料夾的內容變化,當有新增、修改或刪除檔案時,就會觸發 callback 函式。

根據副檔名處理不同邏輯

如果我們想要針對不同的副檔名做不同處理,可以進一步使用 path.extname() 判斷檔案類型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const fs = require('fs');
const path = require('path');
const directoryToWatch = './watched';

fs.watch(directoryToWatch, (eventType, filename) => {
if (filename) {
const fileExtension = path.extname(filename);
switch (fileExtension) {
case '.js':
// ...
break;
case '.txt':
// ...
break;
case '.json':
// ...
break;
default:
console.log(`Ignoring file ${filename} with extension ${fileExtension}`);
}
} else {
console.log('filename not provided');
}
});

不過隨著專案變大,副檔名的判斷邏輯(if-else 或 switch)會越來越複雜、難以維護。這時就可以考慮封裝成 Event Driven 的架構,提升可讀性與擴充性。

將邏輯封裝成 Pub/Sub 模型的 Watcher

Node.js 提供的 EventEmitter 非常適合實作發布/訂閱(Publish/Subscribe)模型。EventEmitter 詳細介紹可以參考這篇文章:

使用 Node.js 的 EventEmitter 建立事件監聽機制:概念 & 實作

fs.watch 偵測到檔案更動,我們使用 .emit(extension) 來「發布」這個事件,而訂閱該副檔名的使用者透過 .on(extension, callback) 就能被「通知」。這讓我們可以針對特定副檔名建立模組化的監聽邏輯,使架構更清晰、易於擴充。

我們利用 class 繼承 EventEmitter,在 constructor 參數傳入資料夾,並呼叫 start() 時開始監控:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// watcher.js
const fs = require('fs');
const path = require('path');
const events = require('events');

class Watcher extends events.EventEmitter {
constructor(watchDir) {
super();
this.watchDir = watchDir;
}

start() {
fs.watch(this.watchDir, (eventType, filename) => {
if (!filename) {
console.warn('Warning: filename not provided');
return;
}
const fileExtension = path.extname(filename);
this.emit(fileExtension, eventType, filename);
})
}
}

module.exports = Watcher;

這段程式碼會根據檔案副檔名觸發對應的事件,達成根據檔案類型處理相對應的 callback。我們建立一個 watcher 並註冊感興趣的副檔名,並呼叫 start() 開始監控的資料夾:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// index.js
const Watcher = require('./watcher');

const directoryToWatch = './watched';
const watcher = new Watcher(directoryToWatch);

watcher.on('.js', (eventType, filename) => {
// ...
});

watcher.on('.txt', (eventType, filename) => {
// ...
});

watcher.on('.json', (eventType, filename) => {
// ...
});

watcher.start();

這樣的寫法使得每個副檔名的處理邏輯可以被模組化,也可以讓多個檔案處理者獨立訂閱自己關心的事件。

Conclusion

透過封裝 fs.watch 並結合 EventEmitter 的事件機制,我們實作了一個簡單且可擴充的資料夾監控系統。這個模式讓你可以專注在各類檔案的邏輯處理,而不需要在程式碼中寫太複雜的 if-else 部分。這樣的架構能讓你在不干擾其他邏輯的情況下,自由地新增更多檔案類型處理器,提升模組化與維護性。

Reference

使用 Node.js 的 EventEmitter 建立事件監聽機制:概念 & 實作

概念

在 Node.js 中,建立 EventListener 可以使用 EventEmitter,概念與 publish/subscribe 非常相似,如果之前曾經使用過 Redis 的 Pub/Sub 機制,會更容易理解。

假設有一個人先訂閱(subscribe)了一個名為 ‘xxx’ 的頻道(channel),之後當有人發布(publish)訊息到 ‘xxx’ 頻道時,訂閱者就會看到發布者發出的訊息。

Event Listener

回到 Node.js 的 Event Listener 機制,我們可以透過 EventEmitter 建立一個 publish/subscribe 物件。執行物件的 .on 方法就類似於訂閱(subscribe),而 .emit 方法則類似於發布(publish)。以下是範例程式碼:

1
2
3
4
5
6
7
8
9
10
const EventEmitter = require('events');
const channel = new EventEmitter();

// 訂閱 'join' 事件:任何人加入時會通知
channel.on('join', (message) => {
console.log(`Received message: ${message}`);
});

// 發布 'join' 事件
channel.emit('join', 'Hello, everyone!');

在這個範例中,我們使用 EventEmitter 建立了一個 channel 並且監聽 ‘join’ 事件。當有使用者加入時,會透過 .emit(‘join’) 通知 ‘join’ 事件。我們可以建立一個 HTTP server 並稍微修改一下來驗證我們的想法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// server.js
const net = require('net');

const EventEmitter = require('events').EventEmitter;
const channel = new EventEmitter;

channel.clients = {};

// 訂閱 'join' 事件:任何人加入時會通知
channel.on('join', (id, client) => {
channel.clients[id] = client;
console.log(`${id} join`);
});

const server = net.createServer(client => {
// 使用 `IP:Port` 當作 ID
const id = `${client.remoteAddress}:${client.remotePort}`;

// 通知 'join' 事件
channel.emit('join', id, client);
})

server.listen(30000);

.emit 不僅用來觸發事件,還可以同時傳入多個參,當你呼叫 .emit('join', arg1, arg2, ...) 時,所有註冊了這個事件的監聽器 .on('join', (arg1, arg2) => {...}) 都會依序被呼叫,且能接收到這些參數。

net.createServer 是使用 Node.js 的 net module 來建立一個 TCP server。當有 client 進來時會執行 callback function,這個 callback 第一個參數代表接收到一個 net.Socket 的物件。

在 socket 的 callback 中,可以透過 client.remoteAddressclient.remotePort 來獲取連線 client 的 IP 與 port,組合成唯一個 id。然後使用 .emit('join', id, client) 觸發一個 join 事件,同時把這個 idclient 傳入給 join 事件的 callback,最後儲存在 channel.clients 中。

我們來測試一下,先開一個 server,然後使用 telnet localhost 30000 來連到 server,在 server 上可以看到 xxx join 的訊息。

1
2
3
4
5
6
7
8
9
# 先開一個 server
$ node server.js
::ffff:127.0.0.1:37282 join

# 然後加入使用者
$ telnet localhost 30000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.

Error Event

在前面的例子,這個 join 事件名稱是可以自定義的,可以是任何字串,所以如果觸發一個不存在的事件 .emit('notExist'),其實是不會發生什麼事情的,但是有一個例外:error 事件。如果沒有人註冊 error 事件,但有人在 error 事件中發出訊息,程式會直接停止運行並印出錯誤訊息。

1
2
3
4
5
6
const events = require('events');
const myEmitter = new events.EventEmitter();
myEmitter.on('error', err => {
console.log(`ERROR: ${err.message}`);
});
myEmitter.emit('error', new Error('Something is wrong.'));

如果在執行 error callback 時又發生錯誤,則會丟出 uncaughtException 錯誤,可以透過 process.on('uncaughtException') 來捕捉這個錯誤。

1
2
3
process.on('uncaughtException', err => {
console.error('There was an uncaught error', err);
});

上述的例子可以增加這兩個事件:

1
2
3
4
5
6
7
process.on('uncaughtException', err => {
console.log("uncaughtException", err.stack);
});

channel.on('error', err => {
console.log("channel error", err.message)
});

廣播給所有使用者

現在 server 建立好了,我們想要讓多個使用者加入,如果其中有一個 client 發話,其他 client 都可以收到訊息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const net = require('net');

const EventEmitter = require('events').EventEmitter;
const channel = new EventEmitter;

channel.clients = {};

channel.on('join', (id, client) => {
channel.clients[id] = client;
channel.on('broadcast', (senderId, message) => {
if (id != senderId) {
channel.clients[id].write(message);
}
});
console.log(`${id} join`);
channel.emit('broadcast', id, `${id} join\n`); // 通知有人加入群組
});

const server = net.createServer(client => {
const id = `${client.remoteAddress}:${client.remotePort}`;
channel.emit('join', id, client);
client.on('data', data => {
data = data.toString();
channel.emit('broadcast', id, data);
});
})

server.listen(30000);

join 事件中,當有 client 加入時,會針對每個 client 註冊 broadcast 事件。當有人發送廣播訊息時 channel.emit('broadcast', ...),就會觸發 callback 事件。這個 callback 需要接收兩個參數:

  • senderId:發送者的 id。
  • message:廣播的訊息。

如果該 client 不是訊息的發送者,則會使用 channel.clients[id].write(message) 將訊息送給這個 client。

最後每當 client 連線到 server 並傳送資料時,在 data 事件中 client.on('data', callback),這個 callback 就會被觸發。這個 callback 會透過我們前面新增的 broadcast 事件來廣播給所有人。

.on('data', callback) 收到的資料通常是 Buffer,所以會先轉成 string。

我們來測試一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# server
$ node server.js
::ffff:127.0.0.1:40542 join
::ffff:127.0.0.1:40556 join
::ffff:127.0.0.1:40572 join

# user1 再另外開三個 Terminal 分別建立連線
$ telnet localhost 30000
::ffff:127.0.0.1:40556 join
::ffff:127.0.0.1:40572 join
akiicat

# user2
$ telnet localhost 30000
::ffff:127.0.0.1:40572 join
akiicat

# user3
$ telnet localhost 30000
akiicat

先開一個 server,然後分別加入使用者並發話,記得最後要按 enter 才會發送,就可以看到發送的訊息會廣播到其他連線的 client。

如果你持續增加使用者,超過 10 個人訂閱同一個事件時,Node.js 會印出警告你,但可以透過 .setMaxListeners(50) 來增加上限。

1
eventEmitter.setMaxListeners(50);

離開群組 removeListener

現在我們的使用者可以加入群組,並且可以廣播訊息給所有人,看起來很不錯,但是使用者加入後卻沒有辦法離開。我們希望使用者輸入 leave 的時候,會將自己從群組中移除,並且斷開連線。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
channel.on('leave', (id) => {
channel.emit('broadcast', id, `${id} has left\n`); // 通知有人離開
channel.clients[id].destroy(); // 斷開連線
delete channel.clients[id];
});

const server = net.createServer(client => {
const id = `${client.remoteAddress}:${client.remotePort}`;
channel.emit('join', id, client);
client.on('data', data => { data = data.toString();
if (data.trim() === 'leave') { // 如果使用者輸入 leave 則觸發 leave 事件
channel.emit('leave', id);
} else {
channel.emit('broadcast', id, data);
}
});
})

看起來功能已經完成了,但是有一個小問題。在 join 事件中,使用者訂閱的 broadcast 事件沒有清除。如果有人離開之後,就會在後續的廣播嘗試寫入不存在的連線,這會造成 Node.js 的錯誤。

因此我們在 leave 時需要用 .removeListener 來取消訂閱,注意 removeListener 帶入的 callback 必須跟 on 的 callback 一樣,

1
2
3
4
// removeListener 帶入的 callback 需要跟 on 的 callback 一樣
const callback = () => {...}
.on('broadcast', callback)
.removeListener('broadcast', callback)

所以我們需要修改一下 server.js

  • 新增 channel.subscriptions,並在 join 事件中,每當有使用者加入時儲存每個 client 的 callback。
  • leave 事件中 removeListener 將對應使用者的 broadcast callback 移除。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const net = require('net');

const EventEmitter = require('events').EventEmitter;
const channel = new EventEmitter;

channel.clients = {};
channel.subscriptions = {}; // 新增 subscriptions 物件用來儲存 client 的 broadcast callback

channel.on('join', (id, client) => {
channel.clients[id] = client;
channel.subscriptions[id] = (senderId, message) => { // 儲存 client 的 broadcast callback
if (id != senderId) {
channel.clients[id].write(message);
}
};
channel.on('broadcast', channel.subscriptions[id]); // client 訂閱 broadcast 事件
channel.emit('broadcast', id, `${id} join\n`); // client 訂閱 broadcast 事件
});

channel.on('leave', (id) => {
channel.removeListener('broadcast', channel.subscriptions[id]); // client 取消訂閱 broadcast 事件
channel.emit('broadcast', id, `${id} has left\n`);
channel.clients[id].destroy();
delete channel.clients[id];
delete channel.subscriptions[id]; // 從物件中移除
});

const server = net.createServer(client => {
const id = `${client.remoteAddress}:${client.remotePort}`;
channel.emit('join', id, client);
client.on('data', data => { data = data.toString();
if (data.trim() === 'leave') {
channel.emit('leave', id);
} else {
channel.emit('broadcast', id, data);
}
});
})

server.listen(30000);

在 Terminal 測試使用者能否正常離開群組:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ node server.js

# user1 使用者離開
$ telnet localhost 30000
::ffff:127.0.0.1:54538 join
::ffff:127.0.0.1:54554 join
Hello
leave
Connection closed by foreign host.

# user2 使用者離開後照樣可正常廣播
$ telnet localhost 30000
::ffff:127.0.0.1:54554 join
Hello
::ffff:127.0.0.1:54528 has left
Hi

# user3
$ telnet localhost 30000
Hello
::ffff:127.0.0.1:54528 has left
Hi

關閉伺服器 removeAllListeners

現在我們新增一個新功能,輸入 shutdown 指令讓所有人取消訂閱,並斷開所有連線。在前一章節所學的 removeListener,我們可以使用迴圈將每個使用者從 broadcast 事件移除。然而,我們可以更間單的使用 .removeAllListeners('broadcast') ,來一次性移除所有訂閱 broadcast 事件上的使用者。最後我們的完整程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
const net = require('net');

const EventEmitter = require('events').EventEmitter;
const channel = new EventEmitter;

channel.clients = {};
channel.subscriptions = {}; // 新增 subscriptions 物件用來儲存 client 的 broadcast callback

channel.on('join', (id, client) => {
channel.clients[id] = client;
channel.subscriptions[id] = (senderId, message) => { // 儲存 client 的 broadcast callback
if (id != senderId) {
channel.clients[id].write(message);
}
};
channel.on('broadcast', channel.subscriptions[id]); // client 訂閱 broadcast 事件
channel.emit('broadcast', id, `${id} join\n`); // client 訂閱 broadcast 事件
});

channel.on('leave', (id) => {
channel.removeListener('broadcast', channel.subscriptions[id]); // client 取消訂閱 broadcast 事件
channel.emit('broadcast', id, `${id} has left\n`);
channel.clients[id].destroy();
delete channel.clients[id];
delete channel.subscriptions[id]; // 從物件中移除
});

channel.on('shutdown', () => {
channel.emit('broadcast', '', 'Server shutdown');
channel.removeAllListeners('broadcast');
Object.keys(channel.clients).forEach(function(id) { channel.clients[id].destroy();
delete channel.clients[id];
delete channel.subscriptions[id];
})
});

const server = net.createServer(client => {
const id = `${client.remoteAddress}:${client.remotePort}`;
channel.emit('join', id, client);
client.on('data', data => { data = data.toString();
if (data.trim() === 'shutdown') {
channel.emit('shutdown', id);
} else if (data.trim() === 'leave') {
channel.emit('leave', id);
} else {
channel.emit('broadcast', id, data);
}
});
})

server.listen(30000);

測試一下,輸入 shutdown 後所有人將會斷開連線:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# server
$ node server.js

# user1
$ telnet localhost 30000
::ffff:127.0.0.1:51130 join
::ffff:127.0.0.1:51136 join
shutdown
Server shutdownConnection closed by foreign host.

# user2
$ telnet localhost 30000
::ffff:127.0.0.1:51136 join
Server shutdownConnection closed by foreign host.

# user3
$ telnet localhost 30000
Server shutdownConnection closed by foreign host.

只要 Event Loop 中還有待處理的非同步操作(例如未完成的 I/O、定時器、網絡請求等),Node.js process 就不會自動退出。這對於需要長期運行、持續監聽請求的 Web 伺服器來說是理想的行為;而對於命令行工具或一次性任務,則可能需要在所有非同步操作完成後主動退出。

Reference

Node.js Event Loop 是如何運作的

Node.js 是 Single Thread 的環境執行,是如何透過 Event Loop 的方式來實現 Non-blocking I/O 的操作。

Event Loop 運作方式

想像一個 HTTP Request 進入 Node.js http.Server 時,Event Loop 會觸發對應的 callback。假設該 callback 需要從資料庫拿到使用者資料,並從磁碟讀取電子郵件 template,然後將使用者資料填入 template 後,回傳 HTTP Response。

Nodejs Event Loop

在這個過程中,Event Loop 會以先進先出 (FIFO) 的方式運行 Event Queue:

  1. 首先,Event Loop 包含一個 Timer,可以透過 setTimeout 或 setInterval 設定。
  2. 接下來,如果 Timeout 或是有新的 I/O Event 進來,則會進入 poll phase (輪詢階段),在此階段會安排執行 (schedule) 對應的 callback。

Event Loop 的概念不會太複雜,有 Event 進來,執行相對應的 callback。

那如果正在執行 poll phase 時,又有新的 I/O Event,Node.js 則會透過 setImmediate 設定,在當前 callback 執行完後立刻執行新進來的 I/O Event callback。

Library

Node.js 的 Non-blocking I/O Opertaion 是透過 libuv 實現的,libuv 實現 Node.js 的 Event Loop、Non-blocking I/O、網路、磁碟、檔案系統等功能,位於下圖的左下角:

Nodejs Library

原先在 Node.js 程式碼是透過 C++ Binding 綁定到 libuv 或其他 library 上,這些 library 會去跟 OS 來溝通。

之後 Google 的 V8 JavaScript 引擎的出現,讓 Node 更加強大。V8 引擎厲害之處就是直接繞過 C++ binding 變成 machine code,這大幅的提升了執行效率,如上圖右半側。

Reference

安裝 Windows 11 虛擬機過程與問題

很久沒安裝 Windows 11 VM 了,原本想說應該很順利,結果裝了一整個晚上,沒想到會踩了一堆雷,所以這篇來紀錄一下安裝 Windows 11 VM 的過程:

讀取 ISO

在 Windows 11 開機的時候,若出現以下提示文字:

Press any key to boot from CD or DVD…

vm-windows-11-installation-1.png

此時,一定要按下鍵盤上的任意鍵,如果只用滑鼠輸入會失敗。

繞過 TPM 檢查

在安裝 Windows 11 時,系統會檢查硬體是否符合規格。如果是在虛擬機上安裝,得需要手動關閉 TPM 的檢查:

  1. 啟動命令提示字元:在安裝畫面按下 Shift + F10,這將開啟命令提示字元 (cmd)。
  2. 開啟登錄編輯器:在命令提示字元中輸入 regedit 並按下 Enter 鍵。
  3. 新增 LabConfig 機碼
  • 找到 HKEY_LOCAL_MACHINE\SYSTEM\Setup
  • 在此路徑下新增一個名為 LabConfig 的資料夾(機碼)。
  1. 繞過檢查的設定:在 HKEY_LOCAL_MACHINE\SYSTEM\Setup\LabConfig 機碼內新增以下三個 DWORD 32-bit 項目
  • BypassTPMCheck,值設為 1。
  • BypassRAMCheck,值設為 1。
  • BypassSecureBootCheck,值設為 1。

vm-windows-11-installation-2.png

完成這些設定後,關閉登錄編輯器,繼續安裝,即可成功繞過檢查。

帳號登入

在安裝過程的最後階段,Windows 可能會要求輸入帳號密碼,目前還不知道要略過此步驟。

建議

安裝完成後,建議馬上建立一個快照 (snapshot),這樣如果日後需要重置或修復系統,就不必重新安裝。

Reference