Vite + Phaser 3 打磚塊小遊戲 (上)

Lastor
Code 隨筆放置場
15 min readSep 30, 2021

承接上一篇【Webpack 簡易打包與 Eva.js 試用】,繼續來玩玩其他 H5 遊戲框架。這次使用 Phaser 3 以及最近前端比較紅的 Vite 進行打包。

主要是跟著 MDN 教學製作的一款打磚塊小遊戲,整理了一些紀錄與心得,最終寫完的內容長度有點超出預期,所以分成上、下兩篇。

上篇集中分享 Vite 的初見心得,下篇則是打磚塊小遊戲的歷程。
1. Vite + Phaser 3 打磚塊小遊戲(上)
2. Vite + Phaser 3 打磚塊小遊戲(下)

最終完成的程式碼,也先附上。
1. Breakout Game (no-framework)
2. Breakout Game with Phaser 3 + Vite

由於主要是心得筆記性質,所以不會講得太鉅細靡遺。

何謂 Vite

先來聊聊對大多前端來說,比較重要的打包工具 Vite 吧。這玩意是 Vue 作者尤雨溪在 Vue3 之外另外開發的項目,其核心訴求是為了解決 Webpack 熱更新太慢的問題,這部分可以參考 Vite 官方文檔裡面「Why Vite」條目。

這個其實蠻有意思的,個人目前做過的案子中,大多不存在熱更新太慢的問題,至少個人體感上姑且能接受,直到有一次我嘗試引入了 Tailwind CSS,才第一次見識到熱更新慢是個甚麼體驗。所以看到 Vite 的贊助者裡面有 Tailwind 就忍不住一直笑。

Vite vs Wepback 熱更新

目前初步掌握的概念,Vite 之所以熱更新很快的原因,是因為他採用了與 Webpack 不同的方式來處理 dev 模式下的熱更新。Webpack 熱更新時也會對整個專案進行編譯與打包,像這樣每進行一個變動,就編譯打包一次,速度自然會比較慢。

Vite 的概念則是盡量不編譯、不打包,並將 module 的 import / export 交給瀏覽器自己用原生的 ES6 Module 來處理,所以熱更新的時候相較 webpack 少幹了很多事情,速度自然快到飛起。而最終 prod build 的部分,則跟 Webpack 一樣,老老實實的進行打包工作。

Vite 這種利用現代瀏覽器原生機能的想法,似乎最早是由另一個打包工具 Snowpack 所提出,兩者都是比較新的東西。Webpack 則是較早期推出的工具,那時瀏覽器還不具有 module 功能,所以 Webpack 是完全在 Node 環境下工作,很純的 bundler。

Vite 作為新一代打包工具,現在的陣仗看來未來很有可能取代 Webpack。但目前 Webpack 仍舊是最大宗的打包工具,且有更為完整的社群,以及各種情況的解決方案,追求穩定的話,Webpack 可能還是比較安全的選擇。

Vite 初體驗與感想

個人 Webpack 的使用,不敢說非常厲害,僅在一些非 React、Vue 的專案中,被迫得自己弄 Webpack,但目前碰過的需求都還算比較單純的,基本都是純粹的打包 npm module 到目標的 JS runtime 下,了不起多加個 babel 或 TypeScript。做 Web 時多半還是直接無腦用 React 與 Vue 提供的腳手架。

Vite 對比 Webpack,在單純需求之下,建構專案的體驗非常的舒服且迅速。這次嘗試的目標很單純,僅需將 npm 拉下來的 Phaser 打包給瀏覽器使用。這情況 Vite 幾乎不用做任何設定,就能開箱即用。

官方提供的安裝方式,有別與傳統的 npm install,而是使用這樣的方式:

$ npm init vite@latest

這個安裝方式個人是第一次看到,似乎可以簡單理解成,npm init 時,直接調用該套件的初始化檔案 (當然,前提是要該 module 有提供)。這樣就省去了本地先 npm init,然後 install,接著再根據不同套件執行特定初始化指令的繁瑣操作。

