enpitsulin

enpitsulin

这个人很懒,没有留下什么🤡
twitter
github
bilibili
nintendo switch
mastodon

使用Tauri構建桌面端應用程序——以TodoMVC為例(下)

使用 jotai 控制應用狀態#

jotai 是一個原子狀態管理工具,名字其實就是日語的状態的羅馬音,api 非常簡潔,我們的前端項目就使用這個庫來做狀態管理。

前端項目中用到的類型聲明#

這裡還有一個需要用的到就是我們 Todo 的類型,其實就是將 rust 之前寫好的 struct 用 ts interface 的方式聲明一下以便前端項目使用。

export interface Todo {
  id: string
  label: string
  done: boolean
  is_delete: boolean
}

這裡其實有一個rust 包能夠在編譯的自動生成 ts 類型綁定,但是我們的項目不大直接自己寫也沒啥問題。

定義原子#

首先先在 src 下建立一個 store 目錄,添加一個 todo.ts 文件來存放聲明的一些原子。

import { atom } from 'jotai'
import { Todo } from '../types/todo'

/** 用於過濾完成狀態 */
export const filterType = atom<'all' | 'completed' | 'active'>('all')

/** 包括is_delete 為true的todos */
export const allTodosAtom = atom<Todo[]>([])

/** 未被軟刪除的todos */
export const todosAtom = atom<Todo[]>((get) => {
  const todos = get(allTodosAtom)
  return todos.filter((todo) => !todo.is_delete)
})

/** 經過過濾的todos */
export const filterAtom = atom((get) => {
  const todos = get(todosAtom)
  return todos.filter((todo) => {
    if (get(filterType) === 'all') return true
    return todo.done === (get(filterType) === 'completed')
  })
})

/** 方便使用的統計未完成的todo數量的原子 */
export const activeTodoCountAtom = atom((get) => {
  const todos = get(todosAtom)
  return todos.filter((todo) => !todo.done).length
})

/** 也是方便實用的檢查是否有todo完成的原子 */
export const anyTodosDone = atom((get) => {
  const todos = get(todosAtom)
  return todos.some((todo) => todo.done)
})

雖然可能在後端查詢表的時候直接將軟刪除的過濾掉更好但是因為我們的數據量應該不會很大而且我懶,所以直接SELECT * from Todo然後把所有數據給前端來過濾,當然這種行為不推薦 =。=

應用中使用原子#

然後我們定義好了原子自然要使用的,需要在頁面頂級組件 App.tsx 引入需要使用的原子,然後我們通過一個副作用使用invoke將原子賦值,並且將得到的 Todo[] 在 TodoList 組件中使用 TodoItem 渲染出來。

那麼我們需要通過 props 傳遞這個數據,首先需要先去定義一下這兩個組件的 props。首先是 TodoItem

import { Todo } from './types/todo'
const TodoItem: React.FC<{ todo:Todo }> = ({ todo }) => {
  return (
    <li>
      <div className="view">
        <input type="checkbox" className="toggle" checked={todo.done} autoFocus />
        <label>{todo.label}</label>
        <button className="destroy"></button>
      </div>
    </li>
  )
}
export default TodoItem

以及 TodoList

import { Todo } from './types/todo'
import TodoItem from './TodoItem'
const TodoList:React.FC<{ todos: Todo[] }> = ({ todos }) => {
  return (
    <>
      <header className="header">
        <h1>todos</h1>
        <input type="text" className="new-todo" placeholder="What needs to be done?" />
      </header>
      <section className="main">
        <input type="checkbox" className="toggle-all" />
        <label htmlFor="togle-all"></label>
        <ul className="todo-list">
          {todos.map((todo) => (
            <TodoItem key={todo.id} todo={todo} />
          ))}
        </ul>
      </section>
      <footer className="footer">
        <span className="todo-count">
          <strong>1</strong> items left
        </span>
        <ul className="filters">
          <li>
            <a>All</a>
          </li>
          <li>
            <a>Active</a>
          </li>
          <li>
            <a>Completed</a>
          </li>
        </ul>
      </footer>
    </>
  )
}
export default TodoList

然後我們在 App.tsx 中使用一個副作用將 invoke ('get_todos') 返回的數據賦值給 AllTodosAtom 原子並且將 filterAtom 相應的數據傳遞下去。然後我們查看應用現在應該是變成一個空的列表的情況

import { invoke } from '@tauri-apps/api'
import { useAtom } from 'jotai'
import { useEffect } from 'react'
import TodoList from './component/TodoList'
import { allTodosAtom, filterAtom } from './store/todos'
import { Todo } from './types/todo'

