[輕前端] Preact + htm without build tools

Lastor
Code 隨筆放置場
11 min readMay 1, 2022

接續上一篇「輕前端與前端工程化」,繼續來分享點心得。

Web 開發做前端時,未必每次都是做一個完整的,很多頁面的網站。時常也會碰到一些單頁面的需求,像是活動頁、產品的宣傳頁等等,這些頁面往往都是獨立一個專案,大多都是 CSS 的工,需要 JS 邏輯處理的可能不多,頂多就是一些 for each 生成相似 html 的需求。

這些其實用原生 JS 或是 jQuery 都是可以做的,但只要享受過 Vue 或 React 的便利性,難免都會有回不去的狀況產生。但就為了一個 page 的專案,特地用 vue-cli、create-react-app、亦或是 vite app 又顯得有點殺雞用牛刀了。

自然地就會開始往無編譯環境,改用 CDN 拉框架的方向走去。

可是用 Vue 或是 React 的 CDN 也存在一些問題。就是他們倆的核心 bundle 檔都大約是 128kb。而一個簡單的活動頁,自己寫的 js 全部加起來可能連 5kb 都不到,整個專案幾乎 99% 的 js 全都是框架的,這就顯得有點難受了。

更遑論,React 還有個致命問題,就是依賴 jsx,不用 jsx 寫 React 會非常痛苦。要在無編譯環境下跑 jsx,就得要去拉 babel 的 CDN,而那 babel standalone 直接又吃掉了 2mb,是 mb 啊!! 這導致 React 基本無法選擇使用 CDN 來開發。

就在這天人交戰的情況下,我找到了一個好東西,Preact + htm。

Preact — React 的輕量級替代方案

就在 google 尋找有沒有甚麼良策之時,意外看到有老外在討論 htm,也間接地查到 Preact 這玩意。這並不是 React 官方出的,而是第三方社群所維護的。似乎在以前 Facebook 修改 React license 方案時,出現了一批用戶出走潮,而許多人選擇的替代方案就是 Preact。

Preact 本身號稱輕量,把很多一般開發前端根本用不到的東西,像是 React Native 的內容全給拿掉了,所以他的整體 size 就比 React 小很多。而他的 API 基本與 React 100% 一致,所以只要會 React 基本就能直接使用。

htm — jsx 的無編譯替代方案

htm 是受到 lit-html 的啟發,用來替代 jsx 的庫。他的寫法大概這個感覺,很像是一種 jsx 字串化的風格。

function App() {
retrun html`
<h1 class="title">Hello</h1>
<${Child} />
`
}

與 jsx 的幾個明顯差異,是他可以直接寫 class 而不是 className,以及要放變數進去的時候,是採用 JS 的模板語法 ${var} 來處理,其他部分則與 jsx 沒有太大的區別。所以也是不太需要學習成本的東西。

而 htm 本身就有與 Preact 合作,有堆出 htm/preact 的 standalone 同捆包。兩者的 size 合計僅需約 13kb,相比 React 與 Vue,整整小了快 10 倍。這讓使用前端框架的成本,瞬間小了很多。

htm 除了跟 Preact 搭配之外,也可以與 React 綁定,取代 jsx。所以有需求就是要用 React 的話,也是沒問題的。

由於 Preact 的 API 與 React 是一致的,所以這邊就不多做介紹,接下來稍微分享一下自己使用過程中撞到的坑。

CDN 引入方式 ESM vs 全域

Preact 與 htm 官方推薦的引入方式,是使用 ES module,而非傳統的全域引入。

// 註冊到全域
<script src="standalone.umd.js">
<script>
console.log(htmPreact)
</script>
// ESM
<script type="module">
import { html, render } from 'standalone.module.js'
render(html`<h1>Hello</h1>`, document.querySelector('#app'))
</script>

無論是 Preact 還是 htm 的文件,給出的都是 ESM 版本的連結。其他的內容並沒有介紹太多。

