Lập trình desktop app với Electron

Lập trình desktop app với Electron
Photo by Umberto from Unsplash

Lâu lắm mới lại lập trình 🤣 Thật tình cờ là vừa trở lại lập trình thì gặp ngay công nghệ mới. Nói thế thôi chứ thực ra là cũ người mới ta. Electron (trước đây gọi là Atom Shell) đã xuất hiện từ rất lâu rồi, và các ứng dụng sử dụng công nghệ này cũng được sử dụng rộng rãi (ví dụ như tôi vẫn hằng ngày sử dụng Visual Studio CodeMicrosoft Teams).

Bối cảnh

Dòng đời xô đẩy thế nào tôi lại được giao một dự án Proof of Concept (PoC). Và yêu cầu là cần giao một sản phẩm mẫu mà khách hàng có thể dễ dàng thực thi và kiểm tra hoạt động. Trong trường hợp này không gì tốt hơn một ứng dụng chạy ngay trên máy tính, không yêu cầu cài đặt hay cấu hình gì nhiều.

Ý tưởng ban đầu là sẽ viết ứng dụng bằng Java, sau đó đóng gói, khách hàng chỉ cần cài JRE là có thể chạy được. Tuy nhiên, tôi không có kinh nghiệm gì về Java ngoại trừ chút kiến thức học từ thời sinh viên. Vừa học vừa làm thì cũng được thôi nhưng dự án PoC thì không có nhiều thời gian thế. Với kinh nghiệm làm web nhiều năm thì lúc này Electron là một lựa chọn tốt. Với lựa chọn này thì bắt buộc phải biết Node.js.

Tuy kinh nghiệm với JavaScript của tôi chỉ toàn là phía client, chưa bao giờ làm lập trình Node.js, tuy nhiên nó cũng là JavaScript cả. Hơn nữa, ứng dụng không có yêu cầu cao về hiệu suất, nó chỉ cần chạy đúng logic là được. Suy đi tính lại thì Electron là lựa chọn hợp lý nhất.

Nói qua một chút về Electron, thì các ứng dụng viết bằng Electron không khác một trình duyệt web là mấy. Khi bật ứng dụng lên cũng có nghĩa là mở trình duyệt lên (Electron có sẵn Chromium ở trong, tuy nhiên JavaScript engine của nó hơi khác một chút) và tải một trang HTML. Việc của lập trình viên là sử dụng HTML, CSS, JS để tạo giao diện cho ứng dụng. Khi người dùng click các nút, thì lúc này có 2 phương án xử lý:

  1. Xử lý như một trang web bình thường, tức là sẽ submit form, gọi Ajax hay tương tự để gửi lên server, nhận kết quả và hiển thị cho người dùng. Với cách làm này, thì các framework như React hay VueJS sẽ là lựa chọn tuyệt vời cho phía client để có một ứng dụng thật “ngầu”. Về phía server, có thể sử dụng Node.js hay bất cứ một ngôn ngữ lập trình nào khác, để lập trình web, quá trình truyền nhận dữ liệu tất cả thông qua API. Nhiều trang web được thiết kế sẵn theo hướng này có thể build app desktop rất nhanh, chỉ cần bỏ HTML, CSS, JS vào Electron build là xong, thế là vừa có web, vừa có app luôn.
  2. Ứng dụng Electron có 2 tiến trình, tiến trình chính (main) và renderer. Renderer là tiến trình mà bật Chromium và hiển thị HTML cho người dùng. Tuy là bật trình duyệt web và hiển thị, tuy nhiên tiến trình này có thể tiến hành một số xử lý trực tiếp bằng code Node.js luôn (ví dụ có thể sử dụng fs để làm việc với files) chứ không bị giới hạn trong môi trường trình duyệt. Một vài xử lý yêu cầu phải chạy ở tiến trình main (ví dụ bật dialog), thì từ renderer có thể invoke một event để tiến trình chính thực hiện, kết quả sẽ được trả về cho renderer để hiển thị.

Với yêu cầu một ứng dụng đơn giản, ít cần cài đặt hay cấu hình thì tôi quyết định sẽ viết ứng dụng theo hướng thứ 2.

Lập trình ứng dụng đơn giản

Trong nội dung bài viết này, tôi sẽ trình bày cách lập trình một ứng dụng đơn giản. Ý tưởng là nhập vào một thư mục và rename tất cả file trong thư mục đó thành 1 tên ngẫu nhiên có độ dài cố định (cũng tuỳ theo input).

Cài đặt và cấu hình

Trước hết, Electron chạy trên Node.js nên yêu cầu máy tính phải cài sẵn Node.jsnpm. Với những người lập trình web thì những công cụ này không còn gì xa lạ nữa, nên trong bài viết này tôi không đi sâu vào chi tiết việc cài đặt này.