function App() {
  const [, setAllTodos] = useAtom(allTodosAtom)
  const [todos] = useAtom(filterAtom)
  useEffect(() => {
    invoke<Todo[]>('get_todos').then((res) => {
      setAllTodos(res)
    })
  }, [])
  return (
    <div className="todoapp">
      <TodoList todos={todos}/>
    </div>
  )
}

export default App

preview|1002x789

新增 Todo#

新增一個 todo 明顯需要通過調用後端的 "new_todo" 向數據庫插入新紀錄,同時我們也要操作原子往 allTodoAtom 中插入新增的數據

所以修改 TodoList 讓其中的 input 標籤起作用以及過濾用的 a 標籤能夠控制 filterTypeAtom

import { useAtom } from 'jotai'
import { v4 as randomUUID } from 'uuid'
import { useState, useCallback, KeyboardEventHandler } from 'react'
import { activeTodoCountAtom, allTodosAtom, anyTodosDone, filterType } from '../store/todos'
import { Todo } from '../types/todo'
import TodoItem from './TodoItem'
import { invoke } from '@tauri-apps/api'

const TodoList: React.FC<{ todos: Todo[] }> = ({ todos }) => {
  const [, setTodos] = useAtom(allTodosAtom)
  const [type, setType] = useAtom(filterType)
  const [activeCount] = useAtom(activeTodoCountAtom)
  const [anyDone] = useAtom(anyTodosDone)

  const [newTodo, setNewTodo] = useState('')

  const addTodo = async (label: string, id: string) => {
    invoke('new_todo', { todo: { id, label, done: false, is_delete: false } })
  }

  const onAddTodo = useCallback<KeyboardEventHandler<HTMLInputElement>>(
    (e) => {
      if (e.key === 'Enter') {
        e.preventDefault()
        if (newTodo) {
          const id = randomUUID()
          addTodo(newTodo, id)
          setTodos((oldTodos) => {
            return [...oldTodos, { label: newTodo, id, done: false } as Todo]
          })
          setNewTodo('')
        }
      }
    },
    [newTodo]
  )

  const onClearComplete = () => {
    setTodos((oldTodos) => {
      return oldTodos.filter((todo) => {
        const isDone = todo.done
        if (isDone) {
          invoke('update_todo', {
            todo: { ...todo, is_delete: true }
          })
          return false
        }
        return true
      })
    })
  }
  return (
    <>
      <header className="header">
        <h1>todos</h1>
        <input
          type="text"
          className="new-todo"
          value={newTodo}
          onChange={(e) => {
            setNewTodo(e.target.value)
          }}
          onKeyPress={onAddTodo}
          placeholder="What needs to be done?"
        />
      </header>
      <section className="main">
        <input type="checkbox" className="toggle-all" />
        <label htmlFor="togle-all"></label>
        <ul className="todo-list">
          {todos.map((todo) => (
            <TodoItem key={todo.id} todo={todo} />
          ))}
        </ul>
      </section>
      <footer className="footer">
        <span className="todo-count">
          <strong>{activeCount}</strong> items left
        </span>
        <ul className="filters">
          <li>
            <a onClick={() => setType('all')} className={type == 'all' ? 'selected' : ''}>
              All
            </a>
          </li>
          <li>
            <a onClick={() => setType('active')} className={type == 'active' ? 'selected' : ''}>
              Active
            </a>
          </li>
          <li>
            <a onClick={() => setType('completed')} className={type == 'completed' ? 'selected' : ''}>
              Completed
            </a>
          </li>
        </ul>
        {anyDone && (
          <button className="clear-completed" onClick={onClearComplete}>
            Clear completed
          </button>
        )}
      </footer>
    </>
  )
}
export default TodoList

然後測試下效果,我們會發現這裡應用會重載一次,因為對 /tauri-src/ 目錄下的文件修改就會觸發 tauri 的 HMR,我們向 sqlite 就是修改了 db.sqlite 文件所以觸發了一次,如果修改路徑可能也會導致打包分發的時候路徑出點問題,這個問題不知道怎麼解決,希望有人能評論指出。

new todo|1002x789

修改和完成和刪除 Todo#

這三個操作我們都放在 TodoItem 組件裡,不過要注意的是對 "toggle_done" 和 "update_todo" 的操作一定要加上防抖,這點也和開發 web 應用一致,可能頻繁觸發的並調用副作用的事件防抖還是很必須的。