https://unpkg.com/htm/preact/standalone.module.js

Preact 技術文件基本還是以 npm 下載為前提在寫的。這也是用 CDN 開發會碰到的最大問題。各 lib 的技術文件,幾乎不會對 CDN 引入有太多的說明。

像是上面的範例 code,就有兩個小問題,一個是他其實也有全域引入的 CDN 檔案,另一個是他全域引入後的變數名叫做「htmPreact」技術文件根本沒有寫,這都得靠自己去發現。

變數名這部分,大多 lib 都會用 lib 名去取,所以引入之後可以自己在 DevTools 輸入關鍵字看有沒有東西,還算是好猜的。

而 CDN 有沒有提供其他版本這點,就真的需要一點經驗了。通常這些 CDN 服務商,直接連去他們的 host 都會是一個說明頁,教你他們的路由規則。像是這邊 Preact 跟 htm 使用的 UNPKG 或是 SkyPack 之類,都可以從中窺探出一些蛛絲馬跡。

大多開發 lib 的命名方式都會有類似之處,例如產品版本會是 .min.js 或是 .prod.js 而開發版本則是 .dev.js 之類。不同的 module 規格也會比照這方式命名,像是 ESM 的話可能會是 .mjs 或是 .module.js 而 UMD 則會是 .umd.js。這邊除了靠猜之外,有些 CDN 服務是可以看到 file list 的。

像是這個連結,可以改一下,就能連到檔案目錄。

// 官方給的 url
https://unpkg.com/htm/preact/standalone.module.js
// 改成這樣
https://unpkg.com/htm/preact/
// unpkg 會自動跳轉到 lib 的檔案目錄
https://unpkg.com/browse/htm@3.1.1/preact/

接著就可以實際看到他有提供哪些版本,取自己需要的就可以了。現在比較多會看到的是無模塊標記的,以及 UMD 與 ESM 三種,無標記的通常會是那種傳統全域註冊的寫法,而 UMD 則是一種混合寫法,在全域註冊的同時,也可以用 ESM 的方式引入。

這種 CDN 引入,用全域註冊的方式多少還是方便一點。但缺點就是沒辦法像 ESM 那樣,清清楚楚的知道每一支 script 檔到底 import 了哪些內容。可維護性會比較低。

但是 ESM 也有麻煩的地方,如果不把那支 js 載下來,那每次引入都得寫一段長網址。且前端框架通常會希望放在 head 優先下載,寫起來會蠻憋扭的。

// 放在 head 優先下載
<head>
<script type="module" src="https://long_CDN_path">
</head>
// 但每一支要引入的 js 都還是得寫一段 import https
import { html, render } from 'https://long_CDN_path'

如果不把檔案載下來的話,可以多用一個 js 去包裝來解決。

// preactHelper.js
import * as htmPreact from 'https://long_CDN_path'
export default htmPreact
// 依舊還是要先下載
<head>
<script type="module" src="preactHelper.js">
</head>
// 但後續其他 js 就可以寫得舒服點
import htmPreact from 'preactHelper.js'
const { html, render } = htmPreact

還要注意一點,如果有習慣使用 ts-check 那 import https 開頭的 url 時,typescript 會抱怨說沒有這個 module。這需要另外寫一支定義檔,讓 js 參照才可以。

// types.d.ts
declare module 'https://*'
// main.js
// @ts-check
/// <reference path="types.d.ts" />

Preact Devtools 開發工具

Preact 也跟 React 一樣有提供瀏覽器的開發工具,叫做 Preact Devtools。只是他引入方式跟 Vue、React 不同。這兩大框架的 CDN 邏輯很簡單,你用 dev 版本就有包含開發工具橋接,但 prod 版本就沒有。可是 Preact 的 CDN 並沒有區分 dev 與 prod,他每一支 js 都是不含 Devtools 的。

