Static Page without JS

日期:2024/03/12 (二)

其實四五年前我就用 ReactJS 寫了自己的個人網頁,但是因為當時用 React+Webpack 的預設 routing 是採用 client-side rendering 的方式,有一些缺點,但遲遲忙於課業,直到最近畢業了找工作前有一段空檔,才終於可以重啟這個計畫, 因此本文旨在紀錄這次重寫網頁的心得。

雖然大家可能會想:寫網頁這種大一大二等級、人人都會的東西有什麼好紀錄的,但我發現以前我很多進階的 CSS 用法都不會,而且其實很多沒有太 fancy 的基本功能根本不用用到 JS,殺雞焉用牛刀。再加上後來接觸了 Linux 後崇尚極簡的文化,因此經過神秘長髮人的提議,身為反 JS 大將軍的我,決定要只用純粹的 HTML 跟 CSS 來寫一個輕量的 static website (from scratch!),算是某種意義上的縛りプレイ吧。

CSR v.s. SSR v.s. SSG

首先根據網頁 render 的方式,我們可以分成三大類型:client-side rendering (CSR)、server-side rendering (SSR)、以及 static site generation (SSG)。

  • Client-Side Rendering

    顧名思義,網頁內容的 HTML、CSS,全部由使用者端載下來的 JS 在 browser render。通常 navigation 也會做在一起,也就是好幾個頁面都載到使用者端後,在 local 做 navigation。這樣的好處是 navigation 可以很順暢,但是網站內容太多時,第一次進入網站時載入就比較久一些,而且內容無法被搜尋引擎爬蟲爬到。

  • Server-Side Rendering

    瀏覽網頁時打 request 跟伺服器要所需頁面的內容,伺服器生成完內容後再回傳給使用者端,是傳統、主流的網頁形式。因為可以被搜尋引擎爬到,所以也對於 search engine optimization (SEO) 等廣告行銷策略非常合適。在瀏覽上,也能體現 demand on use 的精神,只需要載入當前的頁面就好,不需要整個網站抓下來,因此初入網頁的載入速度很快,但 navigation 可能會稍有延遲。此外,伺服器要負擔大量使用者的 request 也是一個缺點。

  • Static Site Generation

    與 SSR 類似,同樣都屬於 pre-rendering,也就是預先生成好 HTML 等等網頁內容。由於 SSR 是 request 當下再在伺服器上生成 HTML,因此可以做出比較動態的內容變化;反之,SSG 的內容是在 build 階段就生成好的,所以比較適合輕量且簡單的靜態網頁內容。

