Xây dựng ứng dụng Desktop siêu nhẹ với Tauri 2.0 và React: Thay thế Electron bằng Rust

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Bắt đầu nhanh (5 phút)

Electron từng tạo nên cuộc cách mạng cho ứng dụng desktop dành cho các nhà phát triển web, nhưng việc đính kèm cả một file thực thi Chromium 120MB chỉ để chạy một tiện ích dung lượng nhẹ thực sự rất lãng phí. Tauri 2.0 giải quyết vấn đề này bằng cách loại bỏ hoàn toàn engine trình duyệt đi kèm. Thay vào đó, nó kết hợp React với một backend Rust nhanh, an toàn bộ nhớ và tận dụng trực tiếp webview tích hợp sẵn của hệ điều hành.

Bạn có thể khởi tạo một ứng dụng desktop hoạt động hoàn chỉnh chỉ trong khoảng năm phút. Trước tiên, hãy kiểm tra ba điều kiện tiên quyết sau trên máy của bạn:

  • Node.js: phiên bản 18 trở lên (kiểm tra bằng node -v)
  • Rust: cài đặt qua rustup (Rust 1.77.2+)
  • Toolchain cho nền tảng: Visual Studio C++ Build Tools trên Windows, Xcode Command Line Tools trên macOS (xcode-select --install), hoặc WebKitGTK và các công cụ build trên Linux (sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libayatana-appindicator3-dev librsvg2-dev)

Chạy lệnh CLI khởi tạo chính thức trong terminal của bạn:

npm create tauri-app@latest

Chọn các tùy chọn sau khi được nhắc:

  1. Tên dự án (Project name): my-desktop-app
  2. Ngôn ngữ frontend (Frontend language): TypeScript / JavaScript
  3. Trình quản lý package (Package manager): npm (hoặc pnpm / yarn)
  4. Template giao diện (UI template): React
  5. Biến thể (Flavor): TypeScript

Di chuyển vào thư mục vừa tạo, cài đặt các package và khởi động server phát triển:

cd my-desktop-app
npm install
npm run tauri dev

Cargo sẽ biên dịch các dependency Rust bên dưới trong lần chạy đầu tiên, mất khoảng 60 đến 90 giây. Sau khi biên dịch xong, một cửa sổ native độc lập sẽ hiển thị giao diện React của bạn. Tính năng hot module replacement hoạt động tức thì: chỉnh sửa bất kỳ component nào trong src/, cửa sổ native sẽ cập nhật trong vài mili-giây mà không làm mất trạng thái ứng dụng.

Đi sâu vào chi tiết

Để xây dựng các công cụ desktop đáng tin cậy, bạn cần nắm vững mô hình runtime bên dưới của Tauri cũng như cách các thông điệp được truyền giữa luồng UI và mã nguồn native của hệ thống.

Kiến trúc của Tauri 2.0

Slack, VS Code và Discord đều dựa vào Electron. Vì mỗi ứng dụng Electron phải đóng gói kèm engine trình duyệt Chromium và runtime Node.js riêng, file cài đặt thường xuyên vượt quá 120MB. Lượng RAM tiêu thụ khi ở trạng thái nhàn rỗi (idle) thường vượt mức 200MB cho mỗi cửa sổ.

Tauri tiếp cận theo hướng tinh gọn triệt để hơn nhiều. Nó chuyển giao toàn bộ việc render HTML, CSS và JavaScript cho engine webview vốn đã được hệ điều hành máy chủ duy trì và cập nhật bảo mật:

  • Windows: Microsoft Edge WebView2 (Evergreen runtime)
  • macOS: WKWebView (WebKit)
  • Linux: WebKitGTK 4.1

Logic nghiệp vụ, các lệnh gọi hệ thống và chính sách bảo mật của bạn sẽ thực thi bên trong mã Rust đã biên dịch. Nhờ không phải đính kèm một engine trình duyệt thừa thãi, bộ cài đặt production được thu nhỏ chỉ còn 8MB–15MB. Bộ nhớ tiêu thụ ở trạng thái chờ giảm đáng kể, thường duy trì trong khoảng từ 25MB đến 45MB RAM.

Cấu trúc dự án

Không gian làm việc của bạn được phân chia rõ ràng giữa mã frontend và logic native:

  • src/: Chứa ứng dụng Vite + React tiêu chuẩn, bao gồm các file JSX, style, hook và tài nguyên tĩnh.
  • src-tauri/: Chứa ứng dụng Rust. Các file chính bao gồm Cargo.toml (các crate dependency), tauri.conf.json (kích thước cửa sổ, định danh bảo mật, cài đặt bundle), và src/lib.rs (điểm khởi đầu nơi Tauri 2.0 khởi tạo plugin và đăng ký các lệnh IPC).

Command IPC: Kết nối React với Rust