Sau khi đã có Node.js thì việc tiếp theo là khởi tạo một project mới bằng lệnh và nhập các thông tin cần thiết

$ npm init

Câu lệnh sẽ khởi tạo 1 project Node.js mới với file package.json chứa các thông tin mà chúng ta đã nhập vào. Bước tiếp theo là cài đặt electron để sử dụng

$ npm install -D electron

Có thể nhiều người thắc mắc tại sao lại có -D trong câu lệnh trên. Nguyên nhân là bởi vì mục đích cuối cùng của project không phải là 1 package Node.js. Mục đích cuối cùng là 1 ứng dụng chạy trên desktop, nên electron chỉ là devDependencies chứ không phải dependencies. Ngoài ra thì việc này có liên quan đến việc build app ở dưới.

Như đã nói ở trên, một ứng dụng Electron thực chất là mở một trình duyệt rồi tải trang HTML, cho nên tôi cần cài đặt thêm các công cụ để build CSS, JS, v.v… Với ví dụ này tôi sử dụng TailwindCSS nên cần cài đặt thêm các package sau:

$ npm install -D postcss postcss-cli autoprefixer tailwindcss

Việc cấu hình những công cụ này cũng không khó, tôi hoàn toàn làm theo hướng dẫn là được.

Viết ứng dụng

Về cơ bản ứng dụng sẽ bao gồm 1 file HTML (đặt tên tuỳ ý), các file CSS, JS đi kèm với file HTML đó và 1 file Node.js để để khởi động Electron bật ứng dụng. Cấu trúc thư mục của ứng dụng sẽ tương tự như dưới đây:

├── static
|   ├── src
|   |   ├── app.css // sẽ  build bằng tailwindcss thành /static/app.css
|   ├── app.js
├── index.html
├── main.js
└── package.json

Nội dung của file index.html như sau (màn hình có 2 trường input và 1 button)

<!DOCTYPE html>
<html lang="ja">
    <meta charset="utf-8">
    <title>Simple app with Electron</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="static/app.css">

    <div
        class="
            flex flex-col
            justify-center
            items-center
            gap-4
            w-screen
            h-screen
        "
    >
        <div class="flex flex-row gap-4 justify-center items-center">
            <label class="text-right font-lg w-32" for="folder">
                Select input
            </label>
            <div class="flex flex-row items-center w-96">
                <input
                    type="text"
                    class="
                        outline-none
                        flex-grow
                        border
                        border-gray-300
                        rounded-l
                        h-12
                        p-4
                    "
                    name="folder"
                    id="folder"
                    disabled
                >
                <button
                    class="
                        outline-none
                        bg-gray-200
                        hover:bg-gray-400
                        border-t
                        border-r
                        border-b
                        border-gray-300
                        h-12
                        px-4
                        -ml-1
                        rounded-r
                    "
                    id="folder-btn"
                >
                    <svg
                        xmlns="http://www.w3.org/2000/svg"
                        class="h-6 w-6 inline-block"
                        fill="none"
                        viewBox="0 0 24 24"
                        stroke="currentColor"
                    >
                        <path
                            stroke-linecap="round"
                            stroke-linejoin="round"
                            stroke-width="2"
                            d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z"
                        />
                    </svg>
                    Choose folder
                </button>
            </div>
        </div>
        <div class="flex flex-row gap-4 justify-center items-center">
            <label class="text-right font-lg w-32" for="length">
                Filename length
            </label>
            <input
                type="number"
                class="
                    border border-gray-200
                    focus:border-blue-500
                    outline-none
                    w-96
                    rounded-l
                    h-12
                    rounded
                    p-4
                "
                value="16"
                name="length"
                id="length"
            >
        </div>

        <div class="ml-36 w-96 text-left">
            <button
                class="
                    outline-none
                    bg-blue-500
                    hover:bg-blue-700
                    px-8
                    py-2
                    text-white
                    uppercase
                "
                id="action-btn"
            >
                <svg
                    xmlns="http://www.w3.org/2000/svg"
                    class="h-6 w-6 inline-block"
                    fill="none"
                    viewBox="0 0 24 24"
                    stroke="currentColor"
                >
                    <path
                        stroke-linecap="round"
                        stroke-linejoin="round"
                        stroke-width="2"
                        d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
                    />
                </svg>
                Run
            </button>
        </div>

        <script src="static/app.js"></script>
    </div>
</html>

File static/src/app.css thì hoàn toàn sử dụng TailwindCSS nên nội dung đơn giản như sau:

@tailwind base;
@tailwind components;
@tailwind utilities;

