[輕前端] Vue 輕量級方案 petite-vue

Lastor
Code 隨筆放置場
19 min readMay 29, 2022

接續上一篇:
[輕前端] Preact + htm without build tools

再來分享一下輕前端的解決方案。之前提過說,開發需求有大有小,而前端時常會碰到一些非常小型的活動頁面、Landing Page 這類需求,該類頁面往往工都花在 CSS 切版上頭,需要 JS 處理的邏輯並不多,頂多就是做一些簡單的 UI 交互、條件渲染、迴圈生成相同結構的 UI 組件。

而這種輕量需求,每次都拉整套 React 或是 Vue app 實在有點殺雞用斬艦刀的感覺,cost 太高了。技術選型就成為一個讓人煩惱的問題,這樣的頁面雖然完全用 pure JS 去寫也沒問題,但寫起來的爽快度低、開發效率不高、維護難度往往也會比較高。

上次介紹了 Preact + htm 的 React 輕量級替代方案,有稍微提過 Vue 體系我沒能找到類似的方案。但又過了一段時日,越想越覺得不對,Vue 怎麼可能沒有能抗衡的東西呢!? 於是嘗試了一些不同的關鍵字去 google,結果終於給我找到了!! 那就是「petite-vue」。

petite-vue 簡介

這玩意與 Preact 不同,不是第三方社群做的,而是 Vue 團隊開發的專案。爬了一下對岸討論,似乎是尤大大在看到一套叫做 Alpine.js 的輕量級前端框架之後,覺得這思路不錯,就催生了這套結合 Vue API 的 petite-vue。

而 Alpine.js 似乎原本也有借鑑 Vue 的風格,可說是兩邊都是互相學習的產物。

petite-vue 面向的使用場景,可以直接參考 github 上的描述,他預設是作為 SSR 渲染的輔助,大部分的邏輯在後端都已經處理掉了,前端僅需要簡單的 UI 交互行為即可。

針對這樣的場合,他將 Vue 裡面不需要的東西全拔了,甚至連虛擬 DOM 那套機制也拔掉了,因為他預設的場景是你已經渲染好 template 了,並以其為基礎添加 JS 交互邏輯。

這也造就了 petite-vue 那充滿競爭力的 size,僅需約 7kb 左右的成本,就可以用 Vue 風格的語法對 DOM 進行操作。相信現在仍舊有許多老式 SSR 架構,前端是採用 jQuery 來管理,要換成 Vue App 做前後端分離就得動大手術。這時 petite-vue 就成為了一個非常優秀的選擇,可以更好的做非破壞性、漸進式的技術迭代。

當然,除了配合 SSR 打輔助之外,他的功能要單獨拿來做單一頁面也是完全夠用的,非常適合用來做輕量級的活動頁開發,使用起來相當的靈活。學習成本也不高。原本就會 Vue 的人基本花個 5 分鐘把 github 文件看一看就能用了。

與 Preact 的差異

Preact 是 100% 還原 React 的 API,所以寫法還是 React 那一套。但 petite-vue 不是,他除了拔掉了許多 Vue 的功能之外,也把 Vue 的寫法整個都做了一個簡化。

體驗過後我只想說,Vue 果然還是 Vue,寫起來就是爽,不像 React 體系,需要思考更多工程面的東西,使用 Vue 體系的時候,我可以更專注在內容本身。

而且他的 cost 比 Preact 更低、更輕量,初始化速度更快,不採用虛擬 DOM 也帶來了 SEO 的相對優勢。承襲 Vue 的特點,不依賴 jsx、htm 這類玩意,不用多拉一套 lib 處理 template。

總之結論就是,我終於可以離開 React 陣營,回歸 Vue 的懷抱了 (笑)。

petite-vue 初始化

其實我很想說,自己去看 github 就好,他就是簡單到一頁 readme 就夠了的程度。但為了湊版面,還是要稍為說明一下,我會盡量提一些文件上沒交代到的使用心得。

首先初始化部分,petite-vue CDN 提供了三種 module,umd、iife、以及 esm。而文件上其實只介紹了 iife 與 esm 兩種,大概是因為 umd 其實用不太到吧 (笑)。

iife 與 umd 都是全域註冊到 window 上,但 iife 的檔案 size 比較小一點,要選的話自然是選 iife 更好。而 petite-vue 還提供了自動初始化,只要在 script 上加一個 init 屬性,就會自動啟動,無須在 js 進行宣告,非常適合交互行為單純的場合。