他背後似乎調用的是 @module_name/create-app,前往 Vite 的 github,確實可以看到在 vitejs/packages 底下有個 create-app 資料夾。

執行上述初始化指令之後,就會出現類似 vue-cli 初始化的選單,當前為 v2.5.4 版,依序會有這些選項:

  1. Project name
  2. Select a framework
  3. Select a variant
Select a framework

這執行過程非常快速,因為選完之後他並不會安裝依賴套件,而是直接退出,並提示要你手動執行 npm install。這樣的作法意外的很舒服,讓我們可以先確認 package.json 到底有些甚麼,再動手安裝。想快速查看 ts 版本長啥樣,也減少很多時間成本。

Select a framework

選項 2 的部分,除了熱門的 Vue 與 React 之外,還有其他的框架可選:

  • vanilla
  • vue
  • react
  • preact
  • lit-element
  • svelte

最意外的是居然有提供 vanilla 的方案。由於個人的目的僅是引入 Phaser 做練習,所以這邊直接選擇 vanilla 的原生 JS 方案。

Select a variant

選完了框架之後,第三個選項則是選擇要用 JS 還是 TS,依照個人需求即可。這邊我為了能專心在 Phaser 上頭,避免在 TS type 定義上消耗額外的時間,所以姑且選擇 JS 再自行使用 JSDoc 搭配 ts-check 的方式。

Vite vanilla 基本架構

一進去會發現主要檔案就只有傳統的 HTML / CSS / Javascript 三個文件,沒有特別的東西,走一個精簡風。

root
- .gitignore
- favicon.svg
- index.html
- main.js
- package.json
- style.css

先來看看 package.json,裡面僅安裝了 vite,沒有其他多餘的東西。scripts 提供了三個指令,dev、build 與 serve,這邊官方文件有簡易的說明:

// Vite 官方文件
{
"scripts": {
"dev": "vite", // 启动开发服务器
"build": "vite build", // 为生产环境构建产物
"serve": "vite preview" // 本地预览生产构建产物
}
}

會提供三項,dev 是常態開發使用的 watch 模式,也就是會用上述提到的熱更新方式來處理。而 serve 則是在本地去預覽打包後的結果,最後 build 就不贅述了。

接著回來看專案的檔案結構,可以發現沒有常規的 src、public 等資料夾。如果建立的是 Vue 或是 React 的專案,是會有這些資料夾的。至於 vanilla 方案沒有放進來的原因,可能是因為很難特定 user 究竟是要搭建怎樣的專案,就乾脆弄得素一點,讓 user 自己來設置。

Vite 由於是依附於瀏覽器來做熱更新,所以他的進入點並不是 main.js 而是 index.html,他會利用這支 html 檔來做所有的後續操作。打開 index.html 可以看到內容也很精簡,最重要的無非就是 body 裡面 script 的引入。

<div id="app"></div>
<script type="module" src="/main.js"></script>

Vite 是以這一行為依據,去取得 JS 的進入點,這一行有一點類似 Webpack 的 entry 設定。

這邊可以自己建立一個 src 資料夾,把 main.js 放進去,然後把 script src 修改一下,Vite 照樣能運作。

<script type="module" src="/src/main.js"></script>

Native ES6 Module

比較需要注意一下的是,因為 Vite 依賴瀏覽器處理 import / export,所以無法使用 CommonJS,也就是不能使用 require / module.exports。

這可以預想,可能實作時會有一些問題,因為 require 不像 ES6 import 有置頂限制,他可以放在任何地方,使用起來比 import 靈活。

例如,使用 dotenv 控制環境變數時,為了區分 prod 與 dev 我們可能會在進入點文件,頂部寫下這樣的 code:

if (process.env.NODE_ENV !== 'production') {
require('dotenv').config()
}