根據 Preact Devtools 官網的說明,需要另外引入 debug 或 devtools 模塊。而我們去翻一下 UNPKG,會發現上面確實有 preact/devtools 的包。

https://unpkg.com/browse/preact@10.7.1/devtools/dist/

這時候坑來了,從 UNPKG 把開發工具引入,會發現完全不 work。查了半天才發現,Preact Devtools 有個 issues, 原來是 Preact Devtools 的寫法無法在 UNPKG 上運作,所以改用 Skypack CDN 來引入,就正常了。

可是用一用會發現,這 Preact 的開發工具雖然可以跟 React 一樣顯示組件的樹狀圖,以及各種 state、props,但卻無法在 Devtools 上即時改 state 去響應 UI 變化。也就是說...... 除了看看樹狀圖之外,基本沒甚麼用。

Function 組件的 Hooks

這算是 Devtools 的延伸問題,如果單純只使用 htm/preact 的同捆包的話,由於 hooks 也包在裡面了,基本不會有這方面的困惑。

但如果要拉 Devtools 的話,會發現不能用 htm/preact 同捆包,得單獨引入 preact 與 htm,這樣 Devtools 才能跟 preact 核心串起來。可是,preact 核心是沒有 hooks 的。

import { useState } from 'https://cdn.skypack.dev/preact@10.7.0'
console.log(useState) // undefined

翻看 Preact 在 hooks 方面的說明,會發現他也是另一個模塊。

import { useState } from 'preact/hooks';

所以 CDN 勢必也得另外拉了。

import {useState} from 'https://cdn.skypack.dev/preact@10.7.0/hooks'
console.log('useState', useState)

SEO 與 Pre-render

最後來談談關於 SEO 的問題。一般 SPA 框架解決 SEO 的作法都是使用預渲染,但是無編譯環境下就沒辦法透過 pre-render 相關套件來處理了。

這邊個人摸索了一陣子之後發現,預渲染的原理其實就是實際去算一次,把初始 html 生出來,然後塞回 index.html 裡面。有些框架是直接裝了 Chrome 核心來做這件事。

而這個初始 html 其實…… 我們直接用 VScode 的 Live Server 把專案用瀏覽器打開,不是就等同是渲染了初始狀態了嗎?

那剩下來要做的事情就簡單了,直接打開 Devtools,在最父層的 DOM 上按右鍵 > Copy > Copy element。然後打開 index.html 貼上,就完成手動預渲染了。

當然,實際上還是會需要做一些調整,且最好一開始在設計時,就把要 pre-render 這一點考慮進去。才不會出現期望的初始頁面,跟 Preact 渲染出來的內容有歧異的狀況。

總結

Preact + htm 那優秀的低容量,是真的有很強的競爭力,這使得 CDN 開發時,Preact 成為一個優先選項,很可惜 Vue 體系沒有出現這類的玩意。

只是 CDN 拉了前端框架之後,還是有一些難以解決的問題。例如一般都會希望不要讓瀏覽器有太多的 request,也就是 js 跟 css 檔不能拆的太碎。雖然現代瀏覽器比較沒有這種問題了,但還是會有些顧慮,不知道是否應該要把所有的 js 寫在同一支檔案會更好,甚至會希望上個 minify 之類的。

所以,雖然花了一堆功夫研究 CDN 的方案,但繞了一圈之後,最終還是會想要上打包工具,來 bundle js 與 css,進了 Node.js 編譯環境之後,也才能更好的整合 Sass、Typescript 什麼的。

一個專案到底大的甚麼程度才要上打包工具,這可能還是純看個人喜好跟判斷了。

--

--

Lastor
Code 隨筆放置場

Web Frontend / 3D Modeling / Game and Animation. 設計本科生,前遊戲業 3D Artist,專擅日本動畫與遊戲相關領域。現在轉職為前端工程師,以專業遊戲美術的角度涉足 Web 前端開發。