iT邦幫忙

2023 iThome 鐵人賽

DAY 11
0

Prepare

開始準備前端的工作,之前跑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

Svelte 不夠,我們用Svelte Kit跑起來

基於tauri只是桌面應用的框架,用起來有點像electron。所以我們可以搭配自己喜歡的前端框架,各種web框架都可以使用,當時我們使用tauri cli幫新開的svelte專案相對陽春。這邊要做一個完整一點的前端,所以在這裡改用SvelteKit,也就是svelte目前官方出版相對功能比較完整的一個應用程式的配套。

附帶一提,tauri在我們的專案中,有時候是前端的後端,有時候是後端的前端,所以可能會依場景的不同,有時候tauri是前端,有時候tauri是後端。(我又進來了 我又出去了?)
tauri簡易前後端架構圖

什麼概念呢,就像vue,建造一個簡單的小功能可以,但要建構大型應用,就要搭配該語言的ecosystem(生態圈),比如加上Vue-RouterPinia(Vue的state management)等相關的套件後,才能比較有效率地搭建出一個完整的應用出來。

我們照著SvelteKit官方的說明找一個空白的資料夾另外建立一個app來對照看有什麼不同:

~$ pnpm create svelte@latest my-app

我們使用pnpm取代官方的npm
選擇svelte app template

選第一個,含範例比較好上手。
選擇TypeScript或JavaScript

選TypeScript。
選擇syntax chekcing

先全勾,以後不要再移除(空白鍵勾選,上下鍵選擇)。
svelte建構完成畫面

完成後會提示安裝並跑起來,不過先等等,我們只是要利用它提供的工具幫我們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
    

    powershell執行畫面

    # 複製剛剛sveltekit demo到tuari專案裡
    C:\Codebase> Copy-Item .\my-app\* .\demo-app\app\ -Recurse  
    

檔案複製好後,記得先把pnpm專案補齊才跑剛剛tauri的批次:

~/demo-app/app$ pnpm i

把tauri跑起來看看:
tauri執行的終端畫面

果然被我們改壞了,上面可以看到vite起的dev server port在5173,而tauri似乎在監聽1420,所以開不了tauri的畫面。等等有看到相關的設定再調整回來,我們試著用vite跑,再用瀏覽器開一下看看(快捷:Ctrl+左鍵點console裡的連結):
用browser開啟svelte dev server

看起來svelte專案是沒問題的,我們利用git一項項加進stage吧:

svelte kit 完成的資料夾結構

folder structure

  • 幾個後面有U符號的是這次新增的檔案,看起來是相關的設定檔,全stage起來
  • .gitignore是要版控要忽略的東西,把新舊兩邊的東西合併起來再stage
  • index.html:原先是SPA進入點,改sveltekit後進入點換成src/app.html,所以放心刪除(stage)
  • package.json:也是兩邊比對,把之前取代掉的套件補回來
  • pnpm-lock.yaml:刪掉,等等執行pnpm i重新產生即可
  • README.md:不重要,直接stage
  • svelte.config.js:感覺只加了些東西,直接stage
  • tsconfig.json:一樣先保留兩邊共有的設定,再補上刪除的tsconfig.node.json裡的選項
  • tsconfig.node.json:刪除(stage)
  • vite.config.ts:補回被洗掉的sever等設定
  • lib/Greet.svelte:連接tauri api的範例檔,revert(把刪除還原)。
  • 其他檔案就直接stage

其他檔案差異,有興趣的朋友可以直接用git diff比較看看,記得改完要pnpm i,再跑tauri dev起來看看:
用 tauri 開啟sveltekit app

單純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:
測試 example 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是我們還沒加頁面。
井字遊戲404畫面

依剛剛header裡的設定建立一個檔案,隨便加幾個字,測一下路由是有效的:

app/src/routes/
└── tic_tac_toe
    └── +page.svelte
<div>
  Hello
</div>

井字遊戲頁面顯示Hello字樣
好,接下來需要從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文本裡使用變數僅用{}包起來即可,我們看一下跑的結果:

畫面出現TypeError訊息

先把瀏覽器開發工具(參考第三篇)叫出來,開啟console分頁:

打開Browser偵錯工具

有沒有看到關鍵字CORS?CORS是因為我們前端http://localhost:1420要去呼叫後端http://localhost:3030,算是不同的來源,基於安全性考量,瀏覽器會擋掉,想了解可以看一下CORS 完全手冊

這個問題要從後端解,找了一下warp的example,沒有看到相關的資料,但在文件裡有提到怎麼使用:
warp提到CORS的文件

加到我們的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就可以給予相對應的提示了:
vs code提示api類別

  {:then value}
+    局號:{value[0]},狀態:{value[1].is_over ? '結束' : '進行中'} ,贏家:{value[1].winner ?? '無'}
-    遊戲:{value}
  {:catch error}

https://ithelp.ithome.com.tw/upload/images/20230925/20162521UoQTNNEglQ.png

一切都很順利,我們繼續往下把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呼叫比較好使用。

加入tailwindCSS

再來要寫畫面,先加入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>

裝完tailwindCSS的hello world

出現有底線的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格的版:
測試tailwindcss grid的排版
當時雖然也有看一下開發工具的內容,但無奈眼拙沒有發現grid拼錯字了:
開發工具顯示的grid
因為拼錯的關係,所以可以看到css的display是block不是grid,如下:
解析完是grid
解析完是block

這邊先照原本的寫法繼續把代碼補上:

<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),試了一下,基本上功能都正常:
可play的畫面

再來就追加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


上一篇
10 所以 rust 的 rest api 終於完成了
下一篇
12 讓前端再好一點點 let Svelte co-work with rust
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
bendwarn
iT邦新手 5 級 ‧ 2023-09-25 03:17:27

Promise.resolve 記得不用明寫?async 會自動將回傳值包成 promise。

另外 grid 沒出來是因為你打錯字,推薦 prettier-plugin-tailwindcss,提示不錯用。

感謝大大提醒及建議,我再修改一下。

我要留言

立即登入留言