Giao diện React giao tiếp với tiến trình Rust thông qua cơ chế Giao tiếp liên tiến trình (IPC) bất đồng bộ. Các hàm native được cung cấp cho webview được gọi là các Command.

Một sai lầm phổ biến của các web developer khi mới làm quen với lập trình desktop là chạy các tác vụ tính toán nặng nề trực tiếp trong JavaScript. Việc chuyển tác vụ đọc ghi file, mã hóa hoặc truy vấn hệ thống sang Rust sẽ giữ cho giao diện người dùng đạt 60 FPS hoàn toàn mượt mà.

Mở file src-tauri/src/lib.rs. Dưới đây là một command tùy chỉnh giúp xác thực chuỗi đầu vào, thu thập thông số CPU của máy chủ và trả về dữ liệu có cấu trúc:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct SystemStats {
    os_name: String,
    cpu_cores: usize,
    status: String,
}

#[tauri::command]
fn get_system_stats(user_label: &str) -> Result<SystemStats, String> {
    if user_label.trim().is_empty() {
        return Err("Nhãn không được để trống".into());
    }

    Ok(SystemStats {
        os_name: std::env::consts::OS.to_string(),
        cpu_cores: std::thread::available_parallelism()
            .map(|n| n.get())
            .unwrap_or(1),
        status: format!("Đang hoạt động (Hồ sơ: {})", user_label),
    })
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![get_system_stats])
        .run(tauri::generate_context!())
        .expect("lỗi khi chạy ứng dụng tauri");
}

Chuyển sang tầng React. Import invoke từ @tauri-apps/api/core để gửi lời gọi trực tiếp đến hàm xử lý Rust của bạn:

import { useState } from 'react';
import { invoke } from '@tauri-apps/api/core';

interface SystemStats {
  os_name: string;
  cpu_cores: number;
  status: string;
}

export function SystemMonitor() {
  const [stats, setStats] = useState<SystemStats | null>(null);
  const [label, setLabel] = useState('Trạm làm việc Production');
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function fetchStats() {
    setLoading(true);
    setError(null);
    try {
      const result = await invoke<SystemStats>('get_system_stats', {
        userLabel: label,
      });
      setStats(result);
    } catch (err) {
      setError(String(err));
    } finally {
      setLoading(false);
    }
  }

  return (
    <div style={{ padding: '1.5rem', fontFamily: 'sans-serif' }}>
      <h3>Bộ theo dõi hệ thống Desktop</h3>
      <input
        value={label}
        onChange={(e) => setLabel(e.target.value)}
        placeholder="Nhập nhãn hồ sơ"
        style={{ marginRight: '8px', padding: '6px' }}
      />
      <button onClick={fetchStats} disabled={loading}>
        {loading ? 'Đang lấy dữ liệu...' : 'Truy vấn Rust Backend'}
      </button>

      {error && <p style={{ color: 'red' }}>Lỗi: {error}</p>}

      {stats && (
        <div style={{ marginTop: '1rem', background: '#f4f4f5', padding: '1rem', borderRadius: '6px' }}>
          <p><strong>Hệ điều hành:</strong> {stats.os_name}</p>
          <p><strong>Số nhân khả dụng:</strong> {stats.cpu_cores}</p>
          <p><strong>Trạng thái:</strong> {stats.status}</p>
        </div>
      )}
    </div>
  );
}

Tauri tự động xử lý việc chuyển đổi định dạng tên trường. Bạn truyền các khóa camelCase trong JavaScript (userLabel), và Serde sẽ deserialize chúng thành các định danh dạng snake_case trong Rust (user_label).

Sử dụng nâng cao

Stream sự kiện hai chiều

Mô hình request-response đơn lẻ rất phù hợp cho các truy vấn nhanh. Nhưng với các tác vụ chạy lâu—chẳng hạn như stream quá trình tải file 2GB, theo dõi giải nén file hay thăm dò cảm biến phần cứng—bạn cần cập nhật theo thời gian thực được đẩy trực tiếp từ Rust sang React.

Hệ thống sự kiện của Tauri xử lý việc này rất tinh gọn. Trong Rust, bạn phát sự kiện liên tục bằng trait Emitter:

use tauri::Emitter;
use tokio::time::{sleep, Duration};

#[tauri::command]
async fn start_progress_task(app: tauri::AppHandle) -> Result<(), String> {
    tauri::async_runtime::spawn(async move {
        for step in 1..=100 {
            sleep(Duration::from_millis(30)).await;
            let _ = app.emit("task-progress", step);
        }
    });
    Ok(())
}

Đăng ký start_progress_task bên trong tauri::generate_handler![] trong lib.rs. Ở phía frontend, bạn gắn một event listener bên trong hook React thông thường:

import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';