來自 github 上的基本範例:

// 全域引入
<script src="https://unpkg.com/petite-vue" defer init></script>
// 用 v-scope 做初始化綁定, 帶入 data (會自動處理成響應式)
// 然後就可以用了
<div v-scope="{ count: 0 }">
{{ count }}
<button @click="count++">inc</button>
</div>

這是不是相當的簡單呢!? 寫起來無比爽快。Vue 的那些 @click、v-bind、v-model 什麼的也全都可以用,跟 Vue 一模一樣的寫法。而 computed() 這類相對比較不需要的 API 則拔掉了,可以參考 github 上「Not Supported」的說明。

如果需要寫 code 比較長,比較複雜點的內容,就需要另寫 script 手動初始化注入內容。要開 js 檔的話,現在比較推薦直接用 type module 來做,不僅可以避免變數全域污染的老問題,也可以更清楚的交代哪個變數是從哪來的,可維護性會更高。

// html
<script type="module" src="main.js">
// main.js
import { createApp } from 'https://unpkg.com/petite-vue?module'
createApp().mount()

如果不熟悉無編譯環境,原生 ESM 的使用方式的話,全域引入的變數名則為 PetiteVue。

// 全域引入
<script src="https://unpkg.com/petite-vue"></script>
<script>
PetiteVue.createApp().mount()
</script>

ESM 使用上會有個小細節,就是看你 script 想放上面還是下面,如果放下面的話,為了避免網路環境差導致 CDN 加載比較慢的情況,可能會需要在上方再補一個引入,讓瀏覽器提前下載。

<script type="module" src="https://unpkg.com/petite-vue?module"><script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
createApp().mount()
</script>

如果想要上個 ts-check 規範一下 js 檔,這種 https 引入是會被 ts 抱怨的,需要加個 type 定義才行。

// @ts-check
/// <reference path="./types.d.ts" />
import { createApp } from 'https://unpkg.com/petite-vue?module'
// types.d.ts
declare module 'https://*'

對 ts-check 不熟的人,可以參考我之前分享過的文章:
JavaScript 如何拆分 JSDoc 以及虛擬加載 TypeScript 定義檔

另外 petite-vue 的源碼是用 Typescript 寫的,所以如果決定用 npm 引入的話,是可以直接對應 TS 開發的。

petite-vue Data 運作方式

這套把 state 也就是 data 的部分大幅度的簡化,但還是保有原本 Vue 的風格。從方才示範的這段 code 應該不難看出,這個 count 就是 data 宣告。

<div v-scope="{ count: 0 }">
{{ count }}
</div>

而這個 data 宣告也可以寫在 createApp 上面,讓所有 v-scope 綁定的對象共享。

<div v-scope>
{{ count }}
</div>
<script>
PetiteVue.createApp({
count: 0
}
).mount()
</script>

petite-vue 也有提供基本的 life cycle hook,可以透過 mounted 觀測一下。會發現這套一樣是依靠 Proxy API 來實現響應式操作。

<div v-scope @vue:mounted="onMounted">
{{ count }}
</div>
<script>
PetiteVue.createApp({
count: 0,
onMounted() { console.log(this) }
}).mount()
</script>
// console.log 結果
// Proxy { count: 0, ... }

這邊的 onMounted 極為 methods 宣告,是可以自由追加自由命名的,也就是將 Vue2 的 data 與 methods 兩個區塊直接整合在一起,語法上更加單純化。

petite-vue 組件 (Component)

現代前端開發基本都是組件式思維,一定會想要做組件化拆分,將各自的域獨立出來。而組件的寫法也做了很大的簡化,跟共用的宣告方式很類似,就是用 function 包一下而已。

// js
function Header() {
return {
count: 0,
onHeaderMounted() {
console.log('after Header mounted')
console.log(this.count)
}
}
}
PetiteVue.createApp({ Header }).mount()// html
<div v-scope="Header()" @vue:mounted="onHeaderMounted">
{{ count }}
</div>

要在 method 裡面呼叫 data,可以跟原本 Vue 一樣透過 this 去呼叫,既然用 this 呼的到,就表示他背後應該是個 Class,初始化時會 new 一個 instance 出來。

組件隔離出單獨的作用域之後,依舊可以與原先全域宣告的方式一起工作。

<div v-scope="Header()">
<p>{{ count }}</p> // 0
<p>{{ foo }}</p> // "bar"
</div>
<script>
function Header() {
return { count: 0 }
}
PetiteVue.createApp({
count: 1,
foo: 'bar',
Header
}
).mount()
</script>

上述例子中,Header 組件與 app 全域各宣了一個 count,實際執行之後,會以 Header 優先。而 Header 不存在的 foo 變數,依舊會比照 JS 的基本規則,往上層尋找,取得在全域的 foo。

要做組件嵌套,父層往子層傳 props 也是可以的,只是子層拿到 props 之後,不會隨著父層 data 改變重新渲染,因為機制被簡化了。

<div v-scope="Parent()">
<h2>{{ count }}</h2>
<button @click="count++">inc</button>
<div v-scope="Child({ childCount: count })">
<h2>{{ childCount }}</h2>
</div>
</div>
<script>
function Parent() {
return { count: 1 }
}
function Child(props) {
return { childCount: props.childCount }
}
PetiteVue.createApp(Parent, Child).mount()
</script>

這邊把 Child 組件嵌套進 Parent,然後將 Parent 的 count 傳給 Child,為了辨識,Child 的 data 名稱改為 childCount。

按下 button 將父層的 count 遞增,可以發現子層並不會因為傳入的 props 改變,而跟著重新渲染。

所以比照原本的習慣,用組件嵌套的方式傳遞 data 的作法是不能用了。另一方面,使用這個 lib 去設計架構時,把組件扁平化不要嵌套或許是更好的選擇,不然會搞得太複雜。

那我們要如何傳遞 data 呢? 可以使用 Vue 的 Reactive API,來做一個專門的 store 管理共用的 data。

petite-vue 全域狀態管理

其實用上述提到的那種,直接宣在 createApp 上的方法或許也是可以的,但在管理上就會產生模糊地帶,一瞬間無法辨識這個 data 到底是誰的。利用 Vue 的 Reactive API 來做全域狀態,可以更好的進行管理。

// js
import {
createApp,
reactive
} from 'https://unpkg.com/petite-vue?module'

const store = reactive({
count: 0,
inc() { this.count++ },
})
// 可以直接在 js 訪問 store, 改變值, UI 會響應式更新
store.inc()

createApp({ store }).mount()
// html
<div v-scope>
{{ store.count }}
<button @click="store.inc">inc</button>
</div>

透過 store 的機制,可以一眼就辨認出這個 data 是屬於 store 的。有多個組件都需要的 state 就可以往 store 放,要 update value 也很容易。其內部一樣是透過 Proxy API 來處理。

Reactive API 的語法也跟 Component 很像,或許組件底層其實也是用 Reactvie API 生出來的吧。

雖然 store 可以讓我們更方便的管理全域狀態,但仍然有一些 data 在分類上並不適合歸到全域狀態,他可能只是 A 組件的 state,但 B 組件會用到,可是 C、D 組件都不需要。這種時候,可能還是會期望將 state 就放在 A 自己身上,並想辦法讓 B 去訪問他,而不是全都無腦往 store 塞。

可惜,目前 petite-vue 提供的機制,沒有直接處理這種需求的 API。應該是默認有 store 就夠了。

但我有研究出一些 hack 的手段可以實現這種管理方式。

組件間傳遞 data 的方法

1. 無腦往 window 塞

這個應該就不用多作解釋了,就把 A 的 state 塞到 window 上,讓 B 去拿就是了 (笑)。但這種做法就是把 data 給完全暴露出來了,不僅汙染 window 且 Typescript 也追蹤不到,以工程角度來說,這不是一個很推薦的作法。

2. 手動做一個 object 來暴露 API

這跟放到 window 的思路是類似的,只是我們多做一層 object 包裝,使其在 module 內部傳遞,這樣就不會暴露到 window 上,另一方面,編輯器的智能提示也才抓的到。(這邊先用 TS 寫,特別標註 componentA 有可能是 undefined)

// ComponentB.ts// 手動做一個 object 包裹
export let componentB: undefined | {
increment(): void
}
function ComponentB() {
return {
count: 1,
onMounted() {
// 暴露特定 API 給其他組件訪問
componentB = {
increment: this.increment,
}
},
increment() {
this.count += 1
}
}
}
export default ComponentB

這樣過一手之後,其他組件就可以透過 componentB 來控制組件 B 的狀態,在組件 A 改變狀態或是 GET 到資料時,透過這個介面就能一併命令 B 改變狀態。

這做法的好處除了不會暴露到 window 之外,也能做到像 Class 那樣區分出私有與公開的內容,同時也能配合 TS 下定義。但如同上面寫的,會有一個小缺點,就是這個暴露用的 componentA 物件,在組件 mount 上去之前會是 undefined,這需要特別注意,否則會採坑。