Ngoài ra thì file static/app.js hiện tại đang trống, chúng ta sẽ bổ sung thêm các xử lý cần thiết sau.

Cuối cùng là file main.js, chúng ta cần khởi tạo app mới, tải trang HTML và hiển thị cho người dùng. Tất cả thao tác đó sử dụng code như sau:

const { app, BrowserWindow } = require("electron");

let mainWin;

/**
 * Hàm dùng để khởi tạo Window
 */
const createWindow = () => {
    // Tạo Window mới với
    mainWin = new BrowserWindow({
        width: 800,
        height: 650,
        icon: "static/icon.jpeg",
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
        },
    });

    // Không cần menu
    mainWin.removeMenu();

    // Tải file html và hiển thị
    mainWin.loadFile("./index.html");

    // mainWin.webContents.openDevTools();
};

// Sau khi khởi động thì mở Window
app.whenReady().then(createWindow);

// Xử lý sau khi Window được đóng
app.on("window-all-closed", () => {
    app.quit();
});

// Xử lý khi app ở trạng thái active, ví dụ click vào icon
app.on("activate", () => {
    // Mở window mới khi không có window nào
    if (BrowserWindow.getAllWindows().length === 0) {
        createWindow();
    }
});

Sau khi đã hoàn thành các file trên, giờ đây chúng ta có thể khởi động app và xem thành quả

$ npx electron .
app

Tương tác

Mặc dù ứng dụng đã trông rất ngầu, nhưng hiện tại nó chưa hoạt động. Bởi các với các button chúng ta chưa xử lý gì. Công việc tiếp theo chính là viết thêm xử lý cho các button này.

Select folder

Để có thể input một folder, chúng ta cần sử dụng đến dialog. Tuy nhiên là dialog chỉ có thể bật từ main process mà không thể bật từ renderer được. Nói qua một chút thì để làm việc với dialog thì tôi đã phải tìm kiếm, chạy thử không biết bao nhiêu lần. Nguyên nhân là các hướng dẫn trên mạng hầu hết đã lỗi thời, khi mà từ renderer có thể gọi remote và bật dialog được luôn.

Có 1 cách khác, đó là sử dụng HTML input file và thêm thuộc tính webkitdirectory. Tuy nhiên cách làm này không hoàn toàn giống với dialog. Sử dụng cách này thì mỗi lần chọn folder, thực chất là chọn input tất cả file trong folder đó.

app.js là file sẽ được thực thi ở renderer, thì chúng ta cần invoke một event để main process xử lý, sau đó nhận kết quả và hiển thị cho người dùng, như kiểu dưới đây:

const { ipcRenderer } = require("electron");


document.querySelector("#folder-btn").addEventListener("click", () => {
    ipcRenderer
        .invoke("select-folder")
        .then((data) => {
            if (!data.canceled) {
                document.querySelector("#folder").value = data.filePaths[0];
            }
        })
        .catch((err) => {
            console.log(err)
        });
});

Lưu ý một chút là dù Electron thực chất là mở một trình duyệt và hiển thị HTML nhưng nó có đôi chút khác biệt so với trình duyệt thông thường. Ở phía client, chúng ta có thể thực thi một số code Node.js mà bình thường trình duyệt không hỗ trợ. Tuy nhiên với ứng dụng trong bài thì điều đó chưa cần thiết.

Ở main process, mọi việc đơn giản là bật dialog để chọn folder rồi trả về cho renderer là được.

const { ipcMain, dialog } = require("electron");

ipcMain.handle("select-folder", async () => {
    const pathObj = await dialog.showOpenDialog(mainWin, {
        properties: ["openDirectory"],
    });
    return pathObj;
});

Kết quả là chúng ta có một dialog để chọn như dưới đây

dialog

Submit

Tương tự như ở trên, đối với button submit, thì ở phía renderer chỉ đơn giản là invoke một event và truyền dữ liệu cho main process.

document.querySelector("#action-btn").addEventListener("click", () => {
    const folder = document.querySelector("#folder").value;
    const length = parseInt(document.querySelector("#length").value);

    ipcRenderer.invoke("rename", {folder: folder, length: length});
});

Toàn bộ xử lý sẽ thực hiện ở main process:

const fs = require("fs");
const path = require("path");

ipcMain.handle("rename", (_, data) => {
    const randomName = (length) => {
        var result = "";
        var characters =
            "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
        var charactersLength = characters.length;
        for (var i = 0; i < length; i++) {
            result += characters.charAt(
                Math.floor(Math.random() * charactersLength)
            );
        }
        return result;
    };

    fs.readdir(data.folder, (_, files) => {
        files.forEach((file) => {
            oldPath = path.resolve(data.folder, file);
            ext = path.extname(file);
            newPath = path.resolve(data.folder, randomName(data.length) + ext);
            fs.rename(oldPath, newPath, (err) => {
                if (err) {
                    console.log(err);
                } else {
                    console.log("Successfully renamed the file " + file);
                }
            });
        });
    });

    dialog.showMessageBoxSync(mainWin, {
        message: "Successfully renamed the all files in " + data.folder,
        type: "info",
    });
});