而 React 最大的特徵(我不知道其他 framework 是不是也一樣)就是將所有 HTML 跟 CSS 用 JSX 的方式跟 JS 寫在一起,因此全部只需要用 JS 撰寫。仔細看大部分專案的架構,都是一個簡單的index.html作為網站的 entry point,然後使用者端的瀏覽器再載入包含所有 HTML、CSS、JS 的大bundle.js在 local 做 rendering,正是 CSR 的做法。不過我當時也是一知半解的照著教學做,不確定是因為 React 還是 Webpack 才會呈現此一架構,但是足夠了解一定同樣可以寫成其他架構使用不同的 rendering 方式吧我想,以後有機會的話或許會再試試。

  • Single Page Application

    因為我的網頁除了中間內容部份會隨著文章的不同有所變化之外,其他部份都是相同的,直覺上 navigation 就會想用 JS 來控制變化的內容,這種做法稱為 single page application (SPA),也是 CSR 的一種。我們知道,網址中#符號後面的 URI fragment 是給瀏覽器看的,即使有所改變也不會丟出 request 促使網頁重新載入,因此利用#符號達成 navigation 就是 SPA 的常用手法。舉個例子:假設主頁的網址為www.example.com,navigate 後改變的網址www.example.com/#/topicA/articleB並不會使網頁重整或是丟出新的 request,而跑在使用者端的 SPA 就可以透過後面的字串來顯示想要的頁面了。

    一開始,我是使用別人寫好的 web component zeromd 來 render 文章的 markdown,再使用 JS 去 parse URL 並修改 attribute 來 navigate。zeromd 的缺點在於 markdown 的轉換是 request 之後在 local 端執行,因此網頁需經過下載 markdown,再等 markdown 轉成 html 後才能看到畫面,會比較久。另外,由於#符號已經被拿來用作 SPA 的 navigation,<a href="#element-id">這種使用 id 來跳到標題的功能就不能用了,反而還要再用 JS 去圓 JS 的謊。

    因此作為熟悉其運作模式的牛刀小試,JS 的使用在此淺嘗則止。不過 zeromd 所使用的 markdown 轉 HTML 轉換器 marked 和 syntax highlighting 插件 prism 很漂亮很好用,所以在之後的改動中還是有沿用下來。

  • The traditional way (不知道叫什麼==)

    傳統(?)的 navigation 是每一個頁面都要有一個index.html作為 entry point,並直接使用相對路徑作為網址來 access,例如:www.example.com/topicA/articleB。假設我每個文章的 markdown 都放在一個資料夾內,命名為content.md,那我就可以將所有頁面共通的部份shared.html複製到每個資料夾中,並且想辦法將content.md的內容插入,最後命名為index.html,如下圖所示。當然,首先我們要先把 markdown 轉換為 html,在此我選擇使用上面提到的 marked。

        public                                     public
        ├── articles                               ├── articles
        │   ├── topicA                             │   ├── topicA
        │   │   └── articleA                       │   │   └── articleA
        │   │       └── content.md                 │   │       ├── content.md
        │   └── topicB                             │   │       └── index.html
        │       ├── articleB             -->       │   └── topicB
        │       │   └── content.md       -->       │       ├── articleB
        │       └── articleC             -->       │       │   ├── content.md
        │           └── content.md                 │       │   └── index.html
        ├── shared.html                            │       └── articleC
        ├── shared.css                             │           ├── content.md
        └── content.md (home page)                 │           └── index.html
                                                   ├── shared.html
                                                   ├── shared.css
                                                   ├── content.md (home page)
                                                   └── index.html
    
    • <object>/<iframe>

      說到在 HTML 中 include HTML,查到的不外乎使用<object><iframe>,但是包住<object><iframe>的容器其寬高必須事先被決定好,並不能跟隨著內部 include 的 HTML 物件多寡伸縮。找到大部分的解法都是使用 JS 去 resize <object><iframe>,甚至還有人說 CSS 的height: 100%有用,天啊,根本在黑白講!

    • CI/CD with the powerfull sed

      最後我受不了了,直接發動 GNU Linux 拋瓦,既然無法調整內容的高度,我直接用sedshared.html剖成兩半,再把content.html塞進去,簡單暴力。一個簡單的 makefile 和一些 shell command 就可達成,還能加一些有的沒的 rules 方便開發,deploy 的時候直接給 yaml call make build,在編寫的時候也不會麻煩,就當作在編譯一樣給他跑一遍就好~

CSS tips