最終組件代碼如下,我額外使用了 react-use 和 use-debounce 這兩個包的一些 hook。本來是想自己寫的,寫了個就擺了,useDoubleClick 可以看最後倉庫代碼

import { useAtom } from 'jotai'
import { ChangeEventHandler, KeyboardEventHandler, useCallback, useRef, useState } from 'react'
import { useClickAway } from 'react-use'
import { useDebouncedCallback } from 'use-debounce'
import { allTodosAtom } from '../store/todos'
import { Todo } from '../types/todo'
import { useDoubleClick } from '../hooks/useDoubleClick'
import { invoke } from '@tauri-apps/api'

const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => {
  const [, setTodos] = useAtom(allTodosAtom)
  const [editing, setEditing] = useState(false)
  const ref = useRef<HTMLInputElement>(null)

  const toggleDone = useDebouncedCallback(() => {
    invoke('toggle_done', { id: todo.id })
  }, 500)

  const setLabel = useDebouncedCallback((label: string) => {
    invoke('update_todo', {
      todo: { ...todo, label }
    })
  }, 500)

  const deleteTodo = useCallback(() => {
    invoke('update_todo', {
      todo: { ...todo, is_delete: true }
    })
  }, [todo])

  const onDelete = () => {
    setTodos((todos) => {
      return todos.filter((t) => {
        return t.id !== todo.id
      })
    })
    deleteTodo()
  }

  const onChange: ChangeEventHandler<HTMLInputElement> = (e) => {
    const label = e?.target.value
    setTodos((todos) => {
      return todos.map((t) => {
        if (t.id === todo.id) {
          setLabel(label)
          return { ...t, label }
        }
        return t
      })
    })
  }

  useClickAway(ref, () => {
    finishEditing()
  })

  const finishEditing = useCallback(() => {
    setEditing(false)
  }, [todo])

  const handleViewClick = useDoubleClick(null, () => {
    setEditing(true)
  })

  const onDone = useCallback(() => {
    setTodos((todos) => {
      return todos.map((t) => {
        if (t.id === todo.id) {
          toggleDone()
          return { ...t, done: !t.done }
        }
        return t
      })
    })
  }, [todo.id])

  const onEnter = useCallback<KeyboardEventHandler<HTMLInputElement>>(
    (event) => {
      if (event.key === 'Enter') {
        event.preventDefault()
        finishEditing()
      }
    },
    [todo]
  )
  return (
    <li
      className={[editing && 'editing', todo.done && 'completed'].filter(Boolean).join(' ')}
      onClick={handleViewClick}
    >
      <div className="view">
        <input type="checkbox" className="toggle" checked={todo.done} onChange={onDone} autoFocus />
        <label>{todo.label}</label>
        <button className="destroy" onClick={onDelete}></button>
      </div>
      {editing && (
        <input
          ref={ref}
          type="text"
          autoFocus={true}
          value={todo.label}
          onChange={onChange}
          className="edit"
          onKeyPress={onEnter}
        />
      )}
    </li>
  )
}
export default TodoItem

打包和分發#

我們只需要執行pnpm tarui build然後就可以在tauri-src/target/release/bundle/msi裡找到 window 平台 的安裝包了,想要自定義安裝程序可以參考,至於 MacOS 和 linux 我也沒嘗試過所以鸽了理論上應該是可以跨平台運行的,但是不太清楚具體操作_(:3」∠)_

關於學習 rust 的討論#

其實一直有種風氣說學習 rust 卷,確實單純來說一個做業務的前端學習 rust 是屬於卷了,但是單純的個人喜好來學習一門新興語言,業餘時間學習學習真的不僅能開拓眼界,同時增加一些經驗,肯定是好處大大滴。

而且 rust 作為一款只要能搞定編譯器就能安全運行的語言,比起日常接觸的 js/ts 寫起來有可能還要和虛無縹緲的類型系統打架來說實在是爽多了(個人體驗)。雖然可能看著語法真的特別變扭,其實核心的思想還是一致的。

而且 rust 在前端基建方面的成果斐然還有 WSAM 方面也是頭號種子選手的,萬一以後前端不得不學 rust 那不是遲了?不如現在先卷別人一步!

參考資料#

  • tarui - 跨平台桌面程序開發框架,瘦身版的 electron
  • TodoMVC - 經典的 web 框架開發實例
  • rusqlite - sqlite with rust
  • jotai - React 的一款 api 簡潔用法靈活的狀態管理工具
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。