export function ProgressBar() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    let unlisten: (() => void) | undefined;

    async function setupListener() {
      unlisten = await listen<number>('task-progress', (event) => {
        setProgress(event.payload);
      });
    }

    setupListener();
    return () => {
      if (unlisten) unlisten();
    };
  }, []);

  return (
    <div>
      <button onClick={() => invoke('start_progress_task')}>Bắt đầu Worker</button>
      <div style={{ background: '#e2e8f0', width: '240px', height: '16px', margin: '10px 0', borderRadius: '4px', overflow: 'hidden' }}>
        <div style={{ background: '#2563eb', width: `${progress}%`, height: '100%', transition: 'width 0.1s linear' }} />
      </div>
      <span>Tiến độ: {progress}%</span>
    </div>
  );
}

Bảo mật và Capabilities trong Tauri 2.0

Tauri 2.0 thay thế cơ chế allowlist cũ của v1 bằng mô hình Capabilities rõ ràng. Webview khởi động hoàn toàn trong môi trường sandbox. Nó không thể truy cập file, mở hộp thoại hệ thống hay chạy lệnh terminal trừ khi bạn cấp quyền rõ ràng cho các hành động đó trong file cấu hình JSON.

Kiểm tra định nghĩa capabilities tại src-tauri/capabilities/default.json:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Phân quyền capability cho cửa sổ ứng dụng chính",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "opener:default"
  ]
}

Kiến trúc zero-trust này bảo vệ an toàn cho người dùng của bạn. Kể cả khi kẻ tấn công tìm thấy lỗ hổng XSS trong dependency ở frontend, chúng vẫn bị cô lập hoàn toàn bên trong sandbox của webview mà không thể chạm tới API của hệ thống máy chủ.

Lời khuyên thực tế

1. Tối ưu hóa Release Profile cho Rust

Cấu hình release mặc định thường ưu tiên tốc độ biên dịch nhanh thay vì tối thiểu hóa kích thước nhị phân. Bằng cách thêm năm chỉ thị tối ưu hóa sau vào src-tauri/Cargo.toml, bạn có thể cắt giảm đáng kể dung lượng thừa của file binary đã biên dịch:

[profile.release]
opt-level = "z"     # Tối ưu hóa tối đa cho kích thước nhị phân
lto = true          # Bật Link Time Optimization trên toàn bộ các crate
codegen-units = 1   # Giảm đơn vị tạo code song song để cải thiện LTO
panic = "abort"     # Loại bỏ các landing pad và bảng unwinding khi panic
strip = true        # Tự động loại bỏ symbols và thông tin debug

Các cờ này giúp giảm kích thước file thực thi native thuần từ khoảng 18MB xuống còn xấp xỉ 4.5MB trên Windows và macOS mà không ảnh hưởng gì đến tốc độ thực thi khi chạy.

2. Giữ gói bundle Frontend gọn gàng

Vite sẽ nén các asset web dùng cho production và nhúng thẳng vào file nhị phân đã biên dịch. Hãy giữ dung lượng frontend của bạn ở mức tối giản:

  • Phân tích kích thước bundle đầu ra bằng rollup-plugin-visualizer để phát hiện sớm các package npm cồng kềnh trước khi phát hành.
  • Thay thế các thư viện tiện ích như lodash hay moment.js bằng các tính năng JavaScript gốc như Intl.DateTimeFormat và các phương thức mảng hiện đại.
  • Lazy-load các màn hình nặng (như dashboard cài đặt hoặc thư viện biểu đồ) bằng React.lazy() và Suspense để giữ thời gian khởi động cửa sổ ban đầu dưới 300ms.

3. Debug Webview trực tiếp trong ứng dụng

Việc debug webview trên desktop diễn ra hoàn toàn tương tự như trên trình duyệt. Nhấn F12 hoặc nhấp chuột phải và chọn Inspect Element trên Windows và Linux để mở DevTools. Trên macOS, đặt "devtools": true trong cấu hình cửa sổ ở file tauri.conf.json, hoặc khởi chạy ứng dụng với TAURI_DEVTOOLS=1 cargo tauri dev để mở WebKit Web Inspector.

4. Đóng gói bộ cài đặt Native

Khi đã sẵn sàng phát hành, bạn chỉ cần chạy một lệnh terminal duy nhất:

npm run tauri build

Tauri sẽ biên dịch gói Vite bundle, build file thực thi Rust với các tối ưu hóa tối đa, và xuất các gói cài đặt đã được ký số cho từng nền tảng vào thư mục src-tauri/target/release/bundle/. Bạn sẽ nhận được các bộ cài đặt gọn nhẹ dạng .msi hoặc .exe cho Windows, file bundle .dmg hoặc .app cho macOS, cùng kho lưu trữ .deb hoặc .AppImage cho Linux. Thay vì bắt người dùng phải tải về bộ cài đặt cồng kềnh hơn 100MB, bạn mang đến một file nhị phân chỉ 12MB, tốc độ cao, phản hồi nhanh và khởi động chỉ trong tích tắc.

Share: