+1

[ElectronJS] Bài 2 - Process Model

Ok... Sau khi quay trở lại Sub-Series NodeJS của Series Web để chuẩn bị thêm một chút kiến thức về Event & Process thì chúng ta đã có thể tiếp tục tìm hiểu về ElectronJS ở đây. Trong bài viết này, chúng ta sẽ tìm hiểu về mô hình quản lý process của framework này và công cụ để thực hiện giao tiếp giữa các process.

Mục tiêu xây dựng

Trước khi bắt đầu tìm hiểu chi tiết về ElectronJS thì chúng ta cần xác định một mục tiêu cụ thể đã. Một framework hỗ trợ xây dựng các ứng dụng native thì hiển nhiên sẽ có rất rất nhiều thứ để tìm hiểu. Tuy nhiên chúng ta chắc chắn cũng không thể kể hết tất cả các chi tiết về giao diện lập trình được cung cấp trong tài liệu của ElectronJS được.

Khi nghĩ đến việc xây dựng một website, thiết kế ứng dụng đơn giản nhất hiển nhiên là một trang blog cá nhân, bởi đó là ứng dụng online phổ biến nhất mà mọi người đều sử dụng. Còn đối với một ứng dụng native, mình nghĩ nền tảng chung nhất là một ứng dụng đơn giản có thể chỉnh sửa nội dung của một tệp văn bản thuần ví dụ như Notepad của Windows. Bởi vì bất kỳ phần mềm nào khác phức tạp hơn, cũng đều sẽ phải thực hiện những chức năng cơ sở như:

  • Mở cửa sổ duyệt các thư mục và tệp dữ liệu.
  • Đọc nội dung của một tệp dữ liệu cần xử lý để nạp vào môi trường của ứng dụng.
  • Chỉnh sửa nội dung và lưu trở lại ở dạng tệp tĩnh.
  • Quản lý nhiều cửa sổ.

Vì vậy nên chúng ta hãy quyết định là xây dựng một ứng dụng như Notepad của Windows đi. 😄 Chúng ta sẽ có thể sử dụng khi cần ghi chú nhanh, phác họa các ý tưởng, hay soạn thảo nhanh các tệp code đơn giản. Sau đó chúng ta sẽ suy nghĩ về việc bổ sung thêm một vài tính năng so với Notepad nguyên bản; Ví dụ như: tự động lưu nội dung ở dạng nháp sau một khoảng thời gian, mở nhiều tệp trong cùng một cửa sổ thành các tab giống như các trình soạn thảo code, v.v... 😄

Mô hình quản lý process

Chúng ta sẽ xuất phát từ điểm khởi chạy phần mềm để theo dõi logic vận hành của bộ code "Hello World". Ở đây mình gọi ứng dụng đang xây dựng là electron-code và sẽ đổi tên thư mục thành như vậy. Lý do là vì mình thường dùng các phần mềm Text Editor đơn giản để code nháp và ghi chú; và cũng rất muốn có một phần mềm đơn giản như Notepad nhưng có khoảng cách giữa các dòng code và khoảng cách với các lề thoáng hơn một chút. Nếu bạn có ý định xây dựng một ứng dụng soạn thảo code xịn như VisualStudio Code thì có thể đặt tên bạn vào thay từ electron cũng được. 😄

{
   "name": "electron-code",
   "version": "1.0.0",
   "description": "Simple text editor",
   "main": "main.js",
   "scripts": {
      "start": "electron ."
   },
   "repository": "https://github.com/semiarthanoian/electron-code",
   "keywords": [ "nodejs", "electronjs", "editor", "tutorial", "beginner" ],
   "author": "Semi Art",
   "license": "CC0-1.0",
   "devDependencies": {
      "electron": "^18.2.3"
   }
}

Vậy là lệnh npm start được thiết lập mặc định là chạy "electron ." ở thư mục cùng cấp, trỏ tới "main": "main.js".

Hmm... code trong tệp main.js có khá nhiều ghi chú. Mình có Google Translate qua rồi, nhưng dịch lại ở đây thì dài dòng quá. Bạn cũng Google Translate sơ qua rồi xem code thu gọn dưới đây nhé. 😄

const { app, BrowserWindow } = require('electron')
const path = require('path')

   // --- display main window

const createWindow = () => {
   var preload = path.join(__dirname, 'preload.js')
   var mainWindow = new BrowserWindow({
      width: 800,
      height: 600,
      webPreferences: { preload }
   })
   mainWindow.loadFile('index.html')
}

app.on('ready', (event) => {
   createWindow()
})

   // --- for unix-based OS

app.on('activate', (event) => {
   var windowsCounter = BrowserWindow.getAllWindows().length
   if (windowsCounter != 0)   /* do nothing */;
   else                       createWindow()
})

app.on('window-all-closed', (event) => {
   var hostOS = process.platform
   if (hostOS == 'darwin')    /* do nothing */;
   else                       app.quit()
})

Ở đây chúng ta có hai phần code: Phần đầu là để khởi tạo và để hiển thị cửa sổ của ứng dụng khi người dùng nhấn vào biểu tượng trên màn hình để mở ứng dụng; Phần thứ hai là thao tác xử lý bổ sung cho các hệ điều hành có tên mã darwin, cụ thể là Mac và các hệ điều hành dòng OpenBSD và chúng ta rất ít gặp. Do đó chúng ta sẽ không cần phải quan tâm chi tiết tới phần code thứ hai.

Chúng ta thấy app là một object dựng sẵn được export bởi module electron và được áp dụng giao diện Event Emitter hoặc NodeEventTarget. Có lẽ ElectronJS đã sử dụng nhiều thiết lập ban đầu cho app do đó việc khởi tạo và hiển thị cửa sổ ứng dụng chỉ được thực hiện khi sự kiện ready được phát động.

Trong hàm khởi tạo cửa sổ ứng dụng createWindow, chúng ta có thêm 2 tệp nữa được tải vào logic xử lý. Đầu tiên là tệp preload.js:

window.addEventListener('DOMContentLoaded', (event) => {
   var replaceText = (selector, text) => {
      var element = document.getElementById(selector)
      if (element == null)    /* do nothing */;
      else                    element.innerText = text
   }
   for (var type of ['chrome', 'node', 'electron']) {
      replaceText(`${type}-version`, process.versions[type])
   }
}) // window.addEventListener

Ồ... một đoạn code gắn hàm xử lý sự kiện trong môi trường trình duyệt web để tìm tới các phần tử #chrome-version, #node-version, và #electron-version để chèn nội dung là thông tin truy xuất từ object process mô tả tiến trình chạy code chính của ứng dụng tạo ra bởi NodeJS.

Như vậy là preload.js vừa có khả năng truy xuất object process trong môi trường NodeJS, và vừa có thể can thiệp vào bên trong môi trường vận hành code JavaScript của cửa sổ trình duyệt Chromium. Bây giờ chúng ta hãy xem lại code template của tệp index.html.

<!doctype html>
<html>
<head>
   <title>Hello World!</title>
   <meta charset="utf-8">
   <meta http-equiv="content-security-policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">
   <link href="./styles.css" rel="stylesheet">
</head>
<body>
   <h1>Hello World!</h1>

   We are using Node.js <span id="node-version"></span>,
   Chromium <span id="chrome-version"></span>,
   and Electron <span id="electron-version"></span>.

   <!-- You can also require other files to run in this process -->
   <script src="./renderer.js"></script>
</body>
</html>

Ở đây chúng ta lại có thêm các tệp styles.cssrenderer.js được nhúng vào. Tệp preload.js có lẽ được nạp vào nhờ code thực thi của class BrowserWindow và chúng ta sẽ tìm hiểu trong tài liệu sau. Còn ở đây thì chúng ta sẽ thử xem renderer.js có thể sử dụng các module do NodeJS cung cấp không.

À mà khỏi cần thử. 😄 Nếu có thì code truy vấn thông tin về main process đâu cần thiết phải viết trong preload.js. 😄 Như vậy là code trong tệp renderer.js sẽ hoạt động như code JavaScript client-side khi lập trình web. Thật kỳ lạ, nếu vậy thì chỉ cần preload.js thôi là đủ, ở đó chúng ta cũng có thể gắn các hàm xử lý sự kiện do thao tác người dùng tạo ra. Có lẽ là đến lúc phải mở tài liệu chính thức của ElectronJS để xem rồi. 😄

electronjs.org -> Docs -> Processes in Electron -> Process Model

ElectronJS nói rằng chúng ta có 2 loại tiến trình: một là main process, và hai là renderer process. Khi chúng ta bắt đầu chạy code tại tệp main.js thì dòng xử lý chính tạo ra main process. Sau đó cứ mỗi cửa sổ BrowserWindow hay mỗi tab trong trình duyệt Chromium sẽ tạo ra một tiến trình phụ renderer process - thực ra là một child process được tạo ra từ main process theo cách mà chúng ta đã biết đến trong Sub-Series NodeJS.

electronjs.org -> Docs -> Processes in Electron -> Context Isolation

Ngoài ra thì chúng ta còn có thêm một mục nội dung Context Isolation nói về giới hạn tài nguyên được sử dụng bởi renderer process. Code JavaScript trong tệp này sẽ mặc định không thể truy xuất tới các tính năng do framework cung cấp và cả các module của NodeJS, tuy nhiên có thể được khai mở bởi preload.js. 😄

Giao tiếp giữa các process

Như vậy là chúng ta có một tệp main.js khởi chạy tiến trình chính main process, và code ở đây sẽ không thể trực tiếp gắn hàm xử lý sự kiện vào các phần tử của giao diện người dùng. Và tệp renderer.js sẽ được tải vào mỗi tab của trình duyệt Chromium và chạy trên các tiến trình phụ renderer process, nơi mà chúng ta không thể viết code trực tiếp require các module của NodeJS hay ElectronJS để sử dụng.

Lúc này nếu chúng ta đứng ở vai trò người sử dụng thì khi thực hiện một thao tác nào đó trên giao diện người dùng, một sự kiện sẽ được trình duyệt Chromium tạo ra và chúng ta chỉ có thể viết code gắn hàm xử lý tại preload.js hoặc renderer.js.

Nếu như là một thao tác người dùng muốn mở một tệp đã lưu trên máy tính để chỉnh sửa nội dung, thì hiển nhiên hàm xử lý sự kiện sẽ cần phải có khả năng sử dụng module File System của NodeJS. Và chúng ta sẽ cần phải viết hàm xử lý sự kiện trong tệp preload.js, hoặc tìm cách để giúp code ở renderer.js có thể giao tiếp được với main process.

Mặc dù phương án xử lý thứ nhất rất đơn giản và phù hợp với ứng dụng electron-code mà mình đang hướng tới. Tuy nhiên để dự phòng là bạn muốn xây dựng một ứng dụng có tính năng đa dạng hơn, và hơn nữa là để chúng ta có thể đọc hiểu code của các project open-source để học hỏi thêm từ cộng đồng - chúng ta sẽ tìm hiểu thêm phương án xử lý thứ hai. 😄

electronjs.org -> Docs -> Processes in Electron -> Inter-Process Communication

Ở đây code ví dụ mà tài liệu của ElectronJS cung cấp cho chúng ta có một hàm xử lý sự kiện được viết tại renderer.js và sử dụng một phương thức openFile() được cung cấp qua giao diện electronAPI.

const btn = document.getElementById('btn')
const filePathElement = document.getElementById('filePath')

btn.addEventListener('click', async () => {
   const filePath = await window.electronAPI.openFile()
   filePathElement.innerText = filePath
})

Và giao diện electronAPI được thiết lập bởi preload.js.

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
   openFile: () => ipcRenderer.invoke('dialog:openFile')
})

Ở đây preload.js sử dụng hai object do module electron cung cấp.

Đầu tiên, là object contextBridge - cầu nối giữa renderer process và môi trường bên ngoài. Trong đó phương thức exposeInMainWorld sẽ khai mở một thuộc tính electronAPI trong môi trường Main World. Ở đây chúng ta lưu ý là ElectronJS sử dụng từ "main world" để nói về môi trường của renderer process chứ không phải là của main process nhé. 😄 Còn thế giới bên ngoài renderer process được gọi là môi trường được tách biệt isolated world. Họ đặt tên như vậy là bởi vì phương thức này được xây dựng và đặt trong module tiện ích dành cho renderer process.

Thứ hai, là object ipcRenderer - được thiết kế để hỗ trợ gửi tương tác tới main process. Trong đó phương thức invoke sẽ gửi một mảng dữ liệu (nếu cần thiết) qua một kênh sự kiện channel. Và ở phía main process sẽ có thể gắn một hàm listener ở kênh sự kiện này bằng phương thức ipcMain.handle().

const { app, BrowserWindow, ipcMain, dialog } = require('electron')
const path = require('path')

   // --- display main window

const createWindow = () => {
   var preload = path.join(__dirname, 'preload.js')
   var mainWindow = new BrowserWindow({
      width: 800,
      height: 600,
      webPreferences: { preload }
   })
   mainWindow.loadFile('index.html')
}

app.on('ready', (event) => {
   var handleFileOpen = async (event, ...args) => {
      var { canceled, filePaths } = await dialog.showOpenDialog()
      if (canceled)  return null
      else           return filePaths[0]
   }
   // --- gắn listener cho kênh sự kiện 'dialog:openFile'
   ipcMain.handle('dialog:openFile', handleFileOpen)
   createWindow()
})

   // --- for unix-based OS ...

Ok... cũng không quá rườm rà. Thế nhưng chúng ta vẫn cần cope/paste thêm code template ở index.html để chạy thử. 😄

<body>
   <button type="button" id="btn"> Open a File </button>
   File path: <strong id="filePath"></strong>

   <script src='./renderer.js'></script>
</body>
npm start

Ô... như vậy là chúng ta còn học được luôn cách mở cửa sổ duyệt các thư mục và các tệp để tìm tới tệp cần chỉnh sửa. 😄

Kết thúc bài viết

Như vậy là chúng ta đã có được hiểu biết tổng quan về các tệp code cơ bản của một ứng dụng ElectronJS, mô hình quản lý các renderer process được tách biệt khỏi tiến trình chính main process. Đồng thời, chúng ta cũng đã biết cách mở ra một giao diện lập trình kết nối giữa renderer processmain process, biết thêm luôn cách mở cửa sổ duyệt các thư mục và các tệp để tìm tới tệp cần chỉnh sửa nữa. 😄

Công việc tiếp theo là viết code tạo giao diện người dùng chi tiết và định nghĩa các kiểu sự kiện người dùng cần xử lý để viết code điều hành logic hoạt động của phần mềm. Tới đây thì mình nghĩ có khả năng là bạn sẽ có nhiều ý tưởng hơn mình; Và trong trường hợp bạn rất muốn nhanh chóng thực hiện những ý tưởng đang có thì điểm cần quan tâm tiếp theo trong tài liệu của ElectronJS cung cấp là chỉ mục Examples:

electronjs.org -> Docs -> Examples

Ở đây có code ví dụ minh họa về những tính năng phổ biến để bạn có thể tích hợp ngay vào ứng dụng đang xây dựng. Sau khi đọc qua về các ví dụ này và tra cứu thông tin liên quan trong hạng mục API, chắc chắn bạn sẽ cảm thấy khá quen thuộc với cách lập tài liệu của ElectronJS. 😄

(Sắp đăng tải) [ElectronJS] Bài 3 - Từ Từ Để Mình Nghĩ Tên Cho Bài Tiếp Theo Đã 😄


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.