不使用 JS 的話,我們勢必要投靠 CSS,因此以下紀錄一些 CSS 要注意的點以及小技巧。

  • nested CSS

    令人能在 CSS 中就看清 HTML 中的結構,也能將相關相似 element 的敘述放在一起,可讀性更高。

    另外,有關於&符號的使用。如下所示,由於 nested CSS 中兩層之間會 parse 成一個空格,因此在使用 combinators (~、+、>、以及空白)時,有無&並不影響語義,但使用 pseudo class/element 時則必須使用,不然下面的例子 browser 會理解成element :hover {}。不過,可能有些瀏覽器尚未支援不加&的 CSS nesting 語法,個人還是習慣全都加上。

        element {
            & + sibling {
                /* equals to "element sibling {}" */
            }
            + sibling {
                /* same as the above. the "&" is thus optional */
            }
            &:hover {
                /* equals to "element:hover {}" */
            }
            :hover {
                /* different from the above. caution!! */
            }
        }
    
  • checkbox technique

    來自 YouTube 上不知名印度朋友的教學影片,用 checkbox 做一個 temp boolean 變數的感覺,很厲害。

  • parent selector, the powerful pseudo class :has()

    我們平時使用的 CSS selector,只能選取 scope 內,也就是自己「包住」的 child elements 或接在自己「後面」的 sibling elements (對的,沒辦法選前面的)。但是如果想要控制 scope 外的 element 怎麼辦呢?我們就可以使用:has()這個強大的 pseudo class,其語法如下(上面是普通的 child selector,下面則是我們的主角):

        parent > child {
            /* common selector selecting elements in scope */
        }
        parent:has(child) {
            /* parent selector */
        }
    

    結合上面兩種技巧,我們就可以達成簡單的 toggle 效果。例如本網站在小視窗時可以透過右下角的 panel 叫出 side-bar,就是用以下邏輯寫成:

        <div id="panel">
            <input type="checkbox" id="button-checkbox">
            <label for="button-checkbox">
                <div id="button">click to toggle side-bar</div>
            </label>
        </div>
        <div id="side-bar">
            <!--blah blah blah-->
        </div>
    
        <style>
            #panel:has(#button-checkbox:checked) + #side-bar {
                /* some animations when toggled */
            }
        </style>
    

    後記:後來發現因為<input><label>之間已經使用 id 連結了,所以其實只要把<input>移到想要控制的元素前面就可以了,並不需要用到:has()

  • box-sizing and padding

    padding 的使用上可能需要注意一下,預設上 padding 加上 width 才是 element 呈現的寬度,也就是假如我今天想要一個 element 寬度為 900px 且左右的 padding 為 30px,那我應該要設 width 為 840px。但有時候我們並沒辦法得知明確的寬度,例如 width 為 100% 時,雖然我們當然可以用calc()函數計算,但有個box-sizing: border-box的設定,使 width 就是想要呈現的寬度,而 padding 變成要往內去扣,個人覺得更方便直覺,好用!

  • 點擊圖片放大的功能,懸浮置中及背景關燈效果

    首先,先將這個功能拆成三個部份來看:懸浮縮放、背景關燈、和點擊。

    • 圖片懸浮置中並且保持 aspect-ratio 的縮放

      固定 width,會導致直立的圖片超出範圍;固定 height,則是橫的圖片會出問題。使用transform: scale()也不好,因為每張圖片的像素、原大小都不同。因此只能讓圖片在一個固定的格子中保持比例的縮放,而這個格子即是整個視窗的大小。但是光是要保持縮放比例,就查到了一堆 JS 的作法。苦思之際,發現其實有一條很簡單的 CSS 就能達成:object-fit: contain,再次感嘆 CSS 的強大。

      最後,我認為延伸到視窗全幅的圖片,給使用者的感覺太壓迫,因此可以再加上一些 padding,順便用上了剛剛所提到 box-sizing 的設定,改善使用者的體驗。

    • 背景關燈效果

      直覺的想,可能會先將圖片調至適當大小後置中,接著再用一塊半透明的黑色方塊蓋住圖片後的內容來達到關燈的效果。以這個角度思考的話,查來查去,因為要控制同一塊與圖片本身無關的黑色方塊,所以都是一些 JS 的做法。但是在我使用了上面的object-fit: contain後,發現因為不同比例而留下的白邊可以拿來運用,直接將顏色調成半透明的黑色就正是我想要的關燈背景!竟然只要加上短短的一句background-color: rgba(0, 0, 0, 0.9)就解決了。

      綜合以上兩點,目前我們用以下簡潔的 CSS 便可達成:

          img {
              /* maintain aspect-ratio */
              width: 100%;
              height: 100%;
              object-fit: contain;
      
              /* float and center */
              transform: translate(-50%, -50%);
              position: fixed;
              left: 50%;
              top: 50%;
      
              /* dimmed background  */
              background-color: rgba(0, 0, 0, 0.9);
          }
      
    • 點擊

      其實也能使用上述的 checkbox technique,但是我並不想在文章使用圖片時還要用一堆額外的<input><label><img>包起來,而且在不能使用 JS 限制下也不能用 web component 之類的方式寫一個 class 模組化,更不能用簡單粗暴的onclick function。最後找到的方法是給<img>設定tabindex="0",便能使用 pseudo class :focus來觸發點擊。另外,由於手機上的點擊行為與電腦不同:hover 是單點一下,直到點擊物件以外的地方才會結束 hover (不確定是否每個平台都是如此),因此在沒有 focus 的手機上我們使用 hover 來代替。

      但是這又衍生了另一個問題:第一次點擊之後,因為圖片 (+關燈的背景) 被放大到整個視窗,無論點擊哪裡都會點到圖片本身,從而無法觸發第二次的點擊結束進行中的 focus/hover 事件。有個很妙的解決方法:使用pointer-events: none來 disable 覆蓋整個畫面的圖片,便能隔山打牛點擊當前圖片以外的地方結束 focus/hover。程式邏輯如下,因為隔山打牛可能會按到網頁中的其他 element,所以pointer-events: none限制的對象其實是整個<body>

          img:focus {
              /* appearance style (omitted) */
          }
          body:has(img:focus) {
              pointer-events: none;
          }
      
  • at-rules/media query

    這次比較可惜 at-rules 只有用到最常用的 media query,希望未來有機會能再深入了解其他的用法。

結語

再次聲明自己不是什麼縛りプレイ廚ww,但有了這次的限制,反而讓寫網頁變得像在解謎一樣有趣,把功能寫出來時也特別有成就感,同時我也有機會確實深入了解 CSS 的用法,發現很多炫泡的東西其實只靠 CSS 就能寫出來,真是太神啦~~~