很顯然,ES6 import 無法進行這類操作。以及 process.env 這個 Node 指令,也無法使用,因為我們的 .js 檔案,是直接在瀏覽器上運行的。

這部分,Vite 有提供其他方式讓我們可以存取,基本上就是新定義一個關鍵字,來替代 process.env。

// NodeJS
process.env.NODE_ENV
// Vite
import.meta.env.MODE

詳細可以查看官方文件,後面也有提供 .env 的處理說明,Vite 一樣是使用 dotenv 來處理環境變數。

至於 require 本身不能用的問題,勢必也會引響到其他動態引入的技巧,這個可能得再研究其他的處理方案了。

Vite build

打包的部分,大概念似乎跟 Webpack 沒太大區別。Vite 在打包之後會在 cmd 顯示各個包的 size,並且預設會對超過一定 size 的包提出警告,方便我們進一步的優化。

Some chunks are larger than 500 KiB

除非後端那邊有想法,或是真碰到了需要嚴格控制流量的專案,不然這邊其實無視也無所謂。稍微要注意的是,Vite 預設會將 node module 的東西全打成一包,所以安裝的套件一多,這一包鐵定會大到難以直視。

這個目前我知道兩種處理方式,一種是單純的大包拆小包,另一種是打包時進行壓縮,讓瀏覽器下載後自行解壓。比較理想的固然是後者,這邊先來聊聊前者的作法。

大包拆小包相信是很好理解的,Vite build 在產出大 size 的包之後,所提供的警告訊息裡面,其實也是提示我們進行大包拆小包的動作,上面會給一個連結,直接連到 rollup.js 的技術文件上,關於 output.manualChunks 的條目,這對應到 vite config 開放出來的 build.rollupOptions 屬性,我們可以在這設定 rollup.js。

// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
// action...
return 'bundle_name'
},
},
},
},
})

在 build 時,rollup 會去調用 manualChunks(),餵進去的參數 id 是該 module 的完整 path,可以在裡面下一個 console.log 查看,然後 build 一次,就能秒懂這是啥。

C:/(略)/breakout_game_phaser/src/main.js
C:/(略)/breakout_game_phaser/src/style.css
C:/(略)/breakout_game_phaser/node_modules/phaser/dist/phaser.js
...

所以這邊把 node module 大包拆小包的操作,思路就很清晰了,僅需要在 path 中把包含 node_module 的模塊挑出來,通常下一層的資料夾名即是該 module 的名稱,所以再把這個 module name 單獨抓出來,就能以模塊為單位去拆成小包了。

// vite.config.js
manualChunks(id) {
// 將 node_modules 依套件名拆小包
if (id.includes('node_modules')) {
const libName = id.split('node_modules/')[1].split('/')[0]
return libName
}
}

不過這個拆小包的操作,是基於 npm module 原本就是一包一包為單位。但要把其中一個 lib,例如 Phaser 再拆碎,可以預期處理起來應該不容易。

所以直接採用另一種方案,將包進行 gzip 壓縮,現代瀏覽器本身是能支持這項操作的,當一個 query 目標有 .gz 壓縮檔時,會優先下載該壓縮包。這個機制我不確定是 Server 控制還是瀏覽器控制,尚未研究太深。總之,下載之後瀏覽器會自己解壓,這對於流量的負擔會瞬間小很多。

// 左邊是原 size, 右邊是壓縮後的 size
dist/assets/phaser.d8885fe6.js.gz 1255.04kb / gzip: 278.69kb

這雖然可能會有 Server 也需要設定的問題,但目前確認 gh-pages 是可以 work 的,所以就先不深入研究。

而壓縮的方法非常的簡單,使用插件 vite-plugin-compression

$ npm i -D vite-plugin-compression

之後在設定檔把插件掛上去,沒太多要求的話,就不需要多做設定,掛上去就好。