Chỉ có 1 điểm tôi muốn nói thêm là ở xử lý trên, tôi tiếp tục sử dụng dialog để thông báo cho người dùng về việc xử lý đã hoàn thành. Có người nhiều sẽ nghĩ sao không dùng alert cho nhanh. Câu trả lời alert vẫn hoạt động (như trình duyệt bình thường) nhưng sau đó thì cửa sổ app sẽ bị treo (chỗ này thì khác trình duyệt rồi 😂). Nguyên nhân là do alert hay kể cả prompt, confirm sẽ block thread đang chạy và như tôi thấy thì sau đó thread sẽ không chạy tiếp nữa (trừ khi click ra đâu đó rồi quay lại) (xem thêm ở đây).

Vì vậy tất cả những xử lý kiểu như thế đều nên sử dụng dialog ở main process thay vì xử lý ở renderer. Kết quả là chúng ta đã rename thành công toàn bộ file trong 1 folder như dưới đây:

renamed

Build app

Sau khi đã lập trình xong tất cả các vấn đề ở trên, chỉ còn 1 bước cuối cùng nữa là hoàn thành. Đó là build app hoàn chỉnh để có thể chạy ở bất kỳ đâu (vì để nguyên như trên thì yêu cầu máy phải cài đặt Node.js và các package).

Có nhiều công cụ khác nhau cho phép làm việc này, tuy nhiên tôi sử dụng electron-builder. Cài đặt package này như sau:

$ npm install -D electron-builder

Sau đó thì tạo một file JS (tên gì cũng được, tôi đặt tên là build-app.js) với nội dung là sử dụng electron-builder để build app như sau:

const builder = require("electron-builder");

builder.build({
    config: {
        appId: "electron.rename",
        productName: "SampleApp",
        win: {
            target: {
                target: "zip",
                arch: "x64",
            },
        },
    },
});

Trên đây chỉ là cấu hình đơn giản, có rất nhiều cấu hình khác nhau, mời các bạn tham khảo thêm ở đây. Sau khi cấu hình xong thì thực thi file để build app là xong:

$ node build-app.js

Chờ mấy phút để app build xong (tuỳ cấu hình máy mạnh hay yếu). Kết quả sẽ được ghi ở thư mục dist bao gồm 1 file zip, 1 thư mục win-unpack (nội dung giống hệt file zip) và một số file debug. Trong thư mục sẽ có các tất cả các file cần thiết để chạy app nên app có thể chạy ở bất kỳ máy nào (dùng x64, x86 thì phải sửa lại cấu hình trên một chút để build). Giờ đây chúng ta chỉ cần click file SampleApp.exe là app sẽ chạy.

Một lưu ý nhỏ là click file exe để chạy app thì tất cả các câu console.log ở main process sẽ chẳng được ghi ra ở đâu cả (hoặc được ghi ra đâu đó mà tôi không biết). Muốn xem những thông tin này (để debug chẳng hạn) thì cần phải gọi file exe từ console (PowerShell, cmd hoặc bash, v.v…).

Nhận xét

Electron thực sự là một giải pháp tốt cho những người chỉ có kinh nghiệm làm web như tôi có thể lập trình ứng dụng cho desktop. Tuy nhiên, cá nhân tôi cho rằng đây chỉ là một giải pháp tình thế mà thôi. Tuy ứng dụng chạy khá nhanh, hiệu suất ổn nhưng dung lượng của nó lại là vấn đề. Ứng dụng đơn giản như trong bài mà build xong file zip cũng có dung lượng gần 80MB, còn file unzip là gần 200MB, đó là những con số khá lớn cho một ứng không có gì là phức tạp.

Tất nhiên là với sự phát triển của công nghệ bán dẫn, việc file có dung lượng hơi lớn một chút hiện nay hoàn toàn không phải là vấn đề gì quá to tát. Nhưng với những ai theo đuổi sự hoàn hảo thì có lẽ một giải pháp native hơn sẽ là tốt hơn.

Tôi xin lỗi nếu bài viết có bất kỳ typo nào. Nếu bạn nhận thấy điều gì bất thường, xin hãy cho tôi biết.

Nếu có bất điều gì muốn nói, bạn có thể liên hệ với tôi qua các mạng xã hội, tạo discussion hoặc report issue trên Github.