開始準備前端的工作,之前跑hello tauri
的時候,我們用cargo tauri dev
,這個會自己watch,所以我們不用寫cargo watch:
@@ run.ps1 @@
+ Write-Host "6) [app]: 執行 tauri 前端 UI"
...
+} elseif ($opt -eq 6) {
+ cargo tauri dev -- -p app
}
@@ run.sh @@
+echo 6: [tauri] dev
...
+ elif [[ $VAR -eq 6 ]]
+ then
+ cargo tauri dev -- -p app
另外我們的前端每次調整package要重新手動pnpm install,,我們一併加入tauri前置的工作裡,tauri啟動會自動幫我們跑:
@@ app/src-tauri/tauri.conf.json @@
+ "beforeDevCommand": "pnpm i && pnpm dev",
- "beforeDevCommand": "pnpm dev",
記得確認一下node.js的版本:
~$ nvm list # 使用新的偶數版就好
v18.17.0
v18.17.1
-> v18.18.0
~$ $ pnpm -v # 確保有安裝pnpm
8.7.6
基於tauri
只是桌面應用的框架,用起來有點像electron。所以我們可以搭配自己喜歡的前端框架,各種web框架都可以使用,當時我們使用tauri cli幫新開的svelte專案相對陽春。這邊要做一個完整一點的前端,所以在這裡改用SvelteKit,也就是svelte目前官方出版相對功能比較完整的一個應用程式的配套。
附帶一提,tauri在我們的專案中,有時候是前端的後端,有時候是後端的前端,所以可能會依場景的不同,有時候tauri是前端,有時候tauri是後端。(我又進來了 我又出去了?)
什麼概念呢,就像vue,建造一個簡單的小功能可以,但要建構大型應用,就要搭配該語言的ecosystem(生態圈),比如加上Vue-Router,Pinia(Vue的state management)等相關的套件後,才能比較有效率地搭建出一個完整的應用出來。
我們照著SvelteKit官方的說明找一個空白的資料夾另外建立一個app來對照看有什麼不同:
~$ pnpm create svelte@latest my-app
我們使用pnpm取代官方的npm
選第一個,含範例比較好上手。
選TypeScript。
先全勾,以後不要再移除(空白鍵勾選,上下鍵選擇)。
完成後會提示安裝並跑起來,不過先等等,我們只是要利用它提供的工具幫我們scaffold專案的架構,所以整個把資料夾複製到我們專案的資料夾去。我們先把舊的前端刪掉(放心刪,人生不能能來,但git可以):
bash
~$ rm demo-app/app/* # 移除 app 底下npm相關檔案
~$ rm demo-app/app/src -r # 移除 svelte 檔案
~$ cp my-app/* demo-app/app/ -r # 複製剛剛sveltekit demo到tuari專案裡
~$ cp my-app/.* demo-app/app/ # 複製隱藏檔
powershell
PS > rm .\demo-app\app\* # ps會逐一子資料夾提示,只有src資料夾選y
# 複製剛剛sveltekit demo到tuari專案裡
C:\Codebase> Copy-Item .\my-app\* .\demo-app\app\ -Recurse
檔案複製好後,記得先把pnpm專案補齊才跑剛剛tauri的批次:
~/demo-app/app$ pnpm i
把tauri跑起來看看:
果然被我們改壞了,上面可以看到vite起的dev server port在5173
,而tauri似乎在監聽1420
,所以開不了tauri的畫面。等等有看到相關的設定再調整回來,我們試著用vite跑,再用瀏覽器開一下看看(快捷:Ctrl+左鍵點console裡的連結):
看起來svelte專案是沒問題的,我們利用git一項項加進stage吧:
.gitignore
是要版控要忽略的東西,把新舊兩邊的東西合併起來再stageindex.html
:原先是SPA進入點,改sveltekit後進入點換成src/app.html
,所以放心刪除(stage)package.json
:也是兩邊比對,把之前取代掉的套件補回來pnpm-lock.yaml
:刪掉,等等執行pnpm i重新產生即可README.md
:不重要,直接stagesvelte.config.js
:感覺只加了些東西,直接stagetsconfig.json
:一樣先保留兩邊共有的設定,再補上刪除的tsconfig.node.json
裡的選項tsconfig.node.json
:刪除(stage)vite.config.ts
:補回被洗掉的sever
等設定lib/Greet.svelte
:連接tauri api的範例檔,revert(把刪除還原)。其他檔案差異,有興趣的朋友可以直接用git diff比較看看,記得改完要pnpm i
,再跑tauri dev起來看看:
單純svelte的部分OK,我們把原本的Greet.svelte加到 about page裡試試看原本tauri的部分有沒有正常,我們把app/src/routes/about/+page.svelte
這個檔案修改一下,上面加三行script區塊,引入原本的Greet.svelte組件檔,再把下面div
裡的介紹資料刪掉,放入我們剛剛import的Greet
:
<!-- app/src/routes/about/+page.svelte -->
<script lang="ts">
import Greet from '$lib/Greet.svelte';
</script>
<svelte:head>
<title>About</title>
<meta name="description" content="About this app" />
</svelte:head>
<div class="text-column">
<h1>About this app</h1>
<Greet />
</div>
$lib
是sveltekit的慣例,會取代為我們專案app/src/lib
這個資料夾svelte的檔案很像vue的SFC,單檔上面放 <script>
區塊,中間放html,下面放<style>
。我們試了一下,原本寫的API都正常作動,Greet按鈕有正確呼叫tauri的api:
平常自己開發的話,最好完成一小步功能就趕緊git stage/git commit,避免一次太多東西不容易查找問題,或是不小心改壞了又要重來一次。
我們先保留原本svelte的demo,打算再開新的頁面來放我們的遊戲。對照sveltekit的官方路由設定,看一下如何使用,首先sveltekit依慣例方式,定義以下路徑為路由資料夾:
src/routes
:是網頁根目錄/
src/routes/about
:對應到的路由是/about
src/routes/blog/[slug]
:建立一個含slug
參數的路由Each route directory contains one or more route files, which can be identified by their + prefix.
在路由資料夾底下,與路由有關的檔案由+
開頭:
+page.svelte
:提供對應其路由資料夾的路徑底下的頁面。+page.js
:主要提供+page.svelte
頁面載入資料所需。+layout.svelte
:主要設定頁面的版面配置,如頁首/頁尾/選單等共用的區塊可以在此設定+layout.js
:與+page.js
一樣作為載入資料使用。看一下現在的目錄結構:
app/src/routes/
├── about
│ ├── +page.svelte
│ └── +page.ts
├── Counter.svelte
├── Header.svelte
├── +layout.svelte
├── +page.svelte
├── +page.ts
├── styles.css
└── sverdle
├── game.test.ts
├── game.ts
├── how-to-play
│ ├── +page.svelte
│ └── +page.ts
├── +page.server.ts
├── +page.svelte
├── reduced-motion.ts
└── words.server.ts
先看一下+layout.svelte
:
<script>
import Header from './Header.svelte';
import './styles.css';
</script>
<div class="app">
<Header />
<main>
<slot />
</main>
<footer>
<p>visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to learn SvelteKit</p>
</footer>
</div>
<!-- 略 (以下為style) -->
可以看到中間有個<slot />
,就是我們切換不同路由會把+page.svelte
放入的地方,另外可以看到有Header/footer等元件,我們看一下Header.svelte
,中間有列舉路由選單,我們加上一段我們的井字遊戲:
<!-- app/src/routes/Header.svelte -->
<li aria-current={$page.url.pathname === '/tic_tac_toe' ? 'page' : undefined}>
<a href="tic_tac_toe">井字遊戲</a>
</li>
補上去看到上面選單有出現了,404
是我們還沒加頁面。
依剛剛header裡的設定建立一個檔案,隨便加幾個字,測一下路由是有效的:
app/src/routes/
└── tic_tac_toe
└── +page.svelte
<div>
Hello
</div>
好,接下來需要從server端取呼叫API取得資料,我們建立一個api的資料夾,把呼叫api集中處理:
知道三層式的話,這層有點像是Data Access層
app/src/api
├── index.ts # 統一對外的接口
└── tic_tac_toe.ts # 有關tic_tac_toe相關的api
這裡預計未來會再加入新的API,所以先提早把API拆出檔案:
井字遊戲模組API:
// app/src/api/tic_tac_toe.ts
export interface TicTacToeApi { // 定義本模組介面
newGame: () => Promise<any>; // todo: 等等再寫model
}
const newGame = async (): Promise<any> => {
let response = await fetch("http://localhost:3030/tic_tac_toe", {
method: "POST",
}); // 使用es原生fetch呼叫rest api
if (response.ok) {
return await response.json();
} else {
return Promise.reject(await response.json());
}
}
export const ticTacToeApi: TicTacToeApi = {
newGame,
}
全局API介面:
// app/src/api/index.ts
import type {TicTacToeApi} from "./tic_tac_toe";
import {ticTacToeApi} from "./tic_tac_toe";
export interface Api {
ticTacToe: TicTacToeApi;
}
export const api: Api = {
ticTacToe: ticTacToeApi,
}
API加好了,回到我們的畫面試著呼叫看看,修改頁面如下:
<!-- app/src/routes/tic_tac_toe/+page.svelte -->
<script lang="ts">
import {api} from "../../api";
let newGame = api.ticTacToe.newGame();
</script>
<div>
{#await newGame}
<p>...loading</p>
{:then value}
遊戲:{value}
{:catch error}
發生錯誤:{error}
{/await }
</div>
svelte允許我們在html文本裡使用await 區塊,我們在這傳入newGame
為呼叫api的結果(Promise),而svelte裡在html文本裡使用變數僅用{}
包起來即可,我們看一下跑的結果:
先把瀏覽器開發工具(參考第三篇)叫出來,開啟console分頁:
有沒有看到關鍵字CORS?CORS是因為我們前端http://localhost:1420要去呼叫後端http://localhost:3030,算是不同的來源,基於安全性考量,瀏覽器會擋掉,想了解可以看一下CORS 完全手冊。
這個問題要從後端解,找了一下warp的example,沒有看到相關的資料,但在文件裡有提到怎麼使用:
加到我們的main裡試試吧:
@ web/src/main.rs @@
+let cors = warp::cors()
+ .allow_any_origin()
+ .allow_methods(vec!["GET", "PUT", "POST", "DELETE"]);
...
let routes = hello
.or(api_games)
.recover(error::handle_rejection)
+ .with(cors)
.with(warp::trace::request())
注意一下我們這邊allow all是為了開發方便,但如果考慮之後要佈署的話,最好是抽變數,再依佈署環境進行設定,好了,我們設定完前端畫面應該出來了:
看不到object,雖然可以用JSON.stringify的方式,但我們秉著做一個完整應用架構的想法,還是先按步就班地加上model:
app/src/model/
└── tic_tac_toe.ts
// app/src/model/tic_tac_toe.ts
export type Symbol = "X" | "O" | null;
export interface Game { // 遊戲資料結構
cells: [
Symbol, Symbol, Symbol,
Symbol, Symbol, Symbol,
Symbol, Symbol, Symbol,
],
is_over: boolean,
winner?: Symbol,
}
export type GameSet = [number, Game]; // POST 回傳資料結構
接著調整我們剛剛的api:
@@ app/src/api/tic_tac_toe.ts @@
+import type {GameSet} from "../model/tic_tac_toe";
export interface TicTacToeApi {
+ newGame: () => Promise<GameSet>;
- newGame: () => Promise<any>;
...
+const newGame = async (): Promise<GameSet> => {
-const newGame = async (): Promise<any> => {
接著我們在使用的時候,IDE就可以給予相對應的提示了:
{:then value}
+ 局號:{value[0]},狀態:{value[1].is_over ? '結束' : '進行中'} ,贏家:{value[1].winner ?? '無'}
- 遊戲:{value}
{:catch error}
一切都很順利,我們繼續往下把API寫完:
const play = async (id: number, step: number): Promise<GameSet> => {
let response = await fetch(`http://localhost:3030/tic_tac_toe/${id}/${step}`, {
method: "PUT",
});
if (response.ok) {
let data = await response.json();
return [id, data]
} else {
return Promise.reject(await response.json());
}
}
Delete 和 Get 的方法有興趣可以看原始碼。這邊我們把後端回傳的Game
,組成GameSet
(含局號),後續UI呼叫比較好使用。
再來要寫畫面,先加入css框架,我們預計使用tailwindcss,至於要怎麼加入到我們的專案,也是直接看文件,tailwind很貼心的提供各框架導入的文件:
~/demo-app/app$ pnpm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
執行後會安裝必要套件及產生設定檔,我們再調整一下設定檔:
@@ app/tailwind.config.js @@
+content: ['./src/**/*.{html,js,svelte,ts}'],
-content: [],
然後在我們的style.css裡引入:
@@ app/src/routes/styles.css @@
@import '@fontsource/fira-mono';
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
這樣就好了,我們來測一下有沒有作用成功(這邊hot-reload不見得有效,無效的話請關掉重跑即可),在about頁面加入以下:
<!-- app/src/routes/about/+page.svelte -->
<h1 class="text-3xl font-bold underline">
Hello world!
</h1>
出現有底線的Hello World就算成功了,
接下來開始調版面,先畫出9宮格,我們使用svelte的foreach,然後搭配tailwincss的grid,開始切版:
{#await newGame}
<p>...loading</p>
{:then value}
<div class="grid grid-cols-3">
{#each value[1].cells as symbol, index}
<div>
{index}: {symbol}
</div>
{/each}
</div>
{:catch error}
發生錯誤:{error}
{/await }
原本在這裡發生一段小插曲,就是我怎麼切都切不出grid,我原本還在想跟tailwind的Postprocessors會不會有關係,畢竟跟我們以往常用的Sass, Less, 及Stylus等在處理CSS上不一樣,加上svelte不像在寫vue時是用Virtual DOM,我自己也還沒到很熟悉。
感謝還好有眼尖的邦友bendwarn神救援,提醒typo並提供prettier-plugin-tailwindcss工具參考。
原本切出來的結果如下圖list無法跑出9格的版:
當時雖然也有看一下開發工具的內容,但無奈眼拙沒有發現grid拼錯字了:
因為拼錯的關係,所以可以看到css的display是block不是grid,如下:
這邊先照原本的寫法繼續把代碼補上:
<script>
let playGame = (id, step) => {
newGame = api.ticTacToe.play(id, step);
}
</script>
{#await newGame}
<p>...loading</p>
{:then value}
<div> 第{value[0]}局</div>
<div class="grid grid-cols-3">
{#each value[1].cells as symbol, index}
<div>
<button on:click={playGame.bind(this, value[0], index+1)}> {index}: {symbol}</button>
</div>
{/each}
</div>
{:catch error}
發生錯誤:{error.message} {error.details}
{/await }
跑出來結果如下:
這邊又遇到另一個問題,就是一出錯就整個畫面沒有,因為我們的代碼把 then 和 catch 分開 render,所以可能和我們想要的同時顯示遊戲內容與錯誤訊息有點落差,在這裡我們改寫一下原本的寫法,設一個變數放置棋局內容:
<script lang="ts">
import {api} from "../../api";
import {emptyGame} from "../../model/tic_tac_toe";
import {onMount} from "svelte";
let gameSet = emptyGame(); // 在model裡新增一個fn建立空白物件,讓下面標籤中的資料綁定不報錯。
const newGame = async () => { // 把呼叫api包成這裡用的function
gameSet = await api.ticTacToe.newGame();
}
onMount(async () => {
await newGame(); // 初始化先從server取得新局
})
</script>
<div>
局號:{gameSet[0]},狀態:{gameSet[1].is_over ? '結束' : '進行中'},贏家:{gameSet[1].winner ?? '無'}
</div>
<div class="grid grid-cols-3">
{#each gameSet[1].cells as symbol, index}
<div>{index}: {symbol}</div>
{/each}
</div>
// app/src/model/tic_tac_toe.ts
export const emptyGame = (): GameSet => [0, {
cells: [
null, null, null,
null, null, null,
null, null, null,
],
is_over: false,
winner: null,
}];
看起來OK,陸續先把功能補上:
<script>
<!-- 略 -->
const playGame = async (index: number) => {
gameSet = await api.ticTacToe.play(gameSet[0], index);
}
</script>
<button on:click={newGame}>新遊戲</button>
<!-- 略 -->
{#each gameSet[1].cells as symbol, index}
<button on:click={playGame.bind(this, index+1)}>{index}: {symbol}</button>
{/each}
加個「新遊戲」的按鈕,呼叫新局,這個比較沒有問題,而對於九宮格,我們把剛剛的div改成button,再綁定事件,以供玩家點擊時可以呼叫play的api,要注意的是參數的傳遞不能像vue一樣直接傳playGame(index+1)
要使用bind,如上例的playGame.bind(this, index+1)
,試了一下,基本上功能都正常:
再來就追加css及附加說明讓它長的比較像遊戲一點(?):
<script lang="ts">
<!-- 略 -->
let error: string | null = null;
const playGame = async (index: number) => {
try {
gameSet = await api.ticTacToe.play(gameSet[0], index);
error = null;
} catch (e) {
let msg = e.message;
if (e.details) {
msg += `: ${e.details}`;
}
error = msg;
}
}
</script>
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
on:click={newGame}
> 新遊戲 </button>
<h2 class="font-bold py-2 px-4 rounded text-2xl">
局號:{gameSet[0]},
{#if gameSet[1].winner}
遊戲結束,贏家:{gameSet[1].winner}!
{:else if gameSet[1].is_over && !gameSet[1].winner}
遊戲結束:平手!
{:else}
遊戲正在進行中...
{/if}
<span class="text-red-500 text-lg"> {error ?? ''} </span>
</h2>
<div class="w-96 grid grid-cols-3">
{#each gameSet[1].cells as symbol, index}
<button
class="h-32 text-9xl text-amber-500 border-2 border-amber-500 rounded-md"
on:click={playGame.bind(this, index+1)}
>{symbol ?? ' '}</button>
{/each}
</div>
成果測試:
tailwindcss的說明我就不逐一講解了,有興趣的去官方查詢,基本上寫過前端的話,應該都可以看的出來是在寫什麼,或是去看兔兔的tailwind CSS教學。
另外測一下檢核訊息是否有正確顯示:
本系列專案源始碼放置於 https://github.com/kenstt/demo-app
Promise.resolve 記得不用明寫?async 會自動將回傳值包成 promise。
另外 grid 沒出來是因為你打錯字,推薦 prettier-plugin-tailwindcss,提示不錯用。
感謝大大提醒及建議,我再修改一下。