// vite.config.js
import viteCompression from 'vite-plugin-compression'
export default defineConfig({
plugins: [viteCompression()], // 太大的包進行 gzip 壓縮
})

這邊 Webpack 自然也有類似的插件,google 搜尋 webpack gzip 之類就能找到相關資訊。

Vite deploy gh-pages

部屬的部分,官方文件也有許多說明,這邊分享一下針對 gh-pages 碰到的一些 path 問題。

這跟 Vue + Webpack 部屬到 gh-pages 類似,打包工具會把 import 有關的路徑做處理,這部分我沒有做太深入的研究,但粗略看來,原理應該是把相對、絕對路徑的頭部,添加 or 替換成 publicPath 設定的內容。

// Vue3, vue.config.js
module.exports = {
publicPath: '/<REPO>/'
}

之後 build 時,Webpack 就會對路徑上前端的 "./”“/” 做加工。

// 可能是類似這種感覺
const MyModule = require("./lib/MyModule.js")

"./lib/MyModule.js"

"/<REPO>/lib/MyModule.js"

實際對於相對路徑的處理應該會更複雜一些,dev watch 跟 prod build 應該會有所差異,不過這部分我就沒花時間去深入研究了。

而 gh-pages 是有設定 route 的,所以實際檔案位置雖然是在 root 底下,但 route 卻隔了一層 /<REPO>/ ,所以沒對 publicPath 做任何設定的情況下,路徑根部會變成 / ,這就導致與 gh-pages 的 route 對不上,而找不到檔案。

// should be
https://<USERNAME>.github.io/<REPO>/main.js
// fail path
https://<USERNAME>.github.io/main.js

Vite 打包時,自然也是一樣的概念,他使用的屬性名不是 publicPath 而是 base。

// vite.config.js
export default defineConfig({
base: '/<REPO>/',
})

由於 Vite 在 dev 的運行機制與 Webpack 不同,所以不需要像 Webpack 這樣區分出 prod 與 dev,也能在 dev 模式正常啟動。

// webpack
{
publicPath: process.env.NODE_ENV === 'production' ? './' : '/'
}

Vite 外部資源 (public)

Vite 的外部靜態資源,預設路徑也是指到 public 資料夾,跟 Webpack 一樣,需要引入時直接使用絕對路徑 / 即可。

但這邊與 Phaser 的配合中,倒是出現了有趣的現象,Phaser 3 有自己加載 assets 的語法。

// "/src/scenes/GameScene.js"
preload() {
this.load.image(paddleKey, '/img/paddle.png')
}

由於我 img 是放在 public/img 底下,自然下意識地就會使用絕對路徑來拉檔案,開發模式完全沒有問題,但 deploy 到 gh-pages 上卻不能 work。瀏覽器回報了這樣的錯誤,顯然指錯了位置。

GET https://<USERNAME>.github.io/img/xxx.png 404 (Not Found)

經過一番測試,Phaser 的 load API,似乎沒有被 Vite 判定這是一個 path,所以沒有被處理,原本怎麼設,build 出來就會長啥樣。

神奇的是,在 base path 有設定為 /<REPO>/ 的情況,Vite 在執行 npm run dev 時,也會把這個 path 自動帶上去。

http://localhost:3000/<REPO>/

這樣一來,開發模式下使用絕對路徑 /img/paddle.png ,情況應該也會跟 gh-pages 一樣抓不到圖才對,但開發模式卻完全沒有問題……

進一步的測試,無論 path 是寫 /img/paddle.png 還是 ./img/paddle.png 亦或是 img/paddle.png ,開發模式竟然全都可以 work,一時之間搞不清楚是個啥情況,反正最後我是先用 img/paddle.png 來處理了。

Vite 就先到這吧,意外的寫了不少,接著下一篇來聊聊 Phaser 3 與打磚塊小遊戲。

--

--

Lastor
Code 隨筆放置場

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