3. 透過 Custom Event API 傳遞

這招算是靈機一動想到的作法,瀏覽器跟 Node.js 都可以讓 user 自行建立 Custom Event,只是 API 的用法不一樣。

我們可以註冊一個事件,在組件 onMounted 的時候掛監聽,就可以透過監聽器的 callback 來訪問組件內容了。

function ComponentA() {
return {
data: {},
onClickBtn() {
// ...GET data
const result = { name: 'A' }
this.date = result
// 建立 CustomEvent 並觸發傳遞 result
const passDataToB = new CustomEvent(
'passDataToB',
{ detail: result }
)
window.dispatchEvent(passDataToB)
},
}
}
function ComponentB() {
return {
data: {},
onMounted() {
// 掛監聽, 取得組件 A 傳回的 data
window.addEventListener('passDataToB', (e) => {
this.data = e.detail
})
},
}
}

4. 將組件 instance 註冊到 app root

前面稍微提過 petite-vue 的組件,真身應該是一個個的 Class,在初始化之後會 new 出 instance,然後透過 this 訪問內部屬性。

既然他機制長這樣,那 function 在 return 物件前去呼叫 this 理論上應該拿到的就會是 root,來試試。

// petite-vue component
function Component() {
const root = this
console.log(root)
return { ... }
}
PetiteVue.createApp({ Component }).mount()// console.log 結果
// Proxy {$refs: {...}, Component: f, ...}

確實,成功拿到 root 的 instance 了,雖然掛了一層 Proxy 不過並不影響。

既然如此,也就表示我們可以添加屬性或方法上去,這樣就能讓兩個組件透過 root 互相溝通了。

function ComponentA() {
const root = this
return {
data: {},
onClickBtn() {
// ...GET data
const result = { name: 'A' }
this.data = result
// 透過暴露到 root API 傳遞 data
root.setData(result)
},
}
}
function ComponentB() {
const root = this
return {
data: {},
onMounted() {
// 將 setData 暴露到 root
root.setData = this.setData
},
setData(val) {
this.data = val
console.log('componentB data', this.data)
}
}
}

不過這個做法問題也蠻明顯的,他跟暴露到 window 其實沒甚麼區別,就是看你是想汙染 window 還是汙染 app root 而已 (笑)。

這樣的添加方式 Typescript 是無法追蹤的,所以內容比較複雜的時候,我們很難知道額外添加的 root 屬性到底有哪些,又是從哪個地方被加上去的,儼然是一個比較粗暴的做法。

總結

petite-vue 在設計上單純簡單,但功能也足夠強大,在不能或不適合上整套 Vue、React app 的時候,會是一個比 jQuery 更方便也更輕巧的選擇,這個方案整體的表現也比 Preact + htm 來的更為優勢。

如果嫌 petite-vue 功能不夠,也可以考慮使用 Alpine.js,他的功能會更豐富一點。如果依然覺得不夠,那就表示前端的複雜度到門檻了,可以直接上整套 Vue 或 React app 了,這一套下來可以說是很符合漸進式增強的思路。

在體驗過這樣的迷你框架之後,真心覺得 jQuery 可以正式退役了,讓我們懷念他。

最後在提一嘴,petite-vue 自然也是可以輕鬆的搭配 Vite 來進行打包的,如果選擇上打包工具的話,也能順便解決一個原生架構無法處理的問題,那就是…… HTML 不能拆分啊!! (翻桌)

這個我個人研究了很久,試圖尋找可行的辦法,如果是用 Preact 方案,那確實可以透過虛擬 DOM 來拆分 HTML,但代價就是犧牲 SEO,我覺得不值得。尋尋覓覓之下,終於在一篇討論看到一個可行的思路,我們可以拉模板引擎來拆分 HTML 啊!!

js 體系比較流行的 Pug 與 handlebars 都有 vite plugin 可用,幾乎也是零配置,比用 webpack 簡單非常的多。

如果決定上 vite 打包的話,多拉一個模板引擎來協助拆分 HTML 也是一個可以考量的選擇。至於具體細節這邊就不贅述了,畢竟這篇重點是在介紹 petite-vue 而不是 vite 與模板引擎。

希望這篇可以給大家帶來一個清新的輕量級前端開發思路,我自己是用的頗爽的就是 (笑)。

--

--

Lastor
Code 隨筆放置場

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